1332 lines
51 KiB
JavaScript
1332 lines
51 KiB
JavaScript
import { attachScene, createLayer, cloneLayer, cloneScene, composeScene, clipTextPixels, translatedOverflow, clipBrushPixels, textClipAt, normalizeTextElement, layerToStorage } from "./scene-model.js";
|
||
import { announce, apiBlob, apiJson, isAppStale } from "./core.js";
|
||
import { readBoardSession, writeBoardSession, loadDefaultBoard, initializeDefaultBoard } from "./board-startup-model.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 externalDraftContext = null;
|
||
let draftPersistenceBlocked = !draftLoad.canPersist;
|
||
let draftProtectionNotice = (
|
||
draftPersistenceBlocked || draftLoad.status === "cleanup-warning"
|
||
) ? draftLoad.notice : "";
|
||
const deviceBootId = document.querySelector('meta[name="device-boot-id"]')?.content || "";
|
||
let boardBootId = "";
|
||
let boardMutation = 0;
|
||
let startupBoardPromise = null;
|
||
if (draftLoad.canPersist && deviceBootId) {
|
||
try {
|
||
const session = readBoardSession(draftStorage, serializeScene(scene), deviceBootId);
|
||
boardBootId = session.bootId || "";
|
||
templateEdit = session.context;
|
||
if (templateEdit && !deserializeScene(templateEdit.baseline)) throw new Error("画板保存基线无效;原草稿已保留。");
|
||
if (templateEdit && !sceneNeedsTemplateSave(templateEdit, serializeScene(scene)) && templateEdit.type === "template") {
|
||
sceneSource = { type: "template", id: templateEdit.id, revision: templateEdit.revision };
|
||
}
|
||
} catch (error) {
|
||
templateEdit = null;
|
||
draftPersistenceBlocked = true;
|
||
draftProtectionNotice = error.message;
|
||
}
|
||
}
|
||
|
||
export function initializeStartupBoard() {
|
||
if (startupBoardPromise) return startupBoardPromise;
|
||
const mutation = boardMutation;
|
||
const canLoad = () => Boolean(deviceBootId) && boardBootId !== deviceBootId
|
||
&& !draftPersistenceBlocked && !draftConflict && !animationEdit && boardMutation === mutation;
|
||
const retain = () => {
|
||
if (draftPersistenceBlocked || draftConflict || animationEdit) return;
|
||
boardBootId = deviceBootId;
|
||
persistScene();
|
||
};
|
||
startupBoardPromise = initializeDefaultBoard({
|
||
canLoad,
|
||
load: () => loadDefaultBoard(apiJson),
|
||
apply: (content) => {
|
||
// Validate before changing either the session marker or the current draft.
|
||
if (!deserializeScene(content.scene)) throw new Error("默认画面场景无效");
|
||
boardBootId = deviceBootId;
|
||
replaceSceneFromStorage(content.scene, {
|
||
reason: "startup-default-load",
|
||
source: content.type === "template" ? { type: "template", id: content.id, revision: content.revision } : null,
|
||
templateEditContext: content,
|
||
});
|
||
},
|
||
retain,
|
||
}).catch((error) => {
|
||
announce(`默认画面读取失败,已保留当前草稿:${error.message}`, true);
|
||
return false;
|
||
});
|
||
return startupBoardPromise;
|
||
}
|
||
|
||
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() {
|
||
boardMutation += 1;
|
||
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");
|
||
const draft = serializeScene(scene);
|
||
// Commit metadata first: a torn pair never restores the wrong binding.
|
||
writeBoardSession(draftStorage, boardBootId, draft, templateEdit);
|
||
draftStorage.setItem(SCENE_STORAGE_KEY, draft);
|
||
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;
|
||
try {
|
||
externalDraftContext = readBoardSession(draftStorage, event.newValue, deviceBootId).context;
|
||
} catch (error) {
|
||
blockDraftPersistence(error.message);
|
||
return;
|
||
}
|
||
externalDraftValue = event.newValue;
|
||
setDraftConflict(true);
|
||
announce("另一标签页修改了画板草稿,请选择要保留的版本", true);
|
||
});
|
||
|
||
loadExternalDraftButton.addEventListener("click", async () => {
|
||
const value = externalDraftValue;
|
||
const context = externalDraftContext;
|
||
if (!value) return;
|
||
if (animationEdit && !await sceneReplacementGuard()) return;
|
||
externalDraftValue = null;
|
||
setDraftConflict(false);
|
||
replaceSceneFromStorage(value, { persist: false, reason: "tab-sync", templateEditContext: context });
|
||
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<rgb.length;i+=3,j+=4) {image.data.set(rgb.subarray(i,i+3),j);image.data[j+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 previewTextPixels() {
|
||
const result=new Map();
|
||
for(const e of scene.elements) {
|
||
let rgba=textLayers.get(e.id)?.rgba;
|
||
if(!rgba) continue;
|
||
if(activeTransform?.id===e.id) {
|
||
const out=new Uint8ClampedArray(rgba.length), t=activeTransform;
|
||
for(let y=0;y<192;y++) for(let x=0;x<192;x++) {
|
||
const sx=Math.floor(t.kind==="drag"?x-t.dx:e.x+64+(x-e.x-64)/t.scale);
|
||
const sy=Math.floor(t.kind==="drag"?y-t.dy:e.y+64+(y-e.y-64)/t.scale);
|
||
if(sx>=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,
|
||
} = {},
|
||
) {
|
||
boardMutation += 1;
|
||
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 };
|
||
boardBootId = deviceBootId;
|
||
persistScene();
|
||
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");
|