初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user