初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user