diff --git a/CLAUDE.md b/CLAUDE.md
index c9fe78c..28864da 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,6 +54,7 @@ When smoke fails, read the **first** `FAIL` line, because later failures often c
- The core state:
- `S` holds settings and the document (tool, colours, sizes, zoom, file handle and name, `pristine`).
- `drag` is the in-progress pointer gesture, keyed by `drag.t`: `pencil`, `brush`, `move`, `select`, `canvas`, `pan`, `objmove`, `objsize` (the Move tool, V, or a shape tool on its own selected shape), and so on.
+ - `objmove` and `objsize` snap (`objDragUpdate()` in `pointer.js`): on the first real move, `drag.snap` gets the lines (`Core.snapLines`) of the frames (`frameOf()`, cached per object in `scene.js`) of the objects in view (`visible()`) that aren't being dragged, plus the canvas; each update snaps the raw pointer delta afresh (`Core.snapMove` / `snapResize`) and keeps `drag.guides`, which `render()` places as `#guides` lines. `drag.free` (⌘, Ctrl elsewhere) turns it off.
- `sel` is the selection; `sel.float` is a lifted or pasted canvas that hasn't been committed yet.
- `textAt` is non-null while the text box is open.
- `objs` is the list of objects, bottom to top: shapes (line, rect, ellipse), strokes (pencil, eraser = a colour-2 pencil, brush; in Transparent a stroke clears what is under it, with `destination-out`), text, image (a committed paste or lift) and fill (a frozen bitmap of what a fill changed); see `Core.objBox` for each kind's data. Every tool that marks the picture adds one **on top** (since the switch, 2026-10-17). A stroke is drawn live as the scene's top object (`setLive()`) through the same tile repaint, and joins on release without a repaint; a pencil or eraser stroke (whole-pixel rects, `Core.stampRects`) is drawn a piece at a time onto a canvas per tile it is in (`liveCv`), not composed again each frame, which a brush (one anti-aliased path per tile) still is. A lift leaves a colour-2 patch (a filled rect object; in Transparent it clears) and commits an image object, one undo step; Delete adds a patch. The fill reads what is seen (`composite()`) in its worker. Only whole-image ops, New/Open and ⌘E change the base. They are kept each with a fractional z-key `o.z` (`Core.zBetween`) that sorts like the list; they are drawn into `#image`'s cache over the base. `objSel` is the selected one's id; `multi` holds the ids when several are selected (Move tool box drag or ⇧-click), and `selList()` gives the selection either way. Every change goes through `install()`, which keeps the scene's index and repaint in step. Objects are immutable (edits make a new one with the same id) and become pixels only through `flattenObjs()` (⌘E). Crop, resize, canvas size, rotate and flip carry them along instead (`docOp(next, map)` with `Core.moveObj` / `scaleObj` / `transformObj`; one left wholly outside is dropped). Save, copy and the picker use `composite()`. See `docs/objects.md`.
diff --git a/README.md b/README.md
index 2d37e9f..4eeb022 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ In Chrome you can install it as an app (install icon in the address bar). It the
## Features
- Pencil, brush (smoothed), eraser, fill, colour picker, text
-- Every mark (strokes, shapes, text, fills, pastes) stays an editable object until you flatten it (⌘E); PNG saves keep them
+- Every mark (strokes, shapes, text, fills, pastes) stays an editable object until you flatten it (⌘E); PNG saves keep them. Moving or resizing one snaps it to the others and to the canvas
- Select, crop, resize, canvas size, rotate, flip
- Open, paste or drop images, including TIFF; ⌘S saves back to the file you opened
- Transparency: PNGs open and save with it. The checkered Transparent swatch erases: with color 2 set to it, the eraser, Delete, moving a selection and a bigger canvas leave transparent pixels, and pencil, brush and fill drawing with it clear
@@ -45,6 +45,7 @@ Press `?` in the app for the full list. ⌘ is Ctrl and ⌥ is Alt on Windows/Li
| ⌘⇧X | Crop to selection |
| ⌘D, ⌘] ⌘[, ⌘E | Objects (every stroke, shape, text, fill and paste stays movable; new ones go on top; PNG saves keep them): duplicate, bring forward / send backward (⌘⇧] ⌘⇧[: to front / back; also buttons in the Move tool's bar), flatten into pixels (with the Move tool and a selection: just those, into image objects). The Fill tool inside a closed shape sets that shape's fill |
| ⌥+drag (Move tool) | Duplicate an object by dragging a copy off it |
+| ⌘ while dragging an object | Moving or resizing an object snaps its edges and centre to other objects' and the canvas's (magenta guides show what lines up); hold ⌘ to place it freely |
| ⌥+drag (Eraser) | Delete every object the drag crosses (⌥ alone outlines the one under the pointer) |
| Tab / Shift+Tab (Move tool, canvas focused) | Select the next / previous object in drawing order; past the last one, focus moves on |
| Shift+R | Resize image (resample) |
diff --git a/docs/objects.md b/docs/objects.md
index d496db8..c02e3c0 100644
--- a/docs/objects.md
+++ b/docs/objects.md
@@ -95,7 +95,7 @@ Everything is a node, including brush strokes (vector paths) and pasted bitmaps
9. **Text as an object:** not done. `commitText()` would create a TEXT object (string, font, size, colour, position) instead of baking, and clicking it with the text tool would reopen the box. The baseline maths (`textBaseline()`) carries over unchanged.
10. **Pasted images as objects:** not done. This would reuse `sel.float`'s canvas as an IMAGE object, so a paste could be moved and scaled later, which is the most Figma-like win for screenshots.
11. **Persistence chunk:** dropped (decided: objects are gone on reopen).
-12. **Rotation, groups, multi-select, snapping, layers list:** not done. Multi-select (⇧-click, marquee over objects) is the next most useful step after text.
+12. **Rotation, groups, layers list:** not done. Multi-select (⇧-click, a box drag with the Move tool) came with total objects, and snapping to other objects and the canvas, with guides, on 2026-10-21 (task.md, "Shapes (objects)").
13. **`task.md`:** "selection resize handles" is listed as out of scope for polish. Objects reopen it as feature work.
14. **Keyboard access to objects:** done. With the Move tool and the canvas focused (`#viewport` is a Tab stop), Tab / ⇧Tab select the next / previous shape in drawing order, and a screen reader hears its kind, size, position and index. Past either end the shape is deselected and the key moves focus on as usual, so focus is never trapped.
diff --git a/src/index.html b/src/index.html
index f1b10e4..a44dfed 100644
--- a/src/index.html
+++ b/src/index.html
@@ -110,7 +110,7 @@
Backward
Back
- Click an object · drag empty canvas to box-select · Space-drag pans
+ Click an object · drag empty canvas to box-select · ⌘ held: no snapping · Space-drag pans
Drag to select · drag inside to move
Paints with color 2 · clears if it’s Transparent
Click: color 1 · right-click: color 2
@@ -130,7 +130,7 @@
-
+
@@ -153,6 +153,7 @@
+
diff --git a/src/js/core.js b/src/js/core.js
index 0e0c4ce..f9900a0 100644
--- a/src/js/core.js
+++ b/src/js/core.js
@@ -874,6 +874,97 @@
return { ...o, a: f(o.a), b: f(o.b), size };
}
+ // Snapping (smart guides, as in Figma, tldraw and Excalidraw): while objects are moved or resized,
+ // the corners and centre of their frame (objFrame: the box the selection shows) snap to the lines
+ // through those of other objects and the canvas: x to their left, centre and right, y to their top,
+ // middle and bottom. Each axis on its own, to the nearest line within tol (image px); objects move
+ // by whole px, so the result is then rounded.
+ // snapLines(frames): the lines to snap to, {x, y}, each sorted by v. A vertical line {v, at} is at
+ // x = v and passes the frame's points at y in at (an edge: two corners; the centre line: the centre);
+ // a horizontal one the other way round.
+ function snapLines(frames) {
+ const x = [], y = [];
+ for (const f of frames) {
+ const r = f.x + f.w, b = f.y + f.h, cx = f.x + f.w / 2, cy = f.y + f.h / 2;
+ x.push({ v: f.x, at: [f.y, b] }, { v: cx, at: [cy] }, { v: r, at: [f.y, b] });
+ y.push({ v: f.y, at: [f.x, r] }, { v: cy, at: [cx] }, { v: b, at: [f.x, r] });
+ }
+ const byV = (p, q) => p.v - q.v;
+ return { x: x.sort(byV), y: y.sort(byV) };
+ }
+ const framePts = (f) => [{ x: f.x, y: f.y }, { x: f.x + f.w, y: f.y }, { x: f.x + f.w, y: f.y + f.h }, { x: f.x, y: f.y + f.h }, { x: f.x + f.w / 2, y: f.y + f.h / 2 }];
+ // Index of the first line at or past v.
+ function lineAt(lines, v) {
+ let lo = 0, hi = lines.length;
+ while (lo < hi) { const m = (lo + hi) >> 1; if (lines[m].v < v) lo = m + 1; else hi = m; }
+ return lo;
+ }
+ // How far (line minus value) the nearest line within tol is from one of vals; 0 if none is.
+ function snapBy(lines, vals, tol) {
+ let by = 0, bd = Infinity;
+ for (const v of vals) {
+ const i = lineAt(lines, v);
+ for (const l of [lines[i - 1], lines[i]]) {
+ const d = l ? Math.abs(l.v - v) : Infinity;
+ if (d <= tol && d < bd) { by = l.v - v; bd = d; }
+ }
+ }
+ return by;
+ }
+ // The guides once snapped, for the points that snap on x (xp) and on y (yp): on each axis, the lines
+ // nearest to one of those points if within half a px (a move is whole px, so an odd-width outline,
+ // drawn on .5, can only get that close to an even one's edge). One guide per position, through every
+ // point on it, its own and the lines': {x, lo, hi, at} is a vertical one from y = lo to hi, marked at
+ // each y in at; {y, lo, hi, at} a horizontal one.
+ function snapGuides(t, xp, yp) {
+ const E = 1e-6;
+ function axis(lines, pts, k, j) {
+ const near = (v) => { const r = []; for (let i = lineAt(lines, v - 0.5 - E); i < lines.length && lines[i].v <= v + 0.5 + E; i++) r.push(lines[i]); return r; };
+ let bd = Infinity;
+ for (const p of pts) for (const l of near(p[k])) bd = Math.min(bd, Math.abs(l.v - p[k]));
+ const on = new Map(); // position -> the points on it
+ for (const p of pts) {
+ for (const l of near(p[k])) {
+ if (Math.abs(l.v - p[k]) > bd + E) continue;
+ if (!on.has(l.v)) on.set(l.v, new Set());
+ const s = on.get(l.v);
+ for (const a of l.at) s.add(a);
+ s.add(p[j]);
+ }
+ }
+ return [...on].sort((p, q) => p[0] - q[0]).map(([v, s]) => {
+ const at = [...s].sort((p, q) => p - q);
+ return { [k]: v, lo: at[0], hi: at[at.length - 1], at };
+ });
+ }
+ return axis(t.x, xp, 'x', 'y').concat(axis(t.y, yp, 'y', 'x'));
+ }
+ // Frame f moved by (dx, dy), snapped to lines t (snapLines): {dx, dy} in whole px, and the guides.
+ // lock: 'x' or 'y', an axis Shift holds still; it is left as it is.
+ function snapMove(t, f, dx, dy, tol, lock) {
+ const pts = framePts(f);
+ if (lock !== 'x') dx = Math.round(dx + snapBy(t.x, pts.map((p) => p.x + dx), tol));
+ if (lock !== 'y') dy = Math.round(dy + snapBy(t.y, pts.map((p) => p.y + dy), tol));
+ const moved = framePts({ x: f.x + dx, y: f.y + dy, w: f.w, h: f.h });
+ return { dx, dy, guides: snapGuides(t, lock === 'x' ? [] : moved, lock === 'y' ? [] : moved) };
+ }
+ // Handle h of shape o dragged by (dx, dy), snapped as a move is, by the points it drags: a corner,
+ // the two ends of a side (on its own axis), or a line's end. {dx, dy, guides}.
+ function snapResize(t, o, h, dx, dy, tol) {
+ const g = drawnObj(o), r = normRect(g.a, g.b);
+ function pts(dx, dy) { // [the points that snap on x, those on y]
+ if (h === 'a' || h === 'b') { const p = { x: g[h].x + dx, y: g[h].y + dy }; return [[p], [p]]; }
+ const x = h.includes('w') ? r.x + dx : h.includes('e') ? r.x + r.w + dx : null;
+ const y = h.includes('n') ? r.y + dy : h.includes('s') ? r.y + r.h + dy : null;
+ if (x != null && y != null) return [[{ x, y }], [{ x, y }]];
+ return x != null ? [[{ x, y: r.y }, { x, y: r.y + r.h }], []] : [[], [{ x: r.x, y }, { x: r.x + r.w, y }]];
+ }
+ const [xp, yp] = pts(dx, dy);
+ dx = Math.round(dx + snapBy(t.x, xp.map((p) => p.x), tol));
+ dy = Math.round(dy + snapBy(t.y, yp.map((p) => p.y), tol));
+ return { dx, dy, guides: snapGuides(t, ...pts(dx, dy)) };
+ }
+
// Fractional z-keys (as Figma and tldraw order siblings): strings that sort in stacking order, so
// an object can go between two others without renumbering the rest. zBetween(a, b) is a key
// strictly between a and b (null: no bound on that side). The scheme is David Greenspan's
@@ -1098,6 +1189,7 @@
}
const Core = { rgbaToU32, colorToU32, colorDistance, floodMask, fillEdge, floodFill, fillPlan, fillCells, line, stampRects, normRect, clampRect, constrainShape, fitDims, anchorOffset, dragResize, sizeError, saveName, stepSize, tileParts, groupRects, rotateDims, Stroke, History, zBetween, BASE_Z, zAboveBase, flattenRuns, dirtyTiles, diffObjs, applyPairs, crc32, pngChunks, readChunk, withChunk, packObj, unpackObj,
- objBox, objFrame, insideShape, segsBox, drawnObj, hitsObject, hitObject, objHandles, handleAt, resizeObj, moveObj, transformObj, scaleObj };
+ objBox, objFrame, insideShape, segsBox, drawnObj, hitsObject, hitObject, objHandles, handleAt, resizeObj, moveObj, transformObj, scaleObj,
+ snapLines, snapGuides, snapMove, snapResize };
module.exports = Core;
})();
diff --git a/src/js/dom.js b/src/js/dom.js
index 90af015..3b9f3d4 100644
--- a/src/js/dom.js
+++ b/src/js/dom.js
@@ -43,6 +43,6 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const MAC = /Mac|iPhone|iPad/.test(navigator.platform);
const keyLabel = (k) => (MAC ? (k === 'Alt' ? '⌥' : k.replace(/Alt\+/g, '⌥'))
: k === '⌘' ? 'Ctrl' : k === '⇧' ? 'Shift' : k === '⌫' ? 'Backspace'
- : k.replace(/⌘/g, 'Ctrl+').replace(/⇧([- ])/g, 'Shift$1').replace(/⇧/g, 'Shift+').replace(/\+\+$/, '+Plus').replace(/\+−$/, '+Minus'));
+ : k.replace(/⌘([- ])/g, 'Ctrl$1').replace(/⌘/g, 'Ctrl+').replace(/⇧([- ])/g, 'Shift$1').replace(/⇧/g, 'Shift+').replace(/\+\+$/, '+Plus').replace(/\+−$/, '+Minus'));
export { $, antsEl, clamp, cloneCanvas, hexToU32, keyLabel, MAC, makeCanvas, ov, pixelAt, ring, setAttr, setData, setProp, setText, stage, textBox, toHex, vp };
diff --git a/src/js/help.js b/src/js/help.js
index 6a19414..78e1840 100644
--- a/src/js/help.js
+++ b/src/js/help.js
@@ -11,7 +11,7 @@ const HELP = [
['Drawing', [['Draw with color 1 / color 2', '~Left / right click'], ['Swap colors', 'X'], ['Smaller / bigger size', '[', ']'],
['Square, circle, 45° line', '⇧', '~drag'], ['Pick color 2 from a swatch', '~right-click / ⇧-click'],
['Apply text', 'Enter'], ['New line in text', '⇧Enter'], ['Cancel text', 'Esc']]],
- ['Objects', [['Next / previous object (Move tool)', 'Tab', '⇧Tab'], ['Duplicate', '⌘D'], ['Duplicate by dragging', 'Alt', '~drag'], ['Add to / take from the selection', '⇧', '~click'], ['Move straight, keep proportions', '⇧', '~drag'],
+ ['Objects', [['Next / previous object (Move tool)', 'Tab', '⇧Tab'], ['Duplicate', '⌘D'], ['Duplicate by dragging', 'Alt', '~drag'], ['Add to / take from the selection', '⇧', '~click'], ['Move straight, keep proportions', '⇧', '~drag'], ['Move or resize without snapping', '⌘', '~drag'],
['Forward / backward, to front / back', '⌘]', '⌘[', '⌘⇧]', '⌘⇧['], ['Flatten (with Move: just the selection)', '⌘E'], ['Delete what the Eraser crosses', 'Alt', '~drag'], ['Delete', 'Delete', '⌫'], ['Nudge (⇧ = 10px)', '←', '↑', '→', '↓']]],
['Image', [['Resize image', '⇧R'], ['Canvas size', '⇧C'], ['Canvas: keep aspect', '⇧', '~drag corner'], ['Cancel canvas drag', 'Esc']]],
['File', [['New', 'Alt+N'], ['Open', '⌘O'], ['Save', '⌘S'], ['Save as', '⌘⇧S']]],
diff --git a/src/js/keyboard.js b/src/js/keyboard.js
index 0dae9d3..af2da41 100644
--- a/src/js/keyboard.js
+++ b/src/js/keyboard.js
@@ -2,7 +2,7 @@ import Core from './core.js';
import { copySel } from './clipboard.js';
import { openNewDialog, openResize } from './dialogs.js';
import { redo, undo } from './doc.js';
-import { $, ring, stage, textBox } from './dom.js';
+import { $, MAC, ring, stage, textBox } from './dom.js';
import { openFile, save } from './fileio.js';
import { openHelp } from './help.js';
import { hasObjs, cycleObj, deleteObj, duplicateObj, flattenObjs, multi, nudgeObj, objSel, objs, reorderObj, restyleObj, selList, selObj, selectObj } from './objects.js';
@@ -19,6 +19,7 @@ import { fitZoom, requestRender, setZoom, zoomStep } from './view.js';
// ---------- keyboard ----------
const TYPING_INPUTS = new Set(['text', 'number', 'search', 'email', 'url', 'password']);
+const SNAP_KEY = MAC ? 'Meta' : 'Control'; // held during a move or resize: no snapping (pointer.js)
function isTyping(t) {
if (!t || !t.tagName) return false;
return t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable ||
@@ -76,6 +77,7 @@ window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') cancelDrag();
else if (e.key === 'Shift' && SHAPES.has(drag.t)) { drag.shift = true; requestRender(); }
else if (e.key === 'Shift' && (drag.t === 'objmove' || drag.t === 'objsize')) { drag.shift = true; objDragUpdate(drag); requestRender(); }
+ else if (e.key === SNAP_KEY && (drag.t === 'objmove' || drag.t === 'objsize')) { drag.free = true; objDragUpdate(drag); requestRender(); } // held: no snapping
if (e.code !== 'Space') e.preventDefault();
return;
}
@@ -139,6 +141,7 @@ window.addEventListener('keyup', (e) => {
if (e.key === 'Alt') { S.altKey = false; requestRender(); }
if (e.key === 'Shift' && drag && SHAPES.has(drag.t)) { drag.shift = false; requestRender(); }
if (e.key === 'Shift' && drag && (drag.t === 'objmove' || drag.t === 'objsize')) { drag.shift = false; objDragUpdate(drag); requestRender(); }
+ if (e.key === SNAP_KEY && drag && (drag.t === 'objmove' || drag.t === 'objsize')) { drag.free = false; objDragUpdate(drag); requestRender(); }
if (e.code === 'Space') { S.panKey = false; if (!drag || drag.t !== 'pan') stage.classList.remove('panning'); }
});
diff --git a/src/js/main.js b/src/js/main.js
index 2e355b4..5c25e7e 100644
--- a/src/js/main.js
+++ b/src/js/main.js
@@ -48,6 +48,7 @@ import { fitZoom, setZoom, updateChrome } from './view.js';
// ---------- boot ----------
document.querySelectorAll('[data-hint]').forEach((el) => { if (el.dataset.hint) el.setAttribute('aria-description', keyLabel(el.dataset.hint)); });
+document.querySelectorAll('.opts .hint').forEach((el) => { el.textContent = keyLabel(el.textContent); }); // the options bar's per-tool hints
// Expose shortcuts to assistive tech (aria-keyshortcuts uses Control/Meta/Shift names).
document.querySelectorAll('[data-kbd]').forEach((el) => {
const k = el.dataset.kbd;
diff --git a/src/js/pointer.js b/src/js/pointer.js
index 25ee53f..d87f9a6 100644
--- a/src/js/pointer.js
+++ b/src/js/pointer.js
@@ -1,16 +1,16 @@
import Core from './core.js';
import { img } from './doc.js';
-import { $, clamp, ov, ring, setProp, stage, textBox, vp } from './dom.js';
+import { $, MAC, clamp, ov, ring, setProp, stage, textBox, vp } from './dom.js';
import { doFill } from './fill.js';
import { endCanvasDrag } from './handles.js';
import { addObj, commitObjDrag, fillShape, groupBox, multi, newObjId, objSel, objs, restoreSel, selectMany, selObj, selectObj, showObjs, zAbove } from './objects.js';
-import { endLive, flushRedraw, growLive, hitAt, objsIn, setLive } from './scene.js';
+import { endLive, flushRedraw, frameOf, growLive, hitAt, objsIn, setLive } from './scene.js';
import { dropSelection, inside, liftSelection, unlift } from './selection.js';
import { S, SHAPES, drag, isClear, sel, setDrag, setSel } from './state.js';
import { commitText, startText } from './text.js';
import { flash } from './toast.js';
import { pick, shapeObj } from './tools.js';
-import { imgPt, requestRender, setCursor, setHover, setZoom, updateChrome, updateStatus } from './view.js';
+import { imgPt, requestRender, setCursor, setHover, setZoom, updateChrome, updateStatus, visible } from './view.js';
// ---------- pointer ----------
// Object editing under the pointer, for the move tool (any object; elsewhere it pans) and the shape
@@ -68,15 +68,46 @@ function objPointerDown(p, e) {
setDrag({ t: hit.h ? 'objsize' : 'objmove', h: hit.h, o: dup || hit.o, src: hit.o, dup: !!dup, p, q: p, sx: e.clientX, sy: e.clientY, moved: false, shift: e.shiftKey, before: objs, selWas: selNow() });
return true;
}
+// Snapping (Core.snapMove, snapResize): a move or resize lines up the edges and centres of what it
+// drags with those of the objects in view and of the canvas, within 8 screen px, and shows guides
+// where they line up. ⌘ (Ctrl elsewhere) held suspends it. The lines are worked out once per drag,
+// on its first real move (a duplicate being dragged may snap to its original).
+const snapTol = () => 8 / S.zoom;
+const snapOff = (e) => (MAC ? e.metaKey : e.ctrlKey);
+function snapTargets(d) {
+ const v = visible(), moving = d.group || new Set([d.o.id]), fs = [{ x: 0, y: 0, w: img.width, h: img.height }];
+ for (const o of objsIn({ x: v.x0, y: v.y0, w: v.x1 - v.x0, h: v.y1 - v.y0 })) if (!moving.has(o.id)) fs.push(frameOf(o));
+ return Core.snapLines(fs);
+}
+// The frame a move drags: one object's, or the box around a group's.
+function dragFrame(d) {
+ if (!d.group || d.group.size < 2) return frameOf(d.o);
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
+ for (const o of d.before) {
+ if (!d.group.has(o.id)) continue;
+ const f = frameOf(o);
+ x0 = Math.min(x0, f.x); y0 = Math.min(y0, f.y); x1 = Math.max(x1, f.x + f.w); y1 = Math.max(y1, f.y + f.h);
+ }
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
+}
// Apply an object drag: the live list (one undo step on release). Shift locks a move to its main
-// axis, keeps a box's aspect ratio and snaps a line end to 45° (Core.resizeObj).
+// axis (the other still snaps), keeps a box's aspect ratio and snaps a line end to 45° (Core.resizeObj;
+// a resize then doesn't snap to objects).
function objDragUpdate(d) {
if (!d.moved) return;
- let dx = Math.round(d.q.x - d.p.x), dy = Math.round(d.q.y - d.p.y);
- if (d.shift && d.t === 'objmove') { if (Math.abs(dx) >= Math.abs(dy)) dy = 0; else dx = 0; }
+ let dx = d.q.x - d.p.x, dy = d.q.y - d.p.y;
+ const lock = d.shift && d.t === 'objmove' ? (Math.abs(Math.round(dx)) >= Math.abs(Math.round(dy)) ? 'y' : 'x') : null;
+ if (lock === 'y') dy = 0; else if (lock === 'x') dx = 0;
+ d.guides = null;
+ if (!d.free && !(d.shift && d.t === 'objsize')) {
+ if (!d.snap) { d.snap = snapTargets(d); if (d.t === 'objmove') d.frame = dragFrame(d); }
+ const s = d.t === 'objmove' ? Core.snapMove(d.snap, d.frame, dx, dy, snapTol(), lock) : Core.snapResize(d.snap, d.box || d.o, d.h, dx, dy, snapTol());
+ dx = s.dx; dy = s.dy; d.guides = s.guides;
+ }
+ dx = Math.round(dx); dy = Math.round(dy);
if (d.box) { // scale the group from its box's old place and size to the dragged one
const a = Core.normRect(d.box.a, d.box.b), b = Core.normRect(...(({ a: p, b: q }) => [p, q])(Core.resizeObj(d.box, d.h, dx, dy, d.shift)));
- if (b.w < 1 || b.h < 1 || !a.w || !a.h) return;
+ if (b.w < 1 || b.h < 1 || !a.w || !a.h) { d.guides = null; return; }
const sx = b.w / a.w, sy = b.h / a.h;
showObjs(d.before.map((x) => (d.group.has(x.id) ? Core.moveObj(Core.scaleObj(Core.moveObj(x, -a.x, -a.y), sx, sy), b.x, b.y) : x)));
return;
@@ -212,7 +243,7 @@ ov.addEventListener('pointermove', (e) => {
} else if (d.t === 'select') {
d.end = { x: clamp(Math.round(p.x), 0, img.width), y: clamp(Math.round(p.y), 0, img.height) };
} else if (d.t === 'objmove' || d.t === 'objsize') {
- d.q = p; d.shift = e.shiftKey;
+ d.q = p; d.shift = e.shiftKey; d.free = snapOff(e);
d.moved = d.moved || Math.hypot(e.clientX - d.sx, e.clientY - d.sy) >= 3;
objDragUpdate(d);
} else if (d.t === 'move') {
diff --git a/src/js/scene.js b/src/js/scene.js
index f097339..f0a48aa 100644
--- a/src/js/scene.js
+++ b/src/js/scene.js
@@ -27,6 +27,14 @@ function entryOf(o) {
if (!e) { const b = boxOf(o); e = { minX: b.x, minY: b.y, maxX: b.x + b.w, maxY: b.y + b.h, o }; entries.set(o, e); }
return e;
}
+// The box the selection shows (Core.objFrame), what a drag snaps to: a stroke's is its box, kept above.
+const frames = new WeakMap();
+function frameOf(o) {
+ if (o.pts || o.segs) return boxOf(o);
+ let f = frames.get(o);
+ if (!f) { f = Core.objFrame(o); frames.set(o, f); }
+ return f;
+}
const tree = new RBush();
const byZ = (p, q) => (p.z < q.z ? -1 : p.z > q.z ? 1 : 0);
@@ -299,4 +307,4 @@ function freshRender() {
return c;
}
-export { boxOf, endLive, freshRender, growLive, setLive, flushRedraw, hitAt, markAll, markDirty, objsIn, redrawCount, sceneSwap, TILE };
+export { boxOf, endLive, frameOf, freshRender, growLive, setLive, flushRedraw, hitAt, markAll, markDirty, objsIn, redrawCount, sceneSwap, TILE };
diff --git a/src/js/view.js b/src/js/view.js
index 2868377..0eff518 100644
--- a/src/js/view.js
+++ b/src/js/view.js
@@ -75,14 +75,18 @@ let ovKey = '', ovBox = null;
// pvAt: the image-px rect #preview covers, and k = its backing px per image px. k is 1 when the
// view is zoomed in (image px, CSS-scaled like #image), and screen resolution when zoomed out.
let pvAt = null;
-function placePreview() {
- const z = S.zoom, k = Math.min(1, z * devicePixelRatio);
- const v = vp.getBoundingClientRect(), r = stage.getBoundingClientRect();
+// The part of the image in view, in whole image px.
+function visible() {
+ const z = S.zoom, v = vp.getBoundingClientRect(), r = stage.getBoundingClientRect();
const L = v.left + vp.clientLeft - r.left, T = v.top + vp.clientTop - r.top;
- const vis = { // visible image px
+ return {
x0: clamp(Math.floor(L / z), 0, img.width), y0: clamp(Math.floor(T / z), 0, img.height),
x1: clamp(Math.ceil((L + vp.clientWidth) / z), 0, img.width), y1: clamp(Math.ceil((T + vp.clientHeight) / z), 0, img.height),
};
+}
+function placePreview() {
+ const z = S.zoom, k = Math.min(1, z * devicePixelRatio);
+ const vis = visible();
const a = pvAt;
if (a && a.z === z && a.k === k && a.W === img.width && a.H === img.height &&
vis.x0 >= a.x && vis.y0 >= a.y && vis.x1 <= a.x + a.w && vis.y1 <= a.y + a.h) return;
@@ -155,6 +159,7 @@ function render() {
]);
placeAnts(mq || sel);
placeObjSel(groupBox() || selObj());
+ placeGuides(drag && drag.guides);
if (shape || (sel && sel.float) || (tail && tail.length) || multi.length) placePreview();
if (key !== ovKey) {
ovKey = key;
@@ -246,6 +251,34 @@ function placeObjHover(o) {
Object.assign(objHoverEl.style, { left: x + 'px', top: y + 'px', width: px(r.x + r.w) - x + 'px', height: px(r.y + r.h) - y + 'px', transform: '' });
}
+// Snap guides of a move or resize (pointer.js, Core.snapGuides): 1 CSS px lines through the corners and
+// centres that line up, each marked with a small ×, in device px like the ants. Pooled divs.
+const guidesEl = $('#guides');
+let guidesKey = '';
+function placeGuides(gs) {
+ const z = S.zoom, d = devicePixelRatio, mid = (v) => Math.round((v * z - 0.5) * d) / d; // the 1 px centred on v
+ const items = [], marks = new Set(); // [class, left, top, width, height]
+ for (const g of gs || []) {
+ const v = g.x != null, c = mid(v ? g.x : g.y), lo = mid(g.lo), len = mid(g.hi) - lo + 1;
+ items.push(v ? ['v', c, lo, 1, len] : ['h', lo, c, len, 1]);
+ for (const a of g.at) {
+ const m = v ? [c - 3, mid(a) - 3] : [mid(a) - 3, c - 3], k = m.join();
+ if (!marks.has(k)) { marks.add(k); items.push(['m', ...m, 7, 7]); }
+ }
+ }
+ const key = items.join(' ');
+ if (key === guidesKey) return;
+ guidesKey = key;
+ while (guidesEl.children.length < items.length) guidesEl.appendChild(document.createElement('div'));
+ [...guidesEl.children].forEach((el, i) => {
+ const it = items[i];
+ el.hidden = !it;
+ if (!it) return;
+ el.className = it[0];
+ Object.assign(el.style, { left: it[1] + 'px', top: it[2] + 'px', width: it[3] + 'px', height: it[4] + 'px' });
+ });
+}
+
let cursor = null; // integer image px under the pointer, for the status bar
let hoverPt = null; // the same point unrounded (null when the pointer is off the canvas); render() places the ring there
const setCursor = (c) => { cursor = c; };
@@ -292,4 +325,4 @@ function updateChrome() {
setProp(EL.redo, 'disabled', !hist.canRedo());
}
-export { fitZoom, imgPt, kickFrame, layout, requestRender, setCursor, setHover, setZoom, updateChrome, updateStatus, zoomStep };
+export { fitZoom, imgPt, kickFrame, layout, requestRender, setCursor, setHover, setZoom, updateChrome, updateStatus, visible, zoomStep };
diff --git a/src/style.css b/src/style.css
index fba0043..c66c44d 100644
--- a/src/style.css
+++ b/src/style.css
@@ -22,6 +22,7 @@
--well-ring: rgba(0, 0, 0, .38);
--accent-fill: #2563eb; /* filled buttons: white text stays above 4.5:1 in both themes */
--danger: #d92d20;
+ --snap: #f0148c; /* snap guides: magenta, apart from the blue selection, over any picture in either theme */
--seg-on: #ffffff;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* UI enter/exit */
}
@@ -258,6 +259,15 @@ button.labeled { padding: 0 9px 0 7px; }
#objhover.line { box-shadow: none; background: var(--accent); transform-origin: 0 50%; }
#objhover[hidden] { display: none; }
#objsel[hidden], #objsel .handle[hidden] { display: none; }
+/* Snap guides while a move or resize lines up (placeGuides): under the selection's handles. */
+#guides { position: absolute; left: 0; top: 0; z-index: 1; pointer-events: none; }
+#guides div { position: absolute; }
+#guides .v, #guides .h { background: var(--snap); }
+#guides .m { /* × on each point that lines up */
+ background: linear-gradient(45deg, transparent 43%, var(--snap) 43% 57%, transparent 57%),
+ linear-gradient(-45deg, transparent 43%, var(--snap) 43% 57%, transparent 57%);
+}
+#guides div[hidden] { display: none; }
#objsel .handle {
position: absolute; left: -4px; top: -4px; width: 8px; height: 8px; box-sizing: border-box;
background: #fff; border: 1px solid var(--accent);
diff --git a/task.md b/task.md
index 2f71e61..8f0c42b 100644
--- a/task.md
+++ b/task.md
@@ -16,6 +16,7 @@ All of this is done and merged to `master`:
- background fill around 12 rings (medians of 3): Chromium total 1909 → 1348 ms, longest frame gap 611 → 236 ms, heap growth 131 → 3 MB; WebKit total 1035 → 920 ms, the pause when it lands 168 → ~90 ms (with the follow-up fix: a tile drew the cells right of and below it too).
- picker: ~1–2 ms in both engines when the GPU is idle, before and after; the whole-`#image` readback CLAUDE.md measured on the M4 doesn't show on a software GPU.
- **INP at 10000²** (PR 15): scene repaint on a pool of scratch tiles behind a GPU fence (nudge 864 → 88 ms), fill readback in its worker (312 → 32 ms), copy and save encoded in `blob-worker.js` (152–160 → 32 ms), and bench `inp …` rows (Event Timing with real input). The `second fill click (with damage)` row was checked only on a software GPU (inp200 1 without `kickFrame()`, 0 with); not yet on the M4.
+- **Object snapping** (2026-10-21): moves and resizes snap to the edges and centres of other objects and the canvas, with magenta guides; ⌘ held suspends it. Decisions under "Shapes (objects)"; checked in `objects.js` section 35 (Chrome and WebKit), the `5k` bench rows unchanged.
## Open
@@ -28,6 +29,7 @@ All of this is done and merged to `master`:
- Superseded big canvases (the old base after a rotate) wait for GC rather than being freed at once: the history and objects may still refer to them, so it wasn't changed.
- iPad: per-canvas cap 8192² (67 MP); `canvasFits()` now refuses bigger documents with a message instead of a blank picture. iOS Safari never shows a `beforeunload` prompt.
- WebKit flake: smoke's "mid-stroke: ink reaches the pointer" failed once in about five WebKit runs (a 50 ms wait).
+- `history.js` on the M4's Chrome (2026-10-21): "undo of paste that grows", "redo of pencil after crop" and "second undo of paste that grows" aren't pixel-exact, on `master` too (CI's Linux Chrome passes). Not looked into.
- Safari draws range sliders natively: it ignores `accent-color` there, so Size/Smoothing are grey, not blue.
- A real Windows machine (labels were checked only with a `navigator.platform` override).
- A VoiceOver pass over dialogs, toasts and the canvas label.
@@ -122,6 +124,11 @@ All of this is done and merged to `master`:
- Hover outlines the shape a click would pick. ⌥-drag copies, placed just above the original.
- With the Move tool, Tab / ⇧Tab cycle shapes and release focus at the ends (no focus trap).
- Crop, resize, canvas size, rotate and flip carry shapes along in one undo step; crop drops shapes wholly outside.
+- Snapping (2026-10-21, after Figma's smart guides, tldraw and Excalidraw; on by default, no switch to turn it off for good, and ⌘ to suspend it are the agent's defaults, not yet confirmed by the user). A move or resize lines up the left / centre / right and top / middle / bottom of what it drags (its frame, the box the selection shows; a group's union) with those of the objects in view and of the canvas, each axis on its own, to the nearest within 8 screen px. Magenta 1 px guides run through the corners and centres that line up, each marked with a × (as in tldraw and Excalidraw; a centre guide runs centre to centre, not across both boxes, and the × keeps a guide readable where it lies along a black outline). Only the closest match shows, and only within half a pixel (moves are whole px, so an odd-width outline on .5 can only get that close to an even one's edge).
+ - ⌘ (Ctrl elsewhere) held suspends it, also pressed or released mid-drag. Ctrl on a Mac would be a right-click; Figma's own key has changed over the years (S on Windows now).
+ - A resize snaps only the edges its handle drags (a line end: its point). Shift keeps its meaning: a move stays on one axis and snaps the other; a resize that keeps the aspect or a line's 45° doesn't snap.
+ - A ⌥-drag copy may snap to its original (so a copy dragged straight down stays in line). Arrow nudges never snap. Nothing snaps before the 3 px threshold.
+ - Not done: equal-spacing (gap) snaps, snapping a new shape while it's drawn, a switch to turn it off for good.
## Measured gotchas (Chrome, M4, 10000² canvas)
diff --git a/test/core.test.js b/test/core.test.js
index 0a1517e..7eee2c2 100644
--- a/test/core.test.js
+++ b/test/core.test.js
@@ -1018,6 +1018,66 @@ test('resizeObj with keep: corners and edges keep the aspect ratio, line ends sn
assert.deepEqual(Core.resizeObj(obj('line', { x: 0, y: 0 }, { x: 10, y: 0 }), 'a', 2, 1, true).a, { x: 2, y: 0 });
});
+test('snapLines: each frame\'s edges and centre as lines on both axes, sorted, with the points on them', () => {
+ const t = Core.snapLines([{ x: 100, y: 0, w: 50, h: 40 }, { x: 0, y: 10, w: 20, h: 20 }]);
+ assert.deepEqual(t.x.map((l) => l.v), [0, 10, 20, 100, 125, 150]);
+ assert.deepEqual(t.y.map((l) => l.v), [0, 10, 20, 20, 30, 40]);
+ assert.deepEqual(t.x[3], { v: 100, at: [0, 40] }); // an edge passes two corners
+ assert.deepEqual(t.x[4], { v: 125, at: [20] }); // the centre line, the centre
+ assert.deepEqual(t.y[1], { v: 10, at: [0, 20] });
+});
+
+test('snapMove: each axis moves by whole px, onto the nearest line within tol; guides show what lines up', () => {
+ const t = Core.snapLines([{ x: 100, y: 0, w: 50, h: 50 }]);
+ const f = { x: 0, y: 200, w: 20, h: 20 };
+ let s = Core.snapMove(t, f, 77.4, 0.3, 8);
+ assert.deepEqual([s.dx, s.dy], [80, 0]); // right edge 97 → 100; nothing near on y
+ assert.deepEqual(s.guides, [{ x: 100, lo: 0, hi: 220, at: [0, 50, 200, 220] }]); // through the corners on it
+ s = Core.snapMove(t, f, 70.6, 0, 8);
+ assert.deepEqual([s.dx, s.dy, s.guides], [71, 0, []]); // 10 px off: no snap, just rounded
+ s = Core.snapMove(t, f, 117, 0, 8); // centre 127 → 125 (2 px) beats left 117 → 125 (8 px)
+ assert.deepEqual([s.dx, s.guides], [115, [{ x: 125, lo: 25, hi: 210, at: [25, 210] }]]); // centre to centre
+ s = Core.snapMove(t, f, 77, -153, 8, 'x'); // locked x: as it is; y top 47 → 50
+ assert.deepEqual([s.dx, s.dy, s.guides], [77, -150, [{ y: 50, lo: 77, hi: 150, at: [77, 97, 100, 150] }]]); // a horizontal guide runs along x
+ const same = Core.snapMove(t, { x: 0, y: 200, w: 50, h: 50 }, 98, 0, 8); // same width: all three line up
+ assert.deepEqual(same.guides.map((g) => g.x), [100, 125, 150]);
+ // An odd-width outline's frame sits on .5: a whole-px move gets within half a pixel, and that shows.
+ const odd = Core.snapLines([{ x: 100.5, y: 0.5, w: 30, h: 30 }]);
+ s = Core.snapMove(odd, f, 77, 0, 8);
+ assert.ok(Math.abs(f.x + f.w + s.dx - 100.5) === 0.5, 'within half a px of the odd edge: ' + s.dx);
+ assert.deepEqual(s.guides.map((g) => g.x), [100.5]);
+});
+
+test('snapGuides: only the closest lines show, one guide per position, through every point on it', () => {
+ const t = Core.snapLines([{ x: 10, y: 0, w: 10, h: 10 }, { x: 10, y: 50, w: 30, h: 10 }, { x: 10.5, y: 90, w: 5, h: 5 }]);
+ assert.deepEqual(Core.snapGuides(t, [{ x: 10, y: 20 }], []), [{ x: 10, lo: 0, hi: 60, at: [0, 10, 20, 50, 60] }]); // both frames at 10, not the one 0.5 off
+ assert.deepEqual(Core.snapGuides(t, [{ x: 10.25, y: 20 }], []), // a tie shows both
+ [{ x: 10, lo: 0, hi: 60, at: [0, 10, 20, 50, 60] }, { x: 10.5, lo: 20, hi: 95, at: [20, 90, 95] }]);
+ assert.deepEqual(Core.snapGuides(t, [{ x: 11.5, y: 20 }], []), []); // over half a px off: none
+ assert.deepEqual(Core.snapGuides(t, [], [{ x: 3, y: 55 }]), [{ y: 55, lo: 3, hi: 25, at: [3, 25] }]); // F2's middle
+});
+
+test('snapResize: the edges a handle drags snap (corners both axes, an edge one), and a line end its point', () => {
+ const t = Core.snapLines([{ x: 60, y: 100, w: 10, h: 10 }]);
+ const o = obj('rect', { x: 0, y: 0 }, { x: 40, y: 40 });
+ let s = Core.snapResize(t, o, 'e', 17, 3, 8);
+ assert.deepEqual([s.dx, s.dy], [20, 3]); // right 57 → 60
+ assert.deepEqual(s.guides, [{ x: 60, lo: 0, hi: 110, at: [0, 40, 100, 110] }]); // the dragged side's two corners
+ s = Core.snapResize(t, o, 'se', 17, 57, 8); // corner: 57 → 60 and 97 → 100
+ assert.deepEqual([s.dx, s.dy], [20, 60]);
+ s = Core.snapResize(t, o, 'w', 57, 0, 8); // the west edge, dragged past the east one: 57 → 60
+ assert.equal(s.dx, 60);
+ s = Core.snapResize(t, o, 'n', 0, 103, 8); // the top edge: 103 → 105 (the target's middle)
+ assert.deepEqual([s.dx, s.dy], [0, 105]);
+ const l = obj('line', { x: 0, y: 0 }, { x: 30, y: 30 });
+ s = Core.snapResize(t, l, 'b', 34, 68, 8); // the end (30, 30) → (64, 98): x 65 (centre), y 100 (top)
+ assert.deepEqual([s.dx, s.dy], [35, 70]);
+ assert.deepEqual(s.guides, [{ x: 65, lo: 100, hi: 105, at: [100, 105] }, { y: 100, lo: 60, hi: 70, at: [60, 65, 70] }]); // through the end itself
+ const odd = obj('rect', { x: 0, y: 0 }, { x: 40, y: 40 }, { size: 1 }); // drawn on .5: right edge 40.5
+ s = Core.snapResize(t, odd, 'e', 17, 0, 8);
+ assert.ok(Math.abs(40.5 + s.dx - 60) === 0.5 && s.guides.length === 1, 'half a px from 60: ' + s.dx);
+});
+
test('transformObj rotates and flips a shape with the image, on the pixel grid its stroke width uses', () => {
// 100 × 50 image. Odd widths draw on pixel centres (px x maps to W-1-x), even ones on pixel edges (W-x).
const odd = obj('rect', { x: 10, y: 5 }, { x: 30, y: 15 }, { size: 1 });
diff --git a/test/e2e/objects.js b/test/e2e/objects.js
index d28def3..878ccda 100644
--- a/test/e2e/objects.js
+++ b/test/e2e/objects.js
@@ -890,6 +890,102 @@ const ok = (c, m) => { if (c) passes++; else { fails++; console.log('FAIL:', m);
await page.mouse.up({ button: 'middle' });
ok(await hover(200, 150) === 'default', 'after the middle-button pan: the arrow');
+ // ---- 35. snapping: a move or resize lines up with other objects and the canvas; ⌘ held suspends it ----
+ // At 200% the reach is 8 screen px = 4 image px. Canvas 400 × 320: its lines are x 0, 200, 400 and y 0, 160, 320.
+ await P(() => { const p = window.__paint; p.newDoc(400, 320); p.setZoom(2); const v = document.querySelector('#viewport'); v.scrollLeft = v.scrollTop = 0; });
+ await frame();
+ const mod = await P(() => (/Mac|iPhone|iPad/.test(navigator.platform) ? 'Meta' : 'Control'));
+ const guides = () => P(() => (window.__paint.drag && window.__paint.drag.guides) || []);
+ const shownGuides = async () => { await frame(); return page.locator('#guides div:not([hidden]):not(.m)').count(); };
+ const shownMarks = async () => { await frame(); return page.locator('#guides .m:not([hidden])').count(); };
+ const byId = async (id) => (await objs()).find((x) => x.id === id);
+ await page.keyboard.press('KeyR');
+ await drag([[40, 40], [100, 100]]); // A: x 40..100, y 40..100
+ const A = await objSel();
+ await drag([[220, 200], [280, 240]]); // B: x 220..280, y 200..240
+ const B = await objSel();
+ await page.keyboard.press('KeyV');
+ let g35 = null, n35 = 0, m35 = 0;
+ const d35 = await depth();
+ await drag([[235, 200], [117, 200]], async () => { g35 = await guides(); n35 = await shownGuides(); m35 = await shownMarks(); }); // left edge 102 → A's right, 100
+ o = await byId(B);
+ ok(o.a.x === 100 && o.b.x === 160 && o.a.y === 200, 'a move 2 px from another object\'s edge snaps onto it: ' + JSON.stringify([o.a, o.b]));
+ ok(JSON.stringify(g35) === JSON.stringify([{ x: 100, lo: 40, hi: 240, at: [40, 100, 200, 240] }]) && n35 === 1, 'one guide while it lines up, through the corners on it: ' + JSON.stringify(g35) + ' ' + n35);
+ ok(m35 === 4, 'a × on each of those four corners: ' + m35);
+ ok(await shownGuides() === 0 && await shownMarks() === 0, 'the guide and its marks go on release');
+ ok(await depth() === d35 + 1, 'a snapped move is one undo step');
+ await page.keyboard.press('Meta+KeyZ');
+ ok((await byId(B)).a.x === 220, 'undo puts it back');
+ await page.keyboard.press('Meta+Shift+KeyZ');
+ ok((await byId(B)).a.x === 100, 'redo snaps it again');
+ // ⌘ (Ctrl elsewhere) held: no snapping; pressed or released mid-drag, it takes effect without a move.
+ {
+ await page.keyboard.down(mod);
+ const [x0, y0] = await at(115, 200); await page.mouse.move(x0, y0); await page.mouse.down();
+ const [x1, y1] = await at(117, 200); await page.mouse.move(x1, y1, { steps: 4 });
+ await frame();
+ const free = (await byId(B)).a.x, gFree = await guides();
+ await page.keyboard.up(mod); await frame();
+ const snapped = (await byId(B)).a.x, gSnap = await guides();
+ await page.keyboard.down(mod); await frame();
+ const freeAgain = (await byId(B)).a.x;
+ await page.mouse.up(); await page.keyboard.up(mod); await frame();
+ ok(free === 102 && gFree.length === 0, `${mod} held: the move is exact, no guide: ` + free);
+ ok(snapped === 100 && gSnap.length === 1, `${mod} released mid-drag: it snaps at once: ` + snapped);
+ ok(freeAgain === 102 && (await byId(B)).a.x === 102, `${mod} pressed again: free again, and it stays where it was dropped`);
+ }
+ // Shift locks the axis; the free one still snaps (centre 198 → the canvas centre, 200).
+ await page.keyboard.down('Shift');
+ await drag([[117, 200], [183, 203]], async () => { g35 = await guides(); });
+ await page.keyboard.up('Shift');
+ o = await byId(B);
+ ok(o.a.x === 170 && o.a.y === 200, 'Shift: along x only, centre snapped to the canvas centre: ' + JSON.stringify(o.a));
+ ok(JSON.stringify(g35) === JSON.stringify([{ x: 200, lo: 160, hi: 220, at: [160, 220] }]), 'a centre guide runs from the canvas centre to its centre: ' + JSON.stringify(g35));
+ // A resize handle snaps the edge it drags (bottom 318 → the canvas bottom, 320).
+ await drag([[200, 240], [201, 318]], async () => { g35 = await guides(); });
+ o = await byId(B);
+ ok(o.a.x === 170 && o.b.x === 230 && o.b.y === 320, 'the bottom handle snaps to the canvas edge: ' + JSON.stringify([o.a, o.b]));
+ ok(JSON.stringify(g35) === JSON.stringify([{ y: 320, lo: 0, hi: 400, at: [0, 170, 230, 400] }]), 'a guide along the canvas edge, through the dragged side\'s corners: ' + JSON.stringify(g35));
+ // ⌥-drag: the copy keeps in line with its original when dragged about straight down.
+ const count35 = (await objs()).length;
+ await page.keyboard.down('Alt');
+ await drag([[40, 55], [42, 175]]);
+ await page.keyboard.up('Alt');
+ const all35 = await objs(), sel35 = await objSel(), dup35 = all35.find((x) => x.id === sel35);
+ ok(all35.length === count35 + 1 && dup35.id !== A && dup35.a.x === 40 && dup35.a.y === 160 && (await byId(A)).a.y === 40,
+ 'a copy dragged 2 px off its original snaps back in line (and its top to the canvas middle): ' + JSON.stringify(dup35 && dup35.a));
+ // Esc mid-drag: the guides go with the drag.
+ await drag([[40, 175], [42, 200]], async () => {
+ ok((await guides()).length > 0 && await shownGuides() > 0, 'guides while lined up');
+ await page.keyboard.press('Escape');
+ ok(await shownGuides() === 0 && await shownMarks() === 0, 'Esc mid-drag hides the guides and marks');
+ });
+ ok((await byId(dup35.id)).a.y === 160, 'Esc put the copy back');
+ // A group snaps by the box around it: A and the copy, bottom 218 → 320 (B's bottom and the canvas edge).
+ await drag([[20, 20], [120, 230]]); // box-select A and the copy (not B, from x 170)
+ ok(JSON.stringify((await P(() => window.__paint.selList().map((x) => x.id))).sort()) === JSON.stringify([A, dup35.id].sort()), 'A and the copy selected');
+ await drag([[40, 55], [40, 153]], async () => { g35 = await guides(); });
+ ok((await byId(A)).a.y === 140 && (await byId(dup35.id)).a.y === 260, 'the group moved until its box\'s bottom met 320');
+ ok(JSON.stringify(g35) === JSON.stringify([{ y: 320, lo: 0, hi: 400, at: [0, 40, 100, 170, 230, 400] }]), 'one guide for the two lines at 320: ' + JSON.stringify(g35));
+ // The group box's side handle snaps too (right 167 → B's left, 170); with Shift (keep proportions) a resize doesn't snap.
+ await drag([[100, 230], [167, 230]], async () => { g35 = await guides(); });
+ ok((await byId(A)).b.x === 170 && g35.length === 1 && g35[0].x === 170, 'the group box\'s right side snaps to B\'s left: ' + JSON.stringify(await byId(A)));
+ await page.keyboard.press('Meta+KeyZ');
+ await page.keyboard.down('Shift');
+ await drag([[100, 230], [167, 230]], async () => { g35 = await guides(); });
+ await page.keyboard.up('Shift');
+ ok((await byId(A)).b.x === 167 && g35.length === 0, 'Shift: kept proportions, no snapping: ' + JSON.stringify(await byId(A)));
+ await page.keyboard.press('Meta+KeyZ');
+ ok((await byId(A)).b.x === 100 && (await byId(A)).a.y === 140, 'both undone');
+ // A line's end snaps its point: (350, 60) → (398, 138) → x 400 (the canvas edge), y 140 (A's top).
+ await page.keyboard.press('KeyL');
+ await drag([[300, 20], [350, 60]]);
+ const L35 = await objSel();
+ await drag([[350, 60], [398, 138]], async () => { g35 = await guides(); });
+ o = await byId(L35);
+ ok(o.a.x === 300 && o.a.y === 20 && o.b.x === 400 && o.b.y === 140, 'a line end snaps on both axes: ' + JSON.stringify([o.a, o.b]));
+ ok(g35.length === 2 && g35[0].x === 400 && g35[1].y === 140, 'a guide on each: ' + JSON.stringify(g35));
+
ok(errs.length === 0, 'no page errors: ' + errs.join(' | '));
} catch (e) {
fails++; console.log('FAIL: threw', e);
diff --git a/test/e2e/smoke.js b/test/e2e/smoke.js
index 308d8a2..dea2e22 100644
--- a/test/e2e/smoke.js
+++ b/test/e2e/smoke.js
@@ -1000,6 +1000,8 @@ const ok = (c, m) => { if (c) passes++; else { fails++; console.log('FAIL:', m);
await wpage.keyboard.press('Escape'); await wpage.waitForTimeout(150);
await wpage.hover('[data-cmd="fit"]'); await wpage.waitForTimeout(700);
ok(!/[⌘⇧]/.test(await wpage.locator('#tip').textContent()), 'no Mac glyphs in tooltip on Windows');
+ const whints = await wpage.evaluate(() => [...document.querySelectorAll('.opts .hint')].map((h) => h.textContent).join(' | '));
+ ok(!/[⌘⇧⌫⌥]/.test(whints) && /Ctrl held: no snapping/.test(whints) && /Ctrl\+E flattens/.test(whints), 'options bar hints in Windows key names: ' + whints);
await wpage.close();
} finally { await browser.close(); }
console.log(`${passes} passed, ${fails} failed`);