208 lines
6.4 KiB
JavaScript
208 lines
6.4 KiB
JavaScript
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,
|
|
});
|
|
}
|