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

767 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getSelectedLayer, boardExtent, editSnapshot, rememberEdit, editHistorySize, clearEditHistory, undoSceneEdit, setExpandedBoard, setMoveFrameVisible, moveSelectedLayer, previewLayerMove, cancelLayerPreview, clearPixelLayer, subscribeScene } from "../scene-board.js";
import {
announce,
createLocalState,
registerWorkspace,
setActiveButton,
} from "../core.js";
import { refreshColorTrigger, setColorValue } from "../color-picker.js";
import { hexToRgb, rgbToHex } from "../color-math.js";
import {
BRUSH_MODE_NORMAL,
BRUSH_MODE_PIXEL,
PixelUndoHistory,
brushModeSpec,
brushStampPoints,
chooseCoordinateGuideStep,
clampCanvasView,
clampFloatingButton,
coordinateGuideTicks,
linePoints,
normalizeBrushSize,
restoreCanvasToolState,
zoomCanvasView,
} from "../canvas-tools-model.js";
import { setControlReady, setControlUnavailable } from "../interaction-feedback.js";
import {
boardPoint,
commitPixelChanges,
fillPixelLayer,
getBoardOverlay,
getInteractionMode,
getPixelBuffer,
renderPixelDraft,
} from "../scene-board.js";
const canvas = getBoardOverlay();
const localState = createLocalState("canvas-tools", 3);
const previousLocalState = createLocalState("canvas-tools", 2).load(null)
|| createLocalState("canvas-tools", 1).load(null);
const legacyState = createLocalState("canvas", 3).load(null)
|| createLocalState("canvas", 2).load(null)
|| {};
const toolButtons = Array.from(document.querySelectorAll("[data-canvas-tool]"));
const paintColor = document.getElementById("paint-color");
const backgroundColor = document.getElementById("background-color");
const brushSizeInput = document.getElementById("brush-size");
const brushSizePresets = Array.from(document.querySelectorAll("[data-brush-size]"));
const pixelEditToggle = document.getElementById("pixel-edit-toggle");
const pixelEditStatus = document.getElementById("pixel-edit-status");
const pixelGrid = document.getElementById("pixel-grid");
const boardStage = document.getElementById("board-stage");
const coordinateGuide = document.getElementById("canvas-coordinate-guide");
const coordinateGuideContext = coordinateGuide.getContext("2d");
const canvasWorkbench = document.querySelector(".canvas-workbench");
const startEditButton = document.getElementById("start-canvas-edit");
const assistive = document.getElementById("canvas-edit-assistive");
const assistiveToggle = document.getElementById("canvas-assistive-toggle");
const assistiveMenu = document.getElementById("canvas-assistive-menu");
const editorModeHint = document.getElementById("canvas-edit-mode-hint");
const coordinateGuideToggle = document.getElementById("toggle-canvas-coordinates");
const undoButton = document.getElementById("undo-canvas-edit");
const exitEditButton = document.getElementById("exit-canvas-edit");
const moveViewButton = document.getElementById("move-canvas-view");
const continueEditButton = document.getElementById("continue-canvas-edit");
const savedTools = restoreCanvasToolState(localState.load(null), previousLocalState || legacyState);
let tool = savedTools.tool;
let previousDrawingTool = tool;
let pixelEditEnabled = savedTools.pixelEditEnabled;
let normalBrushSize = savedTools.normalBrushSize;
let pixelBrushSize = savedTools.pixelBrushSize;
let coordinateGuideEnabled = savedTools.coordinateGuideEnabled;
let drawing = false;
let drawingPointerId = null;
let lastPoint = null;
let drawingBefore = null;
let editorActive = false;
let editorMode = "draw";
let view = { scale: 1, x: 0, y: 0 };
let viewGesture = null;
let assistiveDrag = null;
let suppressAssistiveClick = false;
let coordinateGuideFrame = null;
let assistivePosition = { x: 36, y: window.innerHeight / 2 };
const viewPointers = new Map();
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;
function brushMode() {
return pixelEditEnabled ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL;
}
function currentBrushSize() {
return pixelEditEnabled ? pixelBrushSize : normalBrushSize;
}
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 hexToRgb(paintColor.value);
}
function stampBrush(centerX, centerY, rgb) {
brushStampPoints(centerX+64-getSelectedLayer().x, centerY+64-getSelectedLayer().y, currentBrushSize(), brushMode(),192,192)
.forEach((point) => setPixel(point.x, point.y, rgb));
}
function paintLine(from, to) {
const rgb = activeRgb();
linePoints(from, to).forEach((point) => stampBrush(point.x, point.y, rgb));
}
function paintEvent(event) {
const samples = typeof event.getCoalescedEvents === "function" ? event.getCoalescedEvents() : [event];
for (const sample of samples.length ? samples : [event]) {
const point = boardPoint(sample);
paintLine(lastPoint || point, point);
lastPoint = point;
}
renderPixelDraft();
}
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) {
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() {
return {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
baseSize: Math.max(1, boardStage.offsetWidth),
minVisible: 48,
};
}
function hideCoordinateGuide() {
if (coordinateGuideFrame !== null) {
window.cancelAnimationFrame(coordinateGuideFrame);
coordinateGuideFrame = null;
}
coordinateGuide.hidden = true;
}
function renderCoordinateGuide() {
coordinateGuideFrame = null;
if (!editorActive || !coordinateGuideEnabled || getInteractionMode() !== "pixels") {
coordinateGuide.hidden = true;
return;
}
const width = window.innerWidth;
const height = window.innerHeight;
const ratio = Math.max(1, window.devicePixelRatio || 1);
const physicalWidth = Math.max(1, Math.round(width * ratio));
const physicalHeight = Math.max(1, Math.round(height * ratio));
if (coordinateGuide.width !== physicalWidth || coordinateGuide.height !== physicalHeight) {
coordinateGuide.width = physicalWidth;
coordinateGuide.height = physicalHeight;
}
coordinateGuide.hidden = false;
coordinateGuideContext.setTransform(ratio, 0, 0, ratio, 0, 0);
coordinateGuideContext.clearRect(0, 0, width, height);
const rect = boardStage.getBoundingClientRect();
const left = Math.max(0, rect.left);
const top = Math.max(0, rect.top);
const right = Math.min(width, rect.right);
const bottom = Math.min(height, rect.bottom);
if (right <= left || bottom <= top || rect.width <= 0) return;
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,
visibleEnd: right,
step,
});
const yTicks = coordinateGuideTicks({
count: extent.size, origin: extent.origin,
boardStart: rect.top,
boardSize: rect.height,
visibleStart: top,
visibleEnd: bottom,
step,
});
const rulerSize = 24;
const rulerBottom = Math.min(bottom, top + rulerSize);
const rulerRight = Math.min(right, left + rulerSize);
coordinateGuideContext.save();
coordinateGuideContext.beginPath();
coordinateGuideContext.rect(left, top, right - left, bottom - top);
coordinateGuideContext.clip();
coordinateGuideContext.beginPath();
for (const tick of xTicks) {
coordinateGuideContext.moveTo(tick.position, top);
coordinateGuideContext.lineTo(tick.position, bottom);
}
for (const tick of yTicks) {
coordinateGuideContext.moveTo(left, tick.position);
coordinateGuideContext.lineTo(right, tick.position);
}
coordinateGuideContext.strokeStyle = "rgba(255, 255, 255, 0.42)";
coordinateGuideContext.lineWidth = 1;
coordinateGuideContext.stroke();
coordinateGuideContext.fillStyle = "rgba(255, 255, 255, 0.20)";
coordinateGuideContext.fillRect(left, top, right - left, rulerBottom - top);
coordinateGuideContext.fillRect(left, top, rulerRight - left, bottom - top);
coordinateGuideContext.fillStyle = "rgba(255, 255, 255, 0.34)";
coordinateGuideContext.fillRect(left, top, rulerRight - left, rulerBottom - top);
coordinateGuideContext.beginPath();
for (const tick of xTicks) {
coordinateGuideContext.moveTo(tick.position, rulerBottom - 7);
coordinateGuideContext.lineTo(tick.position, rulerBottom);
}
for (const tick of yTicks) {
coordinateGuideContext.moveTo(rulerRight - 7, tick.position);
coordinateGuideContext.lineTo(rulerRight, tick.position);
}
coordinateGuideContext.strokeStyle = "rgba(255, 255, 255, 0.88)";
coordinateGuideContext.stroke();
coordinateGuideContext.fillStyle = "rgba(255, 255, 255, 0.96)";
coordinateGuideContext.font = "10px ui-monospace, SFMono-Regular, Consolas, monospace";
coordinateGuideContext.textBaseline = "top";
for (const tick of xTicks) {
coordinateGuideContext.fillText(String(tick.value), tick.position + 2, top + 2);
}
for (const tick of yTicks) {
coordinateGuideContext.fillText(String(tick.value), left + 2, tick.position + 2);
}
coordinateGuideContext.restore();
}
function scheduleCoordinateGuideRender() {
if (!editorActive || !coordinateGuideEnabled || getInteractionMode() !== "pixels") {
hideCoordinateGuide();
return;
}
coordinateGuide.hidden = false;
if (coordinateGuideFrame === null) {
coordinateGuideFrame = window.requestAnimationFrame(renderCoordinateGuide);
}
}
function updateCoordinateGuideControl() {
coordinateGuideToggle.setAttribute("aria-pressed", String(coordinateGuideEnabled));
coordinateGuideToggle.textContent = coordinateGuideEnabled ? "隐藏坐标系" : "显示坐标系";
}
function applyCanvasView(next = view) {
view = clampCanvasView(next, viewportBounds());
boardStage.style.setProperty("--canvas-view-scale", String(view.scale));
boardStage.style.setProperty("--canvas-view-x", `${view.x}px`);
boardStage.style.setProperty("--canvas-view-y", `${view.y}px`);
scheduleCoordinateGuideRender();
}
function resetCanvasView() {
view = { scale: 1, x: 0, y: 0 };
applyCanvasView();
}
function applyAssistivePosition(next = assistivePosition) {
assistivePosition = clampFloatingButton(next, {
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
size: 56,
inset: 8,
});
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";
}
function resetAssistivePosition() {
applyAssistivePosition({ x: 36, y: window.innerHeight / 2 });
}
function closeAssistiveMenu({ restoreFocus = false } = {}) {
assistiveMenu.hidden = true;
assistiveToggle.setAttribute("aria-expanded", "false");
assistiveToggle.setAttribute("aria-label", "打开画布编辑菜单");
if (restoreFocus && editorActive) assistiveToggle.focus();
}
function openAssistiveMenu() {
applyAssistivePosition();
assistiveMenu.hidden = false;
assistiveToggle.setAttribute("aria-expanded", "true");
assistiveToggle.setAttribute("aria-label", "关闭画布编辑菜单");
const firstAvailable = assistiveMenu.querySelector('button:not([aria-disabled="true"])');
firstAvailable?.focus();
}
function toggleAssistiveMenu() {
if (assistiveMenu.hidden) openAssistiveMenu();
else closeAssistiveMenu({ restoreFocus: true });
}
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 = editorMode==="layer"?"移动图层:拖动或使用偏移坐标;黄色框为实际屏幕":moving
? "移动模式:单指拖动画布,双指捏合缩放;桌面可使用滚轮缩放。"
: textEditing ? "文字编辑模式:拖动文字或使用缩放手柄。" : "绘画模式:直接在画布上绘制。";
if (moving) {
setControlUnavailable(moveViewButton, "当前已经是移动画布视线模式。");
setControlReady(continueEditButton);
} else {
setControlReady(moveViewButton);
if (editorMode === "layer") setControlReady(continueEditButton);
else setControlUnavailable(continueEditButton, textEditing ? "当前已经是文字编辑模式。" : "当前已经是绘画模式。");
}
updateUndoControl();
}
function resetViewGesture() {
const points = Array.from(viewPointers.values());
if (points.length >= 2) {
const [first, second] = points;
viewGesture = {
kind: "pinch",
startView: { ...view },
startMidpoint: { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 },
startDistance: Math.max(1, Math.hypot(second.x - first.x, second.y - first.y)),
};
} else if (points.length === 1) {
viewGesture = { kind: "pan", startView: { ...view }, startPoint: { ...points[0] } };
} else {
viewGesture = null;
}
}
function setEditorMode(mode) {
if (!editorActive) return;
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() === "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();
window.requestAnimationFrame(() => {
resetCanvasView();
resetAssistivePosition();
scheduleCoordinateGuideRender();
assistiveToggle.focus();
});
announce("已进入全屏画布编辑;左侧悬浮按钮可撤销、移动视线或退出");
}
function exitCanvasEditor({ returnToTools = true } = {}) {
if (!editorActive) return;
finishDrawing();
editorActive = false;
editorMode = "draw";
viewPointers.clear();
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");
delete document.body.dataset.canvasEditorMode;
boardStage.style.removeProperty("--canvas-view-scale");
boardStage.style.removeProperty("--canvas-view-x");
boardStage.style.removeProperty("--canvas-view-y");
view = { scale: 1, x: 0, y: 0 };
if (returnToTools) {
window.requestAnimationFrame(() => {
canvasWorkbench.scrollIntoView({ block: "start", behavior: "smooth" });
startEditButton.focus({ preventScroll: true });
});
}
announce("已退出画布编辑,可以重新选择颜色和画笔工具");
}
function undoPixelOperation() {if(undoSceneEdit())announce("已撤销上一个编辑操作");updateUndoControl();}
function persistTools() {
localState.save({
paintColor: paintColor.value,
backgroundColor: backgroundColor.value,
tool,
pixelEditEnabled,
normalBrushSize,
pixelBrushSize,
coordinateGuideEnabled,
expandedEnabled,
});
}
function toggleCoordinateGuide() {
coordinateGuideEnabled = !coordinateGuideEnabled;
updateCoordinateGuideControl();
persistTools();
scheduleCoordinateGuideRender();
closeAssistiveMenu({ restoreFocus: true });
announce(coordinateGuideEnabled
? "已显示画布坐标系,标尺和辅助线会随视角缩放"
: "已隐藏画布坐标系");
}
function renderBrushSize() {
const mode = brushMode();
const brushSize = currentBrushSize();
const spec = brushModeSpec(mode);
brushSizeInput.min = String(spec.min);
brushSizeInput.max = String(spec.max);
brushSizeInput.value = String(brushSize);
document.getElementById("brush-size-value").textContent = pixelEditEnabled
? `${brushSize} × ${brushSize}`
: `${brushSize} px`;
const preview = document.getElementById("brush-size-preview");
const previewSize = Math.max(4, brushSize * 2);
preview.style.width = `${previewSize}px`;
preview.style.height = `${previewSize}px`;
preview.dataset.shape = pixelEditEnabled ? "square" : "round";
brushSizePresets.forEach((button, index) => {
const value = spec.presets[index];
button.dataset.brushSize = String(value);
button.textContent = String(value);
});
setActiveButton(
brushSizePresets,
brushSizePresets.find((button) => Number(button.dataset.brushSize) === brushSize),
);
pixelEditToggle.setAttribute("aria-pressed", String(pixelEditEnabled));
pixelEditToggle.textContent = pixelEditEnabled ? "退出单像素编辑" : "开启单像素编辑";
pixelEditStatus.hidden = !pixelEditEnabled;
pixelEditStatus.textContent = pixelEditEnabled
? `画布已按 64×64 方形像素分格,当前笔尖为 ${brushSize}×${brushSize}。`
: "";
}
function setBrushSize(value) {
const normalized = normalizeBrushSize(value, brushMode());
if (pixelEditEnabled) pixelBrushSize = normalized;
else normalBrushSize = normalized;
renderBrushSize();
persistTools();
}
function updatePixelGridVisibility() {
pixelGrid.hidden = !(pixelEditEnabled && getInteractionMode() === "pixels");
}
function togglePixelEditMode() {
pixelEditEnabled = !pixelEditEnabled;
renderBrushSize();
updatePixelGridVisibility();
persistTools();
announce(pixelEditEnabled
? `已进入单像素编辑模式:画布已按 64×64 方形像素分格,笔尖为 ${pixelBrushSize}×${pixelBrushSize} 正方形。`
: `已退出单像素编辑模式:恢复 ${normalBrushSize} px 圆形笔尖。`);
}
function selectTool(button) {
tool = button.dataset.canvasTool;
if (tool !== "eyedropper") previousDrawingTool = tool;
setActiveButton(toolButtons, button);
persistTools();
}
toolButtons.forEach((button) => button.addEventListener("click", () => selectTool(button)));
canvas.addEventListener("pointerdown", (event) => {
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 });
canvas.setPointerCapture(event.pointerId);
resetViewGesture();
return;
}
if (getInteractionMode()!=="pixels" || drawing) return;
if (tool === "eyedropper") {
const point = boardPoint(event);
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],
g: pixels[offset + 1],
b: pixels[offset + 2],
}), { recordHistory: true });
tool = previousDrawingTool;
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
persistTools();
announce(`已吸取像素层 (${point.x}, ${point.y}) 的颜色`);
return;
}
event.preventDefault();
drawing = true;
drawingPointerId = event.pointerId;
drawingBefore = pixelSnapshot();
lastPoint = boardPoint(event);
canvas.setPointerCapture(event.pointerId);
paintEvent(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 });
const points = Array.from(viewPointers.values());
if (viewGesture?.kind === "pinch" && points.length >= 2) {
const [first, second] = points;
const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
const distance = Math.max(1, Math.hypot(second.x - first.x, second.y - first.y));
const scaled = zoomCanvasView(
viewGesture.startView,
viewGesture.startView.scale * (distance / viewGesture.startDistance),
viewGesture.startMidpoint,
viewportBounds(),
);
applyCanvasView({
...scaled,
x: scaled.x + midpoint.x - viewGesture.startMidpoint.x,
y: scaled.y + midpoint.y - viewGesture.startMidpoint.y,
});
} else if (viewGesture?.kind === "pan" && points.length === 1) {
applyCanvasView({
...viewGesture.startView,
x: viewGesture.startView.x + points[0].x - viewGesture.startPoint.x,
y: viewGesture.startView.y + points[0].y - viewGesture.startPoint.y,
});
}
return;
}
if (drawing && drawingPointerId === event.pointerId && editorActive
&& editorMode === "draw" && getInteractionMode() === "pixels") paintEvent(event);
});
function finishDrawing() {
if (!drawing) return;
drawing = false;
drawingPointerId = null;
lastPoint = null;
const changed = recordPixelOperation(drawingBefore);
drawingBefore = null;
if (changed) commitPixelChanges("pixel-draw");
persistTools();
}
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;
}
if (drawingPointerId === event.pointerId) finishDrawing();
}
canvas.addEventListener("pointerup", finishCanvasPointer);
canvas.addEventListener("pointercancel", finishCanvasPointer);
canvas.addEventListener("lostpointercapture", finishCanvasPointer);
canvas.addEventListener("wheel", (event) => {
if (!editorActive || editorMode !== "move") return;
event.preventDefault();
const factor = Math.exp(-event.deltaY * 0.002);
applyCanvasView(zoomCanvasView(view, view.scale * factor, {
x: event.clientX,
y: event.clientY,
}, viewportBounds()));
}, { passive: false });
paintColor.addEventListener("input", persistTools);
backgroundColor.addEventListener("input", persistTools);
brushSizeInput.addEventListener("input", () => setBrushSize(brushSizeInput.value));
brushSizePresets.forEach((button) => button.addEventListener("click", () => setBrushSize(button.dataset.brushSize)));
pixelEditToggle.addEventListener("click", togglePixelEditMode);
coordinateGuideToggle.addEventListener("click", toggleCoordinateGuide);
document.getElementById("fill-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
const rgb = hexToRgb(backgroundColor.value);
fillPixelLayer([rgb.r, rgb.g, rgb.b]);
recordPixelOperation(before);
announce("已开启当前画笔图层的背景");
});
document.getElementById("clear-canvas").addEventListener("click", () => {
const before = pixelSnapshot();
clearPixelLayer();
recordPixelOperation(before);
announce("当前图层笔迹已清空,背景保持不变");
});
startEditButton.addEventListener("click", enterCanvasEditor);
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;
event.preventDefault();
event.stopPropagation();
assistiveDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
origin: { ...assistivePosition },
moved: false,
};
assistiveToggle.setPointerCapture(event.pointerId);
});
assistiveToggle.addEventListener("pointermove", (event) => {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
const dx = event.clientX - assistiveDrag.startX;
const dy = event.clientY - assistiveDrag.startY;
if (Math.hypot(dx, dy) > 6) assistiveDrag.moved = true;
if (assistiveDrag.moved) {
closeAssistiveMenu();
applyAssistivePosition({ x: assistiveDrag.origin.x + dx, y: assistiveDrag.origin.y + dy });
}
});
function finishAssistiveDrag(event) {
if (!assistiveDrag || assistiveDrag.pointerId !== event.pointerId) return;
suppressAssistiveClick = assistiveDrag.moved;
assistiveDrag = null;
event.stopPropagation();
}
assistiveToggle.addEventListener("pointerup", finishAssistiveDrag);
assistiveToggle.addEventListener("pointercancel", finishAssistiveDrag);
assistiveToggle.addEventListener("lostpointercapture", finishAssistiveDrag);
assistiveToggle.addEventListener("click", () => {
if (suppressAssistiveClick) {
suppressAssistiveClick = false;
return;
}
toggleAssistiveMenu();
});
document.addEventListener("keydown", (event) => {
if (!editorActive || event.key !== "Escape") return;
event.preventDefault();
if (!assistiveMenu.hidden) closeAssistiveMenu({ restoreFocus: true });
else exitCanvasEditor();
});
window.addEventListener("resize", () => {
if (!editorActive) return;
applyCanvasView();
applyAssistivePosition();
});
document.addEventListener("matrix:composition-applied", clearUndoHistory);
document.addEventListener("matrix:canvas-history-checkpoint", clearUndoHistory);
document.addEventListener("matrix:scene-replaced", clearUndoHistory);
refreshColorTrigger(paintColor);
refreshColorTrigger(backgroundColor);
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
renderBrushSize();
updateCoordinateGuideControl();
persistTools();
updateEditorModeControls();
registerWorkspace({
id: "canvas",
title: "像素画布",
elementId: "workspace-canvas",
order: 30,
persistence: "local",
capabilities: ["scene-template-save"],
onEnter() {
renderPixelDraft();
updatePixelGridVisibility();
},
onLeave() {
exitCanvasEditor({ returnToTools: false });
finishDrawing();
pixelGrid.hidden = true;
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();}});