Add adaptive coordinate guides to canvas editor

This commit is contained in:
2026-09-10 17:48:04 +08:00
parent 8d368de3b5
commit a493fe1757
9 changed files with 292 additions and 13 deletions
+1 -1
View File
@@ -1 +1 @@
2026-09-08T10:19+08:00
2026-09-10T11:10+08:00
@@ -3,6 +3,8 @@ export const BRUSH_MODE_PIXEL = "pixel";
export const CANVAS_VIEW_MIN_SCALE = 1;
export const CANVAS_VIEW_MAX_SCALE = 8;
export const CANVAS_UNDO_LIMIT = 50;
export const COORDINATE_GUIDE_STEPS = Object.freeze([1, 2, 4, 8, 16, 32]);
export const COORDINATE_GUIDE_TARGET_SPACING = 48;
const MODE_SPECS = Object.freeze({
[BRUSH_MODE_NORMAL]: Object.freeze({ min: 1, max: 16, presets: Object.freeze([1, 2, 4, 8]) }),
@@ -23,23 +25,55 @@ export function normalizeBrushSize(value, mode) {
return Math.max(spec.min, Math.min(spec.max, Number.isFinite(parsed) ? parsed : spec.min));
}
export function restoreCanvasToolState(savedV2, savedV1 = null) {
const current = record(savedV2);
const legacy = record(savedV1);
export function restoreCanvasToolState(savedV3, savedV2 = null) {
const current = record(savedV3);
const legacy = record(savedV2);
const source = current || legacy || {};
return {
paintColor: typeof source.paintColor === "string" ? source.paintColor : null,
backgroundColor: typeof source.backgroundColor === "string" ? source.backgroundColor : null,
tool: source.tool === "eraser" ? "eraser" : "brush",
pixelEditEnabled: Boolean(current?.pixelEditEnabled),
pixelEditEnabled: Boolean(source.pixelEditEnabled),
normalBrushSize: normalizeBrushSize(
current?.normalBrushSize ?? source.brushSize,
source.normalBrushSize ?? source.brushSize,
BRUSH_MODE_NORMAL,
),
pixelBrushSize: normalizeBrushSize(current?.pixelBrushSize, BRUSH_MODE_PIXEL),
pixelBrushSize: normalizeBrushSize(source.pixelBrushSize, BRUSH_MODE_PIXEL),
coordinateGuideEnabled: Boolean(current?.coordinateGuideEnabled),
};
}
export function chooseCoordinateGuideStep(
logicalPixelCssSize,
targetSpacing = COORDINATE_GUIDE_TARGET_SPACING,
) {
const pixelSize = Math.max(0, Number(logicalPixelCssSize) || 0);
const target = Math.max(1, Number(targetSpacing) || COORDINATE_GUIDE_TARGET_SPACING);
return COORDINATE_GUIDE_STEPS.reduce((best, candidate) => (
Math.abs(candidate * pixelSize - target) < Math.abs(best * pixelSize - target)
? candidate
: best
), COORDINATE_GUIDE_STEPS[0]);
}
export function coordinateGuideTicks({
boardStart,
boardSize,
visibleStart = Number.NEGATIVE_INFINITY,
visibleEnd = Number.POSITIVE_INFINITY,
step,
}) {
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;
if (position >= visibleStart && position <= visibleEnd) ticks.push({ value, position });
}
return ticks;
}
export function linePoints(from, to) {
const points = [];
let x = from.x;
@@ -104,6 +104,7 @@
<canvas id="scene-overlay" width="64" height="64" aria-label="统一画板交互层"></canvas>
<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>
<p id="board-status" class="board-status" aria-live="polite">正在恢复画板草稿…</p>
<div class="board-actions">
<button id="apply-composition" class="primary-button" type="button">应用统一画板</button>
@@ -118,6 +119,7 @@
</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-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>
<button id="exit-canvas-edit" type="button" role="menuitem">退出编辑</button>
@@ -905,6 +905,15 @@ h3 {
display: none;
}
.canvas-coordinate-guide {
display: none;
pointer-events: none;
}
.canvas-coordinate-guide[hidden] {
display: none;
}
#scene-overlay[data-interaction="readonly"] {
pointer-events: none;
}
@@ -950,6 +959,17 @@ body.canvas-edit-active .board-stage {
will-change: transform;
}
body.canvas-edit-active .canvas-coordinate-guide:not([hidden]) {
position: absolute;
z-index: 1005;
inset: 0;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
mix-blend-mode: difference;
}
body.canvas-edit-active[data-canvas-editor-mode="move"] .board-stage {
cursor: grab;
}
@@ -3317,6 +3317,18 @@
"source": "index.html::#workspace-device > article:nth-of-type(3) > div:nth-of-type(1) > h3:nth-of-type(1)::text[0]",
"text": "设备容量"
},
"copy.83ad7143ff410a09": {
"kind": "html_text",
"placeholders": [],
"render": {
"selector": "#toggle-canvas-coordinates",
"text_index": 0,
"type": "static_text"
},
"scope": "global",
"source": "index.html::#toggle-canvas-coordinates::text[0]",
"text": "显示坐标系"
},
"copy.83e4a810c846111b": {
"kind": "html_text",
"placeholders": [],
@@ -12,8 +12,10 @@ import {
PixelUndoHistory,
brushModeSpec,
brushStampPoints,
chooseCoordinateGuideStep,
clampCanvasView,
clampFloatingButton,
coordinateGuideTicks,
linePoints,
normalizeBrushSize,
restoreCanvasToolState,
@@ -31,8 +33,9 @@ import {
} from "../scene-board.js";
const canvas = getBoardOverlay();
const localState = createLocalState("canvas-tools", 2);
const previousLocalState = createLocalState("canvas-tools", 1).load(null);
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)
|| {};
@@ -45,12 +48,15 @@ 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");
@@ -61,6 +67,7 @@ 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;
@@ -71,6 +78,7 @@ 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();
const undoHistory = new PixelUndoHistory();
@@ -148,11 +156,130 @@ function viewportBounds() {
};
}
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 step = chooseCoordinateGuideStep(rect.width / 64);
const xTicks = coordinateGuideTicks({
boardStart: rect.left,
boardSize: rect.width,
visibleStart: left,
visibleEnd: right,
step,
});
const yTicks = coordinateGuideTicks({
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() {
@@ -254,9 +381,11 @@ function enterCanvasEditor() {
assistive.hidden = false;
closeAssistiveMenu();
updateEditorModeControls();
updateCoordinateGuideControl();
window.requestAnimationFrame(() => {
resetCanvasView();
resetAssistivePosition();
scheduleCoordinateGuideRender();
assistiveToggle.focus();
});
announce("已进入全屏画布编辑;左侧悬浮按钮可撤销、移动视线或退出");
@@ -271,6 +400,7 @@ function exitCanvasEditor({ returnToTools = true } = {}) {
viewGesture = null;
closeAssistiveMenu();
assistive.hidden = true;
hideCoordinateGuide();
delete canvas.dataset.canvasEditing;
document.body.classList.remove("canvas-edit-active");
delete document.body.dataset.canvasEditorMode;
@@ -307,9 +437,21 @@ function persistTools() {
pixelEditEnabled,
normalBrushSize,
pixelBrushSize,
coordinateGuideEnabled,
});
}
function toggleCoordinateGuide() {
coordinateGuideEnabled = !coordinateGuideEnabled;
updateCoordinateGuideControl();
persistTools();
scheduleCoordinateGuideRender();
closeAssistiveMenu({ restoreFocus: true });
announce(coordinateGuideEnabled
? "已显示画布坐标系,标尺和辅助线会随视角缩放"
: "已隐藏画布坐标系");
}
function renderBrushSize() {
const mode = brushMode();
const brushSize = currentBrushSize();
@@ -477,6 +619,7 @@ 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);
@@ -563,6 +706,7 @@ refreshColorTrigger(paintColor);
refreshColorTrigger(backgroundColor);
setActiveButton(toolButtons, toolButtons.find((button) => button.dataset.canvasTool === tool));
renderBrushSize();
updateCoordinateGuideControl();
persistTools();
updateEditorModeControls();
@@ -581,5 +725,6 @@ registerWorkspace({
exitCanvasEditor({ returnToTools: false });
finishDrawing();
pixelGrid.hidden = true;
hideCoordinateGuide();
},
});
@@ -8,8 +8,10 @@ import {
PixelUndoHistory,
brushModeSpec,
brushStampPoints,
chooseCoordinateGuideStep,
clampCanvasView,
clampFloatingButton,
coordinateGuideTicks,
linePoints,
normalizeBrushSize,
restoreCanvasToolState,
@@ -60,7 +62,7 @@ test("line interpolation includes endpoints without coordinate gaps", () => {
}
});
test("v1 tools migrate into normal mode while v2 restores both sizes", () => {
test("older tools migrate while v3 restores the coordinate guide choice", () => {
assert.deepEqual(restoreCanvasToolState(null, {
paintColor: "#123456",
backgroundColor: "#654321",
@@ -73,12 +75,14 @@ test("v1 tools migrate into normal mode while v2 restores both sizes", () => {
pixelEditEnabled: false,
normalBrushSize: 8,
pixelBrushSize: 1,
coordinateGuideEnabled: false,
});
assert.deepEqual(restoreCanvasToolState({
tool: "brush",
pixelEditEnabled: true,
normalBrushSize: 12,
pixelBrushSize: 4,
coordinateGuideEnabled: true,
}), {
paintColor: null,
backgroundColor: null,
@@ -86,8 +90,58 @@ test("v1 tools migrate into normal mode while v2 restores both sizes", () => {
pixelEditEnabled: true,
normalBrushSize: 12,
pixelBrushSize: 4,
coordinateGuideEnabled: true,
});
assert.equal(restoreCanvasToolState("damaged", { brushSize: "bad" }).normalBrushSize, 1);
assert.equal(restoreCanvasToolState("damaged", { coordinateGuideEnabled: true }).coordinateGuideEnabled, false);
assert.deepEqual(restoreCanvasToolState(null, {
pixelEditEnabled: true,
normalBrushSize: 9,
pixelBrushSize: 3,
}), {
paintColor: null,
backgroundColor: null,
tool: "brush",
pixelEditEnabled: true,
normalBrushSize: 9,
pixelBrushSize: 3,
coordinateGuideEnabled: false,
});
});
test("coordinate guide spacing adapts to the transformed logical pixel size", () => {
assert.equal(chooseCoordinateGuideStep(4.625), 8);
assert.equal(chooseCoordinateGuideStep(9.25), 4);
assert.equal(chooseCoordinateGuideStep(18.5), 2);
assert.equal(chooseCoordinateGuideStep(37), 1);
assert.equal(chooseCoordinateGuideStep("damaged"), 1);
});
test("coordinate guide ticks preserve logical values through zoom and pan", () => {
assert.deepEqual(coordinateGuideTicks({
boardStart: 20,
boardSize: 320,
visibleStart: 0,
visibleEnd: 400,
step: 16,
}), [
{ value: 0, position: 20 },
{ value: 16, position: 100 },
{ value: 32, position: 180 },
{ value: 48, position: 260 },
]);
assert.deepEqual(coordinateGuideTicks({
boardStart: -300,
boardSize: 640,
visibleStart: 0,
visibleEnd: 320,
step: 8,
}), [
{ value: 32, position: 20 },
{ value: 40, position: 100 },
{ value: 48, position: 180 },
{ value: 56, position: 260 },
]);
});
test("pixel mode markup and grid stay accessible and non-interactive", async () => {
@@ -168,13 +222,19 @@ test("canvas editor markup, guarded input, and checkpoint events stay wired", as
]);
assert.match(index, /id="start-canvas-edit"[^>]*>开始编辑<\/button>/);
assert.match(index, /id="canvas-assistive-toggle"[^>]+aria-expanded="false"/);
assert.match(index, /id="canvas-coordinate-guide"[^>]+aria-hidden="true"[^>]+hidden/);
assert.match(index, /id="toggle-canvas-coordinates"[^>]+aria-pressed="false"/);
assert.match(index, /id="undo-canvas-edit"[^>]+aria-disabled="true"/);
assert.match(index, /id="move-canvas-view"/);
assert.match(index, /id="continue-canvas-edit"/);
assert.match(style, /body\.canvas-edit-active #board-shell\s*\{[^}]*position:\s*fixed/s);
assert.match(style, /\.canvas-coordinate-guide\s*\{[^}]*pointer-events:\s*none/s);
assert.match(style, /body\.canvas-edit-active \.canvas-coordinate-guide:not\(\[hidden\]\)\s*\{[^}]*z-index:\s*1005/s);
assert.match(style, /#scene-overlay\[data-interaction="pixels"\]:not\(\[data-canvas-editing="true"\]\)\s*\{[^}]*pointer-events:\s*none/s);
assert.match(canvasView, /if \(!editorActive \|\| getInteractionMode\(\) !== "pixels"\) return/);
assert.match(canvasView, /new PixelUndoHistory\(\)/);
assert.match(canvasView, /createLocalState\("canvas-tools", 3\)/);
assert.match(canvasView, /coordinateGuide\.hidden = true/);
assert.match(sceneBoard, /matrix:scene-replaced/);
assert.match(templates, /matrix:canvas-history-checkpoint/);
assert.match(animations, /matrix:canvas-history-checkpoint/);