From d99955943a39da30dd329ff2e2f903ded6e31a13 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 15 May 2026 23:14:30 +0200 Subject: [PATCH 1/2] From a3453f3e8020105e00a1b0a0f4ff60b4ddf0a5c9 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 15 May 2026 23:23:10 +0200 Subject: [PATCH 2/2] feat: support local custom fonts Add an "Upload local font" affordance to the font picker so users can bring their own .woff2 / .woff / .ttf / .otf monospace fonts. Uploaded fonts are persisted in localStorage as base64, registered with document.fonts for the DOM preview, and surfaced through loadCodeFont so Satori uses them on the PNG/SVG export path too. --- src/components/Editor.tsx | 3 +- src/components/FontCombobox.tsx | 160 +++++++++++++++++++++++++++----- src/lib/customFonts.ts | 133 ++++++++++++++++++++++++++ src/lib/fonts.ts | 62 +++++++++++-- src/lib/types.ts | 4 +- 5 files changed, 330 insertions(+), 32 deletions(-) create mode 100644 src/lib/customFonts.ts diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index cbcd60c..738dbe0 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { cssFamilyFor as customCssFamily } from '#/lib/customFonts' import { tokenize } from '#/lib/shiki' import { CODE_FONTS, @@ -25,7 +26,7 @@ interface Props { } const fontFamilyFor = (font: string): string => - CODE_FONTS.find((f) => f.value === font)?.cssFamily ?? font + CODE_FONTS.find((f) => f.value === font)?.cssFamily ?? customCssFamily(font) const overlayColor = (hex: string): string => { const m = hex.match(/^#?([0-9a-fA-F]{6})$/) diff --git a/src/components/FontCombobox.tsx b/src/components/FontCombobox.tsx index 7d55285..a936064 100644 --- a/src/components/FontCombobox.tsx +++ b/src/components/FontCombobox.tsx @@ -1,12 +1,14 @@ -import { useState, useSyncExternalStore } from 'react' +import { useRef, useState, useSyncExternalStore } from 'react' +import { LuLigature } from 'react-icons/lu' import { + RiAddLine, RiArrowDownSLine, RiCheckboxBlankCircleFill, RiCheckboxBlankCircleLine, + RiDeleteBinLine, RiErrorWarningLine, RiLoader4Line, } from 'react-icons/ri' -import { LuLigature } from 'react-icons/lu' import { Button } from '#/components/ui/button' import { Command, @@ -15,31 +17,48 @@ import { CommandInput, CommandItem, CommandList, + CommandSeparator, } from '#/components/ui/command' import { Popover, PopoverContent, PopoverTrigger, } from '#/components/ui/popover' +import { + addCustomFont, + cssFamilyFor as customCssFamily, + type CustomFont, + listCustomFonts, + readFontFile, + removeCustomFont, + subscribeCustomFonts, +} from '#/lib/customFonts' import { type FontStatus, getFontStatus, subscribeFontStatus, } from '#/lib/fonts' -import { CODE_FONTS, type CodeFont } from '#/lib/types' +import { CODE_FONTS } from '#/lib/types' interface Props { - value: CodeFont - onChange: (next: CodeFont) => void + value: string + onChange: (next: string) => void } -const useFontStatus = (font: CodeFont): FontStatus => +const useFontStatus = (font: string): FontStatus => useSyncExternalStore( subscribeFontStatus, () => getFontStatus(font), () => 'idle' as FontStatus, ) +const useCustomFonts = (): ReadonlyArray => + useSyncExternalStore( + subscribeCustomFonts, + listCustomFonts, + () => [] as ReadonlyArray, + ) + const StatusIcon = ({ status }: { status: FontStatus }) => { if (status === 'loading') return ( @@ -93,12 +112,14 @@ const FontRow = ({ ligatures, selected, onSelect, + trailing, }: { - value: CodeFont + value: string cssFamily: string ligatures: boolean selected: boolean onSelect: () => void + trailing?: React.ReactNode }) => { const status = useFontStatus(value) return ( @@ -118,14 +139,60 @@ const FontRow = ({ + {trailing} ) } +const CurrentFontPreview = ({ value }: { value: string }) => { + const status = useFontStatus(value) + const builtIn = CODE_FONTS.find((f) => f.value === value) + const family = builtIn?.cssFamily ?? customCssFamily(value) + return ( + + + {value || 'Pick a font'} + + + + ) +} + +const ACCEPTED_FONT_TYPES = '.woff2,.woff,.ttf,.otf' + export function FontCombobox({ value, onChange }: Props) { const [open, setOpen] = useState(false) - const current = CODE_FONTS.find((f) => f.value === value) - const currentStatus = useFontStatus(value) + const [uploadError, setUploadError] = useState(null) + const fileInputRef = useRef(null) + const customFonts = useCustomFonts() + + const handleFile = async (file: File): Promise => { + setUploadError(null) + try { + const { defaultName, variant } = await readFontFile(file) + const name = defaultName || file.name + addCustomFont({ name, variants: [variant] }) + onChange(name) + setOpen(false) + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to add font' + setUploadError(message) + } + } + + const onFileChange = (e: React.ChangeEvent): void => { + const file = e.target.files?.[0] + e.target.value = '' + if (file) void handleFile(file) + } + + const handleRemove = (name: string): void => { + removeCustomFont(name) + if (value === name) onChange('JetBrains Mono') + } return ( @@ -136,18 +203,7 @@ export function FontCombobox({ value, onChange }: Props) { aria-expanded={open} className="w-full justify-between bg-transparent font-normal text-foreground" > - - - {current?.value ?? 'Pick a font'} - - - + @@ -157,9 +213,43 @@ export function FontCombobox({ value, onChange }: Props) { > - + No font found. - + {customFonts.length > 0 && ( + <> + + {customFonts.map((f) => ( + { + onChange(f.name) + setOpen(false) + }} + trailing={ + + } + /> + ))} + + + + )} + {CODE_FONTS.map((f) => ( +
+ + + {uploadError && ( +

+ {uploadError} +

+ )} +
diff --git a/src/lib/customFonts.ts b/src/lib/customFonts.ts new file mode 100644 index 0000000..ed35ca6 --- /dev/null +++ b/src/lib/customFonts.ts @@ -0,0 +1,133 @@ +// Local custom fonts uploaded by the user. The user picks a font file from +// disk; we read the bytes, persist them to localStorage as base64, and make +// the font available to both render paths: +// * DOM preview — registered via FontFace on document.fonts +// * Satori export — surfaced through loadCodeFont in fonts.ts +// +// Storage is intentionally simple (one localStorage key, base64 payload). +// Browser monospace fonts in woff/woff2 are typically well under the ~5 MB +// localStorage quota even at a few entries. If the quota is exceeded the +// save throws and the upload is reported as failed. + +export type CustomFontStyle = 'normal' | 'italic' +export type CustomFontWeight = 400 | 500 + +export interface CustomFontVariant { + readonly weight: CustomFontWeight + readonly style: CustomFontStyle + readonly data: string // base64-encoded font bytes + readonly mime: string +} + +export interface CustomFont { + readonly name: string + readonly variants: ReadonlyArray +} + +const STORAGE_KEY = 'code-pretty:custom-fonts:v1' + +const listeners = new Set<() => void>() +let cache: ReadonlyArray | undefined + +const notify = (): void => { + for (const l of listeners) l() +} + +const readStorage = (): ReadonlyArray => { + if (typeof window === 'undefined') return [] + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed as ReadonlyArray + } catch { + return [] + } +} + +const writeStorage = (fonts: ReadonlyArray): void => { + if (typeof window === 'undefined') return + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(fonts)) +} + +export const listCustomFonts = (): ReadonlyArray => { + if (cache === undefined) cache = readStorage() + return cache +} + +export const subscribeCustomFonts = (cb: () => void): (() => void) => { + listeners.add(cb) + return () => { + listeners.delete(cb) + } +} + +export const findCustomFont = (name: string): CustomFont | undefined => + listCustomFonts().find((f) => f.name === name) + +export const cssFamilyFor = (name: string): string => + `"${name.replace(/"/g, '\\"')}", monospace` + +const replaceAll = (next: ReadonlyArray): void => { + writeStorage(next) + cache = next + notify() +} + +export const addCustomFont = (font: CustomFont): void => { + const rest = listCustomFonts().filter((f) => f.name !== font.name) + replaceAll([...rest, font]) +} + +export const removeCustomFont = (name: string): void => { + replaceAll(listCustomFonts().filter((f) => f.name !== name)) +} + +const bytesToBase64 = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer) + // Chunked to avoid String.fromCharCode argument-count limits on large files. + const chunkSize = 0x8000 + let binary = '' + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize) + binary += String.fromCharCode(...chunk) + } + return btoa(binary) +} + +export const base64ToBytes = (b64: string): ArrayBuffer => { + const binary = atob(b64) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out.buffer +} + +const mimeFor = (filename: string): string => { + const ext = filename.toLowerCase().split('.').pop() ?? '' + if (ext === 'woff2') return 'font/woff2' + if (ext === 'woff') return 'font/woff' + if (ext === 'otf') return 'font/otf' + return 'font/ttf' +} + +const stripExt = (filename: string): string => + filename.replace(/\.(woff2?|otf|ttf)$/i, '').trim() + +export interface ReadFileResult { + readonly defaultName: string + readonly variant: CustomFontVariant +} + +export const readFontFile = async (file: File): Promise => { + const buffer = await file.arrayBuffer() + return { + defaultName: stripExt(file.name), + variant: { + weight: 400, + style: 'normal', + data: bytesToBase64(buffer), + mime: mimeFor(file.name), + }, + } +} diff --git a/src/lib/fonts.ts b/src/lib/fonts.ts index 534135d..cf2136f 100644 --- a/src/lib/fonts.ts +++ b/src/lib/fonts.ts @@ -1,3 +1,8 @@ +import { + base64ToBytes, + findCustomFont, + subscribeCustomFonts, +} from './customFonts' import type { CodeFont } from './types' // Each variant is loaded lazily via a dynamic import of the woff file as an @@ -160,14 +165,14 @@ export interface SatoriFont { export type FontStatus = 'idle' | 'loading' | 'loaded' | 'error' -const statuses = new Map() +const statuses = new Map() const listeners = new Set<() => void>() const notify = () => { for (const l of listeners) l() } -export const getFontStatus = (font: CodeFont): FontStatus => +export const getFontStatus = (font: string): FontStatus => statuses.get(font) ?? 'idle' export const subscribeFontStatus = (cb: () => void): (() => void) => { @@ -196,7 +201,7 @@ const fetchBuffer = (variant: FontVariant): Promise => { } const loadVariant = async ( - font: CodeFont, + font: string, variant: FontVariant, ): Promise => { const buffer = await fetchBuffer(variant) @@ -226,17 +231,60 @@ const loadVariant = async ( } } -const loadPromises = new Map>>() +const loadPromises = new Map>>() + +// Custom fonts can be added, replaced, or removed at any time; invalidate +// any cached promise/status for non-built-in names so the next consumer +// re-reads from storage. +subscribeCustomFonts(() => { + for (const name of Array.from(loadPromises.keys())) { + if (!(name in REGISTRY)) { + loadPromises.delete(name) + statuses.delete(name) + } + } + notify() +}) + +const isBuiltIn = (font: string): font is CodeFont => font in REGISTRY + +const loadCustomVariants = async ( + font: string, +): Promise> => { + const entry = findCustomFont(font) + if (!entry) throw new Error(`Unknown custom font: ${font}`) + return Promise.all( + entry.variants.map(async (v) => { + const buffer = base64ToBytes(v.data) + if (typeof document !== 'undefined' && 'fonts' in document) { + try { + const face = new FontFace(font, buffer, { + weight: String(v.weight), + style: v.style, + }) + await face.load() + document.fonts.add(face) + } catch { + // Non-fatal: Satori path still works via the buffer. + } + } + return { name: font, data: buffer, weight: v.weight, style: v.style } + }), + ) +} export const loadCodeFont = async ( - font: CodeFont, + font: string, ): Promise> => { const existing = loadPromises.get(font) if (existing) return existing - const variants = REGISTRY[font] statuses.set(font, 'loading') notify() - const promise = Promise.all(variants.map((v) => loadVariant(font, v))) + const promise = ( + isBuiltIn(font) + ? Promise.all(REGISTRY[font].map((v) => loadVariant(font, v))) + : loadCustomVariants(font) + ) .then((result) => { statuses.set(font, 'loaded') notify() diff --git a/src/lib/types.ts b/src/lib/types.ts index c23b78e..3aae8b3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -235,7 +235,9 @@ export interface Settings { code: string language: Language theme: Theme - codeFont: CodeFont + // Built-in font name (member of CodeFont) or a user-uploaded custom + // font's name. Persisted in localStorage; registry key in fonts.ts. + codeFont: string width: number height: number autoHeight: boolean