227 lines
7.5 KiB
TypeScript

"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<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(${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<CaptionStyle["fontFamily"]>) => 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<HTMLDivElement>(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 (
<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"
style={{
position: "fixed",
left: placement.left,
top: placement.top,
width: placement.width,
right: "auto",
}}
>
<div className="se-search">
<Search size={14} />
<input
aria-label="搜索字体"
placeholder="搜索字体"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="se-font-categories">
<button
type="button"
aria-haspopup="menu"
aria-expanded={categoriesOpen}
onClick={() => setCategoriesOpen(!categoriesOpen)}
>
{category === "Google" ? "Google 字体" : `${category}字体`}
<ChevronDown size={12} />
</button>
{categoriesOpen && (
<div role="menu" aria-label="字体分类">
{["全部", "常用", "推荐", "Google"].map((name) => (
<button
type="button"
role="menuitemradio"
aria-checked={category === name}
key={name}
onClick={() => {
setCategory(name);
setCategoriesOpen(false);
}}
>
{name === "Google" ? "Google 字体" : `${name}字体`}
{category === name && <Check size={14} />}
</button>
))}
</div>
)}
</div>
<div
key={`${query}:${category}`}
role="listbox"
style={{ height: placement.height }}
aria-label={`字体库,共 ${options.length} 项`}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: options.length * 40, position: "relative" }}>
{options.slice(first, first + 18).map((font, index) => (
<button
style={{
position: "absolute",
top: (first + index) * 40,
fontFamily: `"${font.family}", "Source Han Sans SC", sans-serif`,
}}
aria-posinset={first + index + 1}
aria-setsize={options.length}
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>
</div>
{!options.length && <div className="se-muted">没有匹配的字体</div>}
{busy && <div role="status">正在加载字体…</div>}
</div>
)}
</div>
);
}