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