Improve Android app UI and controller flows
This commit is contained in:
+181
-108
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user