Files
matrix-screen-controller/核桃派软件源代码/app/static/font-picker.js
T

304 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
});
}