import { announce, apiBlob, isAppStale } from "./core.js"; import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js"; import { RGB_BYTE_LENGTH, SCENE_HEIGHT, SCENE_STORAGE_KEY, SCENE_WIDTH, addTextElement as addTextToScene, compositeRgbaLayers, createRgbPixels, createScene, deleteTextElement as deleteTextFromScene, deserializeScene, duplicateTextElement as duplicateTextInScene, inspectSceneStorageValue, restoreSceneDraft, serializeScene, updateTextElement as updateTextInScene, } from "./scene-model.js"; import { animationEditContextName } from "./animation-ui-model.js"; import { setControlBusy, setControlReady, setControlUnavailable } from "./interaction-feedback.js"; import { createTemplateEditContext, sceneNeedsTemplateSave, templateEditSnapshot, } from "./template-edit-model.js"; const visibleCanvas = document.getElementById("scene-canvas"); const overlayCanvas = document.getElementById("scene-overlay"); const visibleContext = visibleCanvas.getContext("2d"); const overlayContext = overlayCanvas.getContext("2d"); const compositionCanvas = document.createElement("canvas"); compositionCanvas.width = SCENE_WIDTH; compositionCanvas.height = SCENE_HEIGHT; const compositionContext = compositionCanvas.getContext("2d", { willReadFrequently: true }); const scratchCanvas = document.createElement("canvas"); scratchCanvas.width = SCENE_WIDTH; scratchCanvas.height = SCENE_HEIGHT; const scratchContext = scratchCanvas.getContext("2d", { willReadFrequently: true }); const boardTitle = document.getElementById("board-title"); const boardEyebrow = document.getElementById("board-eyebrow"); const boardMode = document.getElementById("board-mode"); const boardStatus = document.getElementById("board-status"); const boardShell = document.getElementById("board-shell"); const standardBoardShell = document.getElementById("standard-board-shell"); const animationPreviewShell = document.getElementById("animation-preview-shell"); const animationBoardPreview = document.getElementById("animation-board-preview"); const animationBoardPreviewContext = animationBoardPreview.getContext("2d"); const applyButton = document.getElementById("apply-composition"); const exportButton = document.getElementById("export-composition"); const exitAnimationEditButton = document.getElementById("exit-animation-edit"); const animationEditNotice = document.getElementById("animation-edit-notice"); const animationEditName = document.getElementById("animation-edit-name"); const animationEditStatus = document.getElementById("animation-edit-status"); const MONITOR_WORKSPACES = new Set(["device", "settings", "templates"]); const DEFAULT_PREVIEW_REFRESH_INTERVAL_MS = 1000; const MIN_PREVIEW_REFRESH_INTERVAL_MS = 1; const MAX_PREVIEW_REFRESH_INTERVAL_MS = 60000; visibleContext.imageSmoothingEnabled = false; compositionContext.imageSmoothingEnabled = false; scratchContext.imageSmoothingEnabled = false; animationBoardPreviewContext.imageSmoothingEnabled = false; const listeners = new Set(); const textLayers = new Map(); function browserStorage() { try { return window.localStorage; } catch (_error) { return null; } } const draftStorage = browserStorage(); const draftLoad = restoreSceneDraft(draftStorage); let scene = draftLoad.scene; let selectedTextId = null; let interactionMode = "readonly"; let activeWorkspace = "device"; let previewKind = "scene"; let previewLabel = ""; let previewSequence = 0; let previewRefreshIntervalMs = DEFAULT_PREVIEW_REFRESH_INTERVAL_MS; let monitorTimer = null; let monitorRequestPending = false; let monitorRefreshQueued = false; let activeTransform = null; let activeGesture = null; let applying = false; let animationEdit = null; let ordinarySceneBackup = null; let templateEdit = null; let sceneReplacementGuard = async () => true; let lastExactRgb = null; let sceneSource = null; let draftConflict = false; let externalDraftValue = null; let draftPersistenceBlocked = !draftLoad.canPersist; let draftProtectionNotice = ( draftPersistenceBlocked || draftLoad.status === "cleanup-warning" ) ? draftLoad.notice : ""; const draftConflictNotice = document.getElementById("draft-conflict-notice"); const loadExternalDraftButton = document.getElementById("load-external-draft"); const keepCurrentDraftButton = document.getElementById("keep-current-draft"); function blockDraftPersistence(message) { draftPersistenceBlocked = true; draftProtectionNotice = message; externalDraftValue = null; draftConflict = false; draftConflictNotice.hidden = true; updateBoardUi(); announce(message, true); } export function persistScene() { if (animationEdit) { animationEdit.dirty = serializeScene(scene) !== animationEdit.baseline; updateBoardUi(); document.dispatchEvent(new CustomEvent("matrix:animation-edit-change", { detail: animationEditState() })); return true; } if (draftConflict || draftPersistenceBlocked) return false; try { if (!draftStorage) throw new Error("browser storage unavailable"); draftStorage.setItem(SCENE_STORAGE_KEY, serializeScene(scene)); return true; } catch (_error) { blockDraftPersistence("浏览器无法保存统一画板草稿;原草稿已保留,本页不会继续覆盖或应用它。"); return false; } } function setDraftConflict(value) { draftConflict = value; draftConflictNotice.hidden = !value; updateBoardUi(); } window.addEventListener("storage", (event) => { if (!draftStorage || event.storageArea !== draftStorage || event.key !== SCENE_STORAGE_KEY) return; const external = inspectSceneStorageValue(event.newValue); if (external.status === "future") { blockDraftPersistence("另一标签页写入了更高版本画板草稿;原值已保留,本页不会覆盖或应用它。"); return; } if (external.status === "damaged") { blockDraftPersistence("另一标签页写入了无法识别的画板草稿;原值已保留,本页不会覆盖或应用它。"); return; } if (external.status !== "valid") return; externalDraftValue = event.newValue; setDraftConflict(true); announce("另一标签页修改了画板草稿,请选择要保留的版本", true); }); loadExternalDraftButton.addEventListener("click", async () => { const value = externalDraftValue; if (!value) return; if (animationEdit && !await sceneReplacementGuard()) return; externalDraftValue = null; setDraftConflict(false); replaceSceneFromStorage(value, { persist: false, reason: "tab-sync" }); announce("已载入另一标签页的最新画板草稿"); }); keepCurrentDraftButton.addEventListener("click", () => { externalDraftValue = null; setDraftConflict(false); persistScene(); announce("已保留当前标签页草稿并覆盖共享草稿"); }); function elementSignature(element) { return JSON.stringify({ text: element.text, font: element.font, size: element.size, x: element.x, y: element.y, align: element.align, color: element.color, }); } function textPayload(element) { return { text: element.text, font: element.font, size: element.size, x: element.x, y: element.y, align: element.align, color: element.color, }; } function elementById(id) { return scene.elements.find((element) => element.id === id) || null; } function closeImage(image) { if (image && typeof image.close === "function") image.close(); } async function imageFromBlob(blob) { if (typeof createImageBitmap === "function") return createImageBitmap(blob); const url = URL.createObjectURL(blob); try { return await new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("透明文字图层解码失败")); image.src = url; }); } finally { URL.revokeObjectURL(url); } } function alphaBounds(rgba) { let left = SCENE_WIDTH; let top = SCENE_HEIGHT; let right = -1; let bottom = -1; for (let y = 0; y < SCENE_HEIGHT; y += 1) { for (let x = 0; x < SCENE_WIDTH; x += 1) { if (rgba[(y * SCENE_WIDTH + x) * 4 + 3] === 0) continue; left = Math.min(left, x); top = Math.min(top, y); right = Math.max(right, x); bottom = Math.max(bottom, y); } } if (right < left || bottom < top) return null; return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 }; } function layerEntry(id) { if (!textLayers.has(id)) { textLayers.set(id, { controller: null, error: null, image: null, rgba: null, bounds: null, signature: null, revision: null, targetSignature: null, targetRevision: null, status: "idle", timer: null, }); } return textLayers.get(id); } function removeUnusedLayers() { const ids = new Set(scene.elements.map((element) => element.id)); textLayers.forEach((entry, id) => { if (ids.has(id)) return; window.clearTimeout(entry.timer); entry.controller?.abort(); closeImage(entry.image); textLayers.delete(id); }); } function scheduleTextLayer(element, immediate = false) { const signature = elementSignature(element); const entry = layerEntry(element.id); if (entry.status === "ready" && entry.signature === signature) { entry.revision = element.revision; entry.targetRevision = element.revision; return; } entry.targetSignature = signature; entry.targetRevision = element.revision; entry.status = "pending"; entry.error = null; window.clearTimeout(entry.timer); entry.timer = window.setTimeout( () => loadTextLayer(element.id, signature, element.revision), immediate ? 0 : 120, ); renderComposition(); } async function loadTextLayer(id, requestedSignature, requestedRevision) { const element = elementById(id); const entry = textLayers.get(id); if ( !element || !entry || elementSignature(element) !== requestedSignature || element.revision !== requestedRevision ) return; entry.controller?.abort(); const controller = new AbortController(); entry.controller = controller; try { const blob = await apiBlob("/api/preview/text-layer", { method: "POST", body: JSON.stringify(textPayload(element)), signal: controller.signal, }); const image = await imageFromBlob(blob); const current = elementById(id); if ( !current || elementSignature(current) !== requestedSignature || current.revision !== requestedRevision || entry.targetSignature !== requestedSignature || entry.targetRevision !== requestedRevision ) { closeImage(image); return; } scratchContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); scratchContext.drawImage(image, 0, 0); const rgba = new Uint8ClampedArray( scratchContext.getImageData(0, 0, SCENE_WIDTH, SCENE_HEIGHT).data, ); closeImage(entry.image); entry.image = image; entry.rgba = rgba; entry.bounds = alphaBounds(rgba); entry.signature = requestedSignature; entry.revision = requestedRevision; entry.targetSignature = requestedSignature; entry.targetRevision = requestedRevision; entry.status = "ready"; entry.error = null; renderComposition(); emitSceneChange("layer"); } catch (error) { if (error.name === "AbortError") return; entry.status = "error"; entry.error = error.message; renderComposition(); announce(`文字图层渲染失败:${error.message}`, true); emitSceneChange("layer-error"); } finally { if (entry.controller === controller) entry.controller = null; } } function layerState() { let pending = 0; let errors = 0; scene.elements.forEach((element) => { const entry = textLayers.get(element.id); const signature = elementSignature(element); if ( !entry || entry.status === "pending" || entry.signature !== signature || entry.revision !== element.revision ) pending += 1; if ( entry?.status === "error" && entry.targetSignature === signature && entry.targetRevision === element.revision ) errors += 1; }); return { pending, errors, ready: pending === 0 && errors === 0 }; } function putRgb(context, rgb) { const image = context.createImageData(SCENE_WIDTH, SCENE_HEIGHT); for (let source = 0, target = 0; source < rgb.length; source += 3, target += 4) { image.data[target] = rgb[source]; image.data[target + 1] = rgb[source + 1]; image.data[target + 2] = rgb[source + 2]; image.data[target + 3] = 255; } context.putImageData(image, 0, 0); } function drawTransformedLayer(context, element, entry) { if (!entry?.image) return; if (!activeTransform || activeTransform.id !== element.id) { context.drawImage(entry.image, 0, 0); return; } context.save(); if (activeTransform.kind === "drag") { context.translate(activeTransform.dx, activeTransform.dy); } else { context.translate(element.x, element.y); context.scale(activeTransform.scale, activeTransform.scale); context.translate(-element.x, -element.y); } context.drawImage(entry.image, 0, 0); context.restore(); } function renderComposition() { removeUnusedLayers(); scene.elements.forEach((element) => scheduleTextLayerIfMissing(element)); const state = layerState(); if (activeTransform) { putRgb(compositionContext, scene.pixels); scene.elements.forEach((element) => drawTransformedLayer( compositionContext, element, textLayers.get(element.id), )); lastExactRgb = null; } else { const rgbaLayers = scene.elements .map((element) => textLayers.get(element.id)?.rgba) .filter(Boolean); const rgb = compositeRgbaLayers(scene.pixels, rgbaLayers); putRgb(compositionContext, rgb); lastExactRgb = state.ready ? rgb : null; } if (previewKind === "scene") { visibleContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); visibleContext.drawImage(compositionCanvas, 0, 0); } animationBoardPreviewContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); animationBoardPreviewContext.drawImage(compositionCanvas, 0, 0); drawSelectionOverlay(); updateBoardUi(state); } function scheduleTextLayerIfMissing(element) { const entry = textLayers.get(element.id); if (!entry || (entry.status === "idle" && !entry.timer)) scheduleTextLayer(element, true); } function updateBoardUi(state = layerState()) { applyButton.textContent = animationEdit ? "保存当前动图帧" : activeWorkspace === "device" ? "恢复统一画板" : "应用统一画板"; exitAnimationEditButton.hidden = !animationEdit; const showAnimationEditNotice = Boolean(animationEdit && ["canvas", "text"].includes(activeWorkspace)); animationEditNotice.hidden = !showAnimationEditNotice; boardMode.hidden = showAnimationEditNotice; if (animationEdit) { const displayName = animationEditContextName(animationEdit.animationName, { name: animationEdit.frameName }, animationEdit.position); animationEditName.textContent = displayName; animationEditStatus.textContent = animationEdit.dirty ? "尚未保存" : "已保存"; animationEditNotice.dataset.state = animationEdit.dirty ? "dirty" : "saved"; } if (applying) { setControlBusy(applyButton, { label: animationEdit ? "正在保存…" : "正在应用…", message: animationEdit ? "正在保存当前动图帧…" : "正在把统一画板应用到屏幕…", }); } else if (isAppStale()) { setControlUnavailable(applyButton, "控制程序已更新,请先刷新页面再提交画板。"); } else if (draftPersistenceBlocked) { setControlUnavailable(applyButton, "浏览器无法安全保存当前草稿,请先处理页面提示后再应用。"); } else if (draftConflict) { setControlUnavailable(applyButton, "请先解决另一标签页的草稿冲突,再应用画板。"); } else if (activeTransform) { setControlUnavailable(applyButton, "请先完成当前文字拖动或缩放操作。"); } else if (!state.ready) { setControlUnavailable(applyButton, state.errors ? "有文字图层渲染失败,请修正后再应用完整画面。" : "请等待文字图层生成完成后再应用画板。"); } else { setControlReady(applyButton); } if (activeTransform) { setControlUnavailable(exportButton, "请先完成当前文字拖动或缩放操作。"); } else if (!state.ready) { setControlUnavailable(exportButton, state.errors ? "有文字图层渲染失败,请修正后再导出。" : "请等待文字图层生成完成后再导出。"); } else { setControlReady(exportButton); } if (previewKind === "monitor") { boardStatus.textContent = previewLabel; boardStatus.hidden = !previewLabel; boardStatus.dataset.state = previewLabel.includes("失败") ? "error" : "pending"; return; } boardStatus.hidden = false; if (draftPersistenceBlocked) { boardStatus.textContent = draftProtectionNotice; boardStatus.dataset.state = "error"; return; } else if (draftProtectionNotice) { boardStatus.textContent = draftProtectionNotice; boardStatus.dataset.state = "error"; return; } else if (previewKind === "test") { boardStatus.textContent = `独占测试预览:${previewLabel}。测试不会修改统一画板草稿。`; boardStatus.dataset.state = "ready"; return; } if (draftConflict) { boardStatus.textContent = "另一标签页已修改共享草稿;当前编辑暂存在本页内存,解决冲突前不会覆盖或应用。"; boardStatus.dataset.state = "error"; } else if (state.errors) { boardStatus.textContent = `${state.errors} 个文字图层渲染失败,修正前不会应用不完整画面。`; boardStatus.dataset.state = "error"; } else if (state.pending) { boardStatus.textContent = `正在生成 ${state.pending} 个精确文字图层…`; boardStatus.dataset.state = "pending"; } else { boardStatus.textContent = animationEdit ? `像素底层 + ${scene.elements.length} 个文字元素 · 动图帧编辑中` : `像素底层 + ${scene.elements.length} 个文字元素 · 草稿已保存在当前浏览器`; boardStatus.dataset.state = "ready"; } } function fallbackBounds(element) { const estimatedWidth = Math.max(4, Math.min(64, Math.ceil(Math.max(1, element.text.length) * element.size * 0.58))); const estimatedHeight = Math.max(4, Math.min(64, Math.ceil(element.size * 1.2))); let x = element.x; if (element.align === "center") x -= estimatedWidth / 2; else if (element.align === "right") x -= estimatedWidth; return { x, y: element.y, width: estimatedWidth, height: estimatedHeight }; } function transformedBounds(element) { const source = textLayers.get(element.id)?.bounds || fallbackBounds(element); if (!activeTransform || activeTransform.id !== element.id) return source; if (activeTransform.kind === "drag") { return { ...source, x: source.x + activeTransform.dx, y: source.y + activeTransform.dy, }; } return { x: element.x + (source.x - element.x) * activeTransform.scale, y: element.y + (source.y - element.y) * activeTransform.scale, width: source.width * activeTransform.scale, height: source.height * activeTransform.scale, }; } function drawSelectionOverlay() { overlayContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); if (interactionMode !== "text" || previewKind !== "scene" || !selectedTextId) return; const element = elementById(selectedTextId); if (!element) return; const bounds = transformedBounds(element); overlayContext.save(); overlayContext.strokeStyle = "#72E0C5"; overlayContext.fillStyle = "#0D1015"; overlayContext.lineWidth = 0.55; overlayContext.setLineDash([1.2, 0.8]); overlayContext.strokeRect(bounds.x - 0.4, bounds.y - 0.4, bounds.width + 0.8, bounds.height + 0.8); overlayContext.setLineDash([]); const handleX = Math.max(1.5, Math.min(62.5, bounds.x + bounds.width)); const handleY = Math.max(1.5, Math.min(62.5, bounds.y + bounds.height)); overlayContext.fillRect(handleX - 1.5, handleY - 1.5, 3, 3); overlayContext.strokeRect(handleX - 1.5, handleY - 1.5, 3, 3); overlayContext.restore(); } function emitSceneChange(reason) { const detail = { scene, selectedTextId, reason }; listeners.forEach((listener) => listener(detail)); document.dispatchEvent(new CustomEvent("matrix:scene-change", { detail })); } export function subscribeScene(listener) { listeners.add(listener); listener({ scene, selectedTextId, reason: "subscribe" }); return () => listeners.delete(listener); } export function getScene() { return scene; } export function animationEditState() { return animationEdit ? { ...animationEdit } : null; } export function templateEditState() { if (animationEdit && ordinarySceneBackup) { return templateEditSnapshot(ordinarySceneBackup.templateEdit, ordinarySceneBackup.scene); } return templateEditSnapshot(templateEdit, serializeScene(scene)); } export function ordinarySceneNeedsTemplateSave() { if (animationEdit && ordinarySceneBackup) { return sceneNeedsTemplateSave(ordinarySceneBackup.templateEdit, ordinarySceneBackup.scene); } return sceneNeedsTemplateSave(templateEdit, serializeScene(scene)); } export function registerSceneReplacementGuard(guard) { sceneReplacementGuard = typeof guard === "function" ? guard : async () => true; } export function enterAnimationEdit(frame) { if (!animationEdit) { ordinarySceneBackup = { scene: serializeScene(scene), source: sceneSource ? { ...sceneSource } : null, templateEdit: templateEdit ? { ...templateEdit } : null, }; } animationEdit = { animationId: frame.animation_id, animationName: frame.animation_name, revision: frame.animation_revision, frameId: frame.id, frameName: frame.name, position: frame.position, dirty: false, baseline: "", }; replaceSceneFromStorage(frame.scene, { persist: false, reason: "animation-frame-load" }); animationEdit.baseline = serializeScene(scene); updateBoardUi(); document.dispatchEvent(new CustomEvent("matrix:animation-edit-change", { detail: animationEditState() })); } export function markAnimationFrameSaved(detail) { if (!animationEdit) return; animationEdit.revision = detail.revision; if (Object.hasOwn(detail, "name")) animationEdit.frameName = detail.name; if (detail.position) animationEdit.position = detail.position; animationEdit.dirty = false; animationEdit.baseline = serializeScene(scene); updateBoardUi(); } export function syncAnimationEditMetadata(animation) { if (!animationEdit || animation?.id !== animationEdit.animationId || !Array.isArray(animation.frames)) return; const frame = animation.frames.find((item) => item.id === animationEdit.frameId); if (!frame) return; animationEdit.animationName = animation.name; animationEdit.revision = animation.revision; animationEdit.frameName = frame.name; animationEdit.position = frame.position; updateBoardUi(); } export function leaveAnimationEdit() { if (!animationEdit) return; const backup = ordinarySceneBackup; animationEdit = null; ordinarySceneBackup = null; if (backup) { replaceSceneFromStorage(backup.scene, { persist: false, reason: "animation-edit-exit", source: backup.source, templateEditContext: backup.templateEdit, }); } updateBoardUi(); document.dispatchEvent(new CustomEvent("matrix:animation-edit-change", { detail: null })); } export function replaceSceneFromStorage( value, { persist = true, reason = "template-load", source = null, templateEditContext = null, } = {}, ) { const restored = deserializeScene(value); if (!restored) throw new Error("模板场景无效,无法载入"); textLayers.forEach((entry) => { window.clearTimeout(entry.timer); entry.controller?.abort(); closeImage(entry.image); }); textLayers.clear(); activeTransform = null; activeGesture = null; scene = restored; sceneSource = source; templateEdit = templateEditContext ? createTemplateEditContext( templateEditContext, templateEditContext.baseline || serializeScene(restored), ) : null; selectedTextId = scene.elements.at(-1)?.id || null; if (persist) persistScene(); scene.elements.forEach((element) => scheduleTextLayer(element, true)); showCompositionPreview(); emitSceneChange(reason); document.dispatchEvent(new CustomEvent("matrix:scene-replaced", { detail: { reason } })); document.dispatchEvent(new CustomEvent("matrix:template-edit-change", { detail: templateEditState() })); return scene; } export function enterTemplateEdit(template) { const source = { type: "template", id: template.id, revision: template.revision }; return replaceSceneFromStorage(template.scene, { source, reason: "template-edit-load", templateEditContext: { id: template.id, name: template.name, revision: template.revision, readOnly: Boolean(template.read_only), }, }); } export function markTemplateSaved(template) { if (animationEdit) return false; templateEdit = createTemplateEditContext(template, serializeScene(scene)); sceneSource = { type: "template", id: template.id, revision: template.revision }; updateBoardUi(); document.dispatchEvent(new CustomEvent("matrix:template-edit-change", { detail: templateEditState() })); return true; } export function getPixelBuffer() { return scene.pixels; } export function getInteractionMode() { return interactionMode; } export function boardPoint(event, { round = true } = {}) { const rect = overlayCanvas.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * SCENE_WIDTH; const y = ((event.clientY - rect.top) / rect.height) * SCENE_HEIGHT; return { x: round ? Math.max(0, Math.min(63, Math.floor(x))) : Math.max(0, Math.min(64, x)), y: round ? Math.max(0, Math.min(63, Math.floor(y))) : Math.max(0, Math.min(64, y)), }; } export function getBoardOverlay() { return overlayCanvas; } export function renderPixelDraft() { renderComposition(); } export function commitPixelChanges(reason = "pixels") { sceneSource = null; persistScene(); renderComposition(); emitSceneChange(reason); } export function fillPixelLayer(rgb) { scene = createScene({ pixels: createRgbPixels(rgb), elements: scene.elements }); commitPixelChanges("pixel-fill"); } export function selectTextElement(id, reason = "selection") { selectedTextId = elementById(id)?.id || null; drawSelectionOverlay(); emitSceneChange(reason); return selectedTextId; } export function getSelectedTextElement() { return elementById(selectedTextId); } export function addTextElement(overrides = {}) { sceneSource = null; scene = addTextToScene(scene, overrides); const created = scene.elements.at(-1); selectedTextId = created.id; persistScene(); scheduleTextLayer(created, true); renderComposition(); emitSceneChange("create"); return created; } export function updateTextElement(id, patch, reason = "update") { const previous = scene; scene = updateTextInScene(scene, id, patch); if (scene === previous) return null; sceneSource = null; const updated = elementById(id); persistScene(); if (updated) scheduleTextLayer(updated); renderComposition(); emitSceneChange(reason); return updated; } export function duplicateTextElement(id = selectedTextId) { const previousLength = scene.elements.length; scene = duplicateTextInScene(scene, id); if (scene.elements.length === previousLength) return null; sceneSource = null; const duplicate = scene.elements.at(-1); selectedTextId = duplicate.id; persistScene(); scheduleTextLayer(duplicate, true); renderComposition(); emitSceneChange("duplicate"); return duplicate; } export function deleteTextElement(id = selectedTextId) { const previousLength = scene.elements.length; scene = deleteTextFromScene(scene, id); if (scene.elements.length === previousLength) return false; sceneSource = null; selectedTextId = scene.elements.at(-1)?.id || null; persistScene(); renderComposition(); emitSceneChange("delete"); return true; } function hitTestText(point) { for (let index = scene.elements.length - 1; index >= 0; index -= 1) { const element = scene.elements[index]; const bounds = transformedBounds(element); if ( point.x >= bounds.x - 1 && point.x <= bounds.x + bounds.width + 1 && point.y >= bounds.y - 1 && point.y <= bounds.y + bounds.height + 1 ) return element.id; } return null; } function resizeHandleHit(point, element) { const bounds = transformedBounds(element); const x = Math.max(1.5, Math.min(62.5, bounds.x + bounds.width)); const y = Math.max(1.5, Math.min(62.5, bounds.y + bounds.height)); return Math.abs(point.x - x) <= 3.5 && Math.abs(point.y - y) <= 3.5; } function startTextGesture(event) { if (interactionMode !== "text" || previewKind !== "scene") return; const point = boardPoint(event, { round: false }); let element = getSelectedTextElement(); if (element && resizeHandleHit(point, element)) { const distance = Math.hypot(point.x - element.x, point.y - element.y); activeGesture = { kind: "resize", id: element.id, pointerId: event.pointerId, startDistance: Math.max(1, distance), startSize: element.size, }; } else { const hit = hitTestText(point); if (!hit) { selectTextElement(null); return; } if (hit !== selectedTextId) selectTextElement(hit); element = elementById(hit); activeGesture = { kind: "drag", id: element.id, pointerId: event.pointerId, startPoint: point, startX: element.x, startY: element.y, }; } event.preventDefault(); overlayCanvas.setPointerCapture(event.pointerId); } function moveTextGesture(event) { if (!activeGesture || activeGesture.pointerId !== event.pointerId) return; const point = boardPoint(event, { round: false }); const element = elementById(activeGesture.id); if (!element) return; if (activeGesture.kind === "drag") { activeTransform = { id: element.id, kind: "drag", dx: point.x - activeGesture.startPoint.x, dy: point.y - activeGesture.startPoint.y, }; } else { const distance = Math.hypot(point.x - element.x, point.y - element.y); const minimum = 1 / activeGesture.startSize; const maximum = 64 / activeGesture.startSize; activeTransform = { id: element.id, kind: "resize", scale: Math.max(minimum, Math.min(maximum, distance / activeGesture.startDistance)), }; } event.preventDefault(); renderComposition(); } function finishTextGesture(event, commit = true) { if (!activeGesture || activeGesture.pointerId !== event.pointerId) return; const gesture = activeGesture; const transform = activeTransform; activeGesture = null; activeTransform = null; if (commit && transform) { if (gesture.kind === "drag") { updateTextElement(gesture.id, { x: gesture.startX + transform.dx, y: gesture.startY + transform.dy, }, "gesture"); } else { updateTextElement(gesture.id, { size: gesture.startSize * transform.scale, }, "gesture"); } } else { renderComposition(); } } overlayCanvas.addEventListener("pointerdown", startTextGesture); overlayCanvas.addEventListener("pointermove", moveTextGesture); overlayCanvas.addEventListener("pointerup", (event) => finishTextGesture(event, true)); overlayCanvas.addEventListener("pointercancel", (event) => finishTextGesture(event, false)); export function setBoardWorkspace(workspaceId) { const wasMonitorWorkspace = MONITOR_WORKSPACES.has(activeWorkspace); activeWorkspace = workspaceId; interactionMode = workspaceId === "canvas" ? "pixels" : workspaceId === "text" ? "text" : "readonly"; overlayCanvas.dataset.interaction = interactionMode; const isMonitor = MONITOR_WORKSPACES.has(workspaceId); const isAnimationWorkspace = workspaceId === "animations"; boardShell.classList.toggle("board-shell-monitor", isMonitor); boardShell.classList.toggle("board-shell-device-monitor", workspaceId === "device"); boardShell.classList.toggle("board-shell-animation", isAnimationWorkspace); standardBoardShell.hidden = isAnimationWorkspace; animationPreviewShell.hidden = !isAnimationWorkspace; boardShell.setAttribute("aria-labelledby", isAnimationWorkspace ? "animation-preview-shell-title" : "board-title"); if (isMonitor) { if (!wasMonitorWorkspace) previewLabel = "正在读取当前屏幕画面…"; refreshCurrentDisplayPreview(); } else { stopCurrentDisplayMonitor(); showCompositionPreview(); } updateBoardUi(); drawSelectionOverlay(); } function clearMonitorTimer() { if (monitorTimer !== null) { window.clearTimeout(monitorTimer); monitorTimer = null; } } function stopCurrentDisplayMonitor() { clearMonitorTimer(); monitorRefreshQueued = false; previewSequence += 1; } function scheduleCurrentDisplayRefresh(delay = previewRefreshIntervalMs) { clearMonitorTimer(); if (!MONITOR_WORKSPACES.has(activeWorkspace) || document.visibilityState !== "visible") return; monitorTimer = window.setTimeout(() => { monitorTimer = null; refreshCurrentDisplayPreview(); }, delay); } export function setPreviewRefreshInterval(value) { const parsed = Number(value); if ( !Number.isInteger(parsed) || parsed < MIN_PREVIEW_REFRESH_INTERVAL_MS || parsed > MAX_PREVIEW_REFRESH_INTERVAL_MS ) return false; const changed = parsed !== previewRefreshIntervalMs; previewRefreshIntervalMs = parsed; if ( changed && MONITOR_WORKSPACES.has(activeWorkspace) && document.visibilityState === "visible" ) { refreshCurrentDisplayPreview(); } return true; } export function refreshCurrentDisplayPreview() { clearMonitorTimer(); if (!MONITOR_WORKSPACES.has(activeWorkspace) || document.visibilityState !== "visible") return; if (monitorRequestPending) { monitorRefreshQueued = true; return; } void showCurrentDisplayPreview(); } async function showCurrentDisplayPreview() { const sequence = ++previewSequence; monitorRequestPending = true; previewKind = "monitor"; const compactDeviceMonitor = activeWorkspace === "device"; boardEyebrow.hidden = compactDeviceMonitor; boardMode.hidden = compactDeviceMonitor; if (compactDeviceMonitor) { renderUiValue(boardEyebrow, ""); renderUiValue(boardMode, ""); } else { renderUiCopy("copy.dynamic.board.monitor_eyebrow", boardEyebrow, "64 × 64 屏幕监视器", {}, { defaultHidden: true }); renderUiCopy("copy.dynamic.board.monitor_mode", boardMode, "预览当前显示屏幕画面", {}, { defaultHidden: true }); } renderUiCopy("copy.dynamic.board.monitor_title", boardTitle, "当前屏幕画面"); overlayContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); updateBoardUi(); try { const blob = await apiBlob("/api/display/current-frame"); const image = await imageFromBlob(blob); if (sequence !== previewSequence || !MONITOR_WORKSPACES.has(activeWorkspace)) { closeImage(image); return; } visibleContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT); visibleContext.drawImage(image, 0, 0); closeImage(image); previewLabel = ""; updateBoardUi(); } catch (error) { if (sequence !== previewSequence || !MONITOR_WORKSPACES.has(activeWorkspace)) return; previewLabel = `当前屏幕画面读取失败:${error.message}`; updateBoardUi(); } finally { monitorRequestPending = false; if (!MONITOR_WORKSPACES.has(activeWorkspace) || document.visibilityState !== "visible") return; if (monitorRefreshQueued) { monitorRefreshQueued = false; refreshCurrentDisplayPreview(); } else { scheduleCurrentDisplayRefresh(); } } } export function showCompositionPreview() { clearMonitorTimer(); previewSequence += 1; previewKind = "scene"; previewLabel = ""; boardEyebrow.hidden = false; boardMode.hidden = false; renderUiCopy("copy.dynamic.board.composition_eyebrow", boardEyebrow, "64 × 64 统一画板", {}, { defaultHidden: true }); renderUiCopy("copy.dynamic.board.composition_title", boardTitle, "合成预览", {}, { defaultHidden: true }); renderUiCopy( animationEdit ? "copy.dynamic.board.animation_edit_mode" : "copy.dynamic.board.composition_mode", boardMode, animationEdit ? "动图帧编辑 · 不会直接更新实屏" : "正向 0° · 统一画板", {}, animationEdit ? {} : { defaultHidden: true }, ); renderComposition(); } function rgbToBase64(rgb) { let binary = ""; for (let index = 0; index < rgb.length; index += 1) binary += String.fromCharCode(rgb[index]); return btoa(binary); } function compositionRgb() { const state = layerState(); if (!state.ready || !lastExactRgb) throw new Error("文字图层尚未全部就绪"); if (lastExactRgb.length !== RGB_BYTE_LENGTH) throw new Error("合成帧尺寸无效"); return lastExactRgb; } export async function applyComposition() { if (applying) return; if (isAppStale()) { throw new Error("页面版本已过期,请刷新后再应用画板"); } const rgb = compositionRgb(); applying = true; updateBoardUi(); boardStatus.textContent = "正在把统一画板应用到屏幕…"; boardStatus.dataset.state = "pending"; try { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; await new Promise((resolve, reject) => { const socket = new WebSocket(`${protocol}//${window.location.host}/ws/canvas`); let settled = false; const timeout = window.setTimeout(() => { if (settled) return; settled = true; socket.close(); reject(new Error("画板应用超时")); }, 10000); const finish = (callback, value) => { if (settled) return; settled = true; window.clearTimeout(timeout); socket.close(); callback(value); }; socket.onopen = () => socket.send(JSON.stringify({ type: "frame_rgb", source: "composition", ...(sceneSource ? { content_source: sceneSource } : {}), width: SCENE_WIDTH, height: SCENE_HEIGHT, encoding: "base64_rgb888", data: rgbToBase64(rgb), })); socket.onmessage = (event) => { const response = JSON.parse(event.data); if (response.type === "ack" && response.mode === "composition") finish(resolve, response); else if (response.type === "error") finish(reject, new Error(response.message)); }; socket.onerror = () => finish(reject, new Error("画板连接失败,请检查设备状态")); socket.onclose = () => { if (!settled) finish(reject, new Error("画板连接已断开")); }; }); showCompositionPreview(); boardStatus.textContent = "统一画板已应用到屏幕"; boardStatus.dataset.state = "ready"; announce("统一画板已应用到屏幕"); document.dispatchEvent(new CustomEvent("matrix:composition-applied")); } catch (error) { boardStatus.textContent = `画板应用失败:${error.message}`; boardStatus.dataset.state = "error"; announce(`画板应用失败:${error.message}`, true); throw error; } finally { applying = false; updateBoardUi(); } } export function exportCompositionPng() { const state = layerState(); if (!state.ready || activeTransform) throw new Error("请等待文字图层完成后再导出"); compositionCanvas.toBlob((blob) => { if (!blob) { announce("合成 PNG 生成失败", true); return; } const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = "matrix-composition.png"; link.click(); URL.revokeObjectURL(url); announce("合成 PNG 已导出"); }, "image/png"); } applyButton.addEventListener("click", () => { if (animationEdit) document.dispatchEvent(new CustomEvent("matrix:save-animation-frame")); else applyComposition().catch(() => {}); }); exitAnimationEditButton.addEventListener("click", () => document.dispatchEvent(new CustomEvent("matrix:request-exit-animation-edit"))); document.addEventListener("matrix:app-stale", () => { updateBoardUi(); }); exportButton.addEventListener("click", () => { try { exportCompositionPng(); } catch (error) { announce(error.message, true); } }); document.addEventListener("matrix:status", (event) => { const interval = event.detail?.ui?.preview_refresh_interval_ms; if (Number.isInteger(interval)) setPreviewRefreshInterval(interval); }); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { refreshCurrentDisplayPreview(); } else { clearMonitorTimer(); monitorRefreshQueued = false; previewSequence += 1; } }); scene.elements.forEach((element) => scheduleTextLayer(element, true)); renderComposition(); if (draftLoad.notice) announce(draftLoad.notice, !draftLoad.canPersist || draftLoad.status === "cleanup-warning");