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

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,349 @@
import { announce, apiFile, apiJson, registerWorkspace } from "../core.js";
import { refreshColorTrigger } from "../color-picker.js";
import { createFontPicker } from "../font-picker.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
import {
runControlAction,
setControlReady,
setControlUnavailable,
} from "../interaction-feedback.js";
import {
addTextElement,
deleteTextElement,
duplicateTextElement,
getScene,
getSelectedTextElement,
selectTextElement,
subscribeScene,
updateTextElement,
} from "../scene-board.js";
const form = document.getElementById("text-form");
const fields = document.getElementById("text-fields");
const contentFields = document.getElementById("text-content-fields");
const list = document.getElementById("text-element-list");
const selectionStatus = document.getElementById("text-selection-status");
const duplicateButton = document.getElementById("duplicate-text");
const deleteButton = document.getElementById("delete-text");
const colorTrigger = document.getElementById("text-color");
const fontInput = document.getElementById("text-font");
const fontOptions = document.getElementById("text-font-options");
const importFontButton = document.getElementById("import-font");
const fontFileInput = document.getElementById("font-file");
const fontFeedback = document.getElementById("font-feedback");
let initialized = false;
let suppressForm = false;
let renderedSelectionId = null;
let defaults = { font: "default", size: 12 };
let fontCatalogReady = false;
let fontMaxUploadBytes = 32 * 1024 * 1024;
let acceptedFontExtensions = [".ttf", ".otf", ".ttc", ".otc"];
const fontPicker = createFontPicker({
input: fontInput,
listbox: fontOptions,
onCommit(item) {
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, { font: item.id }, "form");
setFontFeedbackTemplate(
"copy.dynamic.text.font_selected",
"已选择字体“{name}”",
{ name: item.label },
);
},
});
function field(id) {
return document.getElementById(id);
}
function setFontFeedback(message = "", isError = false) {
fontFeedback.textContent = message;
fontFeedback.hidden = !message;
fontFeedback.dataset.state = isError ? "error" : "success";
}
function setFontFeedbackTemplate(copyId, defaultText, values = {}, isError = false) {
renderUiCopy(copyId, fontFeedback, defaultText, values);
fontFeedback.hidden = false;
fontFeedback.dataset.state = isError ? "error" : "success";
}
function updateFontPickerAvailability(element = getSelectedTextElement()) {
if (!fontCatalogReady) {
fontPicker.setEnabled(false);
setControlUnavailable(fontInput, "字体目录尚未加载完成。");
} else if (!element) {
fontPicker.setEnabled(false);
setControlUnavailable(fontInput, "请先新建或选择一个文字元素;字体导入仍可使用。");
} else {
setControlReady(fontInput);
fontPicker.setEnabled(true);
}
}
function applyElementToFields(element) {
suppressForm = true;
if (element) {
field("text-value").value = element.text;
fontPicker.setValue(element.font);
field("text-size").value = String(element.size);
field("text-x").value = String(element.x);
field("text-y").value = String(element.y);
field("text-align").value = element.align;
colorTrigger.value = element.color;
} else {
field("text-value").value = "";
fontPicker.setValue(defaults.font);
field("text-size").value = String(defaults.size);
field("text-x").value = "32";
field("text-y").value = "26";
field("text-align").value = "center";
colorTrigger.value = "#FFFFFF";
}
refreshColorTrigger(colorTrigger);
contentFields.toggleAttribute("disabled", !element);
fields.toggleAttribute("disabled", !element);
updateFontPickerAvailability(element);
renderedSelectionId = element?.id || null;
suppressForm = false;
}
function displayName(element, index) {
const text = element.text.trim() || "(空文字)";
return `${index + 1}. ${text.replace(/\s+/g, " ").slice(0, 28)}`;
}
function renderElementList(scene, selectedId) {
const items = scene.elements.map((element, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "text-element-item";
button.textContent = displayName(element, index);
button.title = `${element.text || "空文字"} · ${element.size}px · ${element.color}`;
const selected = element.id === selectedId;
button.classList.toggle("active", selected);
button.setAttribute("aria-pressed", String(selected));
button.addEventListener("click", () => selectTextElement(element.id));
return button;
});
list.replaceChildren(...items);
}
function renderSelection({ scene, selectedTextId, reason }) {
const selected = scene.elements.find((element) => element.id === selectedTextId) || null;
renderElementList(scene, selectedTextId);
if (selected) {
setControlReady(duplicateButton);
setControlReady(deleteButton);
} else {
setControlUnavailable(duplicateButton, "请先新建或选择一个文字元素。");
setControlUnavailable(deleteButton, "请先新建或选择一个文字元素。");
}
selectionStatus.textContent = selected
? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放`
: "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。";
const shouldRefreshFields = renderedSelectionId !== selectedTextId
|| ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason);
if (shouldRefreshFields) applyElementToFields(selected);
}
function numericValue(id, fallback) {
const raw = field(id).value.trim();
const value = Number(raw);
return raw && Number.isFinite(value) ? value : fallback;
}
function formPatch(element) {
return {
text: field("text-value").value,
font: fontPicker.getValue() || "default",
size: numericValue("text-size", element.size),
x: numericValue("text-x", element.x),
y: numericValue("text-y", element.y),
align: field("text-align").value,
color: colorTrigger.value,
};
}
form.addEventListener("input", (event) => {
if (event.target === fontInput || event.target === fontFileInput) return;
if (suppressForm) return;
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, formPatch(selected), "form");
});
document.getElementById("create-text").addEventListener("click", () => {
const created = addTextElement({
text: "ok",
font: defaults.font,
size: defaults.size,
x: 32,
y: 26,
align: "center",
color: "#FFFFFF",
});
announce(`已创建文字“${created.text}”,编辑完成后再应用统一画板`);
});
duplicateButton.addEventListener("click", () => {
const duplicate = duplicateTextElement();
if (duplicate) announce(`已复制文字“${duplicate.text}”并置于最上层`);
});
deleteButton.addEventListener("click", () => {
const selected = getSelectedTextElement();
if (selected && deleteTextElement(selected.id)) announce(`已删除文字“${selected.text || "空文字"}”`);
});
subscribeScene(renderSelection);
async function loadFontCatalog() {
const catalog = await apiJson("/api/fonts");
fontPicker.setItems(catalog.items);
acceptedFontExtensions = Array.isArray(catalog.accepted_extensions)
? catalog.accepted_extensions.map((value) => String(value).toLowerCase())
: acceptedFontExtensions;
fontMaxUploadBytes = Number(catalog.max_upload_bytes) || fontMaxUploadBytes;
fontCatalogReady = true;
updateFontPickerAvailability();
if (catalog.warnings?.length) {
setFontFeedback(catalog.warnings.join(";"), true);
}
return catalog;
}
function validateFontFile(file) {
const extension = file.name.includes(".")
? `.${file.name.split(".").at(-1).toLowerCase()}`
: "";
if (!acceptedFontExtensions.includes(extension)) {
return "type";
}
if (file.size > fontMaxUploadBytes) {
return "size";
}
return "";
}
function describeFontImportError(error) {
if (error?.code === "APP_STALE") {
return ["copy.dynamic.text.font_error_stale", "页面版本已过期,请刷新后再导入字体。"];
}
if (error?.code === "REQUEST_TIMEOUT") {
return ["copy.dynamic.text.font_error_timeout", "字体导入超时,请检查核桃派网络或服务状态后重试。"];
}
const messages = {
413: ["copy.dynamic.text.font_error_too_large", "字体导入失败:文件超过 32 MiB。"],
415: ["copy.dynamic.text.font_error_type", "字体导入失败:只支持 TTF、OTF、TTC、OTC 字体文件。"],
422: ["copy.dynamic.text.font_error_invalid", "字体导入失败:文件损坏或不包含可用字体。"],
507: ["copy.dynamic.text.font_error_storage", "字体导入失败:设备存储空间不足。"],
};
if (messages[error?.status]) return messages[error.status];
if (error instanceof TypeError) {
return ["copy.dynamic.text.font_error_network", "字体导入失败:无法连接设备,请检查网络后重试。"];
}
return ["copy.dynamic.text.font_error_unknown", "字体导入失败:服务器未能完成导入,请稍后重试。"];
}
importFontButton.addEventListener("click", () => fontFileInput.click());
fontFileInput.addEventListener("change", async () => {
const file = fontFileInput.files?.[0];
fontFileInput.value = "";
if (!file) return;
const validationError = validateFontFile(file);
if (validationError) {
const copy = validationError === "type"
? ["copy.dynamic.text.font_type_error", "只支持 TTF、OTF、TTC、OTC 字体文件。"]
: ["copy.dynamic.text.font_size_error", "字体文件不能超过 32 MiB。"];
setFontFeedbackTemplate(copy[0], copy[1], {}, true);
announce(fontFeedback.textContent, true);
return;
}
try {
setFontFeedbackTemplate(
"copy.dynamic.text.font_importing",
"正在导入字体“{name}”…",
{ name: file.name },
);
announce(fontFeedback.textContent);
const result = await runControlAction(
importFontButton,
{ label: "正在导入…" },
async () => apiFile(
`/api/fonts/import?filename=${encodeURIComponent(file.name)}`,
file,
{ method: "POST" },
),
);
await loadFontCatalog();
const selected = getSelectedTextElement();
const imported = result.fonts?.[0] || null;
if (selected && imported) {
fontPicker.setValue(imported.id);
updateTextElement(selected.id, { font: imported.id }, "form");
}
const name = imported?.label || file.name;
const feedback = result.created
? (selected
? ["copy.dynamic.text.font_imported_applied", "字体“{name}”已导入并应用到当前文字"]
: ["copy.dynamic.text.font_imported", "字体“{name}”已导入"])
: (selected
? ["copy.dynamic.text.font_exists_applied", "字体“{name}”已经存在,已应用到当前文字"]
: ["copy.dynamic.text.font_exists", "字体“{name}”已经存在"]);
setFontFeedbackTemplate(feedback[0], feedback[1], { name });
announce(fontFeedback.textContent);
} catch (error) {
const feedback = describeFontImportError(error);
setFontFeedbackTemplate(feedback[0], feedback[1], {}, true);
announce(fontFeedback.textContent, true);
}
});
async function initializeTextWorkspace() {
if (!initialized) {
initialized = true;
const [configResult, fontsResult] = await Promise.allSettled([
apiJson("/api/config"),
loadFontCatalog(),
]);
if (configResult.status === "fulfilled") {
const config = configResult.value;
defaults = {
font: config.default_font || "default",
size: Number(config.default_text_size) || 12,
};
} else {
announce(`默认文字设置读取失败,已使用内置默认值:${configResult.reason.message}`, true);
}
if (fontsResult.status === "rejected") {
fontCatalogReady = false;
updateFontPickerAvailability();
setFontFeedbackTemplate(
"copy.dynamic.text.font_catalog_failed",
"字体目录读取失败,当前字体值已保留:{error}",
{ error: fontsResult.reason.message },
true,
);
announce(fontFeedback.textContent, true);
}
}
if (!getSelectedTextElement()) {
const topmost = getScene().elements.at(-1);
if (topmost) selectTextElement(topmost.id);
else applyElementToFields(null);
}
}
registerWorkspace({
id: "text",
title: "文字显示",
elementId: "workspace-text",
order: 40,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter: initializeTextWorkspace,
});