import { useEffect, useMemo, useRef, useState } from "react"; import { googleFontStylesheetUrl, POPULAR_GOOGLE_FONT_FAMILIES, renderAliasFor, } from "./fontCatalog"; import { fontFamilyFromAssetPath, importedFontFaceCss, type ImportedFontAsset } from "./fontAssets"; import { DEFAULT_FONT_FAMILIES, FIELD, GENERIC_FONT_FAMILIES, LABEL, localFontSortScore, sanitizeFontFilePart, sortFontOptions, uniqueFontFamilies, uniqueFontOptions, type FontOption, type LocalFontData, } from "./propertyPanelHelpers"; /* ------------------------------------------------------------------ */ /* Font helper functions */ /* ------------------------------------------------------------------ */ function splitFontFamilies(value: string): string[] { const families: string[] = []; let current = ""; let quote: '"' | "'" | null = null; for (const char of value) { if ((char === '"' || char === "'") && !quote) { quote = char; continue; } if (char === quote) { quote = null; continue; } if (char === "," && !quote) { if (current.trim()) families.push(current.trim()); current = ""; continue; } current += char; } if (current.trim()) families.push(current.trim()); return families.map((f) => f.replace(/^["']|["']$/g, "").trim()).filter(Boolean); } function primaryFontFamily(value: string): string { return splitFontFamilies(value)[0] ?? "inherit"; } function quoteFontFamily(family: string): string { const trimmed = family.trim(); if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed; return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } function buildFontFamilyValue(family: string): string { const trimmed = family.trim(); if (!trimmed) return "inherit"; if (GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed; return `${quoteFontFamily(trimmed)}, ui-sans-serif, system-ui, sans-serif`; } function collectDocumentFontFamilies(): string[] { if (typeof document === "undefined") return []; const fontSet = document.fonts; if (!fontSet) return []; return Array.from(fontSet, (ff) => ff.family.replace(/^["']|["']$/g, "").trim()) .filter(Boolean) .sort((a, b) => a.localeCompare(b)); } function fontSearchKey(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); } function fontMatchesQuery(family: string, query: string): boolean { const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) return true; if (family.toLowerCase().includes(normalizedQuery)) return true; return fontSearchKey(family).includes(fontSearchKey(normalizedQuery)); } function loadGoogleFontStylesheet(family: string): void { if (typeof document === "undefined") return; const trimmed = family.trim(); if (!trimmed) return; const id = `studio-google-font-${trimmed.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; if (document.getElementById(id)) return; const preconnect = document.querySelector('link[data-studio-google-font-preconnect="true"]'); if (!preconnect) { const el = document.createElement("link"); el.setAttribute("data-studio-google-font-preconnect", "true"); el.rel = "preconnect"; el.href = "https://fonts.gstatic.com"; el.crossOrigin = "anonymous"; document.head.appendChild(el); } const link = document.createElement("link"); link.id = id; link.rel = "stylesheet"; link.href = googleFontStylesheetUrl(trimmed); document.head.appendChild(link); } function loadImportedFontStylesheet(asset: ImportedFontAsset): void { if (typeof document === "undefined") return; const id = `studio-imported-font-${asset.family.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`; if (document.getElementById(id)) return; const style = document.createElement("style"); style.id = id; style.textContent = importedFontFaceCss(asset); document.head.appendChild(style); } /* ------------------------------------------------------------------ */ /* FontFamilyField */ /* ------------------------------------------------------------------ */ export function FontFamilyField({ value, disabled, importedFonts, onImportFonts, onCommit, }: { value: string; disabled?: boolean; importedFonts: ImportedFontAsset[]; onImportFonts?: (files: FileList | File[]) => Promise; onCommit: (nextValue: string) => void; }) { const currentFamily = primaryFontFamily(value); const containerRef = useRef(null); const inputRef = useRef(null); const fontInputRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [localFonts, setLocalFonts] = useState([]); const [localFontData, setLocalFontData] = useState([]); const [googleFonts, setGoogleFonts] = useState(() => [...POPULAR_GOOGLE_FONT_FAMILIES]); const [loadingLocalFonts, setLoadingLocalFonts] = useState(false); const [loadingGoogleFonts, setLoadingGoogleFonts] = useState(false); const [importingFonts, setImportingFonts] = useState(false); const [fontNotice, setFontNotice] = useState(null); const canQueryLocalFonts = typeof window !== "undefined" && typeof window.queryLocalFonts === "function"; useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { const target = event.target; if (!(target instanceof Node)) return; if (!containerRef.current?.contains(target)) setOpen(false); }; document.addEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown); }, [open]); useEffect(() => { if (!open) return; requestAnimationFrame(() => inputRef.current?.focus()); }, [open]); useEffect(() => { let cancelled = false; void fetch("/api/fonts") .then((r) => (r.ok ? r.json() : null)) .then((data: { fonts?: string[] } | null) => { if (cancelled || !Array.isArray(data?.fonts)) return; setLocalFonts((cur) => uniqueFontFamilies([...cur, ...data.fonts!])); }) .catch(() => undefined); return () => { cancelled = true; }; }, []); useEffect(() => { let cancelled = false; setLoadingGoogleFonts(true); void fetch("/api/fonts/google") .then((r) => (r.ok ? r.json() : null)) .then((data: { fonts?: string[] } | null) => { if (cancelled || !Array.isArray(data?.fonts)) return; setGoogleFonts(uniqueFontFamilies([...data.fonts!, ...POPULAR_GOOGLE_FONT_FAMILIES])); }) .catch(() => undefined) .finally(() => { if (!cancelled) setLoadingGoogleFonts(false); }); return () => { cancelled = true; }; }, []); useEffect(() => { if (googleFonts.some((f) => f.toLowerCase() === currentFamily.toLowerCase())) { loadGoogleFontStylesheet(currentFamily); } const imported = importedFonts.find( (f) => f.family.toLowerCase() === currentFamily.toLowerCase(), ); if (imported) loadImportedFontStylesheet(imported); }, [currentFamily, googleFonts, importedFonts]); const loadBrowserLocalFonts = async () => { if (!canQueryLocalFonts || !window.queryLocalFonts) { setFontNotice("This browser does not expose installed fonts. Import a font file instead."); return; } setLoadingLocalFonts(true); setFontNotice(null); try { const fonts = await window.queryLocalFonts(); const sorted = [...fonts].sort((a, b) => localFontSortScore(a) - localFontSortScore(b)); const families = sorted .map((f) => f.family) .filter((name): name is string => Boolean(name)) .map((name) => fontFamilyFromAssetPath(`${name}.ttf`)); setLocalFontData(sorted); setLocalFonts((cur) => uniqueFontFamilies([...cur, ...families])); setFontNotice(fonts.length === 0 ? "No browser-local fonts were returned." : null); } catch (error) { const name = error instanceof Error ? error.name : ""; setFontNotice( name === "NotAllowedError" ? "Local font access was denied. Import a font file instead." : "Local font access is unavailable. Import a font file instead.", ); } finally { setLoadingLocalFonts(false); } }; const handleImportFonts = async (files: FileList | File[] | null) => { if (!files?.length || !onImportFonts) return; setImportingFonts(true); setFontNotice(null); try { const imported = await onImportFonts(files); for (const font of imported) loadImportedFontStylesheet(font); const first = imported[0]; if (first) { onCommit(buildFontFamilyValue(first.family)); setQuery(""); setOpen(false); } else { setFontNotice("No supported font files were imported."); } } finally { setImportingFonts(false); } }; const projectFontAssets = useMemo( () => uniqueFontOptions( importedFonts.map((f): FontOption => ({ family: f.family, source: "Imported" })), ), [importedFonts], ); const options = useMemo(() => { const documentFonts = collectDocumentFontFamilies(); const googleSet = new Set(googleFonts.map((f) => f.toLowerCase())); const taggedLocal = localFonts.map( (family): FontOption => ({ family, source: googleSet.has(family.toLowerCase()) ? "Google" : "Local", }), ); return sortFontOptions( uniqueFontOptions([ { family: currentFamily, source: "Current" }, ...documentFonts.map((f): FontOption => ({ family: f, source: "Document" })), ...projectFontAssets, ...googleFonts.map((f): FontOption => ({ family: f, source: "Google" })), ...taggedLocal, ...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })), ]), ); }, [currentFamily, googleFonts, localFonts, projectFontAssets]); const filteredOptions = useMemo(() => { const matches = options.filter((o) => fontMatchesQuery(o.family, query)); if (query.trim()) return matches.slice(0, 200); const bySource = new Map(); for (const m of matches) { const list = bySource.get(m.source) ?? []; list.push(m); bySource.set(m.source, list); } const result: FontOption[] = []; for (const s of ["Current", "Document", "Imported"]) result.push(...(bySource.get(s) ?? [])); result.push(...(bySource.get("Google") ?? []).slice(0, 100)); result.push(...(bySource.get("Local") ?? []).slice(0, 80)); result.push(...(bySource.get("System") ?? [])); return result; }, [options, query]); const importLocalFont = async (family: string): Promise => { if (!onImportFonts) return null; const candidates = localFontData .filter((f) => fontFamilyFromAssetPath(`${f.family}.ttf`) === family) .sort((a, b) => localFontSortScore(a) - localFontSortScore(b)); const font = candidates.find((entry) => typeof entry.blob === "function"); if (!font?.blob) return null; const blob = await font.blob(); const style = sanitizeFontFilePart(font.style ?? "Regular") || "Regular"; const name = sanitizeFontFilePart(`${family} ${style}`) || family; const file = new File([blob], `${name}.ttf`, { type: blob.type || "font/ttf" }); const imported = await onImportFonts([file]); return ( imported.find((a) => a.family.toLowerCase() === family.toLowerCase()) ?? imported[0] ?? null ); }; const importSystemFont = async (family: string): Promise => { if (!onImportFonts) return null; const response = await fetch(`/api/fonts/file?family=${encodeURIComponent(family)}`); if (!response.ok) return null; const blob = await response.blob(); const ext = response.headers.get("Content-Disposition")?.match(/\.(\w+)"?$/)?.[1] ?? "ttf"; const file = new File([blob], `${family}.${ext}`, { type: blob.type || "font/ttf" }); const imported = await onImportFonts([file]); return ( imported.find((a) => a.family.toLowerCase() === family.toLowerCase()) ?? imported[0] ?? null ); }; const commitFamily = async (option: FontOption) => { const needsImport = option.source === "Local" || (option.source === "System" && !GENERIC_FONT_FAMILIES.has(option.family.toLowerCase())); if (needsImport) { setImportingFonts(true); setFontNotice(null); try { const imported = option.source === "Local" ? await importLocalFont(option.family) : await importSystemFont(option.family); if (imported) { loadImportedFontStylesheet(imported); onCommit(buildFontFamilyValue(imported.family)); setQuery(""); setOpen(false); return; } } finally { setImportingFonts(false); } } if (option.source === "Google") loadGoogleFontStylesheet(option.family); const imported = importedFonts.find( (f) => f.family.toLowerCase() === option.family.toLowerCase(), ); if (imported) loadImportedFontStylesheet(imported); onCommit(buildFontFamilyValue(option.family)); setQuery(""); setOpen(false); }; return (
Font family {open && (
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setOpen(false); } if (e.key === "Enter" && filteredOptions[0]) { e.preventDefault(); commitFamily(filteredOptions[0]); } }} className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600" /> {canQueryLocalFonts && ( )} { await handleImportFonts(event.target.files); event.target.value = ""; }} />
{fontNotice && (
{fontNotice}
)}
{filteredOptions.length === 0 ? (
No fonts found.
) : ( filteredOptions.map((option) => ( )) )}
)}
); }