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

628 lines
25 KiB
JavaScript

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();