初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,207 @@
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,
});
}