import { renderUiCopy, unregisterUiCopySubtree } from "./ui-copy-runtime.js"; const SOURCE_ORDER = Object.freeze(["automatic", "imported", "system"]); const SOURCE_LABELS = Object.freeze({ automatic: "自动字体", imported: "已导入字体", system: "系统字体", }); export function normalizeFontItems(items) { const seen = new Set(); const normalized = []; for (const raw of Array.isArray(items) ? items : []) { if (!raw || typeof raw.id !== "string" || !raw.id || seen.has(raw.id)) continue; if (!SOURCE_ORDER.includes(raw.source)) continue; seen.add(raw.id); const family = String(raw.family || "未命名字体").trim() || "未命名字体"; const style = String(raw.style || "Regular").trim() || "Regular"; normalized.push(Object.freeze({ id: raw.id, label: String(raw.label || family).trim() || family, family, style, source: raw.source, })); } return normalized; } export function filterFontItems(items, query = "") { const needle = String(query).trim().toLocaleLowerCase("zh-CN"); if (!needle) return [...items]; return items.filter((item) => ( `${item.label} ${item.family} ${item.style}`.toLocaleLowerCase("zh-CN").includes(needle) )); } export function groupFontItems(items, favoriteIds = []) { const favorites = new Set(favoriteIds); return [{ source: "favorites", label: "收藏字体", items: items.filter(item => favorites.has(item.id)) }, ...SOURCE_ORDER .map((source) => ({ source, label: SOURCE_LABELS[source], items: items.filter((item) => item.source === source), })) .filter((group) => group.items.length)]; } export function createFontPicker({ input, listbox, onCommit, onFavorite, onOpen }) { let items = []; let selectedId = "default"; let visibleItems = []; let activeIndex = -1; let enabled = false; let closeTimer = null; let favoriteIds = []; let queryText = ""; let favoriteBusy = false; let dialogOpen = false; let restoringFocus = false; function selectedItem() { return items.find((item) => item.id === selectedId) || null; } function selectedLabel() { return selectedItem()?.label || "当前字体不可用,将自动回退"; } function restoreLabel() { input.value = selectedLabel(); input.classList.toggle("font-reference-missing", !selectedItem()); } function close({ restore = true } = {}) { window.clearTimeout(closeTimer); listbox.hidden = true; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); activeIndex = -1; if (restore) restoreLabel(); } function optionId(index) { return `${listbox.id}-option-${index}`; } function setActive(index) { if (!visibleItems.length) { activeIndex = -1; input.removeAttribute("aria-activedescendant"); return; } activeIndex = Math.max(0, Math.min(index, visibleItems.length - 1)); input.setAttribute("aria-activedescendant", optionId(activeIndex)); listbox.querySelectorAll('[role="option"]').forEach((option, optionIndex) => { const active = optionIndex === activeIndex; option.classList.toggle("active", active); if (active) option.scrollIntoView({ block: "nearest" }); }); } function commit(item) { if (!item) return; selectedId = item.id; restoreLabel(); close({ restore: false }); onCommit?.(item); } function render(query = "") { const bounds = input.getBoundingClientRect(); listbox.style.width = `${Math.max(bounds.width, Math.min(440, window.innerWidth - bounds.left - 16))}px`; queryText = query; const filtered = filterFontItems(items, query); const groups = groupFontItems(filtered, favoriteIds); visibleItems = groups.flatMap(group => group.items); const nodes = []; let optionIndex = 0; for (const group of groups) { const heading = document.createElement("div"); heading.className = "font-option-group"; if (group.source === "favorites") renderUiCopy("copy.dynamic.font.favorites", heading, "收藏字体"); else heading.textContent = group.label; heading.setAttribute("role", "presentation"); nodes.push(heading); if (group.source === "favorites" && !group.items.length) { const empty = document.createElement("div"); empty.className = "font-options-empty"; renderUiCopy(query.trim() ? "copy.dynamic.font.no_matches" : "copy.dynamic.font.empty", empty, query.trim() ? "没有匹配的收藏字体" : "暂无收藏字体"); nodes.push(empty); } for (const item of group.items) { const row = document.createElement("div"); row.className = "font-option-row"; row.setAttribute("role", "presentation"); const option = document.createElement("div"); option.id = optionId(optionIndex); option.className = "font-option"; option.setAttribute("role", "option"); option.setAttribute("aria-selected", String(item.id === selectedId)); option.setAttribute("aria-label", item.label); option.dataset.fontId = item.id; const label = document.createElement("span"); label.textContent = item.family; const source = document.createElement("small"); source.textContent = item.style; source.setAttribute("aria-hidden", "true"); const star = document.createElement("button"); star.type = "button"; star.className = "font-favorite-button"; const favorite = favoriteIds.includes(item.id); star.textContent = favorite ? "★" : "☆"; star.setAttribute("aria-label", `${favorite ? "取消收藏" : "收藏"}字体“${item.label}”`); star.setAttribute("aria-pressed", String(favorite)); star.disabled = favoriteBusy; star.addEventListener("pointerdown", event => event.stopPropagation()); star.addEventListener("click", event => { event.stopPropagation(); toggleFavorite(item, favorite); }); option.append(label); row.append(option, star, source); option.addEventListener("pointerdown", (event) => event.preventDefault()); row.addEventListener("pointerdown", event => { if (event.target !== star) event.preventDefault(); }); row.addEventListener("click", () => commit(item)); nodes.push(row); optionIndex += 1; } } if (!filtered.length) { const empty = document.createElement("div"); empty.className = "font-options-empty"; empty.textContent = "没有匹配的字体"; nodes.push(empty); } unregisterUiCopySubtree(listbox); listbox.replaceChildren(...nodes); activeIndex = visibleItems.findIndex((item) => item.id === selectedId); if (activeIndex < 0 && visibleItems.length) activeIndex = 0; setActive(activeIndex); } async function toggleFavorite(item, favorite) { if (favoriteBusy || dialogOpen) return; if (favorite) { dialogOpen = true; const dialog = document.createElement("dialog"); dialog.className = "font-favorite-dialog"; dialog.setAttribute("aria-label", "确认取消收藏"); const message = document.createElement("p"); renderUiCopy("copy.dynamic.font.confirm", message, "确认取消收藏字体“{name}/{style}”?", { name: item.family, style: item.style }); dialog.append(message); for (const [value, title] of [["cancel", "取消"], ["confirm", "取消收藏"]]) { const button = document.createElement("button"); button.type = "button"; renderUiCopy(value === "cancel" ? "copy.dynamic.font.cancel" : "copy.dynamic.font.remove", button, title); button.addEventListener("click", () => dialog.close(value)); dialog.append(button); } document.body.append(dialog); const result = new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true })); dialog.showModal(); const confirmed = await result; unregisterUiCopySubtree(dialog); dialog.remove(); dialogOpen = false; if (!confirmed) { restoreInputFocus(); return; } } favoriteBusy = true; render(queryText); try { const result = await onFavorite?.(item, !favorite); if (Array.isArray(result)) favoriteIds = [...result]; } finally { favoriteBusy = false; render(queryText); restoreInputFocus(); } } function restoreInputFocus() { restoringFocus = true; input.focus({ preventScroll: true }); restoringFocus = false; } function open(query = "") { if (!enabled) return; const wasClosed = listbox.hidden; render(query); listbox.hidden = false; input.setAttribute("aria-expanded", "true"); if (wasClosed) onOpen?.(); } input.addEventListener("focus", () => { if (!enabled || restoringFocus) return; input.select(); open(); }); input.addEventListener("click", () => open()); input.addEventListener("input", (event) => { event.stopPropagation(); open(input.value); }); input.addEventListener("keydown", (event) => { if (!enabled) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); if (listbox.hidden) open(); setActive(activeIndex + (event.key === "ArrowDown" ? 1 : -1)); } else if (event.key === "Home" || event.key === "End") { event.preventDefault(); if (listbox.hidden) open(); setActive(event.key === "Home" ? 0 : visibleItems.length - 1); } else if (event.key === "Enter" && !listbox.hidden) { event.preventDefault(); commit(visibleItems[activeIndex]); } else if (event.key === "Escape") { event.preventDefault(); close(); } }); input.addEventListener("blur", () => { closeTimer = window.setTimeout(() => { if (!dialogOpen && !favoriteBusy && !listbox.contains(document.activeElement)) close(); }, 0); }); listbox.addEventListener("focusout", () => { closeTimer = window.setTimeout(() => { if (!dialogOpen && !favoriteBusy && document.activeElement !== input && !listbox.contains(document.activeElement)) close(); }, 0); }); restoreLabel(); return Object.freeze({ setItems(nextItems) { items = normalizeFontItems(nextItems); restoreLabel(); if (!listbox.hidden) render(queryText); }, setFavorites(ids) { favoriteIds = Array.isArray(ids) ? [...ids] : []; if (!listbox.hidden && !favoriteBusy && !dialogOpen) render(queryText); }, setValue(identifier) { selectedId = typeof identifier === "string" && identifier ? identifier : "default"; restoreLabel(); }, getValue() { return selectedId; }, setEnabled(value) { enabled = Boolean(value); input.readOnly = !enabled; if (!enabled) close(); }, open, close, }); }