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