Files
matrix-screen-controller/核桃派软件源代码/app/static/scene-board.js
T

1193 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");