From 59015ccc147f8d1f7149280f531164f1b6f7d0c1 Mon Sep 17 00:00:00 2001 From: oat Date: Wed, 21 Oct 2026 22:18:06 +0700 Subject: [PATCH 1/4] Object snapping: moves and resizes line up with other objects and the canvas, with guides The edges and centre of what a Move-tool drag moves or resizes snap, each axis on its own, to the nearest edge or centre of an object in view or of the canvas, within 8 screen px (as tldraw and Excalidraw). Magenta guides show what lines up. Command (Ctrl elsewhere) held suspends it, also pressed or released mid-drag. Shift: a move keeps its axis lock and snaps the other one; a resize that keeps its aspect or a line's 45 degrees doesn't snap. Arrow nudges never snap. Core.snapLines / snapMove / snapResize / snapGuides are pure and unit-tested; objects.js section 35 checks it end to end (it fails on master). --- src/index.html | 1 + src/js/core.js | 78 ++++++++++++++++++++++++++++++++++++++++- src/js/keyboard.js | 5 ++- src/js/pointer.js | 47 ++++++++++++++++++++----- src/js/scene.js | 10 +++++- src/js/view.js | 35 ++++++++++++++++--- src/style.css | 7 ++++ test/core.test.js | 59 +++++++++++++++++++++++++++++++ test/e2e/objects.js | 84 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 310 insertions(+), 16 deletions(-) diff --git a/src/index.html b/src/index.html index f1b10e4..681ef97 100644 --- a/src/index.html +++ b/src/index.html @@ -153,6 +153,7 @@ + diff --git a/src/js/core.js b/src/js/core.js index 0e0c4ce..2ea6fe5 100644 --- a/src/js/core.js +++ b/src/js/core.js @@ -874,6 +874,81 @@ 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 left, centre and right of their frame (objFrame: the box the selection shows), and its top, + // middle and bottom, snap to those of other objects and the canvas. 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, lo, hi} is + // at x = v and spans its frame from y = lo to hi; a horizontal one the other way round. + function snapLines(frames) { + const x = [], y = []; + for (const f of frames) { + for (const v of [f.x, f.x + f.w / 2, f.x + f.w]) x.push({ v, lo: f.y, hi: f.y + f.h }); + for (const v of [f.y, f.y + f.h / 2, f.y + f.h]) y.push({ v, lo: f.x, hi: f.x + f.w }); + } + const byV = (p, q) => p.v - q.v; + return { x: x.sort(byV), y: y.sort(byV) }; + } + // 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 for frame f, snapped, whose values xs on x and ys on y were the ones that snap: on each + // axis, the lines nearest to one of them 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, + // spanning f and every frame with a line there: {x, lo, hi} (vertical) or {y, lo, hi}. + function snapGuides(t, xs, ys, f) { + const E = 1e-6; + function axis(lines, vals, k, lo, hi) { + 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 v of vals) for (const l of near(v)) bd = Math.min(bd, Math.abs(l.v - v)); + const at = new Map(); + for (const v of vals) { + for (const l of near(v)) { + if (Math.abs(l.v - v) > bd + E) continue; + const g = at.get(l.v); + if (g) { g.lo = Math.min(g.lo, l.lo); g.hi = Math.max(g.hi, l.hi); } + else at.set(l.v, { [k]: l.v, lo: Math.min(lo, l.lo), hi: Math.max(hi, l.hi) }); + } + } + return [...at.values()].sort((p, q) => p[k] - q[k]); + } + return axis(t.x, xs, 'x', f.y, f.y + f.h).concat(axis(t.y, ys, 'y', f.x, f.x + f.w)); + } + // 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 xs = (d) => [f.x + d, f.x + f.w / 2 + d, f.x + f.w + d], ys = (d) => [f.y + d, f.y + f.h / 2 + d, f.y + f.h + d]; + if (lock !== 'x') dx = Math.round(dx + snapBy(t.x, xs(dx), tol)); + if (lock !== 'y') dy = Math.round(dy + snapBy(t.y, ys(dy), tol)); + return { dx, dy, guides: snapGuides(t, lock === 'x' ? [] : xs(dx), lock === 'y' ? [] : ys(dy), { x: f.x + dx, y: f.y + dy, w: f.w, h: f.h }) }; + } + // Handle h of shape o dragged by (dx, dy), snapped as a move is: the edges it drags (a corner's two, + // a side's one), or a line end's point. {dx, dy, guides}. + function snapResize(t, o, h, dx, dy, tol) { + const g = drawnObj(o), r = normRect(g.a, g.b), end = h === 'a' || h === 'b'; + const xs = (d) => (end ? [g[h].x + d] : h.includes('w') ? [r.x + d] : h.includes('e') ? [r.x + r.w + d] : []); + const ys = (d) => (end ? [g[h].y + d] : h.includes('n') ? [r.y + d] : h.includes('s') ? [r.y + r.h + d] : []); + dx = Math.round(dx + snapBy(t.x, xs(dx), tol)); + dy = Math.round(dy + snapBy(t.y, ys(dy), tol)); + return { dx, dy, guides: snapGuides(t, xs(dx), ys(dy), objFrame(resizeObj(o, h, 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 +1173,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/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/pointer.js b/src/js/pointer.js index 25ee53f..70cbe27 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); 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..569f5e4 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,26 @@ 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 centred on the edge or +// centre that lines up, across the boxes on it, in device px like the ants. Pooled: a drag shows a few. +const guidesEl = $('#guides'); +let guidesKey = ''; +function placeGuides(gs) { + const z = S.zoom, d = devicePixelRatio, px = (v) => Math.round(v * z * d) / d, mid = (v) => Math.round((v * z - 0.5) * d) / d; + const key = gs && gs.length ? gs.map((g) => (g.x != null ? ['x', mid(g.x)] : ['y', mid(g.y)]).concat(px(g.lo), px(g.hi)).join()).join(' ') : ''; + if (key === guidesKey) return; + guidesKey = key; + const n = key ? gs.length : 0; + while (guidesEl.children.length < n) guidesEl.appendChild(document.createElement('div')); + [...guidesEl.children].forEach((el, i) => { + el.hidden = i >= n; + if (i >= n) return; + const g = gs[i], v = g.x != null, lo = px(g.lo), len = Math.max(1, px(g.hi) - lo) + 'px'; + el.className = v ? 'v' : 'h'; + Object.assign(el.style, v ? { left: mid(g.x) + 'px', top: lo + 'px', width: '', height: len } : { left: lo + 'px', top: mid(g.y) + 'px', width: len, height: '' }); + }); +} + 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 +317,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..63c997b 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,12 @@ 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; background: var(--snap); } +#guides .v { width: 1px; } +#guides .h { height: 1px; } +#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/test/core.test.js b/test/core.test.js index 0a1517e..47b3427 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -1018,6 +1018,65 @@ 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 frame\'s extent', () => { + 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, lo: 0, hi: 40 }); // a vertical line spans its frame's height + assert.deepEqual(t.y[1], { v: 10, lo: 0, hi: 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 }]); // spans both frames + 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: 0, hi: 220 }]]); + 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 }]]); // a horizontal guide spans 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, spanning every frame 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 }]); + const g = Core.snapGuides(t, [10], [], { x: 10, y: 20, w: 5, h: 5 }); + assert.deepEqual(g, [{ x: 10, lo: 0, hi: 60 }]); // both frames at 10 (not the one 0.5 off), and the moved one + assert.deepEqual(Core.snapGuides(t, [10.25], [], { x: 10.25, y: 20, w: 5, h: 5 }), // a tie shows both + [{ x: 10, lo: 0, hi: 60 }, { x: 10.5, lo: 20, hi: 95 }]); + assert.deepEqual(Core.snapGuides(t, [11.5], [], { x: 11.5, y: 20, w: 5, h: 5 }), []); // over half a px off: none +}); + +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 }]); + 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.map((g) => [g.x, g.y]), [[65, undefined], [undefined, 100]]); + 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..7452092 100644 --- a/test/e2e/objects.js +++ b/test/e2e/objects.js @@ -890,6 +890,90 @@ 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])').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; + const d35 = await depth(); + await drag([[235, 200], [117, 200]], async () => { g35 = await guides(); n35 = await shownGuides(); }); // 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 }]) && n35 === 1, 'one guide while it lines up, across both boxes: ' + JSON.stringify(g35) + ' ' + n35); + ok(await shownGuides() === 0, 'the guide goes 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: 0, hi: 320 }]), 'the canvas centre guide spans the canvas: ' + 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 }]), 'a guide along the canvas edge: ' + 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, 'Esc mid-drag hides the guides'); + }); + 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 }]), 'one guide for the two lines at 320: ' + JSON.stringify(g35)); + // 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); From eb46acffce85c7282b2ed952152d0983c6ae114b Mon Sep 17 00:00:00 2001 From: oat Date: Wed, 21 Oct 2026 22:31:40 +0700 Subject: [PATCH 2/4] =?UTF-8?q?Snapping=20polish:=20=C3=97=20marks=20on=20?= =?UTF-8?q?the=20points=20that=20line=20up,=20centre=20guides=20run=20cent?= =?UTF-8?q?re=20to=20centre;=20docs=20and=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guides now pass through points (a frame's corners and centre, a line's end), as tldraw and Excalidraw draw them: an edge guide through the corners on it, a centre guide from centre to centre. A small × marks each point, so a guide stays readable where it lies along a black outline. Help, README, task.md (decision), CLAUDE.md and docs/objects.md describe snapping. The options bar's per-tool hints now go through keyLabel(), which also turns a lone ⌘ into Ctrl elsewhere (the shapes hint said ⌘E on Windows). objects.js also checks a group box's handle snapping and that a Shift resize doesn't. --- CLAUDE.md | 1 + README.md | 3 +- docs/objects.md | 2 +- src/index.html | 4 +-- src/js/core.js | 86 +++++++++++++++++++++++++++------------------ src/js/dom.js | 2 +- src/js/help.js | 2 +- src/js/main.js | 1 + src/js/view.js | 30 ++++++++++------ src/style.css | 9 +++-- task.md | 6 ++++ test/core.test.js | 29 +++++++-------- test/e2e/objects.js | 30 +++++++++++----- 13 files changed, 127 insertions(+), 78 deletions(-) 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 681ef97..a44dfed 100644 --- a/src/index.html +++ b/src/index.html @@ -110,7 +110,7 @@ - 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 @@