1048 lines
43 KiB
JavaScript
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();
|
|
},
|
|
});
|