"use client"; import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties, } from "react"; import { List, type RowComponentProps } from "react-window"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { getCachedFontAtlas, loadFullFont, prefetchFontAtlas, clearFontAtlasCache, } from "@/lib/fonts/google-fonts"; import type { FontAtlas, FontAtlasEntry } from "@/types/fonts"; import { cn } from "@/utils/ui"; import { ChevronDown, Search, Upload } from "lucide-react"; import { HugeiconsIcon } from "@hugeicons/react"; import { TextFontIcon } from "@hugeicons/core-free-icons"; const FONT_TABS = [ { key: "all", label: "All fonts" }, { key: "favorites", label: "Favorites" }, { key: "my-fonts", label: "My fonts" }, ] as const; type FontTab = (typeof FONT_TABS)[number]["key"]; const ROW_HEIGHT = 40; const SPRITE_ROW_HEIGHT = 40; const PREVIEW_SCALE = 0.8; const LIST_WIDTH = 288; const MAX_LIST_HEIGHT = 288; const OVERSCAN = 15; interface FontPickerProps { defaultValue?: string; onValueChange?: (value: string) => void; className?: string; } export function FontPicker({ defaultValue, onValueChange, className, }: FontPickerProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [activeTab, setActiveTab] = useState("all"); const [atlas, setAtlas] = useState(() => getCachedFontAtlas(), ); const [status, setStatus] = useState<"idle" | "loading" | "error">(() => getCachedFontAtlas() ? "idle" : "loading", ); const searchInputRef = useRef(null); const fontNames = useMemo(() => { if (!atlas) return []; return Object.keys(atlas.fonts).sort(); }, [atlas]); const filteredFonts = useMemo(() => { if (!search) return fontNames; const query = search.toLowerCase(); return fontNames.filter((name) => name.toLowerCase().includes(query)); }, [fontNames, search]); const listHeight = Math.min( MAX_LIST_HEIGHT, filteredFonts.length * ROW_HEIGHT, ); const handleSelect = useCallback( async ({ family }: { family: string }) => { try { await loadFullFont({ family }); onValueChange?.(family); } catch { onValueChange?.(family); } setOpen(false); }, [onValueChange], ); // Load atlas on first open if cache is empty (fallback when prefetch hasn't completed) useEffect(() => { if (!open || atlas) return; setStatus("loading"); prefetchFontAtlas().then((data) => { if (data) { setAtlas(data); setStatus("idle"); } else { setStatus("error"); } }); }, [open, atlas]); useEffect(() => { if (!open) { setSearch(""); setActiveTab("all"); } }, [open]); const handleRetry = useCallback(() => { clearFontAtlasCache(); setStatus("loading"); prefetchFontAtlas().then((data) => { if (data) { setAtlas(data); setStatus("idle"); } else { setStatus("error"); } }); }, []); return (
{defaultValue ?? "Select a font"}
{ event.preventDefault(); searchInputRef.current?.focus(); }} onCloseAutoFocus={(event) => { event.preventDefault(); event.stopPropagation(); }} >
setSearch(event.target.value)} size="xs" className="w-full pl-5 bg-transparent !border-none !shadow-none" />
{FONT_TABS.map((tab) => ( ))}
{status === "loading" && (
Loading fonts...
)} {status === "error" && (

Failed to load font previews.

)} {status === "idle" && fontNames.length > 0 && filteredFonts.length === 0 && (
No fonts found.
)} {status === "idle" && atlas && filteredFonts.length > 0 && ( )}
); } function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) { return (
); } type FontRowProps = { atlas: FontAtlas; filteredFonts: string[]; selectedFont: string | undefined; onFontSelect: (params: { family: string }) => void; }; function FontRow({ index, style, atlas, filteredFonts, selectedFont, onFontSelect, }: RowComponentProps) { const fontName = filteredFonts[index]; const entry = atlas.fonts[fontName]; const isSelected = fontName === selectedFont; return ( ); }