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

1048 lines
43 KiB
JavaScript

import { announce, apiJson, registerWorkspace } from "../core.js";
import {
animationEditState,
enterAnimationEdit,
getScene,
leaveAnimationEdit,
markAnimationFrameSaved,
registerSceneReplacementGuard,
syncAnimationEditMetadata,
} from "../scene-board.js";
import { sceneToStorageValue } from "../scene-model.js";
import {
animationEditContextName,
effectiveFrameName,
formatFrameDuration,
reorderSelectedFrameIds,
validateFrameDuration,
} from "../animation-ui-model.js";
import {
createAnimationPreviewGroup,
createAnimationThumbnailBox,
} from "../animation-thumbnail-preview.js";
import { protectCardDragStart } from "../sortable-card.js";
import { renderUiCopy } from "../ui-copy-runtime.js";
import { refreshStatus } from "./device.js";
import {
setControlBusy,
setControlReady,
setControlUnavailable,
} from "../interaction-feedback.js";
const folders = document.getElementById("animation-folders");
const frames = document.getElementById("animation-frames");
const framePanel = document.getElementById("animation-frame-panel");
const frameTitle = document.getElementById("animation-frame-title");
const feedback = document.getElementById("animation-feedback");
const createButton = document.getElementById("create-animation");
const addFrameButton = document.getElementById("add-animation-frame");
const refreshButton = document.getElementById("refresh-animations");
const previewImage = document.getElementById("animation-preview-image");
const previewPlaceholder = document.getElementById("animation-preview-placeholder");
const previewStatus = document.getElementById("animation-preview-status");
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 dirtyDialog = document.getElementById("animation-dirty-dialog");
const dirtyMessage = document.getElementById("animation-dirty-message");
const copyDialog = document.getElementById("library-copy-dialog");
const copyTarget = document.getElementById("library-copy-target");
const boardShell = document.getElementById("board-shell");
const selectionStatus = document.getElementById("animation-selection-status");
const batchCopyButton = document.getElementById("animation-batch-copy");
const batchMoveButton = document.getElementById("animation-batch-move");
const batchDeleteButton = document.getElementById("animation-batch-delete");
const operationDialog = document.getElementById("animation-operation-dialog");
const operationTitle = document.getElementById("animation-operation-title");
const operationMessage = document.getElementById("animation-operation-message");
const operationConfirm = document.getElementById("animation-operation-confirm");
const copyDestinationDialog = document.getElementById("animation-copy-destination-dialog");
const copyDestination = document.getElementById("animation-copy-destination");
const positionDialog = document.getElementById("animation-position-dialog");
const positionTitle = document.getElementById("animation-position-title");
const positionHint = document.getElementById("animation-position-hint");
const positionTarget = document.getElementById("animation-position-target");
const framePagination = document.getElementById("animation-frame-pagination");
const pagePrevious = document.getElementById("animation-page-prev");
const pageNext = document.getElementById("animation-page-next");
const pageStatus = document.getElementById("animation-page-status");
const SELECTION_LOCK_MESSAGE = "请先完成批量操作或取消全部勾选。";
const NO_SELECTION_MESSAGE = "请先勾选至少一帧。";
const FRAME_PAGE_SIZE = 100;
let list = [];
let current = null;
let busy = false;
let workspaceActive = false;
let frameOffset = 0;
const selectedFrameIds = new Set();
const durationUnits = new Map();
const folderPreviewGroup = createAnimationPreviewGroup();
const currentPreviewGroup = createAnimationPreviewGroup();
function askName(title, initial = "", { allowEmpty = false } = {}) {
nameDialogTitle.textContent = title;
nameDialogNote.textContent = "";
nameDialogNote.hidden = true;
nameInput.value = initial;
nameInput.required = !allowEmpty;
nameDialog.returnValue = "";
nameDialog.showModal();
setTimeout(() => { nameInput.focus(); nameInput.select(); }, 0);
return new Promise((resolve) => nameDialog.addEventListener("close", () => {
resolve(nameDialog.returnValue === "save" ? nameInput.value.trim() : null);
}, { once: true }));
}
function dirtyChoice() {
const state = animationEditState();
const displayName = state
? animationEditContextName(state.animationName, { name: state.frameName }, state.position)
: "当前动图帧";
dirtyMessage.textContent = `“${displayName}”有未保存修改。是否应用当前修改到动图?`;
dirtyDialog.returnValue = "";
dirtyDialog.showModal();
return new Promise((resolve) => dirtyDialog.addEventListener("close", () => {
resolve(dirtyDialog.returnValue || "cancel");
}, { once: true }));
}
function confirmAnimationOperation(title, message, confirmLabel = "确认") {
operationTitle.textContent = title;
operationMessage.textContent = message;
operationConfirm.textContent = confirmLabel;
operationDialog.returnValue = "";
operationDialog.showModal();
return new Promise((resolve) => operationDialog.addEventListener("close", () => {
resolve(operationDialog.returnValue === "confirm");
}, { once: true }));
}
async function chooseCopyDestination(animations) {
copyDestination.replaceChildren(...animations.map((animation) => (
new Option(animation.name, animation.id)
)));
if (!animations.length) return null;
copyDestinationDialog.returnValue = "";
copyDestinationDialog.showModal();
await new Promise((resolve) => copyDestinationDialog.addEventListener("close", resolve, { once: true }));
if (copyDestinationDialog.returnValue !== "next") return null;
return animations.find((animation) => animation.id === copyDestination.value) || null;
}
async function chooseInsertionPosition(
animation,
{ disabledIds = new Set(), title = "选择插入位置", copyMode = false } = {},
) {
positionTitle.textContent = title;
positionHint.textContent = copyMode
? "选择目标帧后,复制的帧将按当前顺序插入到该帧之后;选择“插入到最前”则置于第一帧前。"
: "选择目标帧后,所选帧将按当前顺序插入到该帧之后;选择“插入到最前”则置于第一帧前。";
const options = [new Option("插入到最前", "")];
animation.frames.forEach((frame, index) => {
const disabled = disabledIds.has(frame.id);
const position = frame.position ?? index + 1;
const label = `第 ${position} 帧 · ${effectiveFrameName(animation.name, frame, position)}`;
const option = new Option(disabled ? `${label}(已选择,不可作为移动目标)` : label, frame.id);
if (disabled) {
option.setAttribute("disabled", "");
option.className = "animation-position-disabled";
}
options.push(option);
});
positionTarget.replaceChildren(...options);
positionDialog.returnValue = "";
positionDialog.showModal();
await new Promise((resolve) => positionDialog.addEventListener("close", resolve, { once: true }));
if (positionDialog.returnValue !== "confirm") return undefined;
return positionTarget.value || null;
}
async function resolveDirty() {
const state = animationEditState();
if (!state?.dirty) return true;
const choice = await dirtyChoice();
if (choice === "cancel") return false;
if (choice === "save") return saveCurrentFrame();
return true;
}
export async function resolveAnimationEditBeforeNavigation() {
if (!animationEditState()) return true;
if (!await resolveDirty()) return false;
leaveAnimationEdit();
return true;
}
function selectionMode() {
return selectedFrameIds.size > 0;
}
function frameTotal(animation = current) {
return animation?.frame_total ?? animation?.frame_count ?? 0;
}
function usesPartialFramePage(animation = current) {
return Boolean(animation && frameTotal(animation) > animation.frames.length);
}
function animationPageUrl(id, offset = frameOffset, limit = FRAME_PAGE_SIZE) {
const query = new URLSearchParams({ frame_offset: String(offset), frame_limit: String(limit) });
return `/api/animations/${id}?${query}`;
}
async function loadAnimationPage(id, requestedOffset = frameOffset) {
const offset = Math.max(0, requestedOffset);
let detail = await apiJson(animationPageUrl(id, offset));
if (!detail.frames.length && detail.frame_total > 0 && offset >= detail.frame_total) {
const lastOffset = Math.floor((detail.frame_total - 1) / FRAME_PAGE_SIZE) * FRAME_PAGE_SIZE;
detail = await apiJson(animationPageUrl(id, lastOffset));
}
frameOffset = detail.frame_offset ?? 0;
return detail;
}
function renderFramePagination() {
if (!current) {
framePagination.hidden = true;
return;
}
const total = frameTotal();
const pages = Math.max(1, Math.ceil(total / FRAME_PAGE_SIZE));
const page = Math.min(pages, Math.floor(frameOffset / FRAME_PAGE_SIZE) + 1);
framePagination.hidden = total <= FRAME_PAGE_SIZE;
renderUiCopy(
"copy.dynamic.animation.page_status",
pageStatus,
"第 {page} / {pages} 页 · 共 {total} 帧",
{ page, pages, total },
);
if (busy) {
setControlBusy(pagePrevious);
setControlBusy(pageNext);
} else {
if (frameOffset === 0) setControlUnavailable(pagePrevious, "当前已经是第一页。");
else setControlReady(pagePrevious);
if (frameOffset + current.frames.length >= total) setControlUnavailable(pageNext, "当前已经是最后一页。");
else setControlReady(pageNext);
}
}
function selectedIdsInFrameOrder() {
return current ? current.frames.filter((frame) => selectedFrameIds.has(frame.id)).map((frame) => frame.id) : [];
}
function syncToolbarState() {
const selecting = selectionMode();
selectionStatus.textContent = selecting ? `已选择 ${selectedFrameIds.size} 帧` : "未选择帧";
if (busy) return;
if (selecting) {
setControlUnavailable(createButton, SELECTION_LOCK_MESSAGE);
setControlUnavailable(refreshButton, SELECTION_LOCK_MESSAGE);
setControlUnavailable(addFrameButton, SELECTION_LOCK_MESSAGE);
setControlReady(batchCopyButton);
setControlReady(batchMoveButton);
setControlReady(batchDeleteButton);
return;
}
setControlReady(createButton);
setControlReady(refreshButton);
if (current) setControlReady(addFrameButton);
else setControlUnavailable(addFrameButton, "请先新建或打开一个动图,再把当前画板添加为帧。");
setControlUnavailable(batchCopyButton, NO_SELECTION_MESSAGE);
setControlUnavailable(batchMoveButton, NO_SELECTION_MESSAGE);
setControlUnavailable(batchDeleteButton, NO_SELECTION_MESSAGE);
}
function clearSelection({ render = true } = {}) {
selectedFrameIds.clear();
if (render && current) renderFrames();
if (render) renderFolders();
syncToolbarState();
}
function retainExistingSelection() {
const existing = new Set(current?.frames.map((frame) => frame.id) || []);
for (const frameId of selectedFrameIds) {
if (!existing.has(frameId)) selectedFrameIds.delete(frameId);
}
}
function stopAnimationPreview({ clear = false } = {}) {
currentPreviewGroup.clear();
if (!clear) return;
previewImage.hidden = true;
previewImage.removeAttribute("src");
previewImage.alt = "";
previewPlaceholder.hidden = false;
}
function showPreviewPlaceholder(message) {
stopAnimationPreview({ clear: true });
previewPlaceholder.textContent = message;
previewStatus.textContent = message;
}
function startAnimationPreview() {
stopAnimationPreview();
if (!workspaceActive || document.visibilityState !== "visible") return;
if (!current) {
showPreviewPlaceholder("请先打开一个动图");
return;
}
if (!current.frame_count) {
showPreviewPlaceholder("当前动图还没有帧");
return;
}
previewImage.src = current.thumbnail_url;
previewImage.alt = `${current.name} 动图预览`;
currentPreviewGroup.register({
animation: current,
image: previewImage,
target: previewImage,
onLoad: () => {
if (!workspaceActive) return;
previewImage.hidden = false;
previewPlaceholder.hidden = true;
},
onInitialError: () => {
if (!workspaceActive) return;
previewImage.hidden = true;
previewPlaceholder.hidden = false;
previewPlaceholder.textContent = "帧预览加载失败";
previewStatus.textContent = "帧预览加载失败,将继续尝试下一帧";
},
onFrame: ({ frame, index, total }) => {
const displayName = effectiveFrameName(current.name, frame, frame.position ?? index + 1);
previewImage.alt = `当前动图帧:${displayName}`;
previewStatus.textContent = `${current.name} · 第 ${index + 1}/${total} 帧`;
},
});
currentPreviewGroup.setActive(true);
}
async function mutate(action, message, control = null, { clearSelectionOnSuccess = false } = {}) {
if (busy) return null;
busy = true;
folders.inert = true;
frames.inert = true;
folders.setAttribute("aria-busy", "true");
frames.setAttribute("aria-busy", "true");
const globalControls = [
createButton, refreshButton, addFrameButton,
batchCopyButton, batchMoveButton, batchDeleteButton,
pagePrevious, pageNext,
];
globalControls.forEach((item) => setControlBusy(item));
if (control && !globalControls.includes(control)) setControlBusy(control);
feedback.textContent = "正在处理动图操作…";
feedback.dataset.state = "pending";
try {
const result = await action();
if (result?.frames) syncAnimationEditMetadata(result);
if (clearSelectionOnSuccess) selectedFrameIds.clear();
if (message) announce(message);
await refreshAnimations(current?.id || null);
document.dispatchEvent(new CustomEvent("matrix:library-change", { detail: { source: "animations" } }));
return result;
} catch (error) {
feedback.textContent = error.status === 409 || error.status === 428
? "动图已被其他页面修改,请取消勾选并刷新后重试;本次操作没有部分生效。"
: error.message;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
return null;
} finally {
busy = false;
folders.inert = false;
frames.inert = false;
folders.removeAttribute("aria-busy");
frames.removeAttribute("aria-busy");
globalControls.forEach((item) => setControlReady(item));
if (control) setControlReady(control);
syncToolbarState();
renderFramePagination();
}
}
function button(label, action, className = "") {
const item = document.createElement("button");
item.type = "button";
item.textContent = label;
item.className = className;
item.addEventListener("click", () => action(item));
return item;
}
async function openAnimation(id, control = null) {
if (selectionMode()) {
announce(SELECTION_LOCK_MESSAGE, true);
return;
}
const state = animationEditState();
if (state && state.animationId !== id && !await resolveAnimationEditBeforeNavigation()) return;
if (control) setControlBusy(control, { label: "正在打开…", message: "正在打开动图…" });
try {
current = await loadAnimationPage(id, 0);
selectedFrameIds.clear();
framePanel.hidden = false;
frameTitle.textContent = `${current.name} · ${current.frame_count} 帧`;
feedback.textContent = `已打开“${current.name}”`;
feedback.dataset.state = "ready";
renderFrames();
renderFolders();
syncToolbarState();
startAnimationPreview();
} catch (error) {
feedback.textContent = `动图打开失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
if (control) setControlReady(control);
}
}
function renderFolders() {
folderPreviewGroup.clear();
const selecting = selectionMode();
const cards = list.map((item) => {
const card = document.createElement("article");
card.className = `animation-folder-card panel${current?.id === item.id ? " active" : ""}`;
const imageBox = createAnimationThumbnailBox(folderPreviewGroup, item);
const title = document.createElement("h3");
title.className = "animation-folder-name";
title.textContent = item.name;
title.dataset.hoverTooltip = item.name;
const meta = document.createElement("p");
meta.textContent = `${item.frame_count} 帧 · ${(item.total_duration_ms / 1000).toFixed(2)} 秒`;
const actions = document.createElement("div");
actions.className = "template-card-actions";
const open = button("打开", (control) => openAnimation(item.id, control), "primary-button");
const play = button("播放", (control) => mutate(async () => {
const result = await apiJson(`/api/animations/${item.id}/play`, {
method: "POST",
headers: { "If-Match": `"${item.revision}"` },
body: "{}",
});
await refreshStatus();
return result;
}, `开始播放“${item.name}”`, control), item.frame_count ? "" : "disabled-button");
const rename = button("重命名", async (control) => {
const name = await askName("重命名动图", item.name);
if (name) mutate(() => apiJson(`/api/animations/${item.id}`, {
method: "PATCH",
headers: { "If-Match": `"${item.revision}"` },
body: JSON.stringify({ name }),
}), "动图已重命名", control);
});
const remove = button("删除", async (control) => {
if (await confirmAnimationOperation("确认删除动图", `永久删除动图“${item.name}”及全部帧吗?`, "删除")) {
mutate(() => apiJson(`/api/animations/${item.id}`, {
method: "DELETE",
headers: { "If-Match": `"${item.revision}"` },
}), "动图已删除", control);
}
}, "danger-button");
actions.append(open, play, rename, remove);
if (selecting) {
[...actions.children].forEach((control) => setControlUnavailable(control, SELECTION_LOCK_MESSAGE));
} else if (!item.frame_count) {
setControlUnavailable(play, "请先在该动图中添加至少一帧,再开始播放。");
}
card.append(imageBox, title, meta, actions);
return card;
});
if (!cards.length) {
const empty = document.createElement("p");
empty.className = "template-empty panel";
empty.textContent = "还没有动图,请先新建一个动图。";
cards.push(empty);
}
folders.replaceChildren(...cards);
}
async function copyFrame(frame, control) {
if (selectionMode()) return;
setControlBusy(control, { label: "正在准备…", message: "正在读取可复制的目标…" });
try {
const data = await apiJson("/api/animations");
copyTarget.replaceChildren(
new Option("静态模板", "static"),
...data.animations.map((item) => new Option(`动图:${item.name}`, `animation:${item.id}`)),
);
copyDialog.returnValue = "";
copyDialog.showModal();
await new Promise((resolve) => copyDialog.addEventListener("close", resolve, { once: true }));
if (copyDialog.returnValue !== "copy") return;
const [type, animationId] = copyTarget.value.split(":");
if (type === "static") {
await mutate(() => apiJson("/api/library/copy", {
method: "POST",
body: JSON.stringify({
source_type: "animation_frame",
source_id: frame.id,
source_animation_id: current.id,
source_revision: current.revision,
destination_type: "static",
destination_animation_id: null,
destination_revision: null,
}),
}), "帧已复制为静态模板", control);
return;
}
const destination = await apiJson(`/api/animations/${animationId}`);
const insertAfterFrameId = await chooseInsertionPosition(destination, {
title: "选择单帧复制位置",
copyMode: true,
});
if (insertAfterFrameId === undefined) return;
await mutate(() => apiJson(`/api/animations/${current.id}/frames/copy`, {
method: "POST",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({
frame_ids: [frame.id],
destination_animation_id: destination.id,
destination_revision: destination.revision,
insert_after_frame_id: insertAfterFrameId,
}),
}), "帧已复制到指定位置", control);
} catch (error) {
feedback.textContent = `复制目标读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setControlReady(control);
}
}
async function changeDuration(frame, duration) {
return mutate(() => apiJson(`/api/animations/${current.id}/frames/${frame.id}`, {
method: "PATCH",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ duration_ms: duration }),
}), "帧时长已更新");
}
async function applyDurationToSelection(frame, duration, restoreInput) {
const selectedIds = selectedIdsInFrameOrder();
const confirmed = await confirmAnimationOperation(
"确认批量修改时长",
`将 ${duration} 毫秒应用到已选择的 ${selectedIds.length} 帧吗?`,
"应用到已选帧",
);
if (!confirmed) {
restoreInput();
return false;
}
const result = await mutate(() => apiJson(`/api/animations/${current.id}/frame-durations`, {
method: "PUT",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ duration_ms: duration, frame_ids: selectedIds }),
}), `已更新 ${selectedIds.length} 帧的显示时间`);
return Boolean(result);
}
async function moveFrame(index, delta) {
if (selectionMode()) return;
const frame = current.frames[index];
const targetPosition = frame.position + delta;
if (targetPosition < 1 || targetPosition > frameTotal()) return;
if (usesPartialFramePage()) {
let beforeFrameId = null;
if (delta < 0) {
const previous = index > 0
? current.frames[index - 1]
: (await apiJson(animationPageUrl(current.id, frame.position - 2, 1))).frames[0];
beforeFrameId = previous?.id ?? null;
} else if (frame.position + 1 < frameTotal()) {
const afterNext = index + 2 < current.frames.length
? current.frames[index + 2]
: (await apiJson(animationPageUrl(current.id, frame.position + 1, 1))).frames[0];
beforeFrameId = afterNext?.id ?? null;
}
await mutate(() => apiJson(`/api/animations/${current.id}/frame-move`, {
method: "POST",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ frame_ids: [frame.id], before_frame_id: beforeFrameId }),
}), "帧顺序已更新");
return;
}
const order = current.frames.map((item) => item.id);
const target = index + delta;
if (target < 0 || target >= order.length) return;
[order[index], order[target]] = [order[target], order[index]];
await mutate(() => apiJson(`/api/animations/${current.id}/frame-order`, {
method: "PUT",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ frame_ids: order }),
}), "帧顺序已更新");
}
async function editFrame(frame, control) {
if (selectionMode() || !await resolveDirty()) return;
setControlBusy(control, { label: "正在打开…", message: "正在读取动图帧…" });
try {
const full = await apiJson(`/api/animations/${current.id}/frames/${frame.id}`);
enterAnimationEdit(full);
const displayName = effectiveFrameName(current.name, frame, frame.position);
feedback.textContent = `正在编辑“${displayName}”`;
renderFrames();
boardShell.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
block: "start",
});
} catch (error) {
feedback.textContent = `动图帧读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setControlReady(control);
}
}
function configureDurationInput(input, unit, durationMs, displayName) {
input.value = formatFrameDuration(durationMs, unit);
if (unit === "s") {
input.min = "0.050";
input.max = "604800";
input.step = "0.001";
input.inputMode = "decimal";
input.setAttribute("aria-label", `${displayName}显示时间,单位秒`);
} else {
input.min = "50";
input.max = "604800000";
input.step = "1";
input.inputMode = "numeric";
input.setAttribute("aria-label", `${displayName}显示时间,单位毫秒`);
}
}
function renderFrames() {
if (!current) return;
const selecting = selectionMode();
frames.replaceChildren(...current.frames.map((frame, index) => {
const editing = animationEditState()?.frameId === frame.id;
const selected = selectedFrameIds.has(frame.id);
const displayName = effectiveFrameName(current.name, frame, frame.position);
const card = document.createElement("article");
card.className = `template-card animation-frame-card sortable-card${editing ? " editing" : ""}${selected ? " selected" : ""}`;
card.dataset.frameId = frame.id;
if (!selecting && !usesPartialFramePage()) protectCardDragStart(card);
else card.draggable = false;
if (editing) card.setAttribute("aria-current", "true");
const positionRow = document.createElement("div");
positionRow.className = "animation-frame-position-row";
const positionText = document.createElement("span");
positionText.append(document.createTextNode("第 "));
const currentPosition = document.createElement("span");
currentPosition.className = "animation-frame-position-current";
currentPosition.textContent = String(frame.position);
const totalPosition = document.createElement("span");
totalPosition.className = "animation-frame-position-total";
totalPosition.textContent = String(frameTotal());
positionText.append(currentPosition, document.createTextNode(" / "), totalPosition, document.createTextNode(" 帧"));
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "animation-frame-select";
checkbox.checked = selected;
checkbox.setAttribute("aria-label", `选择${displayName}`);
checkbox.addEventListener("change", async () => {
if (checkbox.checked && animationEditState() && !await resolveAnimationEditBeforeNavigation()) {
checkbox.checked = false;
return;
}
if (checkbox.checked) selectedFrameIds.add(frame.id);
else selectedFrameIds.delete(frame.id);
renderFrames();
renderFolders();
syncToolbarState();
});
positionRow.append(positionText, checkbox);
const title = document.createElement("h3");
title.className = "template-card-name animation-frame-name";
title.textContent = displayName;
const editingBadge = document.createElement("span");
editingBadge.className = "animation-frame-editing-badge";
editingBadge.textContent = "当前正在编辑";
editingBadge.hidden = !editing;
const rename = button("重命名", async (control) => {
const name = await askName("帧名称(留空恢复自动名称)", frame.name || "", { allowEmpty: true });
if (name !== null) mutate(() => apiJson(`/api/animations/${current.id}/frames/${frame.id}`, {
method: "PATCH",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ name: name || null }),
}), name ? "帧名称已更新" : "帧已恢复自动名称", control);
}, "animation-frame-rename");
const durationField = document.createElement("div");
durationField.className = "animation-duration-field";
const durationHeading = document.createElement("div");
durationHeading.className = "animation-duration-heading";
const durationLabel = document.createElement("span");
durationLabel.textContent = "显示时间";
const copyDuration = button("复制到已选帧", () => commitDuration(true));
durationHeading.append(durationLabel, copyDuration);
const durationControls = document.createElement("div");
durationControls.className = "animation-duration-controls";
const duration = document.createElement("input");
duration.type = "number";
const unit = durationUnits.get(frame.id) || "ms";
configureDurationInput(duration, unit, frame.duration_ms, displayName);
const unitButton = button(unit === "ms" ? "毫秒" : "秒", () => {
const nextUnit = (durationUnits.get(frame.id) || "ms") === "ms" ? "s" : "ms";
durationUnits.set(frame.id, nextUnit);
configureDurationInput(duration, nextUnit, frame.duration_ms, displayName);
unitButton.textContent = nextUnit === "ms" ? "毫秒" : "秒";
duration.setAttribute("aria-invalid", "false");
durationError.hidden = true;
}, "animation-duration-unit");
unitButton.setAttribute("aria-label", `${displayName}显示时间单位,点击切换秒或毫秒`);
durationControls.append(duration, unitButton);
const durationError = document.createElement("span");
durationError.className = "animation-duration-error";
durationError.id = `duration-error-${frame.id}`;
durationError.hidden = true;
duration.setAttribute("aria-describedby", durationError.id);
durationField.append(durationHeading, durationControls, durationError);
let durationCommitPending = false;
async function commitDuration(requireSelection = false) {
if (durationCommitPending) return;
if (requireSelection && !selectionMode()) return;
durationCommitPending = true;
const activeUnit = durationUnits.get(frame.id) || "ms";
const validation = validateFrameDuration(duration.value, activeUnit);
duration.setAttribute("aria-invalid", String(!validation.valid));
durationError.textContent = validation.error;
durationError.hidden = validation.valid;
if (!validation.valid) {
announce(validation.error, true);
queueMicrotask(() => { durationCommitPending = false; });
return;
}
const restoreInput = () => {
configureDurationInput(duration, activeUnit, frame.duration_ms, displayName);
duration.setAttribute("aria-invalid", "false");
durationError.hidden = true;
};
if (selectionMode()) {
await applyDurationToSelection(frame, validation.duration, restoreInput);
} else if (validation.duration !== frame.duration_ms) {
await changeDuration(frame, validation.duration);
}
durationCommitPending = false;
}
duration.addEventListener("change", () => commitDuration(false));
duration.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
commitDuration(false);
duration.blur();
}
});
const imageBox = document.createElement("div");
imageBox.className = "template-image-box animation-frame-image-box";
const image = document.createElement("img");
image.className = "template-image";
image.src = frame.thumbnail_url;
image.alt = displayName;
image.width = 64;
image.height = 64;
image.draggable = false;
image.addEventListener("error", () => imageBox.classList.add("template-image-error"));
imageBox.append(image);
const edit = button("编辑此帧", (control) => editFrame(frame, control), "primary-button animation-frame-edit");
const actions = document.createElement("div");
actions.className = "template-card-actions animation-frame-secondary-actions";
const moveUp = button("上移", () => moveFrame(index, -1));
const moveDown = button("下移", () => moveFrame(index, 1));
const copy = button("复制", (control) => copyFrame(frame, control));
const remove = button("删除", async (control) => {
if (await confirmAnimationOperation("确认删除帧", `删除“${displayName}”吗?`, "删除")) {
mutate(() => apiJson(`/api/animations/${current.id}/frames/${frame.id}`, {
method: "DELETE",
headers: { "If-Match": `"${current.revision}"` },
}), "帧已删除", control);
}
}, "danger-button");
actions.append(moveUp, moveDown, copy, remove);
if (selecting) {
[rename, edit, moveUp, moveDown, copy, remove].forEach((control) => {
setControlUnavailable(control, SELECTION_LOCK_MESSAGE);
});
setControlReady(copyDuration);
} else {
setControlUnavailable(copyDuration, NO_SELECTION_MESSAGE);
if (frame.position === 1) setControlUnavailable(moveUp, "当前已经是第一帧,不能继续上移。");
if (frame.position === frameTotal()) setControlUnavailable(moveDown, "当前已经是最后一帧,不能继续下移。");
}
card.addEventListener("dragstart", (event) => {
if (selectionMode() || usesPartialFramePage()) {
event.preventDefault();
return;
}
if (!event.defaultPrevented) event.dataTransfer.setData("text/plain", frame.id);
});
card.addEventListener("dragover", (event) => {
if (!selectionMode() && !usesPartialFramePage()) event.preventDefault();
});
card.addEventListener("drop", (event) => {
if (selectionMode() || usesPartialFramePage()) return;
event.preventDefault();
const source = event.dataTransfer.getData("text/plain");
const order = current.frames.map((item) => item.id);
const from = order.indexOf(source);
if (from < 0 || from === index) return;
order.splice(index, 0, order.splice(from, 1)[0]);
mutate(() => apiJson(`/api/animations/${current.id}/frame-order`, {
method: "PUT",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ frame_ids: order }),
}), "帧顺序已更新");
});
card.append(positionRow, title, editingBadge, rename, durationField, imageBox, edit, actions);
return card;
}));
if (!current.frames.length) {
const empty = document.createElement("p");
empty.textContent = "此动图还没有帧。";
frames.append(empty);
}
renderFramePagination();
}
async function batchMove() {
const selectedIds = selectedIdsInFrameOrder();
const insertAfterFrameId = await chooseInsertionPosition(current, {
disabledIds: new Set(selectedIds),
title: "批量移动帧",
});
if (insertAfterFrameId === undefined) return;
if (usesPartialFramePage()) {
let beforeFrameId = null;
const selected = new Set(selectedIds);
if (insertAfterFrameId === null) {
const first = await apiJson(animationPageUrl(current.id, 0, selectedIds.length + 1));
beforeFrameId = first.frames.find((frame) => !selected.has(frame.id))?.id ?? null;
} else {
const target = current.frames.find((frame) => frame.id === insertAfterFrameId);
const following = await apiJson(animationPageUrl(
current.id,
target?.position ?? 0,
selectedIds.length + 1,
));
beforeFrameId = following.frames.find((frame) => !selected.has(frame.id))?.id ?? null;
}
await mutate(() => apiJson(`/api/animations/${current.id}/frame-move`, {
method: "POST",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ frame_ids: selectedIds, before_frame_id: beforeFrameId }),
}), `已移动 ${selectedIds.length} 帧`, batchMoveButton, { clearSelectionOnSuccess: true });
return;
}
const existing = current.frames.map((frame) => frame.id);
const order = reorderSelectedFrameIds(existing, selectedIds, insertAfterFrameId);
if (!order) {
feedback.textContent = "无法生成批量移动顺序,请刷新后重试。";
announce(feedback.textContent, true);
return;
}
if (order.every((frameId, index) => frameId === existing[index])) {
feedback.textContent = "所选帧已经位于该位置,顺序未变化。";
clearSelection();
announce(feedback.textContent);
return;
}
await mutate(() => apiJson(`/api/animations/${current.id}/frame-order`, {
method: "PUT",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ frame_ids: order }),
}), `已移动 ${selectedIds.length} 帧`, batchMoveButton, { clearSelectionOnSuccess: true });
}
async function batchCopy() {
setControlBusy(batchCopyButton, { label: "正在准备…", message: "正在读取复制目标…" });
try {
const data = await apiJson("/api/animations");
const destinationMetadata = await chooseCopyDestination(data.animations);
if (!destinationMetadata) return;
const destination = await apiJson(`/api/animations/${destinationMetadata.id}`);
const insertAfterFrameId = await chooseInsertionPosition(destination, {
title: "选择批量复制位置",
copyMode: true,
});
if (insertAfterFrameId === undefined) return;
const selectedIds = selectedIdsInFrameOrder();
await mutate(() => apiJson(`/api/animations/${current.id}/frames/copy`, {
method: "POST",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({
frame_ids: selectedIds,
destination_animation_id: destination.id,
destination_revision: destination.revision,
insert_after_frame_id: insertAfterFrameId,
}),
}), `已复制 ${selectedIds.length} 帧到“${destination.name}”`, batchCopyButton, {
clearSelectionOnSuccess: true,
});
} catch (error) {
feedback.textContent = `批量复制准备失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
setControlReady(batchCopyButton);
syncToolbarState();
}
}
async function batchDelete() {
const selectedFrames = current.frames.filter((frame) => selectedFrameIds.has(frame.id));
const names = selectedFrames.slice(0, 3).map((frame, index) => (
effectiveFrameName(current.name, frame, current.frames.indexOf(frame) + 1)
));
const suffix = selectedFrames.length > names.length ? `等 ${selectedFrames.length} 帧` : `${selectedFrames.length} 帧`;
const confirmed = await confirmAnimationOperation(
"确认批量删除",
`永久删除 ${names.join("、")} ${suffix}吗?删除后无法恢复。`,
"批量删除",
);
if (!confirmed) return;
await mutate(() => apiJson(`/api/animations/${current.id}/frames/batch-delete`, {
method: "POST",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ frame_ids: selectedIdsInFrameOrder() }),
}), `已删除 ${selectedFrames.length} 帧`, batchDeleteButton, { clearSelectionOnSuccess: true });
}
export async function refreshAnimations(selectId = null) {
const ownsBusyState = !busy;
if (ownsBusyState) {
setControlBusy(refreshButton, { label: "正在刷新…", message: "正在读取动图…" });
feedback.textContent = "正在读取动图…";
feedback.dataset.state = "pending";
}
try {
const data = await apiJson("/api/animations");
list = data.animations;
const id = selectId || current?.id;
current = id && list.some((item) => item.id === id) ? await loadAnimationPage(id) : null;
if (current) retainExistingSelection();
else selectedFrameIds.clear();
framePanel.hidden = !current;
if (current) frameTitle.textContent = `${current.name} · ${current.frame_count} 帧`;
renderFolders();
if (current) renderFrames();
else renderFramePagination();
feedback.textContent = current ? `已打开“${current.name}”` : `共 ${list.length} 个动图`;
feedback.dataset.state = "ready";
syncToolbarState();
startAnimationPreview();
if (ownsBusyState) announce(feedback.textContent);
} catch (error) {
feedback.textContent = `动图读取失败:${error.message}`;
feedback.dataset.state = "error";
announce(feedback.textContent, true);
} finally {
if (ownsBusyState) setControlReady(refreshButton);
syncToolbarState();
}
}
async function saveCurrentFrame() {
const state = animationEditState();
if (!state) return true;
const result = await mutate(async () => {
const saved = await apiJson(`/api/animations/${state.animationId}/frames/${state.frameId}`, {
method: "PUT",
headers: { "If-Match": `"${state.revision}"` },
body: JSON.stringify({ scene: sceneToStorageValue(getScene()) }),
});
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "animation-frame-save" } }));
return saved;
}, "当前动图帧已保存");
if (!result) return false;
const detail = result.frames.find((item) => item.id === state.frameId);
markAnimationFrameSaved({ revision: result.revision, ...detail });
renderFrames();
startAnimationPreview();
return true;
}
createButton.addEventListener("click", async () => {
const name = await askName("新建动图");
if (!name) return;
const result = await mutate(() => apiJson("/api/animations", {
method: "POST",
body: JSON.stringify({ name }),
}), `动图“${name}”已创建`, createButton);
if (result) openAnimation(result.id);
});
addFrameButton.addEventListener("click", () => current && mutate(async () => {
const result = await apiJson(`/api/animations/${current.id}/frames`, {
method: "POST",
headers: { "If-Match": `"${current.revision}"` },
body: JSON.stringify({ scene: sceneToStorageValue(getScene()), duration_ms: 500 }),
});
document.dispatchEvent(new CustomEvent("matrix:canvas-history-checkpoint", { detail: { reason: "animation-frame-create" } }));
return result;
}, "新帧已创建", addFrameButton));
refreshButton.addEventListener("click", () => refreshAnimations());
batchCopyButton.addEventListener("click", batchCopy);
batchMoveButton.addEventListener("click", batchMove);
batchDeleteButton.addEventListener("click", batchDelete);
pagePrevious.addEventListener("click", async () => {
if (!current || busy || selectionMode() || !await resolveAnimationEditBeforeNavigation()) return;
selectedFrameIds.clear();
current = await loadAnimationPage(current.id, Math.max(0, frameOffset - FRAME_PAGE_SIZE));
renderFrames();
startAnimationPreview();
});
pageNext.addEventListener("click", async () => {
if (!current || busy || selectionMode() || !await resolveAnimationEditBeforeNavigation()) return;
selectedFrameIds.clear();
current = await loadAnimationPage(current.id, frameOffset + FRAME_PAGE_SIZE);
renderFrames();
startAnimationPreview();
});
document.addEventListener("matrix:save-animation-frame", () => saveCurrentFrame());
document.addEventListener("matrix:request-exit-animation-edit", async () => {
if (await resolveDirty()) leaveAnimationEdit();
});
document.addEventListener("matrix:library-change", (event) => {
if (event.detail?.source !== "animations") refreshAnimations(current?.id);
});
document.addEventListener("matrix:open-animation", (event) => openAnimation(event.detail.id));
window.addEventListener("beforeunload", (event) => {
if (animationEditState()?.dirty) {
event.preventDefault();
event.returnValue = "";
}
});
registerSceneReplacementGuard(resolveAnimationEditBeforeNavigation);
registerWorkspace({
id: "animations",
title: "动图管理",
elementId: "workspace-animations",
order: 50,
persistence: "server",
onEnter: async () => {
workspaceActive = true;
folderPreviewGroup.setActive(true);
currentPreviewGroup.setActive(true);
await refreshAnimations();
},
onLeave: () => {
workspaceActive = false;
selectedFrameIds.clear();
folderPreviewGroup.setActive(false);
folderPreviewGroup.clear();
currentPreviewGroup.setActive(false);
stopAnimationPreview();
},
});