304 lines
11 KiB
JavaScript
304 lines
11 KiB
JavaScript
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,
|
||
});
|
||
}
|