124 lines
3.9 KiB
TypeScript

"use client";
import { useEffect, 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<string, Promise<FontFace>>();
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(/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<CaptionStyle["fontFamily"]>) => void;
onError: (message: string) => void;
}) {
const [open, setOpen] = useState(false),
[query, setQuery] = useState(""),
[category, setCategory] = useState("全部"),
[busy, setBusy] = useState(false);
const root = useRef<HTMLDivElement>(null);
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)),
);
return (
<div className="se-font-picker" ref={root}>
<button
className="se-font"
aria-label="字幕字体"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{captionFont(value)?.name || "默认字体"}
<ChevronDown size={14} />
</button>
{open && (
<div className="se-font-menu">
<div className="se-search">
<input
aria-label="搜索字体"
placeholder="搜索字体"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<Search size={14} />
</div>
<div className="se-font-categories">
{["全部", "常用", "推荐", "Google"].map((name) => (
<button
key={name}
aria-pressed={category === name}
onClick={() => setCategory(name)}
>
{name}
</button>
))}
</div>
<div role="listbox" aria-label="字体库">
{options.map((font) => (
<button
role="option"
aria-selected={value === font.id}
key={font.id}
disabled={busy}
onClick={async () => {
setBusy(true);
try {
await loadCaptionFont(font.id);
onChange(font.id);
setOpen(false);
} catch {
onError("字体加载失败,请检查网络后重试");
} finally {
setBusy(false);
}
}}
>
{font.name}
{value === font.id && <Check size={14} />}
</button>
))}
</div>
{!options.length && <div className="se-muted">没有匹配的字体</div>}
{busy && <div role="status">正在加载字体…</div>}
</div>
)}
</div>
);
}