Files
matrix-screen-controller/核桃派软件源代码/app/static/views/text.js
T

359 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
getSelectedLayer,
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 = (getSelectedLayer().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 = reason === "selection" || reason === "undo" || reason === "move-cancel" || reason === "layer-move" || 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 (["text-x", "text-y"].includes(event.target.id)) return;
if (suppressForm) return;
const selected = getSelectedTextElement();
if (!selected) return;
const patch = formPatch(selected);
delete patch.x;
delete patch.y;
updateTextElement(selected.id, patch, "form");
});
for (const id of ["text-x", "text-y"]) {
field(id).addEventListener("change", () => {
const selected = getSelectedTextElement();
if (selected) updateTextElement(selected.id, {
x: numericValue("text-x", selected.x), y: numericValue("text-y", selected.y),
}, "gesture");
});
field(id).addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); field(id).blur(); }
});
}
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 = getSelectedLayer().elements?.at(-1);
if (topmost) selectTextElement(topmost.id);
else applyElementToFields(null);
}
}
export { initializeTextWorkspace };