Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion docs/objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
<button data-cmd="backward" data-tip="Send backward" data-kbd="⌘[" aria-label="Send backward">Backward</button>
<button data-cmd="toBack" data-tip="Send to back" data-kbd="⌘⇧[" aria-label="Send to back">Back</button>
</span>
<span class="hint" data-for="move">Click an object · drag empty canvas to box-select · Space-drag pans</span>
<span class="hint" data-for="move">Click an object · drag empty canvas to box-select · ⌘ held: no snapping · Space-drag pans</span>
<span class="hint" data-for="select">Drag to select · drag inside to move</span>
<span class="hint" data-for="eraser">Paints with color 2 · clears if it’s Transparent</span>
<span class="hint" data-for="fill">Click: color 1 · right-click: color 2</span>
Expand All @@ -130,7 +130,7 @@

<div id="main">
<nav id="rail" class="chrome" role="toolbar" aria-label="Tools" aria-orientation="vertical">
<button data-tool="move" data-tip="Move" data-kbd="V" data-hint="Moves any object · drag empty canvas to box-select, Shift-click to add · Alt+drag copies" aria-label="Move"><svg><use href="#i-move"/></svg></button>
<button data-tool="move" data-tip="Move" data-kbd="V" data-hint="Moves any object, snapping to the others · drag empty canvas to box-select, Shift-click to add · Alt+drag copies" aria-label="Move"><svg><use href="#i-move"/></svg></button>
<button data-tool="select" data-tip="Select" data-kbd="S" aria-label="Select"><svg><use href="#i-select"/></svg></button>
<hr>
<button data-tool="pencil" data-tip="Pencil" data-kbd="P" aria-label="Pencil"><svg><use href="#i-pencil"/></svg></button>
Expand All @@ -153,6 +153,7 @@
<canvas id="overlay" width="1" height="1" aria-hidden="true"></canvas>
<div id="ring" hidden></div>
<div id="objhover" hidden></div>
<div id="guides" aria-hidden="true"></div>
<div id="objsel" hidden></div>
<textarea id="textInput" hidden spellcheck="false" rows="1" aria-label="Text"></textarea>
<div id="resizeGhost" hidden></div>
Expand Down
94 changes: 93 additions & 1 deletion src/js/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
})();
2 changes: 1 addition & 1 deletion src/js/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
2 changes: 1 addition & 1 deletion src/js/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']]],
Expand Down
5 changes: 4 additions & 1 deletion src/js/keyboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 ||
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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'); }
});

Expand Down
1 change: 1 addition & 0 deletions src/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading