"use client"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Check, ChevronDown, Search } from "lucide-react"; import { captionFonts, captionFont } from "@/lib/video-canvas/smart-edit/fonts"; import type { CaptionStyle } from "@/lib/video-canvas/smart-edit/contract"; const loaded = new Map>(); export function loadCaptionFont(id: string) { const font = captionFont(id); if (!font) return Promise.resolve(); if (!loaded.has(id)) { const face = new FontFace( font.family, `url(${font.url ? `/api/canvas/smart-edit/fonts/${font.id}` : `/fonts/smart-edit/${font.file}`})`, { weight: "400", }, ); loaded.set( id, face .load() .then((loadedFace) => { document.fonts.add(loadedFace); return loadedFace; }) .catch((error) => { loaded.delete(id); throw error; }), ); } return loaded.get(id)!; } export function CaptionFontPicker({ value, onChange, onError, }: { value: CaptionStyle["fontFamily"]; onChange: (id: NonNullable) => void; onError: (message: string) => void; }) { const [open, setOpen] = useState(false), [query, setQuery] = useState(""), [category, setCategory] = useState("全部"), [categoriesOpen, setCategoriesOpen] = useState(false), [busy, setBusy] = useState(false), [scrollTop, setScrollTop] = useState(0); const root = useRef(null); const [placement, setPlacement] = useState({ left: 0, top: 0, width: 300, height: 300 }); useLayoutEffect(() => { if (!open) return; const place = () => { const rect = root.current?.getBoundingClientRect(); if (!rect) return; const below = window.innerHeight - rect.bottom - 16; const above = rect.top - 16; const up = below < 394 && above > below; const height = Math.max(40, Math.min(300, (up ? above : below) - 94)); const width = Math.min(window.innerWidth - 16, rect.width + 32); setPlacement({ left: Math.max(8, Math.min(rect.left - 16, window.innerWidth - width - 8)), top: up ? Math.max(8, rect.top - height - 94 - 8) : rect.bottom + 8, width, height, }); }; place(); const scroll = (event: Event) => { if (!root.current?.contains(event.target as Node)) place(); }; window.addEventListener("resize", place); window.addEventListener("scroll", scroll, true); return () => { window.removeEventListener("resize", place); window.removeEventListener("scroll", scroll, true); }; }, [open]); useEffect(() => { const close = (e: globalThis.PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", close); return () => document.removeEventListener("pointerdown", close); }, []); const options = captionFonts.filter( (font) => font.name.toLowerCase().includes(query.toLowerCase()) && (category === "全部" || (category === "常用" && font.common) || (category === "推荐" && font.recommended) || (category === "Google" && font.google)), ); const first = Math.max(0, Math.floor(scrollTop / 40) - 4); useEffect(() => { setScrollTop(0); }, [query, category, open]); useEffect(() => { if (!open) return; let stopped = false, index = 0; const visible = options.slice(first, first + 18); const worker = async () => { while (!stopped && index < visible.length) { const font = visible[index++]; try { await loadCaptionFont(font.id); } catch { /* Selection reports any download failure. */ } } }; void worker(); void worker(); return () => { stopped = true; }; }, [open, first, query, category]); return (
{open && (
setQuery(e.target.value)} />
{categoriesOpen && (
{["全部", "常用", "推荐", "Google"].map((name) => ( ))}
)}
setScrollTop(e.currentTarget.scrollTop)} >
{options.slice(first, first + 18).map((font, index) => ( ))}
{!options.length &&
没有匹配的字体
} {busy &&
正在加载字体…
}
)}
); }