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