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) { return 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 }) { let items = []; let selectedId = "default"; let visibleItems = []; let activeIndex = -1; let enabled = false; let closeTimer = null; 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 = "") { visibleItems = filterFontItems(items, query); const groups = groupFontItems(visibleItems); const nodes = []; let optionIndex = 0; for (const group of groups) { const heading = document.createElement("div"); heading.className = "font-option-group"; heading.textContent = group.label; heading.setAttribute("role", "presentation"); nodes.push(heading); for (const item of group.items) { 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"); option.append(label, source); option.addEventListener("pointerdown", (event) => event.preventDefault()); option.addEventListener("click", () => commit(item)); nodes.push(option); optionIndex += 1; } } if (!visibleItems.length) { const empty = document.createElement("div"); empty.className = "font-options-empty"; empty.textContent = "没有匹配的字体"; nodes.push(empty); } listbox.replaceChildren(...nodes); activeIndex = visibleItems.findIndex((item) => item.id === selectedId); if (activeIndex < 0 && visibleItems.length) activeIndex = 0; setActive(activeIndex); } function open(query = "") { if (!enabled) return; render(query); listbox.hidden = false; input.setAttribute("aria-expanded", "true"); } input.addEventListener("focus", () => { if (!enabled) 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(() => close(), 0); }); restoreLabel(); return Object.freeze({ setItems(nextItems) { items = normalizeFontItems(nextItems); restoreLabel(); if (!listbox.hidden) render(); }, 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, }); }