350 lines
12 KiB
JavaScript
350 lines
12 KiB
JavaScript
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,
|
||
});
|