124 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|