import { attachScene, createLayer, cloneLayer, cloneScene, composeScene, clipTextPixels, translatedOverflow, clipBrushPixels, textClipAt, normalizeTextElement, layerToStorage } from "./scene-model.js"; 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 = 192; scratchCanvas.height = 192; 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 selectedLayerId = scene.layers[0].id; let expandedBoard = false; let layerMovePreview = null; let moveFrameVisible = false; const editHistory = []; 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({ layerId: scene.layers.find(layer => layer.elements?.some(item => item.id === element.id))?.id, elementId: element.id, text: element.text, font: element.font, size: element.size, x: element.x, y: element.y, align: element.align, color: element.color, clip: element.clip, }); } function textPayload(element) { return {text:element.text,font:element.font,size:element.size,x:element.x+64,y:element.y+64, align:element.align,color:element.color,edit_size:192}; } 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 = 192; let top = 192; let right = -1; let bottom = -1; for (let y = 0; y < 192; y += 1) { for (let x = 0; x < 192; x += 1) { if (rgba[(y * 192 + 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-64, y: top-64, 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, 192, 192); scratchContext.drawImage(image, 0, 0); const rgba = new Uint8ClampedArray( scratchContext.getImageData(0, 0, 192, 192).data, ); closeImage(entry.image); entry.image = image; entry.rgba = clipTextPixels(rgba, current); entry.bounds = alphaBounds(entry.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 side=Math.sqrt(rgb.length/3), image=context.createImageData(side,side); for(let i=0,j=0;i=0&&sx<192&&sy>=0&&sy<192) out.set(rgba.subarray((sy*192+sx)*4,(sy*192+sx)*4+4),(y*192+x)*4); } rgba=out; } result.set(e.id,rgba); } return result; } function renderComposition() { removeUnusedLayers(); scene.elements.forEach(scheduleTextLayerIfMissing); const state=layerState(), pixels=previewTextPixels(); const rgb=composeScene(scene,pixels,64,layerMovePreview); putRgb(compositionContext,rgb); lastExactRgb=state.ready&&!activeTransform&&!layerMovePreview?rgb:null; if(previewKind==="scene") putRgb(visibleContext,expandedBoard?composeScene(scene,pixels,192,layerMovePreview):rgb); animationBoardPreviewContext.clearRect(0,0,64,64); 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 || layerMovePreview) { setControlUnavailable(applyButton, "请先完成当前文字拖动或缩放操作。"); } else if (!state.ready) { setControlUnavailable(applyButton, state.errors ? "有文字图层渲染失败,请修正后再应用完整画面。" : "请等待文字图层生成完成后再应用画板。"); } else { setControlReady(applyButton); } if (activeTransform || layerMovePreview) { 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.layers.length} 个图层 · ${scene.elements.length} 个文字元素 · 动图帧编辑中` : ` ${scene.layers.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 local = textLayers.get(element.id)?.bounds || fallbackBounds(element); const layer=scene.layers.find(l=>l.elements?.some(e=>e.id===element.id)); const source={...local,x:local.x+(layer?.x||0),y:local.y+(layer?.y||0)}; 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+(getSelectedLayer().x||0)+(source.x-element.x-(getSelectedLayer().x||0))*activeTransform.scale, y: element.y+(getSelectedLayer().y||0)+(source.y-element.y-(getSelectedLayer().y||0))*activeTransform.scale, width: source.width * activeTransform.scale, height: source.height * activeTransform.scale, }; } function drawSelectionOverlay() { overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); const origin=expandedBoard?-64:0; document.getElementById("actual-screen-frame").hidden=!(expandedBoard||moveFrameVisible); if (interactionMode !== "text" || previewKind !== "scene" || !selectedTextId) return; const element = elementById(selectedTextId); if (!element) return; const bounds = transformedBounds(element); overlayContext.save(); overlayContext.translate(-origin,-origin); 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((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.x + bounds.width)); const handleY = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.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, selectedLayerId, reason }; listeners.forEach((listener) => listener(detail)); document.dispatchEvent(new CustomEvent("matrix:scene-change", { detail })); } export function subscribeScene(listener) { listeners.add(listener); listener({ scene, selectedTextId, selectedLayerId, 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; clearEditHistory(); 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; selectedLayerId = scene.layers[0].id; selectedTextId = getSelectedLayer()?.elements?.at(-1)?.id || null; syncInteractionMode(); 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; clearEditHistory(); 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 getSelectedLayer() {return scene.layers.find(l=>l.id===selectedLayerId)||scene.layers[0];} export function getPixelBuffer() {return getSelectedLayer().pixels;} export function getInteractionMode() {return interactionMode;} export function boardExtent() {return {size:expandedBoard?192:64,origin:expandedBoard?-64:0};} export function boardPoint(event,{round=true}={}) { const rect=overlayCanvas.getBoundingClientRect(), {size,origin}=boardExtent(); const x=((event.clientX-rect.left)/rect.width)*size+origin, y=((event.clientY-rect.top)/rect.height)*size+origin; return {x:round?Math.max(origin,Math.min(origin+size-1,Math.floor(x))):x,y:round?Math.max(origin,Math.min(origin+size-1,Math.floor(y))):y}; } export function setExpandedBoard(value) { expandedBoard=Boolean(value); const size=expandedBoard?192:64; visibleCanvas.width=visibleCanvas.height=overlayCanvas.width=overlayCanvas.height=size; visibleContext.imageSmoothingEnabled=false; document.getElementById("board-stage").classList.toggle("board-expanded",expandedBoard); renderComposition(); } export function setMoveFrameVisible(value) {moveFrameVisible=value;drawSelectionOverlay();} function syncInteractionMode() { interactionMode=activeWorkspace==="canvas"?(getSelectedLayer().type==="text"?"text":"pixels"):"readonly"; overlayCanvas.dataset.interaction=interactionMode; } export function selectLayer(id) { if(!scene.layers.some(l=>l.id===id)) return; selectedLayerId=id;selectedTextId=getSelectedLayer().elements?.at(-1)?.id||null; syncInteractionMode();renderComposition();emitSceneChange("selection"); } export function rememberEdit(before=serializeScene(scene)) { if(editHistory.at(-1)!==before) editHistory.push(before); if(editHistory.length>50)editHistory.shift(); document.dispatchEvent(new CustomEvent("matrix:edit-history")); } export function editSnapshot() {return serializeScene(scene);} export function editHistorySize() {return editHistory.length;} export function clearEditHistory() {editHistory.length=0;document.dispatchEvent(new CustomEvent("matrix:edit-history"));} export function undoSceneEdit() { const value=editHistory.pop();if(!value)return false; const selected=selectedLayerId;scene=deserializeScene(value); if(scene.layers.some(l=>l.id===selected))selectedLayerId=selected;else selectedLayerId=scene.layers[0].id; selectedTextId=getSelectedLayer().elements?.at(-1)?.id||null; syncInteractionMode();scene.elements.forEach(e=>scheduleTextLayer(e,true));commitPixelChanges("undo"); document.dispatchEvent(new CustomEvent("matrix:edit-history"));return true; } export function changeLayer(action,value) { const layer=getSelectedLayer(),before=editSnapshot(); if(action==="create") {const next=createLayer(value,scene.layers);scene.layers.push(next);selectLayer(next.id);} if(action==="duplicate") {const next=cloneLayer(layer,scene.layers);scene.layers.push(next);selectLayer(next.id);} if(action==="delete") { if(layer.type==="brush"&&scene.layers.filter(l=>l.type==="brush").length===1) {announce("必须保留一个画笔图层",true);return;} scene=attachScene(scene.layers.filter(l=>l.id!==layer.id));selectLayer(scene.layers.at(-1).id); } if(action==="rename") {const name=value.trim();if(!name||name.length>80)throw Error("图层名称需为1..80个字符");layer.name=name;} if(action==="up"||action==="down") {const index=scene.layers.indexOf(layer),target=index+(action==="up"?1:-1);if(target<0||target>=scene.layers.length)return;[scene.layers[index],scene.layers[target]]=[scene.layers[target],scene.layers[index]];} if(before!==editSnapshot())rememberEdit(before);commitPixelChanges("layers"); } export function previewLayerMove(x,y) {const l=getSelectedLayer();layerMovePreview={layerId:l.id,x:Math.max(-64,Math.min(64,Math.round(x))),y:Math.max(-64,Math.min(64,Math.round(y)))};renderComposition();} export function cancelLayerPreview() {layerMovePreview=null;renderComposition();} export function moveSelectedLayer(x,y) { const layer=getSelectedLayer();if(!layerState().ready)throw Error("请等待文字渲染完成后移动图层"); const nextX=Math.max(-64,Math.min(64,Math.round(x))),nextY=Math.max(-64,Math.min(64,Math.round(y))); if(!Number.isFinite(nextX)||!Number.isFinite(nextY))throw Error("偏移必须为整数"); if(nextX!==Math.round(x)||nextY!==Math.round(y))announce("图层偏移限制为−64到64;黄色框为实际屏幕"); const checks=layer.type==="brush"?[translatedOverflow(layer.pixels,nextX,nextY)]:layer.elements.map(e=>translatedOverflow(textLayers.get(e.id).rgba,nextX,nextY)); const overflow=checks.some(c=>c.outside);previewLayerMove(nextX,nextY); if(overflow&&!window.confirm("有背景以外的内容移出192×192编辑区域。确定后将清除越界部分;取消恢复本次移动。")) {cancelLayerPreview();emitSceneChange("move-cancel");return false;} const before=editSnapshot();layer.x=nextX;layer.y=nextY; if(overflow&&layer.type==="brush") {layer.pixels=clipBrushPixels(layer.pixels,nextX,nextY);layer.revision++;} if(overflow&&layer.type==="text") layer.elements=layer.elements.flatMap((e,i)=>!checks[i].inside?[]:[checks[i].outside?{...e,clip:textClipAt(e,nextX,nextY),revision:e.revision+1}:e]); layerMovePreview=null;if(before!==editSnapshot())rememberEdit(before); scene.elements.forEach(e=>scheduleTextLayer(e,true));commitPixelChanges("layer-move");return true; } export function getBoardOverlay() { return overlayCanvas; } export function renderPixelDraft() { renderComposition(); } export function commitPixelChanges(reason = "pixels") { sceneSource = null; if(getSelectedLayer().type==="brush")getSelectedLayer().revision++; persistScene(); renderComposition(); emitSceneChange(reason); } export function fillPixelLayer(rgb) { const l=getSelectedLayer();l.backgroundColor="#"+rgb.map(n=>n.toString(16).padStart(2,"0")).join("").toUpperCase();l.background=l.backgroundColor;commitPixelChanges("pixel-fill"); } export function clearPixelLayer() {const l=getSelectedLayer();l.pixels.fill(0);commitPixelChanges("pixel-clear");} export function setLayerBackground(enabled,color) {const l=getSelectedLayer();if(l.type!=="brush")return;const before=editSnapshot();l.backgroundColor=color;l.background=enabled?color:null;if(before!==editSnapshot())rememberEdit(before);commitPixelChanges("background");} export function selectTextElement(id, reason = "selection") { const layer=scene.layers.find(l=>l.elements?.some(e=>e.id===id)); if(layer)selectedLayerId=layer.id; 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, selectedLayerId); const created = getSelectedLayer().elements.at(-1); selectedTextId = created.id; persistScene(); scheduleTextLayer(created, true); renderComposition(); emitSceneChange("create"); return created; } export function updateTextElement(id, patch, reason="update") { const current=elementById(id);if(!current)return null; const layer=scene.layers.find(l=>l.elements?.some(e=>e.id===id)); const updated=normalizeTextElement({...current,...patch}),before=editSnapshot(); const dx=updated.x-current.x,dy=updated.y-current.y; if(dx||dy) { const rgba=textLayers.get(id)?.rgba; if(!rgba||!layerState().ready){announce("请等待文字渲染完成后移动",true);emitSceneChange("move-cancel");return current;} const check=translatedOverflow(rgba,dx+layer.x,dy+layer.y); if(check.outside) { if(!window.confirm("文字移出192×192编辑区域。确定后清除越界部分;取消恢复本次移动。")){emitSceneChange("move-cancel");return current;} if(!check.inside) {scene=deleteTextFromScene(scene,id);selectedTextId=layer.elements.find(e=>e.id!==id)?.id||null;rememberEdit(before);commitPixelChanges("gesture");return null;} patch={...patch,clip:textClipAt(updated,layer.x,layer.y)}; } } scene=updateTextInScene(scene,id,patch);sceneSource=null; if(dx||dy||reason==="gesture")rememberEdit(before); const target=elementById(id);persistScene();if(target)scheduleTextLayer(target); renderComposition();emitSceneChange(reason);return target; } 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 = getSelectedLayer().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 = getSelectedLayer().elements?.at(-1)?.id || null; persistScene(); renderComposition(); emitSceneChange("delete"); return true; } function hitTestText(point) { const elements=getSelectedLayer().elements||[]; for (let index = elements.length - 1; index >= 0; index -= 1) { const element = 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((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.5, bounds.x + bounds.width)); const y = Math.max((expandedBoard?-64:0)+1.5, Math.min((expandedBoard?128:64)-1.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" || (document.body.dataset.canvasEditorMode && document.body.dataset.canvasEditorMode!=="draw")) return; const point = boardPoint(event, { round: false }); let element = getSelectedTextElement(); if (element && resizeHandleHit(point, element)) { const distance = Math.hypot(point.x-element.x-getSelectedLayer().x,point.y-element.y-getSelectedLayer().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-getSelectedLayer().x,point.y-element.y-getSelectedLayer().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; syncInteractionMode(); 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 || layerMovePreview) 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");