227 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|