Add bundled bus fonts and shared font favorites

This commit is contained in:
2026-10-12 15:00:40 +08:00
parent c9dd03c23b
commit 4be74a34f9
181 changed files with 2078575 additions and 76 deletions
@@ -1,3 +1,5 @@
import { renderUiCopy, unregisterUiCopySubtree } from "./ui-copy-runtime.js";
const SOURCE_ORDER = Object.freeze(["automatic", "imported", "system"]);
const SOURCE_LABELS = Object.freeze({
automatic: "自动字体",
@@ -33,23 +35,29 @@ export function filterFontItems(items, query = "") {
));
}
export function groupFontItems(items) {
return SOURCE_ORDER
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);
.filter((group) => group.items.length)];
}
export function createFontPicker({ input, listbox, onCommit }) {
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;
@@ -101,17 +109,31 @@ export function createFontPicker({ input, listbox, onCommit }) {
}
function render(query = "") {
visibleItems = filterFontItems(items, query);
const groups = groupFontItems(visibleItems);
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";
heading.textContent = group.label;
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";
@@ -124,34 +146,96 @@ export function createFontPicker({ input, listbox, onCommit }) {
const source = document.createElement("small");
source.textContent = item.style;
source.setAttribute("aria-hidden", "true");
option.append(label, source);
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());
option.addEventListener("click", () => commit(item));
nodes.push(option);
row.addEventListener("pointerdown", event => { if (event.target !== star) event.preventDefault(); });
row.addEventListener("click", () => commit(item));
nodes.push(row);
optionIndex += 1;
}
}
if (!visibleItems.length) {
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) return;
if (!enabled || restoringFocus) return;
input.select();
open();
});
@@ -179,7 +263,15 @@ export function createFontPicker({ input, listbox, onCommit }) {
}
});
input.addEventListener("blur", () => {
closeTimer = window.setTimeout(() => close(), 0);
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();
@@ -187,7 +279,11 @@ export function createFontPicker({ input, listbox, onCommit }) {
setItems(nextItems) {
items = normalizeFontItems(nextItems);
restoreLabel();
if (!listbox.hidden) render();
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";