import { announce, apiJson, registerWorkspace } from "../core.js"; import { animationEditState, enterTemplateEdit, getScene, markTemplateSaved, ordinarySceneNeedsTemplateSave, refreshCurrentDisplayPreview, templateEditState, } from "../scene-board.js"; import { sceneToStorageValue } from "../scene-model.js"; import { protectCardDragStart, reorderByKey } from "../sortable-card.js"; import { resolveAnimationEditBeforeNavigation } from "./animations.js"; import { refreshStatus } from "./device.js"; import { setControlBusy, setControlReady, setControlUnavailable } from "../interaction-feedback.js"; import { renderUiCopy } from "../ui-copy-runtime.js"; import { createAnimationPreviewGroup, createAnimationThumbnailBox, } from "../animation-thumbnail-preview.js"; const grid = document.getElementById("template-grid"); const feedback = document.getElementById("template-feedback"); const refreshButton = document.getElementById("refresh-templates"); const selectDefaultButton = document.getElementById("select-default-content"); const saveButton = document.getElementById("save-template"); const nameDialog = document.getElementById("template-name-dialog"); const nameDialogTitle = document.getElementById("template-dialog-title"); const nameDialogNote = document.getElementById("template-dialog-note"); const nameInput = document.getElementById("template-name-input"); const confirmDialog = document.getElementById("template-confirm-dialog"); const confirmMessage = document.getElementById("template-confirm-message"); const saveChoiceDialog = document.getElementById("template-save-choice-dialog"); const saveChoiceMessage = document.getElementById("template-save-choice-message"); const saveCurrentButton = document.getElementById("template-save-current"); const copyDialog = document.getElementById("library-copy-dialog"); const copyTarget = document.getElementById("library-copy-target"); let loading = false; let animationList = []; let activeAnimation = null; let libraryOrder = { items: [], revision: "" }; let draggingLibraryKey = null; let defaultContent = null; let selectingDefault = false; const demoRestrictedMessage = "演示案例不得编辑,请复制"; const libraryDragType = "application/x-matrix-library-item"; const templateAnimationPreviewGroup = createAnimationPreviewGroup(); function formatBytes(value) { const bytes = Number(value); if (!Number.isFinite(bytes) || bytes < 0) return "-"; if (bytes < 1024) return `${bytes} B`; const units = ["KiB", "MiB", "GiB", "TiB"]; let size = bytes / 1024; let unit = units[0]; for (let index = 1; index < units.length && size >= 1024; index += 1) { size /= 1024; unit = units[index]; } return `${size >= 10 ? size.toFixed(1) : size.toFixed(2)} ${unit}`; } function askName(title, initial = "", { note = "" } = {}) { nameDialogTitle.textContent = title; nameDialogNote.textContent = note; nameDialogNote.hidden = !note; nameInput.value = initial; nameInput.required = true; nameDialog.returnValue = ""; nameDialog.showModal(); window.setTimeout(() => { nameInput.focus(); nameInput.select(); }, 0); return new Promise((resolve) => { nameDialog.addEventListener("close", () => { resolve(nameDialog.returnValue === "save" ? nameInput.value.trim() : null); }, { once: true }); }); } function askConfirmation(message, { copyId = "", values = {} } = {}) { if (copyId) renderUiCopy(copyId, confirmMessage, message, values); else confirmMessage.textContent = message; confirmDialog.returnValue = ""; confirmDialog.showModal(); return new Promise((resolve) => { confirmDialog.addEventListener("close", () => { resolve(confirmDialog.returnValue === "confirm"); }, { once: true }); }); } function actionButton(label, action, className = "") { const button = document.createElement("button"); button.type = "button"; button.textContent = label; if (className) button.className = className; button.addEventListener("click", () => action(button)); return button; } function restrictDemoAction(control, message = demoRestrictedMessage) { control.classList.add("demo-restricted-action"); setControlUnavailable(control, message); return control; } function syncSaveButton() { if (!animationEditState() && templateEditState()) { renderUiCopy("copy.dynamic.template.save_bound", saveButton, "保存模板"); } else { renderUiCopy("copy.dynamic.template.save_new", saveButton, "保存为模板"); } } function askSaveChoice(context) { renderUiCopy( "copy.dynamic.template.save_choice", saveChoiceMessage, "请选择“{name}”的保存方式。", { name: context.name }, ); saveCurrentButton.classList.remove("demo-restricted-action"); setControlReady(saveCurrentButton); if (context.readOnly) { restrictDemoAction(saveCurrentButton, "演示案例不得覆盖,请另存为新模板"); } saveChoiceDialog.returnValue = ""; saveChoiceDialog.showModal(); return new Promise((resolve) => { saveChoiceDialog.addEventListener("close", () => { resolve(["current", "new"].includes(saveChoiceDialog.returnValue) ? saveChoiceDialog.returnValue : "cancel"); }, { once: true }); }); } async function confirmOrdinarySceneReplacement(name) { if (!ordinarySceneNeedsTemplateSave()) return true; return askConfirmation( "当前内容尚未保存为模板。继续编辑“{name}”后,当前内容将会丢失。是否继续?", { copyId: "copy.dynamic.template.unsaved_replace", values: { name }, }, ); } function setBusy(value) { loading = value; if (value) { setControlBusy(refreshButton); setControlBusy(selectDefaultButton); setControlBusy(saveButton); grid.setAttribute("aria-busy", "true"); } else { setControlReady(refreshButton); setControlReady(selectDefaultButton); setControlReady(saveButton); grid.removeAttribute("aria-busy"); } grid.toggleAttribute("inert", value); } function isDefaultContent(type, id) { return defaultContent?.type === type && defaultContent?.id === id; } function syncDefaultSelectionButton() { selectDefaultButton.setAttribute("aria-pressed", String(selectingDefault)); selectDefaultButton.classList.toggle("active", selectingDefault); selectDefaultButton.textContent = selectingDefault ? "取消设置默认内容" : "设置默认显示内容"; } async function chooseDefaultContent(type, item) { await mutate(async () => { defaultContent = await apiJson("/api/display/default-content", { method: "PUT", body: JSON.stringify({ type, id: item.id }), }); selectingDefault = false; syncDefaultSelectionButton(); await refreshStatus(); }, `已将“${item.name}”设为默认显示内容并立即显示`); } function decorateDefaultContent(card, type, item, { available = true } = {}) { if (isDefaultContent(type, item.id)) { card.classList.add("template-default-content"); const badge = document.createElement("span"); badge.className = "template-default-badge"; badge.textContent = "当前默认"; card.insertBefore(badge, card.querySelector(".template-card-content-actions")); } if (!selectingDefault) return card; const actions = document.createElement("div"); actions.className = "template-card-actions template-card-default-actions"; const choose = actionButton( isDefaultContent(type, item.id) ? "当前默认" : "设为默认", () => chooseDefaultContent(type, item), "primary-button", ); if (isDefaultContent(type, item.id)) { setControlUnavailable(choose, "这项已经是当前默认显示内容。"); } else if (!available) { setControlUnavailable(choose, "空动图不能设为默认,请先添加至少一帧。"); } actions.append(choose); card.append(actions); return card; } async function mutate(action, successMessage) { setBusy(true); feedback.textContent = "正在处理模板操作…"; feedback.dataset.state = "pending"; try { await action(); announce(successMessage); setBusy(false); await refreshTemplates(); } catch (error) { const conflict = error.code !== "APP_STALE" && (error.status === 409 || error.status === 428); feedback.textContent = conflict ? "模板已被其他页面修改,列表已刷新;请检查最新内容后重新操作。" : error.message; feedback.dataset.state = "error"; announce(feedback.textContent, true); if (conflict) { setBusy(false); await refreshTemplates(); } } finally { setBusy(false); } } function appendContentActions(card, { play, edit, copy, rename, remove }) { const actions = document.createElement("div"); actions.className = "template-card-actions template-card-content-actions"; remove.classList.add("template-card-delete-action"); actions.append(play, edit, copy, rename, remove); card.append(actions); } async function playStaticTemplate(template) { await mutate(async () => { await apiJson(`/api/templates/${template.id}/play`, { method: "POST", headers: { "If-Match": `"${template.revision}"` }, body: "{}", }); refreshCurrentDisplayPreview(); await refreshStatus(); }, `开始播放“${template.name}”`); } async function editStaticTemplate(template) { if (!await resolveAnimationEditBeforeNavigation()) return; if (!await confirmOrdinarySceneReplacement(template.name)) return; setBusy(true); feedback.textContent = `正在读取“${template.name}”…`; feedback.dataset.state = "pending"; try { const full = await apiJson(`/api/templates/${template.id}`); enterTemplateEdit(full); window.location.hash = "#canvas"; announce(`正在编辑模板“${template.name}”`); } catch (error) { feedback.textContent = `模板读取失败:${error.message}`; feedback.dataset.state = "error"; announce(feedback.textContent, true); } finally { setBusy(false); } } function renderCard(template) { const card = document.createElement("article"); card.className = "template-card"; const imageBox = document.createElement("div"); imageBox.className = "template-image-box"; const image = document.createElement("img"); image.className = "template-image"; image.src = template.thumbnail_url; image.alt = `${template.name} 模板缩略图`; image.width = 64; image.height = 64; image.draggable = false; image.loading = "lazy"; image.addEventListener("error", () => { image.hidden = true; imageBox.classList.add("template-image-error"); imageBox.setAttribute("aria-label", "缩略图加载失败"); }); imageBox.appendChild(image); const name = document.createElement("h3"); name.className = "template-card-name"; name.textContent = template.name; const badge = document.createElement("span"); badge.className = "content-type-badge"; badge.textContent = "静态"; const size = document.createElement("p"); size.className = "template-card-size"; size.textContent = `占用 ${formatBytes(template.size_bytes)}`; const play = actionButton("播放", () => playStaticTemplate(template), "primary-button"); const edit = actionButton("编辑", () => editStaticTemplate(template)); const copy = actionButton("复制", (control) => copyStaticTemplate(template, control)); const rename = actionButton("重命名", async () => { const nextName = await askName("重命名模板", template.name); if (!nextName || nextName === template.name) return; mutate(() => apiJson(`/api/templates/${template.id}`, { method: "PATCH", headers: { "If-Match": `"${template.revision}"` }, body: JSON.stringify({ name: nextName }), }), `模板已重命名为“${nextName}”`); }); const remove = actionButton("删除", async () => { if (!await askConfirmation(`永久删除模板“${template.name}”及其缩略图吗?`)) return; mutate(() => apiJson(`/api/templates/${template.id}`, { method: "DELETE", headers: { "If-Match": `"${template.revision}"` }, }), `模板“${template.name}”已删除`); }, "danger-button"); if (template.read_only) { restrictDemoAction(rename); restrictDemoAction(remove); } card.append(imageBox, badge, name, size); appendContentActions(card, { play, edit, copy, rename, remove }); return decorateDefaultContent(card, "template", template); } async function chooseCopyTarget() { const data = await apiJson("/api/animations"); copyTarget.replaceChildren(new Option("静态模板", "static"), ...data.animations.map((item) => new Option(`动图:${item.name}`, `animation:${item.id}:${item.revision}`))); copyDialog.returnValue = ""; copyDialog.showModal(); await new Promise((resolve) => copyDialog.addEventListener("close", resolve, { once: true })); if (copyDialog.returnValue !== "copy") return null; const [type, animationId, revision] = copyTarget.value.split(":"); return { type, animationId, revision }; } async function copyStaticTemplate(template, control) { setControlBusy(control, { label: "正在准备…", message: "正在读取可复制的目标…" }); try { const target = await chooseCopyTarget(); if (!target) return; await mutate(() => apiJson("/api/library/copy", { method: "POST", body: JSON.stringify({ source_type: "template", source_id: template.id, source_revision: template.revision, destination_type: target.type, destination_animation_id: target.animationId || null, destination_revision: target.revision || null, }) }), `模板“${template.name}”已复制`); } catch (error) { feedback.textContent = `复制目标读取失败:${error.message}`; feedback.dataset.state = "error"; announce(feedback.textContent, true); } finally { setControlReady(control); } } function renderAnimationCard(animation) { const card = document.createElement("article"); card.className = "template-card"; const imageBox = createAnimationThumbnailBox(templateAnimationPreviewGroup, animation); const badge = document.createElement("span"); badge.className = "content-type-badge content-type-animation"; badge.textContent = "动图"; const name = document.createElement("h3"); name.className = "template-card-name animation-folder-name"; name.textContent = animation.name; name.dataset.hoverTooltip = animation.name; const active = activeAnimation?.id === animation.id; const meta = document.createElement("p"); meta.className = "template-card-size"; meta.textContent = `${animation.frame_count} 帧 · ${(animation.total_duration_ms / 1000).toFixed(2)} 秒 · ${formatBytes(animation.size_bytes)}${active ? activeAnimation.revision === animation.revision ? " · 正在播放" : " · 正在播放旧版本" : ""}`; const play = actionButton("播放", () => mutate(async () => { await apiJson(`/api/animations/${animation.id}/play`, { method: "POST", headers: { "If-Match": `"${animation.revision}"` }, body: "{}", }); refreshCurrentDisplayPreview(); await refreshStatus(); }, `开始播放“${animation.name}”`), "primary-button"); if (!animation.frame_count) setControlUnavailable(play, "请先在该动图中添加至少一帧,再开始播放。"); const copy = actionButton("复制", () => mutate(() => apiJson(`/api/animations/${animation.id}/copy`, { method: "POST", body: "{}" }), `动图“${animation.name}”已复制`)); const edit = actionButton("编辑", () => { window.location.hash = "#animations"; document.dispatchEvent(new CustomEvent("matrix:open-animation", { detail: { id: animation.id } })); }); const rename = actionButton("重命名", async () => { const next = await askName("重命名动图", animation.name); if (next) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "PATCH", headers: { "If-Match": `"${animation.revision}"` }, body: JSON.stringify({ name: next }) }), "动图已重命名"); }); const remove = actionButton("删除", async () => { if (await askConfirmation(`永久删除动图“${animation.name}”及全部帧吗?`)) mutate(() => apiJson(`/api/animations/${animation.id}`, { method: "DELETE", headers: { "If-Match": `"${animation.revision}"` } }), "动图已删除"); }, "danger-button"); if (animation.read_only) { restrictDemoAction(edit); restrictDemoAction(rename); restrictDemoAction(remove); } card.append(imageBox, badge, name, meta); appendContentActions(card, { play, edit, copy, rename, remove }); return decorateDefaultContent(card, "animation", animation, { available: animation.frame_count > 0 }); } function libraryItemKey(item) { return `${item.type}:${item.id}`; } function clearLibraryDragIndicators() { grid.querySelectorAll(".library-drop-target").forEach((card) => card.classList.remove("library-drop-target")); } function saveLibraryOrder(keys) { const byKey = new Map(libraryOrder.items.map((item) => [libraryItemKey(item), item])); const items = keys.map((key) => byKey.get(key)).filter(Boolean); if (items.length !== libraryOrder.items.length) return; mutate(() => apiJson("/api/library/order", { method: "PUT", headers: { "If-Match": `"${libraryOrder.revision}"` }, body: JSON.stringify({ items }), }), "模板顺序已更新"); } function moveLibraryItem(index, offset) { const keys = libraryOrder.items.map(libraryItemKey); const target = index + offset; if (target < 0 || target >= keys.length) return; [keys[index], keys[target]] = [keys[target], keys[index]]; saveLibraryOrder(keys); } function prepareDemoCard(card) { card.classList.add("library-demo-fixed"); card.querySelectorAll("img").forEach((image) => { image.draggable = false; }); protectCardDragStart(card, { locked: true }); const orderActions = document.createElement("div"); orderActions.className = "template-card-actions template-card-order-actions"; const moveUp = restrictDemoAction( actionButton("上移", () => {}), "演示案例固定在最前,不能移动。", ); const moveDown = restrictDemoAction( actionButton("下移", () => {}), "演示案例固定在最前,不能移动。", ); orderActions.append(moveUp, moveDown); card.insertBefore(orderActions, card.querySelector(".template-card-default-actions")); return card; } function prepareSortableLibraryCard(card, item, index, total) { const key = libraryItemKey(item); card.classList.add("sortable-card", "library-sortable-card"); card.dataset.libraryKey = key; protectCardDragStart(card); const orderActions = document.createElement("div"); orderActions.className = "template-card-actions template-card-order-actions"; const moveUp = actionButton("上移", () => moveLibraryItem(index, -1)); const moveDown = actionButton("下移", () => moveLibraryItem(index, 1)); if (index === 0) setControlUnavailable(moveUp, "两个演示案例固定在最前,当前已经是第一项。"); if (index === total - 1) setControlUnavailable(moveDown, "当前已经是最后一项,不能继续下移。"); orderActions.append(moveUp, moveDown); card.insertBefore(orderActions, card.querySelector(".template-card-default-actions")); card.addEventListener("dragstart", (event) => { if (event.defaultPrevented) return; draggingLibraryKey = key; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData(libraryDragType, key); event.dataTransfer.setData("text/plain", key); }); card.addEventListener("dragover", (event) => { if (!draggingLibraryKey || draggingLibraryKey === key) return; event.preventDefault(); event.dataTransfer.dropEffect = "move"; clearLibraryDragIndicators(); card.classList.add("library-drop-target"); }); card.addEventListener("dragleave", (event) => { if (!card.contains(event.relatedTarget)) card.classList.remove("library-drop-target"); }); card.addEventListener("drop", (event) => { if (!draggingLibraryKey) return; event.preventDefault(); const source = event.dataTransfer.getData(libraryDragType) || draggingLibraryKey; const keys = libraryOrder.items.map(libraryItemKey); const reordered = reorderByKey(keys, source, key); clearLibraryDragIndicators(); if (reordered) saveLibraryOrder(reordered); }); card.addEventListener("dragend", () => { draggingLibraryKey = null; clearLibraryDragIndicators(); }); return card; } export async function refreshTemplates() { if (loading) return; setControlBusy(refreshButton, { label: "正在刷新…", message: "正在读取模板和动图…" }); feedback.textContent = "正在读取模板…"; feedback.dataset.state = "pending"; try { const [data, animations, order, selectedDefault] = await Promise.all([ apiJson("/api/templates?include_demo=true"), apiJson("/api/animations?include_demo=true"), apiJson("/api/library/order"), apiJson("/api/display/default-content"), ]); defaultContent = selectedDefault; animationList = animations.animations; libraryOrder = order; const demoCards = [ ...data.templates.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "static" })), ...animationList.filter((item) => item.is_demo).map((item) => ({ ...item, content_type: "animation" })), ].sort((left, right) => left.demo_order - right.demo_order); const userItems = [ ...data.templates.filter((item) => !item.is_demo).map((item) => ({ type: "template", id: item.id, value: item })), ...animationList.filter((item) => !item.is_demo).map((item) => ({ type: "animation", id: item.id, value: item })), ]; const userByKey = new Map(userItems.map((item) => [libraryItemKey(item), item])); const orderedUsers = order.items.map((item) => userByKey.get(libraryItemKey(item))).filter(Boolean); templateAnimationPreviewGroup.clear(); const renderedDemos = demoCards.map((item) => prepareDemoCard(item.content_type === "static" ? renderCard(item) : renderAnimationCard(item))); const renderedUsers = orderedUsers.map((item, index) => prepareSortableLibraryCard( item.type === "template" ? renderCard(item.value) : renderAnimationCard(item.value), item, index, orderedUsers.length, )); grid.replaceChildren(...renderedDemos, ...renderedUsers); if (!userItems.length) { const empty = document.createElement("p"); empty.className = "template-empty panel"; empty.textContent = "还没有模板。请在画图页保存当前统一画板。"; grid.appendChild(empty); } feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`; feedback.dataset.state = "ready"; announce(feedback.textContent); } catch (error) { feedback.textContent = `模板读取失败:${error.message}`; feedback.dataset.state = "error"; announce(feedback.textContent, true); } finally { setControlReady(refreshButton); } } async function saveSceneAsNew({ initial = "", note = "", bind = true } = {}) { const name = await askName("保存为模板", initial, { note }); if (!name) return; await mutate(async () => { const result = await apiJson("/api/templates", { method: "POST", body: JSON.stringify({ name, scene: sceneToStorageValue(getScene()) }), }); if (bind) markTemplateSaved(result); document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-save" } })); return result; }, `模板“${name}”已保存`); } async function saveSceneToCurrentTemplate(context) { await mutate(async () => { const result = await apiJson(`/api/templates/${context.id}`, { method: "PUT", headers: { "If-Match": `"${context.revision}"` }, body: JSON.stringify({ scene: sceneToStorageValue(getScene()) }), }); markTemplateSaved(result); document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "template-update" } })); return result; }, `模板“${context.name}”已保存`); } saveButton.addEventListener("click", async () => { if (animationEditState()) { await saveSceneAsNew({ note: "当前帧将被保存为一个静态图模板", bind: false, }); return; } const context = templateEditState(); if (!context) { await saveSceneAsNew(); return; } const choice = await askSaveChoice(context); if (choice === "current") { await saveSceneToCurrentTemplate(context); } else if (choice === "new") { await saveSceneAsNew({ initial: `${context.name} - 副本` }); } }); refreshButton.addEventListener("click", refreshTemplates); selectDefaultButton.addEventListener("click", async () => { if (loading) return; selectingDefault = !selectingDefault; syncDefaultSelectionButton(); announce(selectingDefault ? "请选择下方内容设为默认显示" : "已取消设置默认显示内容"); await refreshTemplates(); }); document.addEventListener("matrix:library-change", refreshTemplates); document.addEventListener("matrix:template-edit-change", syncSaveButton); document.addEventListener("matrix:animation-edit-change", syncSaveButton); document.addEventListener("matrix:status", (event) => { const state = event.detail?.state; activeAnimation = state?.animation_active ? { id: state.animation_id, revision: state.animation_revision } : null; }); registerWorkspace({ id: "templates", title: "模板管理", elementId: "workspace-templates", order: 60, persistence: "server", onEnter: async () => { templateAnimationPreviewGroup.setActive(true); await refreshTemplates(); }, onLeave: () => { templateAnimationPreviewGroup.setActive(false); templateAnimationPreviewGroup.clear(); }, }); syncDefaultSelectionButton(); syncSaveButton();