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 };