Add adaptive coordinate guides to canvas editor
This commit is contained in:
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user