Improve Android app UI and controller flows

This commit is contained in:
2026-10-09 16:43:58 +08:00
parent c0e26b97fe
commit 608229a497
90 changed files with 8103 additions and 1133 deletions
@@ -12,6 +12,7 @@ import "./color-picker.js";
import "./views/canvas.js";
import { refreshStatus } from "./views/device.js";
import "./views/text.js";
import "./views/layers.js";
import "./views/media-import.js";
import "./views/animations.js";
import "./views/templates.js";
@@ -377,3 +378,6 @@ document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") pollStatus();
});
pollStatus();
if(window.location.hash==="#text")window.location.hash="#canvas";
window.addEventListener("hashchange",()=>{if(window.location.hash==="#text")window.location.hash="#canvas";});
@@ -62,13 +62,15 @@ export function coordinateGuideTicks({
visibleStart = Number.NEGATIVE_INFINITY,
visibleEnd = Number.POSITIVE_INFINITY,
step,
count = 64,
origin = 0,
}) {
const start = Number(boardStart) || 0;
const size = Math.max(0, Number(boardSize) || 0);
const interval = COORDINATE_GUIDE_STEPS.includes(step) ? step : COORDINATE_GUIDE_STEPS[0];
const ticks = [];
for (let value = 0; value < 64; value += interval) {
const position = start + (value / 64) * size;
for (let value = origin; value < origin + count; value += interval) {
const position = start + ((value-origin) / count) * size;
if (position >= visibleStart && position <= visibleEnd) ticks.push({ value, position });
}
return ticks;
+38 -15
View File
@@ -103,6 +103,7 @@
<div id="board-stage" class="board-stage">
<canvas id="scene-canvas" width="64" height="64" aria-label="统一画板的正向合成预览"></canvas>
<canvas id="scene-overlay" width="64" height="64" aria-label="统一画板交互层"></canvas>
<div id="actual-screen-frame" aria-hidden="true" hidden><span>实际屏幕 64×64</span></div>
<div id="pixel-grid" class="pixel-grid" aria-hidden="true" hidden></div>
</div>
<canvas id="canvas-coordinate-guide" class="canvas-coordinate-guide" aria-hidden="true" hidden></canvas>
@@ -120,6 +121,16 @@
</button>
<div id="canvas-assistive-menu" class="canvas-assistive-menu" role="menu" aria-label="画布编辑操作" hidden>
<p id="canvas-edit-mode-hint" role="status" aria-live="polite">绘画模式:直接在画布上绘制</p>
<button id="toggle-expanded-board" type="button" role="menuitem" aria-pressed="false">显示扩展画面</button>
<button id="move-current-layer" type="button" role="menuitem" aria-pressed="false">移动当前图层</button>
<section id="layer-move-controls" class="layer-move-controls" aria-label="图层偏移" hidden>
<button id="exit-layer-move" type="button">退出图层移动</button>
<p>移动图层 · 范围 −64..64</p>
<label>X <input id="layer-offset-x" type="number" min="-64" max="64" value="0" step="1"></label>
<div class="offset-steps" data-axis="x"><button type="button" data-offset="-10">−10</button><button type="button" data-offset="-1">−1</button><button type="button" data-offset="1">+1</button><button type="button" data-offset="10">+10</button></div>
<label>Y <input id="layer-offset-y" type="number" min="-64" max="64" value="0" step="1"></label>
<div class="offset-steps" data-axis="y"><button type="button" data-offset="-10">−10</button><button type="button" data-offset="-1">−1</button><button type="button" data-offset="1">+1</button><button type="button" data-offset="10">+10</button></div>
</section>
<button id="toggle-canvas-coordinates" type="button" role="menuitem" aria-pressed="false">显示坐标系</button>
<button id="undo-canvas-edit" type="button" role="menuitem" aria-disabled="true"
data-unavailable-reason="还没有可以撤销的像素操作。">撤销</button>
@@ -218,13 +229,14 @@
</div>
<article class="panel canvas-workbench">
<div id="brush-layer-tools">
<div class="canvas-toolbar">
<div class="color-field"><span>画笔颜色</span>
<div class="color-field"><div class="canvas-color-heading"><span>画笔颜色</span></div>
<button id="paint-color" class="color-trigger" type="button" value="#FFFFFF" data-color-picker aria-label="选择画笔颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#FFFFFF</span>
</button>
</div>
<div class="color-field"><span>背景颜色</span>
<div class="color-field"><div class="canvas-color-heading"><span>背景颜色</span><label class="layer-background-toggle"><input id="layer-background-enabled" type="checkbox"><span>开启图层背景</span></label></div>
<button id="background-color" class="color-trigger" type="button" value="#000000" data-color-picker aria-label="选择画布背景颜色">
<span class="color-trigger-swatch" aria-hidden="true"></span><span class="color-trigger-value">#000000</span>
</button>
@@ -252,20 +264,8 @@
<button id="fill-canvas" type="button">填充背景</button>
<button id="clear-canvas" type="button">清空像素层</button>
</div>
<button id="start-canvas-edit" class="primary-button full-width" type="button">开始编辑</button>
<p class="hint">画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。</p>
</article>
</section>
<section id="workspace-text" class="workspace" aria-labelledby="text-title" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow" data-ui-copy-default-text="内容"></p>
<h2 id="text-title" aria-label="文字显示" data-ui-copy-default-text="文字显示"></h2>
</div>
<span class="orientation-label" data-ui-copy-default-text="正向 0°"></span>
</div>
<div id="text-layer-tools" hidden>
<article class="panel text-elements-panel">
<div class="panel-heading">
<div>
@@ -329,6 +329,22 @@
</div>
</fieldset>
</form>
</div>
<section id="layer-panel" class="layer-panel" aria-labelledby="layer-panel-title">
<h3 id="layer-panel-title">图层</h3>
<div class="layer-actions">
<button id="new-brush-layer" type="button">新建画笔层</button><button id="new-text-layer" type="button">新建文字层</button>
<button id="copy-layer" type="button">复制图层…</button><button id="rename-layer" type="button">重命名</button>
<button id="raise-layer" type="button">上移</button><button id="lower-layer" type="button">下移</button>
<button id="delete-layer" class="danger-button" type="button">删除图层</button>
</div>
<div id="layer-list" class="layer-list" role="group" aria-label="图层顺序,上方覆盖下方"></div>
<p class="hint">上方图层覆盖下方。至少保留一个画笔层。</p>
</section>
<button id="start-canvas-edit" class="primary-button full-width" type="button">开始编辑</button>
<p class="hint">画布默认不会直接落笔。选好颜色和工具后,点击“开始编辑”进入全屏画布。</p>
</article>
</section>
<section id="workspace-media-import" class="workspace" aria-labelledby="media-import-title" hidden>
@@ -942,5 +958,12 @@
<div id="control-feedback-toast" class="control-feedback-toast" role="status" aria-live="polite" aria-atomic="true" hidden></div>
<noscript>此控制台需要启用 JavaScript。</noscript>
<script type="module" src="/static/app.js"></script>
<dialog id="layer-copy-dialog" class="layer-copy-dialog">
<h2>复制当前图层</h2>
<label>目标画面 <select id="layer-copy-target"><option value="current">当前画板</option></select></label>
<section id="layer-copy-frames" hidden><p>选择目标帧</p><button id="layer-copy-all" type="button">全选</button><button id="layer-copy-none" type="button">清空选择</button><div id="layer-copy-frame-list"></div></section>
<p id="layer-copy-feedback" role="status" aria-live="polite"></p>
<div class="layer-actions"><button id="layer-copy-submit" type="button">复制到最上方</button><button id="layer-copy-cancel" type="button">取消</button></div>
</dialog>
</body>
</html>
@@ -0,0 +1,54 @@
// Fixed 192x192 RGBA PNG codec, independent of DOM and shared by Node tests.
import { zlibSync, unzlibSync } from "./vendor/fflate/fflate.js";
const SIDE = 192, STRIDE = SIDE * 4;
const SIGNATURE = [137,80,78,71,13,10,26,10];
const crcTable = Uint32Array.from({ length: 256 }, (_, n) => {
for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
return n >>> 0;
});
function crc(bytes) { let n = 0xffffffff; for (const b of bytes) n = crcTable[(n ^ b) & 255] ^ (n >>> 8); return (n ^ 0xffffffff) >>> 0; }
function chunk(name, data) {
const out = new Uint8Array(data.length + 12), view = new DataView(out.buffer);
view.setUint32(0, data.length); out.set(Array.from(name, c => c.charCodeAt(0)), 4); out.set(data, 8);
view.setUint32(out.length - 4, crc(out.subarray(4, out.length - 4))); return out;
}
export function encodePixels(rgba) {
if (rgba.length !== SIDE * STRIDE) throw new Error("画笔像素长度无效");
const ihdr = new Uint8Array(13), v = new DataView(ihdr.buffer);
v.setUint32(0, SIDE); v.setUint32(4, SIDE); ihdr[8] = 8; ihdr[9] = 6;
const raw = new Uint8Array((STRIDE + 1) * SIDE);
for (let y = 0; y < SIDE; y++) raw.set(rgba.subarray(y * STRIDE, (y + 1) * STRIDE), y * (STRIDE + 1) + 1);
const parts = [new Uint8Array(SIGNATURE), chunk("IHDR", ihdr), chunk("IDAT", zlibSync(raw, { level: 6 })), chunk("IEND", new Uint8Array())];
const png = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let at = 0;
for (const p of parts) { png.set(p, at); at += p.length; }
let binary = ""; for (const b of png) binary += String.fromCharCode(b);
return btoa(binary);
}
function paeth(a,b,c) { const p=a+b-c, x=Math.abs(p-a), y=Math.abs(p-b), z=Math.abs(p-c); return x<=y && x<=z ? a : y<=z ? b : c; }
export function decodePixels(value) {
if (typeof value !== "string" || value.length > 300000 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error("画笔PNG无效");
const bytes = Uint8Array.from(atob(value), c => c.charCodeAt(0)), view = new DataView(bytes.buffer);
if (!SIGNATURE.every((b,i) => bytes[i] === b)) throw new Error("画笔PNG签名无效");
const parts=[]; let header=false, ended=false;
for (let at=8; at+12<=bytes.length;) {
const n=view.getUint32(at); if (n>bytes.length-at-12) throw new Error("PNG截断");
const name=String.fromCharCode(...bytes.subarray(at+4,at+8)), data=bytes.subarray(at+8,at+8+n);
if (crc(bytes.subarray(at+4,at+8+n))!==view.getUint32(at+8+n)) throw new Error("PNG校验失败");
if (name==="IHDR") { if (header || n!==13 || view.getUint32(at+8)!==SIDE || view.getUint32(at+12)!==SIDE || data[8]!==8 || data[9]!==6 || data[10] || data[11] || data[12]) throw new Error("PNG必须为192×192 RGBA8"); header=true; }
else if (name==="IDAT") { if (!header || ended) throw new Error("PNG顺序无效"); parts.push(data); }
else if (name==="IEND") { if (n || at+12!==bytes.length) throw new Error("PNG结尾无效"); ended=true; }
else if (name[0]===name[0].toUpperCase()) throw new Error("PNG不支持此块");
at+=n+12;
}
if (!header || !ended || !parts.length) throw new Error("PNG不完整");
const compressed=new Uint8Array(parts.reduce((n,p)=>n+p.length,0)); let at=0; for (const p of parts) { compressed.set(p,at); at+=p.length; }
const expected=(STRIDE+1)*SIDE, raw=unzlibSync(compressed,{out:new Uint8Array(expected+1)});
if (raw.length!==expected) throw new Error("PNG展开长度无效");
const out=new Uint8ClampedArray(SIDE*STRIDE);
for (let y=0;y<SIDE;y++) { const filter=raw[y*(STRIDE+1)]; if (filter>4) throw new Error("PNG过滤器无效");
for (let x=0;x<STRIDE;x++) { const i=y*STRIDE+x, a=x>=4?out[i-4]:0, b=y?out[i-STRIDE]:0, c=y&&x>=4?out[i-STRIDE-4]:0;
out[i]=(raw[y*(STRIDE+1)+1+x]+([0,a,b,Math.floor((a+b)/2),paeth(a,b,c)][filter]))&255;
}
}
return out;
}
+181 -108
View File
@@ -1,3 +1,4 @@
import { attachScene, createLayer, cloneLayer, cloneScene, composeScene, clipTextPixels, translatedOverflow, clipBrushPixels, textClipAt, normalizeTextElement, layerToStorage } from "./scene-model.js";
import { announce, apiBlob, isAppStale } from "./core.js";
import { renderUiCopy, renderUiValue } from "./ui-copy-runtime.js";
import {
@@ -34,8 +35,8 @@ 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;
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");
@@ -76,6 +77,11 @@ 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";
@@ -175,6 +181,8 @@ keepCurrentDraftButton.addEventListener("click", () => {
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,
@@ -182,19 +190,13 @@ function elementSignature(element) {
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,
y: element.y,
align: element.align,
color: element.color,
};
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) {
@@ -221,13 +223,13 @@ async function imageFromBlob(blob) {
}
function alphaBounds(rgba) {
let left = SCENE_WIDTH;
let top = SCENE_HEIGHT;
let left = 192;
let top = 192;
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;
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);
@@ -235,7 +237,7 @@ function alphaBounds(rgba) {
}
}
if (right < left || bottom < top) return null;
return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
return { x: left-64, y: top-64, width: right - left + 1, height: bottom - top + 1 };
}
function layerEntry(id) {
@@ -318,15 +320,15 @@ async function loadTextLayer(id, requestedSignature, requestedRevision) {
closeImage(image);
return;
}
scratchContext.clearRect(0, 0, SCENE_WIDTH, SCENE_HEIGHT);
scratchContext.clearRect(0, 0, 192, 192);
scratchContext.drawImage(image, 0, 0);
const rgba = new Uint8ClampedArray(
scratchContext.getImageData(0, 0, SCENE_WIDTH, SCENE_HEIGHT).data,
scratchContext.getImageData(0, 0, 192, 192).data,
);
closeImage(entry.image);
entry.image = image;
entry.rgba = rgba;
entry.bounds = alphaBounds(rgba);
entry.rgba = clipTextPixels(rgba, current);
entry.bounds = alphaBounds(entry.rgba);
entry.signature = requestedSignature;
entry.revision = requestedRevision;
entry.targetSignature = requestedSignature;
@@ -369,14 +371,9 @@ function layerState() {
}
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);
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) {
@@ -397,34 +394,35 @@ function drawTransformedLayer(context, element, entry) {
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((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);
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) {
@@ -455,7 +453,7 @@ function updateBoardUi(state = layerState()) {
setControlUnavailable(applyButton, "浏览器无法安全保存当前草稿,请先处理页面提示后再应用。");
} else if (draftConflict) {
setControlUnavailable(applyButton, "请先解决另一标签页的草稿冲突,再应用画板。");
} else if (activeTransform) {
} else if (activeTransform || layerMovePreview) {
setControlUnavailable(applyButton, "请先完成当前文字拖动或缩放操作。");
} else if (!state.ready) {
setControlUnavailable(applyButton, state.errors
@@ -464,7 +462,7 @@ function updateBoardUi(state = layerState()) {
} else {
setControlReady(applyButton);
}
if (activeTransform) {
if (activeTransform || layerMovePreview) {
setControlUnavailable(exportButton, "请先完成当前文字拖动或缩放操作。");
} else if (!state.ready) {
setControlUnavailable(exportButton, state.errors
@@ -504,8 +502,8 @@ function updateBoardUi(state = layerState()) {
boardStatus.dataset.state = "pending";
} else {
boardStatus.textContent = animationEdit
? `像素底层 + ${scene.elements.length} 个文字元素 · 动图帧编辑中`
: `像素底层 + ${scene.elements.length} 个文字元素 · 草稿已保存在当前浏览器`;
? ` ${scene.layers.length} 个图层 · ${scene.elements.length} 个文字元素 · 动图帧编辑中`
: ` ${scene.layers.length} 个图层 · ${scene.elements.length} 个文字元素 · 草稿已保存在当前浏览器`;
boardStatus.dataset.state = "ready";
}
}
@@ -520,7 +518,9 @@ function fallbackBounds(element) {
}
function transformedBounds(element) {
const source = textLayers.get(element.id)?.bounds || fallbackBounds(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 {
@@ -530,42 +530,45 @@ function transformedBounds(element) {
};
}
return {
x: element.x + (source.x - element.x) * activeTransform.scale,
y: element.y + (source.y - element.y) * activeTransform.scale,
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, SCENE_WIDTH, SCENE_HEIGHT);
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(1.5, Math.min(62.5, bounds.x + bounds.width));
const handleY = Math.max(1.5, Math.min(62.5, bounds.y + bounds.height));
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, 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, reason: "subscribe" });
listener({ scene, selectedTextId, selectedLayerId, reason: "subscribe" });
return () => listeners.delete(listener);
}
@@ -621,6 +624,7 @@ export function enterAnimationEdit(frame) {
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;
@@ -684,7 +688,9 @@ export function replaceSceneFromStorage(
templateEditContext.baseline || serializeScene(restored),
)
: null;
selectedTextId = scene.elements.at(-1)?.id || 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();
@@ -710,6 +716,7 @@ export function enterTemplateEdit(template) {
export function markTemplateSaved(template) {
if (animationEdit) return false;
clearEditHistory();
templateEdit = createTemplateEditContext(template, serializeScene(scene));
sceneSource = { type: "template", id: template.id, revision: template.revision };
updateBoardUi();
@@ -717,22 +724,75 @@ export function markTemplateSaved(template) {
return true;
}
export function getPixelBuffer() {
return scene.pixels;
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 getInteractionMode() {
return interactionMode;
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 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 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() {
@@ -745,17 +805,21 @@ export function renderPixelDraft() {
export function commitPixelChanges(reason = "pixels") {
sceneSource = null;
if(getSelectedLayer().type==="brush")getSelectedLayer().revision++;
persistScene();
renderComposition();
emitSceneChange(reason);
}
export function fillPixelLayer(rgb) {
scene = createScene({ pixels: createRgbPixels(rgb), elements: scene.elements });
commitPixelChanges("pixel-fill");
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);
@@ -768,8 +832,8 @@ export function getSelectedTextElement() {
export function addTextElement(overrides = {}) {
sceneSource = null;
scene = addTextToScene(scene, overrides);
const created = scene.elements.at(-1);
scene = addTextToScene(scene, overrides, selectedLayerId);
const created = getSelectedLayer().elements.at(-1);
selectedTextId = created.id;
persistScene();
scheduleTextLayer(created, true);
@@ -778,17 +842,25 @@ export function addTextElement(overrides = {}) {
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 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) {
@@ -796,7 +868,7 @@ export function duplicateTextElement(id = selectedTextId) {
scene = duplicateTextInScene(scene, id);
if (scene.elements.length === previousLength) return null;
sceneSource = null;
const duplicate = scene.elements.at(-1);
const duplicate = getSelectedLayer().elements.at(-1);
selectedTextId = duplicate.id;
persistScene();
scheduleTextLayer(duplicate, true);
@@ -810,7 +882,7 @@ export function deleteTextElement(id = selectedTextId) {
scene = deleteTextFromScene(scene, id);
if (scene.elements.length === previousLength) return false;
sceneSource = null;
selectedTextId = scene.elements.at(-1)?.id || null;
selectedTextId = getSelectedLayer().elements?.at(-1)?.id || null;
persistScene();
renderComposition();
emitSceneChange("delete");
@@ -818,8 +890,9 @@ export function deleteTextElement(id = selectedTextId) {
}
function hitTestText(point) {
for (let index = scene.elements.length - 1; index >= 0; index -= 1) {
const element = scene.elements[index];
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
@@ -833,17 +906,17 @@ function hitTestText(point) {
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));
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") return;
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, point.y - element.y);
const distance = Math.hypot(point.x-element.x-getSelectedLayer().x,point.y-element.y-getSelectedLayer().y);
activeGesture = {
kind: "resize",
id: element.id,
@@ -885,7 +958,7 @@ function moveTextGesture(event) {
dy: point.y - activeGesture.startPoint.y,
};
} else {
const distance = Math.hypot(point.x - element.x, point.y - element.y);
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 = {
@@ -928,7 +1001,7 @@ overlayCanvas.addEventListener("pointercancel", (event) => finishTextGesture(eve
export function setBoardWorkspace(workspaceId) {
const wasMonitorWorkspace = MONITOR_WORKSPACES.has(activeWorkspace);
activeWorkspace = workspaceId;
interactionMode = workspaceId === "canvas" ? "pixels" : workspaceId === "text" ? "text" : "readonly";
syncInteractionMode();
overlayCanvas.dataset.interaction = interactionMode;
const isMonitor = MONITOR_WORKSPACES.has(workspaceId);
const isAnimationWorkspace = workspaceId === "animations";
@@ -1142,7 +1215,7 @@ export async function applyComposition() {
export function exportCompositionPng() {
const state = layerState();
if (!state.ready || activeTransform) throw new Error("请等待文字图层完成后再导出");
if (!state.ready || activeTransform || layerMovePreview) throw new Error("请等待文字图层完成后再导出");
compositionCanvas.toBlob((blob) => {
if (!blob) {
announce("合成 PNG 生成失败", true);
@@ -0,0 +1,577 @@
export const SCENE_VERSION = 1;
export const SCENE_WIDTH = 64;
export const SCENE_HEIGHT = 64;
export const RGB_CHANNELS = 3;
export const RGBA_CHANNELS = 4;
export const RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGB_CHANNELS;
export const RGBA_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGBA_CHANNELS;
export const SCENE_STORAGE_KEY = "matrixController:scene";
export const LEGACY_STORAGE_KEYS = Object.freeze({
sceneV1: "matrixController:scene:v1",
canvasV3: "matrixController:canvas:v3",
canvasV2: "matrixController:canvas:v2",
canvasRgb: "matrixCanvasRgb",
textV1: "matrixController:text:v1",
});
export const DEFAULT_TEXT_ELEMENT = Object.freeze({
type: "text",
text: "ok",
font: "default",
size: 12,
x: 32,
y: 26,
align: "center",
color: "#FFFFFF",
revision: 0,
});
const ALIGNMENTS = new Set(["left", "center", "right"]);
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function finiteInteger(value, fallback, minimum, maximum) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(number)));
}
function normalizeColor(value, fallback = DEFAULT_TEXT_ELEMENT.color) {
return typeof value === "string" && HEX_COLOR.test(value.trim())
? value.trim().toUpperCase()
: fallback;
}
function normalizeId(value) {
if (typeof value !== "string") return null;
const id = value.trim();
return id && id.length <= 128 ? id : null;
}
function byteArray(value, expectedLength) {
if (value === null || value === undefined || typeof value.length !== "number") return null;
if (value.length !== expectedLength) return null;
const result = new Uint8ClampedArray(expectedLength);
for (let index = 0; index < expectedLength; index += 1) {
const channel = value[index];
if (!Number.isInteger(channel) || channel < 0 || channel > 255) return null;
result[index] = channel;
}
return result;
}
function encodeBase64(bytes) {
let encoded = "";
for (let index = 0; index < bytes.length; index += 3) {
const first = bytes[index];
const hasSecond = index + 1 < bytes.length;
const hasThird = index + 2 < bytes.length;
const second = hasSecond ? bytes[index + 1] : 0;
const third = hasThird ? bytes[index + 2] : 0;
encoded += BASE64_ALPHABET[first >> 2];
encoded += BASE64_ALPHABET[((first & 3) << 4) | (second >> 4)];
encoded += hasSecond ? BASE64_ALPHABET[((second & 15) << 2) | (third >> 6)] : "=";
encoded += hasThird ? BASE64_ALPHABET[third & 63] : "=";
}
return encoded;
}
function decodeBase64(value) {
if (typeof value !== "string" || value.length % 4 !== 0) return null;
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
const result = new Uint8ClampedArray((value.length / 4) * 3 - padding);
let output = 0;
for (let index = 0; index < value.length; index += 4) {
const first = BASE64_ALPHABET.indexOf(value[index]);
const second = BASE64_ALPHABET.indexOf(value[index + 1]);
const third = value[index + 2] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 2]);
const fourth = value[index + 3] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 3]);
const packed = (first << 18) | (second << 12) | (third << 6) | fourth;
if (output < result.length) result[output++] = (packed >> 16) & 255;
if (output < result.length) result[output++] = (packed >> 8) & 255;
if (output < result.length) result[output++] = packed & 255;
}
return result;
}
function parseJsonValue(value) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch (_error) {
return null;
}
}
function sourceValue(source, alias, storageKey) {
if (!isRecord(source)) return null;
if (Object.prototype.hasOwnProperty.call(source, alias)) return source[alias];
if (Object.prototype.hasOwnProperty.call(source, storageKey)) return source[storageKey];
return null;
}
function copyTextElement(element) {
return {
id: element.id,
type: "text",
text: element.text,
font: element.font,
size: element.size,
x: element.x,
y: element.y,
align: element.align,
color: element.color,
revision: element.revision,
};
}
function storageTextElement(element) {
const { revision: _revision, ...stored } = copyTextElement(element);
return stored;
}
function normalizedElementList(elements) {
if (!Array.isArray(elements)) return [];
const normalized = [];
const usedIds = new Set();
for (const value of elements) {
const element = normalizeTextElement(value, { strict: true });
if (!element || usedIds.has(element.id)) continue;
usedIds.add(element.id);
normalized.push(element);
}
return normalized;
}
function assertScene(scene) {
if (!isRecord(scene)) throw new TypeError("scene must be an object");
if (scene.version !== SCENE_VERSION || scene.width !== SCENE_WIDTH || scene.height !== SCENE_HEIGHT) {
throw new RangeError("scene must be version 1 with a 64x64 frame");
}
const pixels = byteArray(scene.pixels, RGB_BYTE_LENGTH);
if (!pixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
if (!Array.isArray(scene.elements)) throw new TypeError("scene elements must be an array");
const elements = normalizedElementList(scene.elements);
if (elements.length !== scene.elements.length) throw new TypeError("scene contains an invalid or duplicate element");
return { pixels, elements };
}
function sceneWithElements(scene, elements) {
const checked = assertScene(scene);
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixels: checked.pixels,
elements: elements.map(copyTextElement),
};
}
export function createRgbPixels(color = [0, 0, 0]) {
const channels = byteArray(color, RGB_CHANNELS);
if (!channels) throw new RangeError("color must contain three RGB bytes");
const pixels = new Uint8ClampedArray(RGB_BYTE_LENGTH);
for (let offset = 0; offset < pixels.length; offset += RGB_CHANNELS) pixels.set(channels, offset);
return pixels;
}
export function createScene({ pixels = createRgbPixels(), elements = [] } = {}) {
const checkedPixels = byteArray(pixels, RGB_BYTE_LENGTH);
if (!checkedPixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixels: checkedPixels,
elements: normalizedElementList(elements),
};
}
export function sceneToStorageValue(scene) {
const checked = assertScene(scene);
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixelRgb: encodeBase64(checked.pixels),
elements: checked.elements.map(storageTextElement),
};
}
export function serializeScene(scene) {
return JSON.stringify(sceneToStorageValue(scene));
}
export function deserializeScene(value) {
const stored = parseJsonValue(value);
if (!isRecord(stored)) return null;
if (stored.version !== SCENE_VERSION || stored.width !== SCENE_WIDTH || stored.height !== SCENE_HEIGHT) return null;
const decoded = decodeBase64(stored.pixelRgb);
if (!decoded || decoded.length !== RGB_BYTE_LENGTH) return null;
return createScene({
pixels: decoded,
elements: normalizedElementList(stored.elements),
});
}
export function inspectSceneStorageValue(value) {
if (value === null || value === undefined) return { status: "missing", scene: null };
let stored = value;
if (typeof value === "string") {
try {
stored = JSON.parse(value);
} catch (_error) {
return { status: "damaged", scene: null };
}
}
if (!isRecord(stored)) return { status: "damaged", scene: null };
if (Number.isInteger(stored.version) && stored.version > SCENE_VERSION) {
return { status: "future", scene: null };
}
const restored = deserializeScene(stored);
return restored
? { status: "valid", scene: restored }
: { status: "damaged", scene: null };
}
export function nextTextElementId(elements = []) {
const used = new Set(
Array.isArray(elements)
? elements.map((element) => normalizeId(element?.id)).filter(Boolean)
: [],
);
let sequence = 1;
while (used.has(`text-${sequence}`)) sequence += 1;
return `text-${sequence}`;
}
export function normalizeTextElement(value = {}, { id = null, strict = false } = {}) {
if (!isRecord(value) || (value.type !== undefined && value.type !== "text")) return null;
if (strict && (typeof value.text !== "string" || !normalizeId(value.id))) return null;
const normalizedId = normalizeId(value.id) || normalizeId(id) || "text-1";
const text = typeof value.text === "string" ? value.text.slice(0, 512) : DEFAULT_TEXT_ELEMENT.text;
const font = typeof value.font === "string" && value.font.trim()
? value.font.trim().slice(0, 512)
: DEFAULT_TEXT_ELEMENT.font;
return {
id: normalizedId,
type: "text",
text,
font,
size: finiteInteger(value.size, DEFAULT_TEXT_ELEMENT.size, 1, 64),
x: finiteInteger(value.x, DEFAULT_TEXT_ELEMENT.x, -128, 128),
y: finiteInteger(value.y, DEFAULT_TEXT_ELEMENT.y, -128, 128),
align: ALIGNMENTS.has(value.align) ? value.align : DEFAULT_TEXT_ELEMENT.align,
color: normalizeColor(value.color),
revision: finiteInteger(value.revision, 0, 0, Number.MAX_SAFE_INTEGER),
};
}
export function createTextElement(existingElements = [], overrides = {}) {
const id = nextTextElementId(existingElements);
return normalizeTextElement({ ...DEFAULT_TEXT_ELEMENT, ...overrides, id }, { id });
}
export function addTextElement(scene, overrides = {}) {
const checked = assertScene(scene);
const element = createTextElement(checked.elements, overrides);
return sceneWithElements(scene, [...checked.elements, element]);
}
export function updateTextElement(scene, id, patch = {}) {
const checked = assertScene(scene);
const targetId = normalizeId(id);
const index = checked.elements.findIndex((element) => element.id === targetId);
if (index < 0 || !isRecord(patch)) return scene;
const current = checked.elements[index];
const updated = normalizeTextElement({
...current,
...patch,
id: current.id,
type: "text",
revision: current.revision + 1,
}, { id: current.id });
const elements = checked.elements.map((element, elementIndex) => (
elementIndex === index ? updated : copyTextElement(element)
));
return sceneWithElements(scene, elements);
}
export function deleteTextElement(scene, id) {
const checked = assertScene(scene);
const targetId = normalizeId(id);
if (!checked.elements.some((element) => element.id === targetId)) return scene;
return sceneWithElements(scene, checked.elements.filter((element) => element.id !== targetId));
}
export function duplicateTextElement(scene, id) {
const checked = assertScene(scene);
const source = checked.elements.find((element) => element.id === normalizeId(id));
if (!source) return scene;
const duplicate = createTextElement(checked.elements, {
...source,
x: source.x + 2,
y: source.y + 2,
revision: 0,
});
return sceneWithElements(scene, [...checked.elements, duplicate]);
}
function legacyCanvasPixels(value) {
const parsed = parseJsonValue(value);
if (!isRecord(parsed)) return null;
return byteArray(parsed.pixels, RGB_BYTE_LENGTH);
}
function legacyCsvPixels(value) {
if (typeof value !== "string") return byteArray(value, RGB_BYTE_LENGTH);
const channels = value.split(",").map((channel) => Number(channel));
return byteArray(channels, RGB_BYTE_LENGTH);
}
function migrateTextV1(value, options) {
const parsed = parseJsonValue(value);
if (!isRecord(parsed)) return null;
const text = parsed["text-value"] ?? parsed.text;
if (typeof text !== "string") return null;
return normalizeTextElement({
id: "text-1",
type: "text",
text,
font: parsed["text-font"] ?? parsed.font ?? options.defaultFont,
size: parsed["text-size"] ?? parsed.size ?? options.defaultTextSize,
x: parsed["text-x"] ?? parsed.x,
y: parsed["text-y"] ?? parsed.y,
align: parsed["text-align"] ?? parsed.align,
color: parsed["text-color"] ?? parsed.color,
}, { id: "text-1" });
}
export function migrateLegacyScene(source = {}, options = {}) {
const migrationOptions = {
defaultFont: typeof options.defaultFont === "string" && options.defaultFont.trim()
? options.defaultFont.trim()
: DEFAULT_TEXT_ELEMENT.font,
defaultTextSize: finiteInteger(options.defaultTextSize, DEFAULT_TEXT_ELEMENT.size, 1, 64),
};
const canvasCandidates = [
["canvasV3", LEGACY_STORAGE_KEYS.canvasV3],
["canvasV2", LEGACY_STORAGE_KEYS.canvasV2],
];
let pixels = null;
for (const [alias, storageKey] of canvasCandidates) {
pixels = legacyCanvasPixels(sourceValue(source, alias, storageKey));
if (pixels) break;
}
if (!pixels) {
pixels = legacyCsvPixels(sourceValue(source, "matrixCanvasRgb", LEGACY_STORAGE_KEYS.canvasRgb));
}
const text = migrateTextV1(
sourceValue(source, "textV1", LEGACY_STORAGE_KEYS.textV1),
migrationOptions,
);
if (!pixels && !text) return null;
return createScene({
pixels: pixels || createRgbPixels(),
elements: text ? [text] : [],
});
}
function draftLoadResult(scene, {
canPersist,
status,
notice = null,
} = {}) {
return { scene, canPersist, status, notice };
}
function blockedDraft(scene, status, notice) {
return draftLoadResult(scene, { canPersist: false, status, notice });
}
function readStorageSnapshot(storage) {
if (!storage || typeof storage.getItem !== "function") {
throw new TypeError("browser storage is unavailable");
}
const values = {};
values.current = storage.getItem(SCENE_STORAGE_KEY);
for (const [alias, key] of Object.entries(LEGACY_STORAGE_KEYS)) {
values[alias] = storage.getItem(key);
}
return values;
}
function invalidSplitLegacyKeys(values) {
const invalid = [];
if (values.canvasV3 !== null && !legacyCanvasPixels(values.canvasV3)) invalid.push(LEGACY_STORAGE_KEYS.canvasV3);
if (values.canvasV2 !== null && !legacyCanvasPixels(values.canvasV2)) invalid.push(LEGACY_STORAGE_KEYS.canvasV2);
if (values.canvasRgb !== null && !legacyCsvPixels(values.canvasRgb)) invalid.push(LEGACY_STORAGE_KEYS.canvasRgb);
if (values.textV1 !== null && !migrateTextV1(values.textV1, {
defaultFont: DEFAULT_TEXT_ELEMENT.font,
defaultTextSize: DEFAULT_TEXT_ELEMENT.size,
})) invalid.push(LEGACY_STORAGE_KEYS.textV1);
return invalid;
}
function writeMigratedDraft(storage, scene, hasLegacyData) {
try {
if (typeof storage.setItem !== "function") throw new TypeError("browser storage is read-only");
storage.setItem(SCENE_STORAGE_KEY, serializeScene(scene));
} catch (_error) {
return blockedDraft(
scene,
"write-failed",
hasLegacyData
? "浏览器无法写入升级后的画板草稿;旧草稿已原样保留,本页不会覆盖它。"
: "浏览器无法初始化画板草稿;本页编辑不会自动保存或应用。",
);
}
if (!hasLegacyData) {
return draftLoadResult(scene, { canPersist: true, status: "created" });
}
const cleanupFailures = [];
for (const key of Object.values(LEGACY_STORAGE_KEYS)) {
try {
if (typeof storage.removeItem !== "function") throw new TypeError("browser storage cannot remove old keys");
storage.removeItem(key);
} catch (_error) {
cleanupFailures.push(key);
}
}
if (cleanupFailures.length) {
return draftLoadResult(scene, {
canPersist: true,
status: "cleanup-warning",
notice: "画板草稿已升级,但部分旧版草稿键无法清理;旧值仍被保留。",
});
}
return draftLoadResult(scene, {
canPersist: true,
status: "migrated",
notice: "已将旧版浏览器画板草稿升级到当前格式。",
});
}
export function restoreSceneDraft(storage) {
const blankScene = createScene();
let values;
try {
values = readStorageSnapshot(storage);
} catch (_error) {
return blockedDraft(
blankScene,
"read-failed",
"浏览器草稿存储不可用;本页不会自动保存或应用空白画板。",
);
}
const current = inspectSceneStorageValue(values.current);
if (current.status === "valid") {
return draftLoadResult(current.scene, { canPersist: true, status: "current" });
}
if (current.status === "future") {
return blockedDraft(
blankScene,
"future-version",
"检测到更高版本的浏览器画板草稿;原值已保留,本页不会覆盖或应用它。",
);
}
if (current.status === "damaged") {
return blockedDraft(
blankScene,
"damaged-current",
"浏览器画板草稿损坏或格式无法识别;原值已保留,本页不会覆盖或应用它。",
);
}
const legacyScene = inspectSceneStorageValue(values.sceneV1);
if (legacyScene.status === "future") {
return blockedDraft(
blankScene,
"future-legacy-version",
"旧版草稿键中包含更高版本数据;原值已保留,本页不会覆盖或应用它。",
);
}
if (legacyScene.status === "damaged") {
return blockedDraft(
blankScene,
"damaged-legacy-scene",
"旧版画板草稿损坏或无法迁移;原值已保留,本页不会覆盖或应用它。",
);
}
const hasSplitLegacyData = ["canvasV3", "canvasV2", "canvasRgb", "textV1"]
.some((alias) => values[alias] !== null);
const hasLegacyData = values.sceneV1 !== null || hasSplitLegacyData;
const invalidLegacyKeys = invalidSplitLegacyKeys(values);
const migrated = migrateLegacyScene({
canvasV3: values.canvasV3,
canvasV2: values.canvasV2,
matrixCanvasRgb: values.canvasRgb,
textV1: values.textV1,
});
if (invalidLegacyKeys.length) {
return blockedDraft(
legacyScene.scene || migrated || blankScene,
"legacy-migration-failed",
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
);
}
if (legacyScene.status === "valid") {
return writeMigratedDraft(storage, legacyScene.scene, hasLegacyData);
}
if (hasSplitLegacyData && !migrated) {
return blockedDraft(
blankScene,
"legacy-migration-failed",
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
);
}
return writeMigratedDraft(storage, migrated || blankScene, hasLegacyData);
}
function compositeInto(target, rgba) {
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < rgba.length; sourceOffset += 4, targetOffset += 3) {
const alpha = rgba[sourceOffset + 3];
if (alpha === 0) continue;
if (alpha === 255) {
target[targetOffset] = rgba[sourceOffset];
target[targetOffset + 1] = rgba[sourceOffset + 1];
target[targetOffset + 2] = rgba[sourceOffset + 2];
continue;
}
const inverse = 255 - alpha;
target[targetOffset] = Math.round((rgba[sourceOffset] * alpha + target[targetOffset] * inverse) / 255);
target[targetOffset + 1] = Math.round((rgba[sourceOffset + 1] * alpha + target[targetOffset + 1] * inverse) / 255);
target[targetOffset + 2] = Math.round((rgba[sourceOffset + 2] * alpha + target[targetOffset + 2] * inverse) / 255);
}
}
export function compositeRgbaLayer(pixels, rgba) {
const result = byteArray(pixels, RGB_BYTE_LENGTH);
const layer = byteArray(rgba, RGBA_BYTE_LENGTH);
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
if (!layer) throw new RangeError("layer must contain 64x64 RGBA8888 bytes");
compositeInto(result, layer);
return result;
}
export function compositeRgbaLayers(pixels, layers = []) {
const result = byteArray(pixels, RGB_BYTE_LENGTH);
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
if (!Array.isArray(layers)) throw new TypeError("layers must be an array");
for (const value of layers) {
const layer = byteArray(value, RGBA_BYTE_LENGTH);
if (!layer) throw new RangeError("each layer must contain 64x64 RGBA8888 bytes");
compositeInto(result, layer);
}
return result;
}
+121 -573
View File
@@ -1,577 +1,125 @@
export const SCENE_VERSION = 1;
export const SCENE_WIDTH = 64;
export const SCENE_HEIGHT = 64;
export const RGB_CHANNELS = 3;
export const RGBA_CHANNELS = 4;
export const RGB_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGB_CHANNELS;
export const RGBA_BYTE_LENGTH = SCENE_WIDTH * SCENE_HEIGHT * RGBA_CHANNELS;
export const SCENE_STORAGE_KEY = "matrixController:scene";
export const LEGACY_STORAGE_KEYS = Object.freeze({
sceneV1: "matrixController:scene:v1",
canvasV3: "matrixController:canvas:v3",
canvasV2: "matrixController:canvas:v2",
canvasRgb: "matrixCanvasRgb",
textV1: "matrixController:text:v1",
});
export const DEFAULT_TEXT_ELEMENT = Object.freeze({
type: "text",
text: "ok",
font: "default",
size: 12,
x: 32,
y: 26,
align: "center",
color: "#FFFFFF",
revision: 0,
});
const ALIGNMENTS = new Set(["left", "center", "right"]);
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
import * as legacy from "./scene-model-v1.js";
import { encodePixels, decodePixels } from "./pixel-png.js";
export const SCENE_VERSION=2, EDIT_SIZE=192, EDIT_MIN=-64, EDIT_MAX=128;
export const {SCENE_WIDTH,SCENE_HEIGHT,RGB_BYTE_LENGTH,RGBA_BYTE_LENGTH,RGB_CHANNELS,RGBA_CHANNELS,SCENE_STORAGE_KEY,LEGACY_STORAGE_KEYS,DEFAULT_TEXT_ELEMENT,createRgbPixels,createTextElement,nextTextElementId,compositeRgbaLayer,compositeRgbaLayers,migrateLegacyScene}=legacy;
const fields=(v,keys)=>v&&Object.keys(v).sort().join()===[...keys].sort().join();
const uid=()=>globalThis.crypto?.randomUUID?.()||`layer-${Date.now()}-${Math.random().toString(36).slice(2)}`;
export function normalizeTextElement(v={},options={}) {const e=legacy.normalizeTextElement(v,options);
return e?{...e,clip:v.clip?[...v.clip]:null}:null;}
export function attachScene(layers) {
const scene={version:2,width:64,height:64,layers};
Object.defineProperty(scene,"elements",{get:()=>scene.layers.flatMap(l=>l.elements||[])});
Object.defineProperty(scene,"pixels",{get:()=>{const rgba=scene.layers.find(l=>l.type==="brush").pixels,rgb=new Uint8ClampedArray(RGB_BYTE_LENGTH);
for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=((y+64)*192+x+64)*4;rgb.set(rgba.subarray(i,i+3),(y*64+x)*3);}
return rgb;}});
return scene;
}
function finiteInteger(value, fallback, minimum, maximum) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(minimum, Math.min(maximum, Math.round(number)));
export function nextLayerName(layers){let n=1;
const names=new Set(layers.map(l=>l.name));while(names.has(`图层${n}`))n++;
return `图层${n}`;}
export function createLayer(type,layers=[]) {return {id:uid(),name:nextLayerName(layers),type,x:0,y:0,...(type==="brush"?{background:null,backgroundColor:"#000000",pixels:new Uint8ClampedArray(192*192*4),revision:0}:{elements:[]})};}
export function createScene({pixels=null,elements=[],layers=null}={}) {
if(layers)return attachScene(layers);
const brush=createLayer("brush");brush.id="layer-1";
if(pixels)for(let y=0;y<64;y++)for(let x=0;x<64;x++){const i=(y*64+x)*3,j=((y+64)*192+x+64)*4;brush.pixels.set([pixels[i],pixels[i+1],pixels[i+2],255],j);}
const result=[brush];
if(elements.length)result.push({id:"layer-2",name:"图层2",type:"text",x:0,y:0,elements:elements.map(e=>normalizeTextElement(e,{strict:true})).filter(Boolean)});
return attachScene(result);
}
function normalizeColor(value, fallback = DEFAULT_TEXT_ELEMENT.color) {
return typeof value === "string" && HEX_COLOR.test(value.trim())
? value.trim().toUpperCase()
: fallback;
export function blankScene(){return attachScene([createLayer("brush")]);}
export function layerToStorage(layer){const common={id:layer.id,name:layer.name,type:layer.type,x:layer.x,y:layer.y};
if(layer.type==="brush"){if(layer._encodedRevision!==layer.revision||!layer._png){layer._png=encodePixels(layer.pixels);layer._encodedRevision=layer.revision;}
return {...common,background:layer.background,backgroundColor:layer.backgroundColor||layer.background||"#000000",pixelPng:layer._png};}
return {...common,elements:layer.elements.map(({revision,...e})=>({...e,clip:e.clip||null}))};
}
function normalizeId(value) {
if (typeof value !== "string") return null;
const id = value.trim();
return id && id.length <= 128 ? id : null;
}
function byteArray(value, expectedLength) {
if (value === null || value === undefined || typeof value.length !== "number") return null;
if (value.length !== expectedLength) return null;
const result = new Uint8ClampedArray(expectedLength);
for (let index = 0; index < expectedLength; index += 1) {
const channel = value[index];
if (!Number.isInteger(channel) || channel < 0 || channel > 255) return null;
result[index] = channel;
}
return result;
}
function encodeBase64(bytes) {
let encoded = "";
for (let index = 0; index < bytes.length; index += 3) {
const first = bytes[index];
const hasSecond = index + 1 < bytes.length;
const hasThird = index + 2 < bytes.length;
const second = hasSecond ? bytes[index + 1] : 0;
const third = hasThird ? bytes[index + 2] : 0;
encoded += BASE64_ALPHABET[first >> 2];
encoded += BASE64_ALPHABET[((first & 3) << 4) | (second >> 4)];
encoded += hasSecond ? BASE64_ALPHABET[((second & 15) << 2) | (third >> 6)] : "=";
encoded += hasThird ? BASE64_ALPHABET[third & 63] : "=";
}
return encoded;
}
function decodeBase64(value) {
if (typeof value !== "string" || value.length % 4 !== 0) return null;
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
const result = new Uint8ClampedArray((value.length / 4) * 3 - padding);
let output = 0;
for (let index = 0; index < value.length; index += 4) {
const first = BASE64_ALPHABET.indexOf(value[index]);
const second = BASE64_ALPHABET.indexOf(value[index + 1]);
const third = value[index + 2] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 2]);
const fourth = value[index + 3] === "=" ? 0 : BASE64_ALPHABET.indexOf(value[index + 3]);
const packed = (first << 18) | (second << 12) | (third << 6) | fourth;
if (output < result.length) result[output++] = (packed >> 16) & 255;
if (output < result.length) result[output++] = (packed >> 8) & 255;
if (output < result.length) result[output++] = packed & 255;
}
return result;
}
function parseJsonValue(value) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch (_error) {
return null;
}
}
function sourceValue(source, alias, storageKey) {
if (!isRecord(source)) return null;
if (Object.prototype.hasOwnProperty.call(source, alias)) return source[alias];
if (Object.prototype.hasOwnProperty.call(source, storageKey)) return source[storageKey];
return null;
}
function copyTextElement(element) {
return {
id: element.id,
type: "text",
text: element.text,
font: element.font,
size: element.size,
x: element.x,
y: element.y,
align: element.align,
color: element.color,
revision: element.revision,
};
}
function storageTextElement(element) {
const { revision: _revision, ...stored } = copyTextElement(element);
return stored;
}
function normalizedElementList(elements) {
if (!Array.isArray(elements)) return [];
const normalized = [];
const usedIds = new Set();
for (const value of elements) {
const element = normalizeTextElement(value, { strict: true });
if (!element || usedIds.has(element.id)) continue;
usedIds.add(element.id);
normalized.push(element);
}
return normalized;
}
function assertScene(scene) {
if (!isRecord(scene)) throw new TypeError("scene must be an object");
if (scene.version !== SCENE_VERSION || scene.width !== SCENE_WIDTH || scene.height !== SCENE_HEIGHT) {
throw new RangeError("scene must be version 1 with a 64x64 frame");
}
const pixels = byteArray(scene.pixels, RGB_BYTE_LENGTH);
if (!pixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
if (!Array.isArray(scene.elements)) throw new TypeError("scene elements must be an array");
const elements = normalizedElementList(scene.elements);
if (elements.length !== scene.elements.length) throw new TypeError("scene contains an invalid or duplicate element");
return { pixels, elements };
}
function sceneWithElements(scene, elements) {
const checked = assertScene(scene);
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixels: checked.pixels,
elements: elements.map(copyTextElement),
};
}
export function createRgbPixels(color = [0, 0, 0]) {
const channels = byteArray(color, RGB_CHANNELS);
if (!channels) throw new RangeError("color must contain three RGB bytes");
const pixels = new Uint8ClampedArray(RGB_BYTE_LENGTH);
for (let offset = 0; offset < pixels.length; offset += RGB_CHANNELS) pixels.set(channels, offset);
return pixels;
}
export function createScene({ pixels = createRgbPixels(), elements = [] } = {}) {
const checkedPixels = byteArray(pixels, RGB_BYTE_LENGTH);
if (!checkedPixels) throw new RangeError("scene pixels must contain 64x64 RGB888 bytes");
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixels: checkedPixels,
elements: normalizedElementList(elements),
};
}
export function sceneToStorageValue(scene) {
const checked = assertScene(scene);
return {
version: SCENE_VERSION,
width: SCENE_WIDTH,
height: SCENE_HEIGHT,
pixelRgb: encodeBase64(checked.pixels),
elements: checked.elements.map(storageTextElement),
};
}
export function serializeScene(scene) {
return JSON.stringify(sceneToStorageValue(scene));
}
export function deserializeScene(value) {
const stored = parseJsonValue(value);
if (!isRecord(stored)) return null;
if (stored.version !== SCENE_VERSION || stored.width !== SCENE_WIDTH || stored.height !== SCENE_HEIGHT) return null;
const decoded = decodeBase64(stored.pixelRgb);
if (!decoded || decoded.length !== RGB_BYTE_LENGTH) return null;
return createScene({
pixels: decoded,
elements: normalizedElementList(stored.elements),
});
}
export function inspectSceneStorageValue(value) {
if (value === null || value === undefined) return { status: "missing", scene: null };
let stored = value;
if (typeof value === "string") {
try {
stored = JSON.parse(value);
} catch (_error) {
return { status: "damaged", scene: null };
}
}
if (!isRecord(stored)) return { status: "damaged", scene: null };
if (Number.isInteger(stored.version) && stored.version > SCENE_VERSION) {
return { status: "future", scene: null };
}
const restored = deserializeScene(stored);
return restored
? { status: "valid", scene: restored }
: { status: "damaged", scene: null };
}
export function nextTextElementId(elements = []) {
const used = new Set(
Array.isArray(elements)
? elements.map((element) => normalizeId(element?.id)).filter(Boolean)
: [],
);
let sequence = 1;
while (used.has(`text-${sequence}`)) sequence += 1;
return `text-${sequence}`;
}
export function normalizeTextElement(value = {}, { id = null, strict = false } = {}) {
if (!isRecord(value) || (value.type !== undefined && value.type !== "text")) return null;
if (strict && (typeof value.text !== "string" || !normalizeId(value.id))) return null;
const normalizedId = normalizeId(value.id) || normalizeId(id) || "text-1";
const text = typeof value.text === "string" ? value.text.slice(0, 512) : DEFAULT_TEXT_ELEMENT.text;
const font = typeof value.font === "string" && value.font.trim()
? value.font.trim().slice(0, 512)
: DEFAULT_TEXT_ELEMENT.font;
return {
id: normalizedId,
type: "text",
text,
font,
size: finiteInteger(value.size, DEFAULT_TEXT_ELEMENT.size, 1, 64),
x: finiteInteger(value.x, DEFAULT_TEXT_ELEMENT.x, -128, 128),
y: finiteInteger(value.y, DEFAULT_TEXT_ELEMENT.y, -128, 128),
align: ALIGNMENTS.has(value.align) ? value.align : DEFAULT_TEXT_ELEMENT.align,
color: normalizeColor(value.color),
revision: finiteInteger(value.revision, 0, 0, Number.MAX_SAFE_INTEGER),
};
}
export function createTextElement(existingElements = [], overrides = {}) {
const id = nextTextElementId(existingElements);
return normalizeTextElement({ ...DEFAULT_TEXT_ELEMENT, ...overrides, id }, { id });
}
export function addTextElement(scene, overrides = {}) {
const checked = assertScene(scene);
const element = createTextElement(checked.elements, overrides);
return sceneWithElements(scene, [...checked.elements, element]);
}
export function updateTextElement(scene, id, patch = {}) {
const checked = assertScene(scene);
const targetId = normalizeId(id);
const index = checked.elements.findIndex((element) => element.id === targetId);
if (index < 0 || !isRecord(patch)) return scene;
const current = checked.elements[index];
const updated = normalizeTextElement({
...current,
...patch,
id: current.id,
type: "text",
revision: current.revision + 1,
}, { id: current.id });
const elements = checked.elements.map((element, elementIndex) => (
elementIndex === index ? updated : copyTextElement(element)
));
return sceneWithElements(scene, elements);
}
export function deleteTextElement(scene, id) {
const checked = assertScene(scene);
const targetId = normalizeId(id);
if (!checked.elements.some((element) => element.id === targetId)) return scene;
return sceneWithElements(scene, checked.elements.filter((element) => element.id !== targetId));
}
export function duplicateTextElement(scene, id) {
const checked = assertScene(scene);
const source = checked.elements.find((element) => element.id === normalizeId(id));
if (!source) return scene;
const duplicate = createTextElement(checked.elements, {
...source,
x: source.x + 2,
y: source.y + 2,
revision: 0,
});
return sceneWithElements(scene, [...checked.elements, duplicate]);
}
function legacyCanvasPixels(value) {
const parsed = parseJsonValue(value);
if (!isRecord(parsed)) return null;
return byteArray(parsed.pixels, RGB_BYTE_LENGTH);
}
function legacyCsvPixels(value) {
if (typeof value !== "string") return byteArray(value, RGB_BYTE_LENGTH);
const channels = value.split(",").map((channel) => Number(channel));
return byteArray(channels, RGB_BYTE_LENGTH);
}
function migrateTextV1(value, options) {
const parsed = parseJsonValue(value);
if (!isRecord(parsed)) return null;
const text = parsed["text-value"] ?? parsed.text;
if (typeof text !== "string") return null;
return normalizeTextElement({
id: "text-1",
type: "text",
text,
font: parsed["text-font"] ?? parsed.font ?? options.defaultFont,
size: parsed["text-size"] ?? parsed.size ?? options.defaultTextSize,
x: parsed["text-x"] ?? parsed.x,
y: parsed["text-y"] ?? parsed.y,
align: parsed["text-align"] ?? parsed.align,
color: parsed["text-color"] ?? parsed.color,
}, { id: "text-1" });
}
export function migrateLegacyScene(source = {}, options = {}) {
const migrationOptions = {
defaultFont: typeof options.defaultFont === "string" && options.defaultFont.trim()
? options.defaultFont.trim()
: DEFAULT_TEXT_ELEMENT.font,
defaultTextSize: finiteInteger(options.defaultTextSize, DEFAULT_TEXT_ELEMENT.size, 1, 64),
};
const canvasCandidates = [
["canvasV3", LEGACY_STORAGE_KEYS.canvasV3],
["canvasV2", LEGACY_STORAGE_KEYS.canvasV2],
];
let pixels = null;
for (const [alias, storageKey] of canvasCandidates) {
pixels = legacyCanvasPixels(sourceValue(source, alias, storageKey));
if (pixels) break;
}
if (!pixels) {
pixels = legacyCsvPixels(sourceValue(source, "matrixCanvasRgb", LEGACY_STORAGE_KEYS.canvasRgb));
}
const text = migrateTextV1(
sourceValue(source, "textV1", LEGACY_STORAGE_KEYS.textV1),
migrationOptions,
);
if (!pixels && !text) return null;
return createScene({
pixels: pixels || createRgbPixels(),
elements: text ? [text] : [],
});
}
function draftLoadResult(scene, {
canPersist,
status,
notice = null,
} = {}) {
return { scene, canPersist, status, notice };
}
function blockedDraft(scene, status, notice) {
return draftLoadResult(scene, { canPersist: false, status, notice });
}
function readStorageSnapshot(storage) {
if (!storage || typeof storage.getItem !== "function") {
throw new TypeError("browser storage is unavailable");
}
const values = {};
values.current = storage.getItem(SCENE_STORAGE_KEY);
for (const [alias, key] of Object.entries(LEGACY_STORAGE_KEYS)) {
values[alias] = storage.getItem(key);
}
return values;
}
function invalidSplitLegacyKeys(values) {
const invalid = [];
if (values.canvasV3 !== null && !legacyCanvasPixels(values.canvasV3)) invalid.push(LEGACY_STORAGE_KEYS.canvasV3);
if (values.canvasV2 !== null && !legacyCanvasPixels(values.canvasV2)) invalid.push(LEGACY_STORAGE_KEYS.canvasV2);
if (values.canvasRgb !== null && !legacyCsvPixels(values.canvasRgb)) invalid.push(LEGACY_STORAGE_KEYS.canvasRgb);
if (values.textV1 !== null && !migrateTextV1(values.textV1, {
defaultFont: DEFAULT_TEXT_ELEMENT.font,
defaultTextSize: DEFAULT_TEXT_ELEMENT.size,
})) invalid.push(LEGACY_STORAGE_KEYS.textV1);
return invalid;
}
function writeMigratedDraft(storage, scene, hasLegacyData) {
try {
if (typeof storage.setItem !== "function") throw new TypeError("browser storage is read-only");
storage.setItem(SCENE_STORAGE_KEY, serializeScene(scene));
} catch (_error) {
return blockedDraft(
scene,
"write-failed",
hasLegacyData
? "浏览器无法写入升级后的画板草稿;旧草稿已原样保留,本页不会覆盖它。"
: "浏览器无法初始化画板草稿;本页编辑不会自动保存或应用。",
);
}
if (!hasLegacyData) {
return draftLoadResult(scene, { canPersist: true, status: "created" });
}
const cleanupFailures = [];
for (const key of Object.values(LEGACY_STORAGE_KEYS)) {
try {
if (typeof storage.removeItem !== "function") throw new TypeError("browser storage cannot remove old keys");
storage.removeItem(key);
} catch (_error) {
cleanupFailures.push(key);
}
}
if (cleanupFailures.length) {
return draftLoadResult(scene, {
canPersist: true,
status: "cleanup-warning",
notice: "画板草稿已升级,但部分旧版草稿键无法清理;旧值仍被保留。",
});
}
return draftLoadResult(scene, {
canPersist: true,
status: "migrated",
notice: "已将旧版浏览器画板草稿升级到当前格式。",
});
}
export function restoreSceneDraft(storage) {
const blankScene = createScene();
let values;
try {
values = readStorageSnapshot(storage);
} catch (_error) {
return blockedDraft(
blankScene,
"read-failed",
"浏览器草稿存储不可用;本页不会自动保存或应用空白画板。",
);
}
const current = inspectSceneStorageValue(values.current);
if (current.status === "valid") {
return draftLoadResult(current.scene, { canPersist: true, status: "current" });
}
if (current.status === "future") {
return blockedDraft(
blankScene,
"future-version",
"检测到更高版本的浏览器画板草稿;原值已保留,本页不会覆盖或应用它。",
);
}
if (current.status === "damaged") {
return blockedDraft(
blankScene,
"damaged-current",
"浏览器画板草稿损坏或格式无法识别;原值已保留,本页不会覆盖或应用它。",
);
}
const legacyScene = inspectSceneStorageValue(values.sceneV1);
if (legacyScene.status === "future") {
return blockedDraft(
blankScene,
"future-legacy-version",
"旧版草稿键中包含更高版本数据;原值已保留,本页不会覆盖或应用它。",
);
}
if (legacyScene.status === "damaged") {
return blockedDraft(
blankScene,
"damaged-legacy-scene",
"旧版画板草稿损坏或无法迁移;原值已保留,本页不会覆盖或应用它。",
);
}
const hasSplitLegacyData = ["canvasV3", "canvasV2", "canvasRgb", "textV1"]
.some((alias) => values[alias] !== null);
const hasLegacyData = values.sceneV1 !== null || hasSplitLegacyData;
const invalidLegacyKeys = invalidSplitLegacyKeys(values);
const migrated = migrateLegacyScene({
canvasV3: values.canvasV3,
canvasV2: values.canvasV2,
matrixCanvasRgb: values.canvasRgb,
textV1: values.textV1,
});
if (invalidLegacyKeys.length) {
return blockedDraft(
legacyScene.scene || migrated || blankScene,
"legacy-migration-failed",
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
);
}
if (legacyScene.status === "valid") {
return writeMigratedDraft(storage, legacyScene.scene, hasLegacyData);
}
if (hasSplitLegacyData && !migrated) {
return blockedDraft(
blankScene,
"legacy-migration-failed",
"旧版画布或文字草稿损坏,无法安全迁移;旧值已保留,本页不会覆盖或应用它。",
);
}
return writeMigratedDraft(storage, migrated || blankScene, hasLegacyData);
}
function compositeInto(target, rgba) {
for (let sourceOffset = 0, targetOffset = 0; sourceOffset < rgba.length; sourceOffset += 4, targetOffset += 3) {
const alpha = rgba[sourceOffset + 3];
if (alpha === 0) continue;
if (alpha === 255) {
target[targetOffset] = rgba[sourceOffset];
target[targetOffset + 1] = rgba[sourceOffset + 1];
target[targetOffset + 2] = rgba[sourceOffset + 2];
continue;
}
const inverse = 255 - alpha;
target[targetOffset] = Math.round((rgba[sourceOffset] * alpha + target[targetOffset] * inverse) / 255);
target[targetOffset + 1] = Math.round((rgba[sourceOffset + 1] * alpha + target[targetOffset + 1] * inverse) / 255);
target[targetOffset + 2] = Math.round((rgba[sourceOffset + 2] * alpha + target[targetOffset + 2] * inverse) / 255);
}
}
export function compositeRgbaLayer(pixels, rgba) {
const result = byteArray(pixels, RGB_BYTE_LENGTH);
const layer = byteArray(rgba, RGBA_BYTE_LENGTH);
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
if (!layer) throw new RangeError("layer must contain 64x64 RGBA8888 bytes");
compositeInto(result, layer);
return result;
}
export function compositeRgbaLayers(pixels, layers = []) {
const result = byteArray(pixels, RGB_BYTE_LENGTH);
if (!result) throw new RangeError("base pixels must contain 64x64 RGB888 bytes");
if (!Array.isArray(layers)) throw new TypeError("layers must be an array");
for (const value of layers) {
const layer = byteArray(value, RGBA_BYTE_LENGTH);
if (!layer) throw new RangeError("each layer must contain 64x64 RGBA8888 bytes");
compositeInto(result, layer);
}
return result;
export function sceneToStorageValue(scene){return {version:2,width:64,height:64,layers:scene.layers.map(layerToStorage)};}
export function serializeScene(scene){return JSON.stringify(sceneToStorageValue(scene));}
export function deserializeScene(value){try{
const raw=typeof value==="string"?JSON.parse(value):value;
if(raw?.version===1){
if(!fields(raw,["version","width","height","pixelRgb","elements"])||!Array.isArray(raw.elements))return null;
const old=legacy.deserializeScene(raw);
if(!old||old.elements.length!==raw.elements.length||raw.elements.some(e=>!fields(e,["id","type","text","font","size","x","y","align","color"])))return null;
const upgraded=sceneToStorageValue(createScene(old));
if(raw.elements.length)upgraded.layers[1].elements=raw.elements.map(element=>({...element,clip:null}));
return deserializeScene(upgraded);
}
if(!fields(raw,["version","width","height","layers"])||raw.version!==2||raw.width!==64||raw.height!==64||!Array.isArray(raw.layers))return null;
const ids=new Set(),elements=new Set();
const layers=raw.layers.map(l=>{
if(!fields(l,["id","name","type","x","y",...(l.type==="brush"?["background","pixelPng",...(Object.hasOwn(l,"backgroundColor")?["backgroundColor"]:[])]:["elements"])])||!["brush","text"].includes(l.type)||typeof l.id!=="string"||!l.id.trim()||l.id.length>128||ids.has(l.id)||typeof l.name!=="string"||!l.name.trim()||l.name.length>80||![l.x,l.y].every(n=>Number.isInteger(n)&&Math.abs(n)<=64))throw Error("invalid layer");ids.add(l.id);
if(l.type==="brush"){if(l.background!==null&&!/^#[0-9a-f]{6}$/i.test(l.background))throw Error("invalid background");
if(Object.hasOwn(l,"backgroundColor")&&!/^#[0-9a-f]{6}$/i.test(l.backgroundColor))throw Error("invalid background color");
const {pixelPng,...rest}=l;
return {...rest,backgroundColor:l.backgroundColor||l.background||"#000000",pixels:decodePixels(pixelPng),revision:0,_png:pixelPng,_encodedRevision:0};}
if(!Array.isArray(l.elements))throw Error("invalid text layer");
return {...l,elements:l.elements.map(e=>{
if(!fields(e,["id","type","text","font","size","x","y","align","color","clip"])||elements.has(e.id)||typeof e.id!=="string"||!e.id.trim()||e.id.length>128||e.type!=="text"||typeof e.text!=="string"||e.text.length>512||typeof e.font!=="string"||!e.font.trim()||e.font.length>512||!Number.isInteger(e.size)||e.size<1||e.size>64||![e.x,e.y].every(n=>Number.isInteger(n)&&Math.abs(n)<=128)||!["left","center","right"].includes(e.align)||!/^#[0-9a-f]{6}$/i.test(e.color))throw Error("invalid text");
if(e.clip!==null&&(!Array.isArray(e.clip)||e.clip.length!==4||!e.clip.every(n=>Number.isFinite(n)&&Math.abs(n)<=4096)||e.clip[2]<e.clip[0]||e.clip[3]<e.clip[1]))throw Error("invalid clip");elements.add(e.id);
return normalizeTextElement(e,{strict:true});
})};});
return layers.some(l=>l.type==="brush")?attachScene(layers):null;
}
catch{return null;}}
export function inspectSceneStorageValue(value){if(value==null)return {status:"missing",scene:null};try{const raw=typeof value==="string"?JSON.parse(value):value;
if(Number.isInteger(raw?.version)&&raw.version>2)return {status:"future",scene:null};
const scene=deserializeScene(raw);
return {status:scene?"valid":"damaged",scene};}
catch{return {status:"damaged",scene:null};}}
export function restoreSceneDraft(storage){let raw;try{raw=storage?.getItem(SCENE_STORAGE_KEY);}
catch{return {scene:blankScene(),canPersist:false,status:"read-failed",notice:"浏览器草稿存储不可用;原草稿已保留。"};}
if(raw){const result=inspectSceneStorageValue(raw);
if(result.status!=="valid")return {...result,status:result.status==="future"?"future-version":"damaged-current",scene:blankScene(),canPersist:false,notice:"草稿损坏或来自未来版本;原值已保留。"};
if(JSON.parse(raw).version===1){try{storage.setItem(SCENE_STORAGE_KEY,serializeScene(result.scene));}
catch{return {...result,canPersist:false,status:"write-failed",notice:"无法保存升级草稿;原值已保留。"};}}
return {...result,canPersist:true,status:"current"};}
try {
const oldValue=storage?.getItem(LEGACY_STORAGE_KEYS.sceneV1);
if(oldValue!=null&&!deserializeScene(oldValue))return {scene:blankScene(),canPersist:false,status:"damaged-legacy",notice:"旧场景草稿无法安全迁移;全部原值已保留。"};
} catch {return {scene:blankScene(),canPersist:false,status:"read-failed",notice:"旧草稿读取失败;全部原值已保留。"};}
const proxy=storage?{getItem:k=>storage.getItem(k),removeItem:k=>storage.removeItem(k),setItem:(k,v)=>{
const oldScene=legacy.deserializeScene(v);storage.setItem(k,oldScene?serializeScene(createScene(oldScene)):v);
}}:null;
const old=legacy.restoreSceneDraft(proxy);
const scene=old.status==="created"?blankScene():createScene(old.scene);
if(old.status==="created"&&old.canPersist){
try {storage?.setItem(SCENE_STORAGE_KEY,serializeScene(scene));}
catch {return {...old,scene,canPersist:false,status:"write-failed",notice:"无法保存新草稿。"};}
}
return {...old,scene};
}
export function cloneScene(scene){return deserializeScene(serializeScene(scene));}
export function addTextElement(scene,overrides={},layerId=null){const target=scene.layers.find(l=>l.type==="text"&&(layerId?l.id===layerId:true))||createLayer("text",scene.layers),element=normalizeTextElement({...legacy.createTextElement(scene.elements,overrides),clip:overrides.clip||null});
return attachScene(scene.layers.some(l=>l.id===target.id)?scene.layers.map(l=>l.id===target.id?{...l,elements:[...l.elements,element]}:l):[...scene.layers,{...target,elements:[element]}]);}
export function updateTextElement(scene,id,patch={}){if(!scene.elements.some(e=>e.id===id))return scene;
return attachScene(scene.layers.map(l=>l.type!=="text"?l:{...l,elements:l.elements.map(e=>e.id===id?normalizeTextElement({...e,...patch,id:e.id,revision:e.revision+1}):e)}));}
export function deleteTextElement(scene,id){if(!scene.elements.some(e=>e.id===id))return scene;
return attachScene(scene.layers.map(l=>l.type!=="text"?l:{...l,elements:l.elements.filter(e=>e.id!==id)}));}
export function duplicateTextElement(scene,id){const l=scene.layers.find(l=>l.elements?.some(e=>e.id===id)),e=l?.elements.find(e=>e.id===id);
return e?addTextElement(scene,{...e,x:e.x+2,y:e.y+2},l.id):scene;}
export function cloneLayer(layer,layers){const copy=layer.type==="brush"?{...layer,pixels:layer.pixels.slice()}:{...layer,elements:layer.elements.map(e=>({...e,id:uid(),clip:e.clip?[...e.clip]:null}))};
return {...copy,id:uid(),name:nextLayerName(layers)};}
export function clipTextPixels(rgba,element){if(!element.clip)return rgba;
const out=rgba.slice(),[a,b,c,d]=element.clip;
for(let y=0;y<192;y++)for(let x=0;x<192;x++){const u=(x-64-element.x)/element.size,v=(y-64-element.y)/element.size;
if(u<a||u>=c||v<b||v>=d)out.fill(0,(y*192+x)*4,(y*192+x)*4+4);}
return out;}
export function translatedOverflow(rgba,dx,dy){let outside=false,inside=false;
for(let y=0;y<192;y++)for(let x=0;x<192;x++)if(rgba[(y*192+x)*4+3]){if(x+dx<0||x+dx>=192||y+dy<0||y+dy>=192)outside=true;else inside=true;}
return {outside,inside};}
export function clipBrushPixels(rgba,dx,dy){const out=rgba.slice();
for(let y=0;y<192;y++)for(let x=0;x<192;x++)if(x+dx<0||x+dx>=192||y+dy<0||y+dy>=192)out.fill(0,(y*192+x)*4,(y*192+x)*4+4);
return out;}
export function textClipAt(e,dx,dy){const n=[(-64-dx-e.x)/e.size,(-64-dy-e.y)/e.size,(128-dx-e.x)/e.size,(128-dy-e.y)/e.size];
return e.clip?[Math.max(n[0],e.clip[0]),Math.max(n[1],e.clip[1]),Math.min(n[2],e.clip[2]),Math.min(n[3],e.clip[3])]:n;}
export function composeScene(scene,textPixels=new Map(),size=64,preview=null){const origin=size===192?-64:0,out=new Uint8ClampedArray(size*size*3);
function paint(rgba,dx,dy){for(let y=0;y<size;y++)for(let x=0;x<size;x++){const sx=x+origin+64-dx,sy=y+origin+64-dy;
if(sx<0||sx>=192||sy<0||sy>=192)continue;
const i=(sy*192+sx)*4,j=(y*size+x)*3,a=rgba[i+3]/255;
for(let c=0;c<3;c++)out[j+c]=Math.floor(rgba[i+c]*a+out[j+c]*(1-a)+0.5);}}
for(const l of scene.layers){const dx=preview?.layerId===l.id?preview.x:l.x,dy=preview?.layerId===l.id?preview.y:l.y;
if(l.type==="brush"){if(l.background){const color=l.background.slice(1).match(/../g).map(v=>parseInt(v,16));
for(let y=0;y<size;y++)for(let x=0;x<size;x++)if(x+origin>=-64+dx&&x+origin<128+dx&&y+origin>=-64+dy&&y+origin<128+dy)out.set(color,(y*size+x)*3);}paint(l.pixels,dx,dy);}
else for(const e of l.elements){const p=textPixels.get(e.id);
if(p)paint(p,dx,dy);}}
return out;}
@@ -905,6 +905,28 @@ h3 {
display: none;
}
.board-expanded .pixel-grid { background-size: calc(100% / 192) calc(100% / 192); }
.layer-panel { margin-block: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
.layer-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-block: .75rem; }
.layer-actions button { flex: 1 1 7rem; }
.layer-list { display: grid; gap: .4rem; max-height: 18rem; overflow: auto; }
.layer-item { text-align: left; overflow-wrap: anywhere; }
.layer-item[aria-pressed="true"] { border-color: #72E0C5; background: #163832; }
.layer-move-controls { display: grid; gap: .35rem; }
.layer-move-controls[hidden], #brush-layer-tools[hidden], #text-layer-tools[hidden] { display: none; }
.layer-move-controls label { display: flex; align-items: center; gap: .5rem; }
.layer-move-controls input { min-width: 0; width: 100%; }
.offset-steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: .2rem; }
.offset-steps button { padding: .2rem; text-align: center; }
.layer-copy-dialog { width: min(36rem, calc(100vw - 2rem)); max-height: 85dvh; overflow: auto; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 1rem; padding: 1rem; box-shadow: var(--shadow); }
.layer-copy-dialog::backdrop { background: rgb(0 0 0 / 65%); }
.layer-copy-dialog select { width: 100%; }
#layer-copy-frame-list { display: grid; gap: .5rem; max-height: 45dvh; overflow: auto; }
#layer-copy-frame-list label { display: flex; align-items: center; gap: .6rem; min-width: 0; min-height: 44px; cursor: pointer; }
#layer-copy-frame-list label span { min-width: 0; overflow-wrap: anywhere; }
#layer-copy-frame-list input[type="checkbox"], .layer-background-toggle input[type="checkbox"] { flex: 0 0 20px; width: 20px; height: 20px; min-height: 20px; padding: 0; margin: 0; accent-color: var(--accent); cursor: pointer; }
body.canvas-edit-active[data-canvas-editor-mode="layer"] .board-stage { cursor: move; }
.canvas-coordinate-guide {
display: none;
pointer-events: none;
@@ -1028,6 +1050,8 @@ body.canvas-edit-active[data-canvas-editor-mode="move"] .board-stage:active {
position: absolute;
top: -28px;
left: 38px;
max-height: var(--assistive-menu-height, min(85dvh, calc(100dvh - 32px)));
overflow-y: auto;
display: grid;
width: min(14rem, calc(100vw - 88px));
gap: 8px;
@@ -1919,8 +1943,15 @@ textarea {
.canvas-toolbar .color-field {
margin-top: 0;
min-width: 0;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.canvas-color-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; min-height: 44px; }
.layer-background-toggle { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer; }
.canvas-toolbar .color-trigger { width: 100%; min-width: 0; grid-template-columns: 34px minmax(0, 1fr); }
.segmented {
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3121,3 +3152,8 @@ noscript {
transition-duration: 0.01ms !important;
}
}
#actual-screen-frame { position:absolute; inset:0; z-index:4; border:2px solid #FFCC66; pointer-events:none; }
#actual-screen-frame[hidden] { display:none; }
#actual-screen-frame span { position:absolute; top:2px; left:3px; font-size:11px; color:#FFCC66; background:#000b; padding:2px; white-space:nowrap; }
.board-expanded #actual-screen-frame { inset:33.333333%; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Arjun Barrett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,7 @@
fflate 0.8.2, MIT.
Source: https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz
Tarball SHA-256: 61fd5061e2fc8e5e3e3129f7f2fec7bd78a313e1bf4becbf1cc1cc9998d141dc
Bundled offline PNG compression; no CDN or npm runtime dependency.
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
import { getSelectedLayer, boardExtent, editSnapshot, rememberEdit, editHistorySize, clearEditHistory, undoSceneEdit, setExpandedBoard, setMoveFrameVisible, moveSelectedLayer, previewLayerMove, cancelLayerPreview, clearPixelLayer, subscribeScene } from "../scene-board.js";
import {
announce,
createLocalState,
@@ -81,7 +82,13 @@ let suppressAssistiveClick = false;
let coordinateGuideFrame = null;
let assistivePosition = { x: 36, y: window.innerHeight / 2 };
const viewPointers = new Map();
const undoHistory = new PixelUndoHistory();
let expandedEnabled=Boolean(localState.load({}).expandedEnabled);
let layerDrag=null;
const expandedToggle=document.getElementById("toggle-expanded-board");
const moveLayerButton=document.getElementById("move-current-layer");
const exitLayerMoveButton=document.getElementById("exit-layer-move");
const moveControls=document.getElementById("layer-move-controls");
const offsetX=document.getElementById("layer-offset-x"),offsetY=document.getElementById("layer-offset-y");
paintColor.value = savedTools.paintColor || paintColor.value;
backgroundColor.value = savedTools.backgroundColor || backgroundColor.value;
@@ -94,21 +101,19 @@ function currentBrushSize() {
return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
}
function setPixel(x, y, rgb) {
if (x < 0 || x > 63 || y < 0 || y > 63) return;
const offset = (y * 64 + x) * 3;
const pixels = getPixelBuffer();
pixels[offset] = rgb.r;
pixels[offset + 1] = rgb.g;
pixels[offset + 2] = rgb.b;
function setPixel(x,y,rgb) {
const layer=getSelectedLayer(),{origin,size}=boardExtent(),worldX=x-64+layer.x,worldY=y-64+layer.y;
if(x<0||x>=192||y<0||y>=192||worldX<origin||worldX>=origin+size||worldY<origin||worldY>=origin+size)return;
const i=(y*192+x)*4;
layer.pixels.set(tool==="eraser"?[0,0,0,0]:[rgb.r,rgb.g,rgb.b,255],i);
}
function activeRgb() {
return tool === "eraser" ? hexToRgb(backgroundColor.value) : hexToRgb(paintColor.value);
return hexToRgb(paintColor.value);
}
function stampBrush(centerX, centerY, rgb) {
brushStampPoints(centerX, centerY, currentBrushSize(), brushMode())
brushStampPoints(centerX+64-getSelectedLayer().x, centerY+64-getSelectedLayer().y, currentBrushSize(), brushMode(),192,192)
.forEach((point) => setPixel(point.x, point.y, rgb));
}
@@ -127,24 +132,14 @@ function paintEvent(event) {
renderPixelDraft();
}
function pixelSnapshot() {
return Uint8Array.from(getPixelBuffer());
}
function updateUndoControl() {
if (undoHistory.size > 0) setControlReady(undoButton);
else setControlUnavailable(undoButton, "还没有可以撤销的像素操作。");
}
function clearUndoHistory() {
undoHistory.clear();
updateUndoControl();
}
function pixelSnapshot() {return {scene:editSnapshot(),pixels:getSelectedLayer().pixels?.slice()};}
function updateUndoControl() {if(editHistorySize()>0)setControlReady(undoButton);else setControlUnavailable(undoButton,"还没有可以撤销的编辑操作。");}
function clearUndoHistory() {clearEditHistory();updateUndoControl();}
function recordPixelOperation(before) {
const recorded = undoHistory.record(before, getPixelBuffer());
updateUndoControl();
return recorded;
if(!before)return false;
const l=getSelectedLayer(),changed=before.pixels?.some((v,i)=>v!==l.pixels[i]);
if(changed)l.revision++;
if(before.scene!==editSnapshot()){rememberEdit(before.scene);updateUndoControl();return true;}return false;
}
function viewportBounds() {
@@ -191,8 +186,10 @@ function renderCoordinateGuide() {
const bottom = Math.min(height, rect.bottom);
if (right <= left || bottom <= top || rect.width <= 0) return;
const step = chooseCoordinateGuideStep(rect.width / 64);
const extent=boardExtent();
const step = chooseCoordinateGuideStep(rect.width / extent.size);
const xTicks = coordinateGuideTicks({
count: extent.size, origin: extent.origin,
boardStart: rect.left,
boardSize: rect.width,
visibleStart: left,
@@ -200,6 +197,7 @@ function renderCoordinateGuide() {
step,
});
const yTicks = coordinateGuideTicks({
count: extent.size, origin: extent.origin,
boardStart: rect.top,
boardSize: rect.height,
visibleStart: top,
@@ -296,6 +294,8 @@ function applyAssistivePosition(next = assistivePosition) {
});
assistive.style.setProperty("--assistive-x", `${assistivePosition.x}px`);
assistive.style.setProperty("--assistive-y", `${assistivePosition.y}px`);
const room=assistivePosition.y>window.innerHeight/2?assistivePosition.y:window.innerHeight-assistivePosition.y;
assistive.style.setProperty("--assistive-menu-height",`${Math.max(120,room+16)}px`);
assistive.dataset.menuHorizontal = assistivePosition.x > window.innerWidth / 2 ? "left" : "right";
assistive.dataset.menuVertical = assistivePosition.y > window.innerHeight / 2 ? "up" : "down";
}
@@ -327,17 +327,20 @@ function toggleAssistiveMenu() {
function updateEditorModeControls() {
const moving = editorMode === "move";
const textEditing = getSelectedLayer().type === "text";
moveLayerButton.setAttribute("aria-pressed",String(editorMode==="layer"));
if (editorActive) document.body.dataset.canvasEditorMode = editorMode;
else delete document.body.dataset.canvasEditorMode;
editorModeHint.textContent = moving
editorModeHint.textContent = editorMode==="layer"?"移动图层:拖动或使用偏移坐标;黄色框为实际屏幕":moving
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
: "绘画模式:直接在画布上绘制。";
: textEditing ? "文字编辑模式:拖动文字或使用缩放手柄。" : "绘画模式:直接在画布上绘制。";
if (moving) {
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
setControlReady(continueEditButton);
} else {
setControlReady(moveViewButton);
setControlUnavailable(continueEditButton, "当前已经是绘画模式。");
if (editorMode === "layer") setControlReady(continueEditButton);
else setControlUnavailable(continueEditButton, textEditing ? "当前已经是文字编辑模式。" : "当前已经是绘画模式。");
}
updateUndoControl();
}
@@ -361,24 +364,36 @@ function resetViewGesture() {
function setEditorMode(mode) {
if (!editorActive) return;
if (mode === "move") finishDrawing();
editorMode = mode === "move" ? "move" : "draw";
const pointerIds = [...viewPointers.keys()];
if (layerDrag) {
pointerIds.push(layerDrag.id);
layerDrag = null;
cancelLayerPreview();
}
viewPointers.clear();
viewGesture = null;
for (const id of pointerIds) {
if (canvas.hasPointerCapture(id)) canvas.releasePointerCapture(id);
}
if (mode === "move") finishDrawing();
editorMode = ["move","layer"].includes(mode)?mode:"draw";
if (editorMode === "draw") moveControls.hidden = true;
setMoveFrameVisible(editorMode==="layer" || !moveControls.hidden);
updateEditorModeControls();
closeAssistiveMenu({ restoreFocus: true });
announce(editorMode === "move"
? "已进入移动画布视线模式:单指拖动,双指缩放"
: "已继续编辑画布");
: editorMode === "layer" ? "已进入移动当前图层模式:拖动图层或输入偏移" : "已继续编辑画布");
}
function enterCanvasEditor() {
if (editorActive || getInteractionMode() !== "pixels") return;
if (editorActive || getInteractionMode() === "readonly") return;
editorActive = true;
editorMode = "draw";
canvas.dataset.canvasEditing = "true";
document.body.classList.add("canvas-edit-active");
assistive.hidden = false;
setExpandedBoard(expandedEnabled);
closeAssistiveMenu();
updateEditorModeControls();
updateCoordinateGuideControl();
@@ -400,6 +415,7 @@ function exitCanvasEditor({ returnToTools = true } = {}) {
viewGesture = null;
closeAssistiveMenu();
assistive.hidden = true;
moveControls.hidden=true;setMoveFrameVisible(false);setExpandedBoard(false);cancelLayerPreview();layerDrag=null;
hideCoordinateGuide();
delete canvas.dataset.canvasEditing;
document.body.classList.remove("canvas-edit-active");
@@ -417,17 +433,7 @@ function exitCanvasEditor({ returnToTools = true } = {}) {
announce("已退出画布编辑,可以重新选择颜色和画笔工具");
}
function undoPixelOperation() {
const previous = undoHistory.undo();
if (!previous) {
updateUndoControl();
return;
}
getPixelBuffer().set(previous);
commitPixelChanges("pixel-undo");
updateUndoControl();
announce("已撤销上一个像素操作");
}
function undoPixelOperation() {if(undoSceneEdit())announce("已撤销上一个编辑操作");updateUndoControl();}
function persistTools() {
localState.save({
@@ -438,6 +444,7 @@ function persistTools() {
normalBrushSize,
pixelBrushSize,
coordinateGuideEnabled,
expandedEnabled,
});
}
@@ -516,7 +523,10 @@ function selectTool(button) {
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
canvas.addEventListener("pointerdown", (event) => {
if (!editorActive || getInteractionMode() !== "pixels") return;
if (!editorActive || getInteractionMode() === "readonly") return;
if (editorMode === "layer") {
event.preventDefault();const l=getSelectedLayer();layerDrag={id:event.pointerId,start:boardPoint(event,{round:false}),x:l.x,y:l.y};canvas.setPointerCapture(event.pointerId);return;
}
if (editorMode === "move") {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
@@ -524,10 +534,11 @@ canvas.addEventListener("pointerdown", (event) => {
resetViewGesture();
return;
}
if (drawing) return;
if (getInteractionMode()!=="pixels" || drawing) return;
if (tool === "eyedropper") {
const point = boardPoint(event);
const offset = (point.y * 64 + point.x) * 3;
const l=getSelectedLayer();
const offset = ((point.y+64-l.y)*192+point.x+64-l.x)*4;
const pixels = getPixelBuffer();
setColorValue(paintColor, rgbToHex({
r: pixels[offset],
@@ -550,6 +561,7 @@ canvas.addEventListener("pointerdown", (event) => {
});
canvas.addEventListener("pointermove", (event) => {
if(layerDrag?.id===event.pointerId) {const p=boardPoint(event,{round:false});previewLayerMove(layerDrag.x+p.x-layerDrag.start.x,layerDrag.y+p.y-layerDrag.start.y);return;}
if (editorActive && editorMode === "move" && viewPointers.has(event.pointerId)) {
event.preventDefault();
viewPointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
@@ -594,6 +606,12 @@ function finishDrawing() {
}
function finishCanvasPointer(event) {
if(layerDrag?.id===event.pointerId) {
const drag=layerDrag;layerDrag=null;
if(event.type==="pointercancel"||event.type==="lostpointercapture")cancelLayerPreview();
else {const p=boardPoint(event,{round:false});try{moveSelectedLayer(drag.x+p.x-drag.start.x,drag.y+p.y-drag.start.y);}catch(e){cancelLayerPreview();announce(e.message,true);}}
syncOffsets();return;
}
if (viewPointers.delete(event.pointerId)) {
resetViewGesture();
return;
@@ -625,13 +643,13 @@ document.getElementById("fill-canvas").addEventListener("click", () => {
const rgb = hexToRgb(backgroundColor.value);
fillPixelLayer([rgb.r, rgb.g, rgb.b]);
recordPixelOperation(before);
announce("背景颜色已填充到像素层,文字元素保持不变");
announce("已开启当前画笔图层的背景");
});
document.getElementById("clear-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
fillPixelLayer([0, 0, 0]);
clearPixelLayer();
recordPixelOperation(before);
announce("像素层已清空,文字元素保持不变");
announce("当前图层笔迹已清空,背景保持不变");
});
startEditButton.addEventListener("click", enterCanvasEditor);
@@ -639,6 +657,7 @@ undoButton.addEventListener("click", undoPixelOperation);
exitEditButton.addEventListener("click", () => exitCanvasEditor());
moveViewButton.addEventListener("click", () => setEditorMode("move"));
continueEditButton.addEventListener("click", () => setEditorMode("draw"));
exitLayerMoveButton.addEventListener("click", () => setEditorMode("draw"));
assistiveToggle.addEventListener("pointerdown", (event) => {
if (!editorActive) return;
@@ -728,3 +747,20 @@ registerWorkspace({
hideCoordinateGuide();
},
});
function syncOffsets(){const l=getSelectedLayer();offsetX.value=l.x;offsetY.value=l.y;}
function commitOffsets(){try{moveSelectedLayer(Number(offsetX.value),Number(offsetY.value));}catch(e){announce(e.message,true);}syncOffsets();}
expandedToggle.setAttribute("aria-pressed",String(expandedEnabled));
expandedToggle.textContent=expandedEnabled?"隐藏扩展画面":"显示扩展画面";
expandedToggle.addEventListener("click",()=>{finishDrawing();expandedEnabled=!expandedEnabled;setExpandedBoard(expandedEnabled);expandedToggle.setAttribute("aria-pressed",String(expandedEnabled));expandedToggle.textContent=expandedEnabled?"隐藏扩展画面":"显示扩展画面";persistTools();updatePixelGridVisibility();applyCanvasView();});
moveLayerButton.addEventListener("click",()=>{finishDrawing();moveControls.hidden=false;syncOffsets();setEditorMode("layer");openAssistiveMenu();});
for(const input of [offsetX,offsetY]){
input.addEventListener("change",commitOffsets);
input.addEventListener("keydown",e=>{if(e.key==="Enter"){e.preventDefault();input.blur();}});
}
for(const button of document.querySelectorAll("[data-offset]"))button.addEventListener("click",()=>{
const l=getSelectedLayer(),axis=button.parentElement.dataset.axis,delta=Number(button.dataset.offset);
try{moveSelectedLayer(l.x+(axis==="x"?delta:0),l.y+(axis==="y"?delta:0));}catch(e){announce(e.message,true);}syncOffsets();
});
document.addEventListener("matrix:edit-history",updateUndoControl);
subscribeScene(({reason})=>{if(["selection","layer-move","undo","move-cancel"].includes(reason)){syncOffsets();updatePixelGridVisibility();}});
@@ -0,0 +1,121 @@
import { announce, apiJson } from "../core.js";
import { runControlAction, setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import { layerToStorage, sceneToStorageValue } from "../scene-model.js";
import { getScene, getSelectedLayer, subscribeScene, selectLayer, changeLayer, setLayerBackground,
animationEditState, templateEditState, enterTemplateEdit, enterAnimationEdit, markTemplateSaved,
markAnimationFrameSaved } from "../scene-board.js";
import { initializeTextWorkspace } from "./text.js";
import { refreshTemplates } from "./templates.js";
import { refreshAnimations } from "./animations.js";
const byId=id=>document.getElementById(id);
const list=byId("layer-list"), background=byId("layer-background-enabled"), color=byId("background-color");
const dialog=byId("layer-copy-dialog"),target=byId("layer-copy-target"),feedback=byId("layer-copy-feedback");
const submit=byId("layer-copy-submit"),frames=byId("layer-copy-frame-list");
let source=null,targets=new Map(),targetDetail=null,loadSequence=0,busy=false;
function render({scene}) {
const selected=getSelectedLayer(),brush=selected.type==="brush";
byId("brush-layer-tools").hidden=!brush;byId("text-layer-tools").hidden=brush;
background.checked=Boolean(selected.background);
if(brush){color.value=selected.backgroundColor||selected.background||"#000000";color.dispatchEvent(new Event("input"));}
list.replaceChildren(...[...scene.layers].reverse().map(l=>{
const button=document.createElement("button");button.type="button";button.className="layer-item";
button.textContent=`${l.name} · ${l.type==="brush"?"画笔":"文字"}`;button.setAttribute("aria-pressed",String(l.id===selected.id));
button.addEventListener("click",()=>selectLayer(l.id));return button;
}));
const index=scene.layers.indexOf(selected);
for(const [id,allowed,reason] of [["delete-layer",!brush||scene.layers.filter(l=>l.type==="brush").length>1,"必须保留一个画笔图层。"],
["raise-layer",index<scene.layers.length-1,"已经在最上方。"],["lower-layer",index>0,"已经在最下方。"]]) {
if(allowed)setControlReady(byId(id));else setControlUnavailable(byId(id),reason);
}
if(!brush)initializeTextWorkspace().catch(e=>announce(e.message,true));
}
subscribeScene(render);
for(const [id,action,value] of [["new-brush-layer","create","brush"],["new-text-layer","create","text"],
["delete-layer","delete"],["raise-layer","up"],["lower-layer","down"]])byId(id).addEventListener("click",()=>changeLayer(action,value));
byId("rename-layer").addEventListener("click",()=>{
const name=window.prompt("图层名称(1..80个字符)",getSelectedLayer().name);
if(name!==null)try{changeLayer("rename",name);}catch(e){announce(e.message,true);}
});
background.addEventListener("change",()=>setLayerBackground(background.checked,color.value));
color.addEventListener("input",()=>{if(getSelectedLayer().type==="brush"&&getSelectedLayer().backgroundColor!==color.value)setLayerBackground(background.checked,color.value);});
function dirtyChoice(){
const d=document.createElement("dialog");d.className="layer-copy-dialog";
const title=document.createElement("h3");title.textContent="目标画面正在编辑,存在未保存修改";d.append(title);
for(const [value,label] of [["save","保存后复制"],["discard","放弃修改后复制"],["cancel","取消"]]){
const b=document.createElement("button");b.type="button";b.textContent=label;b.addEventListener("click",()=>d.close(value));d.append(b);
}
document.body.append(d);d.showModal();return new Promise(resolve=>d.addEventListener("close",()=>{const choice=d.returnValue||"cancel";d.remove();resolve(choice);},{once:true}));
}
async function loadFrames(){
const sequence=++loadSequence;targetDetail=null;frames.replaceChildren();byId("layer-copy-frames").hidden=true;
const ref=targets.get(target.value);
if(!ref){setControlReady(submit);return;}
setControlUnavailable(submit,"正在读取目标帧。");feedback.textContent="正在读取目标帧…";
try{
const detail=await apiJson(ref.type==="template"?`/api/templates/${ref.id}`:`/api/animations/${ref.id}/frames`);if(sequence!==loadSequence)return;
targetDetail=detail;
if(ref.type==="template"){feedback.textContent="副本默认置顶。";setControlReady(submit);return;}
byId("layer-copy-frames").hidden=false;
for(const [i,frame] of detail.frames.entries()) {
const label=document.createElement("label"),input=document.createElement("input");input.type="checkbox";input.value=frame.id;
const name=document.createElement("span");name.textContent=`${i+1}. ${frame.name||"未命名帧"}`;
label.append(input,name);frames.append(label);
}
feedback.textContent=detail.frames.length?"选择一个或多个目标帧,复制后置顶。":"目标动图没有帧,请先添加帧。";
if(detail.frames.length)setControlReady(submit);else setControlUnavailable(submit,"目标动图没有帧。");
}catch(e){if(sequence===loadSequence){feedback.textContent=e.message;setControlUnavailable(submit,"读取失败,请重新选择目标。");}}
}
byId("copy-layer").addEventListener("click",async()=>{
const sequence=++loadSequence;
source=layerToStorage(getSelectedLayer());targets=new Map();target.replaceChildren(new Option("当前画板","current"));
feedback.textContent="正在读取可复制目标…";dialog.showModal();setControlUnavailable(submit,"正在读取目标。");
try{
const [templates,animations]=await Promise.all([apiJson("/api/templates"),apiJson("/api/animations")]);
if(sequence!==loadSequence)return;
for(const [type,items] of [["template",templates.templates],["animation",animations.animations]])for(const item of items.filter(i=>!i.is_demo&&!i.read_only)){
const key=`${type}:${item.id}`;targets.set(key,{...item,type});target.append(new Option(`${type==="template"?"静态":"动图"} · ${item.name}`,key));
}
feedback.textContent="副本默认置顶,不改变原图层。";await loadFrames();
}catch(e){feedback.textContent=e.message;setControlReady(submit);}
});
target.addEventListener("change",loadFrames);
for(const [id,checked] of [["layer-copy-all",true],["layer-copy-none",false]])byId(id).addEventListener("click",()=>frames.querySelectorAll("input").forEach(input=>input.checked=checked));
byId("layer-copy-cancel").addEventListener("click",()=>{if(!busy){loadSequence++;dialog.close();}});
dialog.addEventListener("cancel",e=>{if(busy)e.preventDefault();else loadSequence++;});
submit.addEventListener("click",()=>runControlAction(submit,{message:"正在复制图层…"},async()=>{
busy=true;target.disabled=true;frames.inert=true;
try{
if(target.value==="current"){changeLayer("duplicate");dialog.close();announce("图层已复制到当前画板最上方");return;}
const ref=targets.get(target.value);if(!ref)throw Error("请选择有效目标");
const edit=ref.type==="template"?templateEditState():animationEditState();
// An ordinary template parked behind animation editing is not the active board.
const editing=edit&&(ref.type==="template"?edit.id===ref.id&&!animationEditState():edit.animationId===ref.id);
let savedRevision=null;
if(editing&&edit.dirty){
const choice=await dirtyChoice();if(choice==="cancel")return;
if(choice==="save"){
const path=ref.type==="template"?`/api/templates/${ref.id}`:`/api/animations/${ref.id}/frames/${edit.frameId}`;
const saved=await apiJson(path,{method:"PUT",headers:{"If-Match":`"${edit.revision}"`},body:JSON.stringify({scene:sceneToStorageValue(getScene())})});
if(ref.type==="template")markTemplateSaved(saved);else markAnimationFrameSaved(saved);
savedRevision=saved.revision;
}
}
const current=targetDetail;
if(!current)throw Error("目标状态已失效,请重新选择");
// Preserve the revision from frame selection: intervening writes must conflict.
const frameIds=Array.from(frames.querySelectorAll("input:checked"),input=>input.value);
if(ref.type==="animation"&&!frameIds.length)throw Error("请至少选择一个目标帧");
const revision=savedRevision||current.revision;
const result=await apiJson(ref.type==="template"?`/api/templates/${ref.id}/layers`:`/api/animations/${ref.id}/frames/layers`,{
method:"POST",headers:{"If-Match":`"${revision}"`},body:JSON.stringify({layer:source,...(ref.type==="animation"?{frame_ids:frameIds}:{})})});
if(editing){if(ref.type==="template")enterTemplateEdit(result);else{
const frame=await apiJson(`/api/animations/${ref.id}/frames/${edit.frameId}`);
enterAnimationEdit({...frame,animation_id:ref.id,animation_name:result.name,animation_revision:result.revision});
}}
dialog.close();announce("图层已复制到目标最上方");
await Promise.all([refreshTemplates(),refreshAnimations()]);
}catch(e){feedback.textContent=`复制失败:${e.message}`;announce(feedback.textContent,true);}
finally{busy=false;target.disabled=false;frames.inert=false;}
}));
@@ -528,7 +528,7 @@ export async function refreshTemplates() {
if (!userItems.length) {
const empty = document.createElement("p");
empty.className = "template-empty panel";
empty.textContent = "还没有模板。请在像素画布或文字显示中保存当前统一画板。";
empty.textContent = "还没有模板。请在画图页保存当前统一画板。";
grid.appendChild(empty);
}
feedback.textContent = `共 ${data.templates.length} 个静态模板、${animationList.length} 个动图`;
@@ -12,6 +12,7 @@ import {
deleteTextElement,
duplicateTextElement,
getScene,
getSelectedLayer,
getSelectedTextElement,
selectTextElement,
subscribeScene,
@@ -116,7 +117,7 @@ function displayName(element, index) {
}
function renderElementList(scene, selectedId) {
const items = scene.elements.map((element, index) => {
const items = (getSelectedLayer().elements||[]).map((element, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "text-element-item";
@@ -144,7 +145,7 @@ function renderSelection({ scene, selectedTextId, reason }) {
selectionStatus.textContent = selected
? `已选中“${selected.text || "空文字"}” · 可在画板拖动,拖右下角控制点等比缩放`
: "请先新建或选择一个文字元素;也可点击画板中文字或从列表选择。";
const shouldRefreshFields = renderedSelectionId !== selectedTextId
const shouldRefreshFields = reason === "selection" || reason === "undo" || reason === "move-cancel" || reason === "layer-move" || renderedSelectionId !== selectedTextId
|| ["subscribe", "selection", "create", "duplicate", "delete", "gesture"].includes(reason);
if (shouldRefreshFields) applyElementToFields(selected);
}
@@ -169,12 +170,28 @@ function formPatch(element) {
form.addEventListener("input", (event) => {
if (event.target === fontInput || event.target === fontFileInput) return;
if (["text-x", "text-y"].includes(event.target.id)) return;
if (suppressForm) return;
const selected = getSelectedTextElement();
if (!selected) return;
updateTextElement(selected.id, formPatch(selected), "form");
const patch = formPatch(selected);
delete patch.x;
delete patch.y;
updateTextElement(selected.id, patch, "form");
});
for (const id of ["text-x", "text-y"]) {
field(id).addEventListener("change", () => {
const selected = getSelectedTextElement();
if (selected) updateTextElement(selected.id, {
x: numericValue("text-x", selected.x), y: numericValue("text-y", selected.y),
}, "gesture");
});
field(id).addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); field(id).blur(); }
});
}
document.getElementById("create-text").addEventListener("click", () => {
const created = addTextElement({
text: "ok",
@@ -332,18 +349,10 @@ async function initializeTextWorkspace() {
}
}
if (!getSelectedTextElement()) {
const topmost = getScene().elements.at(-1);
const topmost = getSelectedLayer().elements?.at(-1);
if (topmost) selectTextElement(topmost.id);
else applyElementToFields(null);
}
}
registerWorkspace({
id: "text",
title: "文字显示",
elementId: "workspace-text",
order: 40,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter: initializeTextWorkspace,
});
export { initializeTextWorkspace };