From b8251813560615b41134e8c5c5d5c5d30a725685 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 16:25:38 -0400
Subject: [PATCH 01/23] Pen tool Phase D close-out: add/delete anchor,
smooth/corner, bbox refit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Finished the deferred Phase D pieces from PEN_TOOL_FEATURE.md.
New affordances on the editable pen layer:
- Click a segment between anchors → inserts a new anchor at the
click point via de Casteljau cubic split. Curve shape is
preserved exactly: a's hOut, b's hIn, and the new mid-anchor's
handles all derive from the split. Lines (no handles either
side) get a plain midpoint anchor with no handles. The new
anchor is selected and immediately drag-able.
- Right-click an anchor → delete it. Suppresses the browser
context menu on the pen overlay always.
- Backspace / Delete with an anchor selected → delete it.
Layer-delete still wins when no anchor is selected. The pen
tool's selected-anchor sentinel `_penSelectedAnchor` is reset
on tool change.
- Alt-click an anchor → toggle between `kind: 'corner'` (handles
move independently) and `kind: 'smooth'` (handles stay mirrored
across the anchor). Promoting to smooth re-derives the missing
handle (or re-mirrors both if independent) so the curve
actually becomes continuous. New nodes from pen click-drag
default to smooth; new nodes from a single click are corners.
- Drag an anchor or handle past the original 0–100 viewBox →
the layer's bounding rect auto-refits. New `refitPenLayerIfNeeded`
computes the bbox over all nodes + handles, translates
layer.x/y by the bbox offset (scaled to current w/h), updates
layer.w/h, then remaps every node into the new 0–100 space.
Path stays visually anchored to the canvas — no more clipping
at the layer edge.
Anchor glyphs now distinguish kind: smooth = circle (existing
look), corner = square. The selected anchor renders with an amber
stroke (#f97316) so the Backspace-to-delete target is unmistakable.
Spec updated to mark Phase D complete and the deferred items
shipped. Rotated paths are still not node-editable in v1 (the
deferral remains).
---
docs/PEN_TOOL_FEATURE.md | 18 ++-
src/index.html | 289 +++++++++++++++++++++++++++++++++++++--
2 files changed, 291 insertions(+), 16 deletions(-)
diff --git a/docs/PEN_TOOL_FEATURE.md b/docs/PEN_TOOL_FEATURE.md
index 4b68bc2..752a452 100644
--- a/docs/PEN_TOOL_FEATURE.md
+++ b/docs/PEN_TOOL_FEATURE.md
@@ -24,12 +24,18 @@ tool-mode system with flanking toolbars to host it.
shape-layer commit.
- **Phase C** — done. Pen click/drag anchors, rubber-band preview, close /
Enter / dbl-click / Esc, closed-fill-on-close, `shape.pen.nodes` stored.
-- **Phase D** — partial. Editable anchors + Bézier handles for selected
- `tool:'pen'` paths; drag-to-move anchor (carries handles) and drag-to-move
- handle, regenerates `d`, one undo per drag. *Deferred:* add/delete anchor,
- corner↔smooth toggle, click-segment-to-insert, and bbox-refit when an
- anchor is dragged past the original 0–100 viewBox (path clips at the box
- edge until refit lands). Rotated paths are not node-editable in v1.
+- **Phase D** — done (close-out 2026-06-01). On top of the existing
+ drag-anchor / drag-handle path: click on a segment between anchors
+ inserts a new anchor via a de Casteljau cubic split (curve shape
+ preserved exactly); right-click or Backspace on a selected anchor
+ deletes it; Alt-click on an anchor toggles between **corner**
+ (handles move independently, square anchor glyph) and **smooth**
+ (handles stay mirrored across the anchor, circle anchor glyph);
+ dragging a node or handle past the original 0–100 viewBox refits
+ the layer's bounding rect + remaps coordinates so the path stays
+ visually anchored without clipping. The currently-selected anchor
+ renders with an amber stroke so Backspace-to-delete is unambiguous.
+ Rotated paths are still not node-editable in v1.
- **Phase E** — done. `layer.hidden` flag, eye toggle in the layer list, all
six renderers + Canvas2D export skip hidden layers.
- **Unified vector editing** — done. With the Pen tool active, the options
diff --git a/src/index.html b/src/index.html
index 6f30222..add6ef1 100644
--- a/src/index.html
+++ b/src/index.html
@@ -9312,6 +9312,8 @@
let _editorTool = 'select';
let _penOverlay = null; // active SVG overlay element
let _penDraft = null; // in-progress drawing state
+ let _penSelectedAnchor = -1; // index of currently-selected anchor on the editable layer, or -1
+ const PEN_SEGMENT_HIT_TOL = 0.012; // segment-click tolerance (fraction of canvas unit) for insert-anchor
const PEN_MIN_DIST = 0.012; // min inch gap between pencil points
const PEN_CLOSE_HIT = 0.09; // inch radius to "click the first anchor"
const EDITOR_TOOLS = ['select', 'hand', 'pen', 'pencil', 'shape', 'text'];
@@ -9354,6 +9356,7 @@
if (!EDITOR_TOOLS.includes(tool)) tool = 'select';
if (_editorTool === tool) return;
cancelPenDraft();
+ _penSelectedAnchor = -1;
_editorTool = tool;
syncToolUI();
syncPenOverlay();
@@ -9495,6 +9498,16 @@
svg.addEventListener('pointerdown', onPenPointerDown);
svg.addEventListener('pointermove', onPenPointerHover);
svg.addEventListener('dblclick', (e) => { e.preventDefault(); finishPenPath(false); });
+ // Right-click on an editable pen anchor deletes it. Suppress
+ // the browser context menu on the overlay always, then route
+ // the actual delete through the regular hit-test.
+ svg.addEventListener('contextmenu', (e) => {
+ e.preventDefault();
+ const editLayer = getEditablePenLayer();
+ if (!editLayer) return;
+ const hit = hitTestPenEdit(editLayer, e);
+ if (hit && hit.part === 'anchor') deletePenAnchor(editLayer, hit.nodeIndex);
+ });
}
// Keep the overlay glued to the right host + viewBox after a render
@@ -9606,7 +9619,9 @@
return;
}
}
- const node = { x: p.x, y: p.y, hIn: null, hOut: null };
+ // kind='corner' by default; click-drag below promotes it to 'smooth'
+ // so editing later keeps the handles mirrored across the anchor.
+ const node = { x: p.x, y: p.y, hIn: null, hOut: null, kind: 'corner' };
_penDraft.nodes.push(node);
_penDraft.cursor = p;
renderPenDraft();
@@ -9619,6 +9634,7 @@
dragged = true;
node.hOut = { x: pt.x, y: pt.y };
node.hIn = { x: node.x - (pt.x - node.x), y: node.y - (pt.y - node.y) };
+ node.kind = 'smooth';
renderPenDraft();
};
const up = () => {
@@ -9642,11 +9658,27 @@
// If a draft is in progress, continue the create flow.
if (_penDraft && _penDraft.tool === 'pen') { penPointerDown(evt); return; }
// No draft: if an editable pen layer is selected and the click hits one
- // of its anchors/handles, start an edit drag; otherwise begin a new path.
+ // of its anchors/handles/segments, route to the appropriate edit op.
+ // Otherwise begin a new path.
const editLayer = getEditablePenLayer();
if (editLayer) {
const hit = hitTestPenEdit(editLayer, evt);
- if (hit) { startPenEditDrag(editLayer, hit, evt); return; }
+ if (hit) {
+ if (hit.part === 'anchor' && evt.altKey) {
+ // Alt-click an anchor → toggle corner / smooth.
+ togglePenAnchorKind(editLayer, hit.nodeIndex);
+ return;
+ }
+ if (hit.part === 'segment') {
+ // Click on a segment (between anchors) → insert anchor + grab it.
+ insertPenAnchorAt(editLayer, hit.segIndex, hit.t);
+ // Start dragging the new anchor immediately for ergonomics.
+ startPenEditDrag(editLayer, { nodeIndex: hit.segIndex + 1, part: 'anchor' }, evt);
+ return;
+ }
+ startPenEditDrag(editLayer, hit, evt);
+ return;
+ }
}
penPointerDown(evt);
}
@@ -9811,6 +9843,7 @@
const hitR = Math.max(dims.w, dims.h) * 0.018;
const hitR2 = hitR * hitR;
const nodes = layer.shape.pen.nodes;
+ // Pass 1: anchors + handles. These take priority over segments.
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
for (const part of ['hOut', 'hIn']) {
@@ -9823,9 +9856,74 @@
const dx = ap.x - pt.x, dy = ap.y - pt.y;
if (dx * dx + dy * dy < hitR2) return { nodeIndex: i, part: 'anchor' };
}
+ // Pass 2: segment hit (for insert-anchor). Sample each segment
+ // and report the closest hit if within tolerance.
+ const segTol = Math.max(dims.w, dims.h) * PEN_SEGMENT_HIT_TOL;
+ const segTol2 = segTol * segTol;
+ const closed = !!layer.shape.pen.closed;
+ const segCount = closed ? nodes.length : Math.max(0, nodes.length - 1);
+ let best = null;
+ for (let s = 0; s < segCount; s++) {
+ const a = nodes[s];
+ const b = nodes[(s + 1) % nodes.length];
+ const sample = sampleSegment(a, b, 24);
+ for (const { t, x, y } of sample) {
+ const p2 = penNodeToInch(layer, { x, y });
+ const dx = p2.x - pt.x, dy = p2.y - pt.y;
+ const d2 = dx * dx + dy * dy;
+ if (d2 < segTol2 && (!best || d2 < best.d2)) {
+ best = { d2, segIndex: s, t };
+ }
+ }
+ }
+ if (best) return { part: 'segment', segIndex: best.segIndex, t: best.t };
return null;
}
+ // Sample N points along a Bezier (or line) segment between two
+ // pen nodes. Returns { t, x, y } per sample. Used by the
+ // segment hit-test and by the insert-anchor split.
+ function sampleSegment(a, b, count = 24) {
+ const out = [];
+ const c1 = a.hOut || { x: a.x, y: a.y };
+ const c2 = b.hIn || { x: b.x, y: b.y };
+ const isLine = !a.hOut && !b.hIn;
+ for (let i = 1; i < count; i++) {
+ const t = i / count;
+ if (isLine) {
+ out.push({ t, x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
+ } else {
+ const u = 1 - t;
+ const x = u * u * u * a.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * b.x;
+ const y = u * u * u * a.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * b.y;
+ out.push({ t, x, y });
+ }
+ }
+ return out;
+ }
+
+ // de Casteljau split of a cubic at parameter t. Returns the new
+ // mid-anchor + the updated control points for the left and
+ // right sub-curves. Used when inserting an anchor on a segment.
+ function splitCubic(a, b, t) {
+ const c1 = a.hOut || { x: a.x, y: a.y };
+ const c2 = b.hIn || { x: b.x, y: b.y };
+ const lerp = (p, q, k) => ({ x: p.x + (q.x - p.x) * k, y: p.y + (q.y - p.y) * k });
+ const p01 = lerp(a, c1, t);
+ const p12 = lerp(c1, c2, t);
+ const p23 = lerp(c2, b, t);
+ const p012 = lerp(p01, p12, t);
+ const p123 = lerp(p12, p23, t);
+ const mid = lerp(p012, p123, t);
+ return {
+ aHOut: p01, // a's new hOut
+ midHIn: p012, // mid's hIn
+ midPos: mid,
+ midHOut: p123, // mid's hOut
+ bHIn: p23, // b's new hIn
+ };
+ }
+
function regenPenLayerD(layer) {
layer.shape = {
...layer.shape,
@@ -9837,6 +9935,7 @@
pushHistory('edit pen path');
try { _penOverlay.setPointerCapture(evt.pointerId); } catch {}
const node = layer.shape.pen.nodes[hit.nodeIndex];
+ _penSelectedAnchor = hit.nodeIndex;
const move = (e) => {
const pt = overlayPointToInches(e);
if (!pt) return;
@@ -9849,7 +9948,19 @@
if (node.hOut) { node.hOut.x += dx; node.hOut.y += dy; }
} else {
node[hit.part] = { x: local.x, y: local.y };
+ // Mirror the opposite handle if the anchor is smooth, so
+ // editing one side of a curve keeps the curve smooth on
+ // the other side too.
+ if (node.kind === 'smooth') {
+ const opp = hit.part === 'hOut' ? 'hIn' : 'hOut';
+ node[opp] = { x: 2 * node.x - local.x, y: 2 * node.y - local.y };
+ }
}
+ // Refit the layer's bounding box if the drag pushed any
+ // node or handle outside the original 0–100 viewBox. The
+ // path stays visually anchored to the canvas — only the
+ // layer's x/y/w/h + the nodes' local coords change.
+ refitPenLayerIfNeeded(layer);
regenPenLayerD(layer);
renderLayers();
renderPenEditHandles(layer);
@@ -9865,6 +9976,137 @@
_penOverlay.addEventListener('pointercancel', up);
}
+ // When an edit drag pushes any node or handle outside the
+ // 0–100 viewBox the path renders clipped at the layer's
+ // bounding rect. Detect that and re-normalize: compute the
+ // bbox over all nodes + handles, translate layer.x/y by the
+ // bbox offset (in inches), scale layer.w/h to fit, and remap
+ // every node's coords into the new 0–100 space. The path's
+ // on-canvas position is preserved.
+ function refitPenLayerIfNeeded(layer) {
+ const nodes = layer.shape.pen.nodes;
+ const pts = [];
+ for (const n of nodes) {
+ pts.push({ x: n.x, y: n.y });
+ if (n.hIn) pts.push(n.hIn);
+ if (n.hOut) pts.push(n.hOut);
+ }
+ if (!pts.length) return;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const p of pts) {
+ if (p.x < minX) minX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y > maxY) maxY = p.y;
+ }
+ // No-op unless we're outside [0, 100] on any side. A bit of
+ // tolerance to avoid micro-jiggle during normal in-bounds drags.
+ const EPS = 0.5;
+ if (minX > -EPS && minY > -EPS && maxX < 100 + EPS && maxY < 100 + EPS) return;
+ const newW = Math.max(EPS, maxX - minX);
+ const newH = Math.max(EPS, maxY - minY);
+ const sx = newW / 100;
+ const sy = newH / 100;
+ // Layer position shifts in inches by the bbox offset scaled
+ // to the existing layer w/h.
+ layer.x = layer.x + (minX / 100) * layer.w;
+ layer.y = layer.y + (minY / 100) * layer.h;
+ layer.w = Math.max(0.01, layer.w * sx);
+ layer.h = Math.max(0.01, layer.h * sy);
+ const remap = (p) => ({
+ x: (p.x - minX) / newW * 100,
+ y: (p.y - minY) / newH * 100,
+ });
+ for (const n of nodes) {
+ const r = remap(n);
+ n.x = r.x; n.y = r.y;
+ if (n.hIn) { const ri = remap(n.hIn); n.hIn = ri; }
+ if (n.hOut) { const ro = remap(n.hOut); n.hOut = ro; }
+ }
+ }
+
+ // Insert a new anchor on a segment between nodes[segIndex] and
+ // nodes[segIndex+1] (or wraps around when closed). Uses de
+ // Casteljau split at t so the curve shape is preserved exactly.
+ function insertPenAnchorAt(layer, segIndex, t) {
+ const nodes = layer.shape.pen.nodes;
+ const a = nodes[segIndex];
+ const bIdx = (segIndex + 1) % nodes.length;
+ const b = nodes[bIdx];
+ pushHistory('insert pen anchor');
+ // Lines (no handles) get a plain mid-anchor with no handles
+ // — preserves the straight segment.
+ if (!a.hOut && !b.hIn) {
+ const midPos = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
+ nodes.splice(segIndex + 1, 0, { x: midPos.x, y: midPos.y, hIn: null, hOut: null, kind: 'corner' });
+ } else {
+ const split = splitCubic(a, b, t);
+ a.hOut = split.aHOut;
+ b.hIn = split.bHIn;
+ nodes.splice(segIndex + 1, 0, {
+ x: split.midPos.x, y: split.midPos.y,
+ hIn: split.midHIn, hOut: split.midHOut,
+ kind: 'smooth',
+ });
+ }
+ _penSelectedAnchor = segIndex + 1;
+ regenPenLayerD(layer);
+ render();
+ }
+
+ // Delete the currently-selected anchor (or a specific one by
+ // index). Closed paths just lose the node. Open paths lose the
+ // node; if it was an endpoint, the path stays open with the
+ // new endpoint being the prior next-inward node.
+ function deletePenAnchor(layer, idx) {
+ const nodes = layer.shape.pen.nodes;
+ if (idx < 0 || idx >= nodes.length) return;
+ // A path needs at least 2 nodes to survive (one node is just a point).
+ if (nodes.length <= 2) {
+ toastError?.('Path needs at least two anchors. Delete the layer instead.');
+ return;
+ }
+ pushHistory('delete pen anchor');
+ nodes.splice(idx, 1);
+ _penSelectedAnchor = -1;
+ regenPenLayerD(layer);
+ render();
+ }
+
+ // Toggle a node between 'corner' (handles move independently)
+ // and 'smooth' (handles mirror across the anchor). When
+ // promoting to smooth, mirror hOut from hIn (or vice versa) so
+ // the curve actually becomes continuous through this node.
+ function togglePenAnchorKind(layer, idx) {
+ const nodes = layer.shape.pen.nodes;
+ const n = nodes[idx];
+ if (!n) return;
+ pushHistory('toggle pen anchor kind');
+ if (n.kind === 'smooth') {
+ n.kind = 'corner';
+ } else {
+ n.kind = 'smooth';
+ if (n.hIn && !n.hOut) {
+ n.hOut = { x: 2 * n.x - n.hIn.x, y: 2 * n.y - n.hIn.y };
+ } else if (n.hOut && !n.hIn) {
+ n.hIn = { x: 2 * n.x - n.hOut.x, y: 2 * n.y - n.hOut.y };
+ } else if (n.hIn && n.hOut) {
+ // Average direction and re-mirror so the smoothing is
+ // visible (handles likely were independent before).
+ const inLen = Math.hypot(n.hIn.x - n.x, n.hIn.y - n.y);
+ const outLen = Math.hypot(n.hOut.x - n.x, n.hOut.y - n.y);
+ const ax = (n.hOut.x - n.x) - (n.hIn.x - n.x);
+ const ay = (n.hOut.y - n.y) - (n.hIn.y - n.y);
+ const al = Math.hypot(ax, ay) || 1;
+ const dirX = ax / al, dirY = ay / al;
+ n.hOut = { x: n.x + dirX * outLen, y: n.y + dirY * outLen };
+ n.hIn = { x: n.x - dirX * inLen, y: n.y - dirY * inLen };
+ }
+ }
+ regenPenLayerD(layer);
+ render();
+ }
+
function renderPenEditHandles(layer) {
if (!_penOverlay) return;
const ns = 'http://www.w3.org/2000/svg';
@@ -9891,13 +10133,31 @@
hd.setAttribute('fill', '#3b82f6');
_penOverlay.appendChild(hd);
}
- const dot = document.createElementNS(ns, 'circle');
- dot.setAttribute('cx', a.x); dot.setAttribute('cy', a.y);
- dot.setAttribute('r', String(dotR));
- dot.setAttribute('fill', '#ffffff');
- dot.setAttribute('stroke', '#3b82f6');
- dot.setAttribute('stroke-width', String(lineW * 1.6));
- _penOverlay.appendChild(dot);
+ // Anchor glyph: smooth = circle, corner = square. Selected
+ // gets a thicker stroke + accent color so the active anchor
+ // is obvious for Backspace-to-delete.
+ const isSelected = i === _penSelectedAnchor;
+ const stroke = isSelected ? '#f97316' : '#3b82f6';
+ if (n.kind === 'smooth') {
+ const dot = document.createElementNS(ns, 'circle');
+ dot.setAttribute('cx', a.x); dot.setAttribute('cy', a.y);
+ dot.setAttribute('r', String(dotR));
+ dot.setAttribute('fill', '#ffffff');
+ dot.setAttribute('stroke', stroke);
+ dot.setAttribute('stroke-width', String(lineW * (isSelected ? 2.4 : 1.6)));
+ _penOverlay.appendChild(dot);
+ } else {
+ const sq = document.createElementNS(ns, 'rect');
+ const s = dotR * 1.6;
+ sq.setAttribute('x', String(a.x - s / 2));
+ sq.setAttribute('y', String(a.y - s / 2));
+ sq.setAttribute('width', String(s));
+ sq.setAttribute('height', String(s));
+ sq.setAttribute('fill', '#ffffff');
+ sq.setAttribute('stroke', stroke);
+ sq.setAttribute('stroke-width', String(lineW * (isSelected ? 2.4 : 1.6)));
+ _penOverlay.appendChild(sq);
+ }
});
}
@@ -29078,6 +29338,15 @@
if (k === 'enter' && _penDraft && _penDraft.tool === 'pen') { e.preventDefault(); finishPenPath(false); return; }
if (!_penDraft) {
if (k === 'delete' || k === 'backspace') {
+ // Pen tool + an anchor is selected → delete the anchor,
+ // not the whole layer. Keeps Backspace the standard
+ // "delete the selected thing" key in both modes.
+ const penEdit = getEditablePenLayer?.();
+ if (penEdit && _penSelectedAnchor >= 0) {
+ e.preventDefault();
+ deletePenAnchor(penEdit, _penSelectedAnchor);
+ return;
+ }
if (selectedLayerIds().length) { e.preventDefault(); deleteSelectedLayer(); }
return;
}
From e5c06b741513f11424a9a26d28e1a23f1d29da12 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 16:36:19 -0400
Subject: [PATCH 02/23] Pen tool: uniform-scale remap (fixes stroke distortion)
+ smaller handles
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two visual issues on top of the Phase D close-out commit:
1. **Stroke width varied along curves.** The pre-existing pipeline
stored nodes in 0-100 × 0-100 viewBox space and let the SVG
renderer pick `vbH = 100 * layer.h / layer.w` with
`preserveAspectRatio="none"`. For any non-square layer, the
0-100×0-100 node range got stretched non-uniformly into a 100×vbH
viewBox, distorting the path: strokes appeared wider where the
curve traveled more in one axis. Phase D's refit made this much
more likely because dragging anchors out of bounds routinely
produced non-square layers.
Fix in two places:
- finishPenPath: uniform-scale the bbox by `100/bbox.w` so y
range lands at exactly `100 * h/w = vbH`. The viewBox aspect
matches the layer aspect, no stretching.
- refitPenLayerIfNeeded: same uniform-scale invariant. Also
respects the layer's current vbH when deciding whether nodes
are "out of bounds" — was previously hard-coded at 100, which
wrongly fired refit on every drag in already-non-square layers.
2. **Anchor + handle glyphs were too prominent.** On a 1920px-wide
slide preview the old `dotR = 0.013 * unit` rendered ~25px
dots. Reduced to `0.0075 * unit` (~12px) and stroke from
`0.003 * unit` to `0.0018 * unit`. The in-progress draft
renderer matches.
Strokes now stay constant width all the way around a curve, and
the editing UI reads as overlay rather than as part of the artwork.
---
src/index.html | 54 +++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 42 insertions(+), 12 deletions(-)
diff --git a/src/index.html b/src/index.html
index add6ef1..6808551 100644
--- a/src/index.html
+++ b/src/index.html
@@ -9999,23 +9999,39 @@
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
- // No-op unless we're outside [0, 100] on any side. A bit of
- // tolerance to avoid micro-jiggle during normal in-bounds drags.
+ // Layer viewBox is 0-100 × 0-vbH where vbH = 100*layer.h/layer.w
+ // — the renderer's hard-coded formula. We want nodes to fit
+ // x:[0,100] and y:[0,vbH], matching the layer's actual aspect
+ // ratio after refit. The current viewBox-y bound is implied
+ // by what the renderer would compute given new layer.w/h.
+ //
+ // No-op unless we're outside the current viewBox on any side
+ // (with a small tolerance to avoid micro-jiggle during normal
+ // in-bounds drags). The current viewBox y-max is `vbH` computed
+ // from existing layer w/h.
+ const currentVbH = layer.w > 0 ? 100 * layer.h / layer.w : 100;
const EPS = 0.5;
- if (minX > -EPS && minY > -EPS && maxX < 100 + EPS && maxY < 100 + EPS) return;
+ if (minX > -EPS && minY > -EPS && maxX < 100 + EPS && maxY < currentVbH + EPS) return;
const newW = Math.max(EPS, maxX - minX);
const newH = Math.max(EPS, maxY - minY);
+ // Layer scales: in inches, layer.w grows in proportion to
+ // newW vs current viewBox-x (always 100); layer.h grows in
+ // proportion to newH vs the existing vbH.
const sx = newW / 100;
- const sy = newH / 100;
+ const sy = newH / currentVbH;
// Layer position shifts in inches by the bbox offset scaled
// to the existing layer w/h.
layer.x = layer.x + (minX / 100) * layer.w;
- layer.y = layer.y + (minY / 100) * layer.h;
+ layer.y = layer.y + (minY / currentVbH) * layer.h;
layer.w = Math.max(0.01, layer.w * sx);
layer.h = Math.max(0.01, layer.h * sy);
+ // Uniform-scale remap so the new node range matches the new
+ // viewBox aspect exactly — no stretching when the SVG paints
+ // into the refitted layer rectangle.
+ const remapScale = 100 / newW;
const remap = (p) => ({
- x: (p.x - minX) / newW * 100,
- y: (p.y - minY) / newH * 100,
+ x: (p.x - minX) * remapScale,
+ y: (p.y - minY) * remapScale,
});
for (const n of nodes) {
const r = remap(n);
@@ -10113,8 +10129,13 @@
clearPenDraftRender();
const dims = currentCanvasDims();
const unit = Math.max(dims.w, dims.h);
- const lineW = unit * 0.003;
- const dotR = unit * 0.013;
+ // Smaller, less prominent anchors/handles — earlier sizes
+ // (0.003 stroke, 0.013 anchor) rendered ~25px dots on a
+ // 1920px-wide preview, which obscured short paths and made
+ // the canvas feel cluttered. ~50% smaller reads as deliberate
+ // editing UI without dominating.
+ const lineW = unit * 0.0018;
+ const dotR = unit * 0.0075;
const nodes = layer.shape.pen.nodes;
nodes.forEach((n, i) => {
const a = penNodeToInch(layer, n);
@@ -10469,7 +10490,14 @@
if (n.hOut) allPts.push(n.hOut);
}
const bbox = pointsBbox(allPts);
- const remap = (pt) => ({ x: (pt.x - bbox.x) / bbox.w * 100, y: (pt.y - bbox.y) / bbox.h * 100 });
+ // Uniform scaling: divide both x and y by bbox.w so nodes land
+ // in 0-100 × 0-(100*h/w) — matching the renderer's vbH formula
+ // (= 100 * layer.h / layer.w). Independent x/y scaling here
+ // forces a 0-100×0-100 node range into a non-square viewBox
+ // later, stretching curves and varying stroke widths around
+ // the path. Uniform scaling keeps strokes a constant width.
+ const scale = bbox.w > 0 ? 100 / bbox.w : 1;
+ const remap = (pt) => ({ x: (pt.x - bbox.x) * scale, y: (pt.y - bbox.y) * scale });
const normNodes = nodes.map(n => ({
x: remap(n).x, y: remap(n).y,
hIn: n.hIn ? remap(n.hIn) : null,
@@ -10515,8 +10543,10 @@
clearPenDraftRender();
const dims = currentCanvasDims();
const unit = Math.max(dims.w, dims.h);
- const lineW = unit * 0.004;
- const dotR = unit * 0.012;
+ // Sized to match renderPenEditHandles so the in-progress
+ // draw and the post-commit edit handles look consistent.
+ const lineW = unit * 0.0025;
+ const dotR = unit * 0.0075;
if (_penDraft.tool === 'pencil') {
const pts = _penDraft.points;
From 3831b39b2e72f9cccf506f075fb7e6dc2f106a99 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 16:46:58 -0400
Subject: [PATCH 03/23] Pen tool: write viewBox.h to match layer aspect for
uniform strokes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The previous "uniform-scale remap" commit fixed node coordinates to
land in 0-100 × 0-(100*h/w), but the renderer (buildShapeSvgInner
path branch) reads shape.viewBox and computes sx = 100/vb.w,
sy = vbH/vb.h. With shape.viewBox.h hardcoded to 100, sx ≠ sy for
non-square layers and the path got stretched again — strokes
distorted around curves after the path was committed, even though
the in-progress draft (which renders inside the pen overlay, not
the layer SVG) looked fine.
Fix is the matching half: also write shape.viewBox.h = 100*lh/lw
wherever we touch the pen data, so sx == sy and the renderer
applies a uniform scale.
- finishPenPath now sets viewBox.h = vbH from the bbox aspect.
- regenPenLayerD (called on every edit drag + insert/delete/toggle)
recomputes vbH from current layer.w/h before writing the new d
string. This catches the rare case where layer.w/h change between
refit calls without my refit running.
- The "Make editable path" conversion remaps primitiveToPenNodes /
parsePathDToNodes results (which always come in 0-100×0-100) into
the layer's actual aspect space, and writes viewBox.h to match.
Result: converting a rect → editable path on a non-square layer
keeps its proportions, and the resulting pen path edits cleanly
with uniform strokes.
Strokes now stay a constant width all the way around closed
shapes, even after the path is fully committed and re-rendered
through the standard shape-layer pipeline.
---
src/index.html | 45 ++++++++++++++++++++++++++++++++++++---------
1 file changed, 36 insertions(+), 9 deletions(-)
diff --git a/src/index.html b/src/index.html
index 6808551..1fc28c1 100644
--- a/src/index.html
+++ b/src/index.html
@@ -9925,8 +9925,15 @@
}
function regenPenLayerD(layer) {
+ // The renderer applies sx=100/vb.w, sy=vbH/vb.h; we want sx==sy.
+ // So whenever we regenerate the d-string we also update the
+ // stored viewBox.h to match the layer's current aspect, keeping
+ // strokes uniform. Nodes are kept in 0-100 × 0-(100*h/w) space
+ // by the uniform-scale remap in finishPenPath + refit.
+ const vbH = layer.w > 0 ? 100 * layer.h / layer.w : 100;
layer.shape = {
...layer.shape,
+ viewBox: { x: 0, y: 0, w: 100, h: vbH },
d: penNodesToPathD(layer.shape.pen.nodes, layer.shape.pen.closed)
};
}
@@ -10439,11 +10446,28 @@
width: Math.max(0.5, Number(sh.strokeWidth) || 6), dash: 'solid' };
layer.fill = { type: 'none' };
}
+ // primitiveToPenNodes / parsePathDToNodes return nodes in
+ // 0-100 × 0-100. The renderer paints pen paths with sx=100/vb.w,
+ // sy=vbH/vb.h, where vbH = 100*layer.h/layer.w — so for any
+ // non-square layer this would stretch the path. Remap node y
+ // coordinates to 0..vbH so the resulting viewBox is the layer's
+ // actual aspect, giving uniform sx==sy at render time.
+ const lw = Number(layer.w) > 0 ? Number(layer.w) : 1;
+ const lh = Number(layer.h) > 0 ? Number(layer.h) : 1;
+ const vbH = 100 * lh / lw;
+ const yScale = vbH / 100;
+ const scaledNodes = result.nodes.map(n => ({
+ x: n.x,
+ y: n.y * yScale,
+ hIn: n.hIn ? { x: n.hIn.x, y: n.hIn.y * yScale } : null,
+ hOut: n.hOut ? { x: n.hOut.x, y: n.hOut.y * yScale } : null,
+ kind: n.kind || 'corner',
+ }));
layer.shape = {
kind: 'path',
- viewBox: { x: 0, y: 0, w: 100, h: 100 },
- pen: { closed: result.closed, tool: 'pen', nodes: result.nodes },
- d: penNodesToPathD(result.nodes, result.closed)
+ viewBox: { x: 0, y: 0, w: 100, h: vbH },
+ pen: { closed: result.closed, tool: 'pen', nodes: scaledNodes },
+ d: penNodesToPathD(scaledNodes, result.closed)
};
render();
return { ok: true };
@@ -10491,11 +10515,13 @@
}
const bbox = pointsBbox(allPts);
// Uniform scaling: divide both x and y by bbox.w so nodes land
- // in 0-100 × 0-(100*h/w) — matching the renderer's vbH formula
- // (= 100 * layer.h / layer.w). Independent x/y scaling here
- // forces a 0-100×0-100 node range into a non-square viewBox
- // later, stretching curves and varying stroke widths around
- // the path. Uniform scaling keeps strokes a constant width.
+ // in 0-100 × 0-(100*h/w). Then store shape.viewBox.h to match,
+ // so the renderer (buildShapeSvgInner path branch, sx = 100/vb.w,
+ // sy = H/vb.h) ends up with sx == sy = uniform scaling. The
+ // earlier hardcoded viewBox.h = 100 with bbox-rescaled nodes
+ // forced the renderer to stretch the path vertically into a
+ // non-square layer rectangle, varying stroke widths around
+ // curves.
const scale = bbox.w > 0 ? 100 / bbox.w : 1;
const remap = (pt) => ({ x: (pt.x - bbox.x) * scale, y: (pt.y - bbox.y) * scale });
const normNodes = nodes.map(n => ({
@@ -10503,6 +10529,7 @@
hIn: n.hIn ? remap(n.hIn) : null,
hOut: n.hOut ? remap(n.hOut) : null
}));
+ const vbH = bbox.w > 0 ? 100 * bbox.h / bbox.w : 100;
const d = penNodesToPathD(normNodes, closed);
pushHistory('draw pen path');
const fill = (closed && _penToolOpts.penFill)
@@ -10516,7 +10543,7 @@
x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h,
rotate: 0, opacity: 1, z: nextLayerZ(),
shape: {
- kind: 'path', d, viewBox: { x: 0, y: 0, w: 100, h: 100 },
+ kind: 'path', d, viewBox: { x: 0, y: 0, w: 100, h: vbH },
pen: { closed: !!closed, tool: 'pen', nodes: normNodes }
},
fill,
From 9c638c0676946193a503361e3f4a668424c55e86 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 17:06:57 -0400
Subject: [PATCH 04/23] Pen tool: pixel-space viewBox so stroke widths match
across shapes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two pen paths with the same stored stroke number rendered at very
different on-screen thicknesses — strokes scaled with layer size
because nodes lived in a 0-100 normalized viewBox. The pencil tool
solved this exact problem (see commitPencilStroke ~line 9580 with
the inline comment about pixel-space viewBox = "true on-screen
thickness, identical no matter how long the stroke is"). Switching
pen paths to the same scheme.
Pen nodes now live in a pixel-space viewBox where 1 unit = 1 px
at 96 dpi: viewBox dims = (layer.w * 96, layer.h * 96). Because
the viewBox aspect matches the layer aspect, the renderer's
sx == sy (uniform scale) AND the same stroke-width number produces
the same device-pixel thickness on every pen path regardless of
size.
Five touchpoints updated:
- finishPenPath: node remap multiplies bbox-relative coords by 96
instead of normalizing to 0-100; viewBox.w / viewBox.h written
as bbox.w*96 / bbox.h*96.
- penNodeToInch / inchToPenNode: convert via /96 (was /100 of
layer dim).
- regenPenLayerD: viewBox rewritten as (layer.w*96, layer.h*96)
on every edit. Catches user-resizes via corner handles too.
- refitPenLayerIfNeeded: bounds checked against pixel-space
viewBox; layer position/size translated via /96; node remap is
now a pure translation (no rescale, since the units stay
pixel-space).
- "Make editable path" conversion: 0-100 primitive nodes mapped
to pixel-space relative to the existing layer's dimensions.
Strokes are now consistent across pen paths of any size, and the
in-progress draft (rendered in the pen overlay's inch-space) still
matches the final committed path's visual stroke weight at the
canvas's natural 96-DPI mapping.
---
src/index.html | 122 +++++++++++++++++++++++--------------------------
1 file changed, 57 insertions(+), 65 deletions(-)
diff --git a/src/index.html b/src/index.html
index 1fc28c1..e87dc08 100644
--- a/src/index.html
+++ b/src/index.html
@@ -9828,12 +9828,16 @@
return layer;
}
- // Pen nodes live in the path's 0–100 viewBox; map to/from canvas inches.
+ // Pen nodes live in a pixel-space viewBox (1 unit = 1px @ 96dpi).
+ // Layer dimensions are inches; map nodes ↔ canvas inches via /96.
+ // The viewBox dims (layer.w * 96, layer.h * 96) match the layer's
+ // aspect so stroke-width is consistent on-screen regardless of
+ // layer size.
function penNodeToInch(layer, p) {
- return { x: layer.x + (p.x / 100) * layer.w, y: layer.y + (p.y / 100) * layer.h };
+ return { x: layer.x + p.x / 96, y: layer.y + p.y / 96 };
}
function inchToPenNode(layer, pt) {
- return { x: (pt.x - layer.x) / layer.w * 100, y: (pt.y - layer.y) / layer.h * 100 };
+ return { x: (pt.x - layer.x) * 96, y: (pt.y - layer.y) * 96 };
}
function hitTestPenEdit(layer, evt) {
@@ -9925,15 +9929,17 @@
}
function regenPenLayerD(layer) {
- // The renderer applies sx=100/vb.w, sy=vbH/vb.h; we want sx==sy.
- // So whenever we regenerate the d-string we also update the
- // stored viewBox.h to match the layer's current aspect, keeping
- // strokes uniform. Nodes are kept in 0-100 × 0-(100*h/w) space
- // by the uniform-scale remap in finishPenPath + refit.
- const vbH = layer.w > 0 ? 100 * layer.h / layer.w : 100;
+ // Pen nodes live in pixel-space (layer.w * 96 × layer.h * 96).
+ // We re-write the stored viewBox to match the layer's current
+ // inch dimensions so the renderer's path branch (sx=100/vb.w,
+ // sy=vbH/vb.h, vbH = 100*lh/lw) ends up uniform: sx == sy. Strokes
+ // stay constant width around curves and consistent across shapes
+ // regardless of size — same as the pencil tool's behavior.
+ const vbW = (layer.w || 0) * 96;
+ const vbH = (layer.h || 0) * 96;
layer.shape = {
...layer.shape,
- viewBox: { x: 0, y: 0, w: 100, h: vbH },
+ viewBox: { x: 0, y: 0, w: vbW, h: vbH },
d: penNodesToPathD(layer.shape.pen.nodes, layer.shape.pen.closed)
};
}
@@ -10006,40 +10012,24 @@
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
- // Layer viewBox is 0-100 × 0-vbH where vbH = 100*layer.h/layer.w
- // — the renderer's hard-coded formula. We want nodes to fit
- // x:[0,100] and y:[0,vbH], matching the layer's actual aspect
- // ratio after refit. The current viewBox-y bound is implied
- // by what the renderer would compute given new layer.w/h.
- //
- // No-op unless we're outside the current viewBox on any side
- // (with a small tolerance to avoid micro-jiggle during normal
- // in-bounds drags). The current viewBox y-max is `vbH` computed
- // from existing layer w/h.
- const currentVbH = layer.w > 0 ? 100 * layer.h / layer.w : 100;
+ // Nodes live in pixel-space: x:[0, layer.w*96], y:[0, layer.h*96].
+ // Refit unless every point is inside (with a small tolerance to
+ // avoid micro-jiggle).
+ const vbWNow = (layer.w || 0) * 96;
+ const vbHNow = (layer.h || 0) * 96;
const EPS = 0.5;
- if (minX > -EPS && minY > -EPS && maxX < 100 + EPS && maxY < currentVbH + EPS) return;
+ if (minX > -EPS && minY > -EPS && maxX < vbWNow + EPS && maxY < vbHNow + EPS) return;
const newW = Math.max(EPS, maxX - minX);
const newH = Math.max(EPS, maxY - minY);
- // Layer scales: in inches, layer.w grows in proportion to
- // newW vs current viewBox-x (always 100); layer.h grows in
- // proportion to newH vs the existing vbH.
- const sx = newW / 100;
- const sy = newH / currentVbH;
- // Layer position shifts in inches by the bbox offset scaled
- // to the existing layer w/h.
- layer.x = layer.x + (minX / 100) * layer.w;
- layer.y = layer.y + (minY / currentVbH) * layer.h;
- layer.w = Math.max(0.01, layer.w * sx);
- layer.h = Math.max(0.01, layer.h * sy);
- // Uniform-scale remap so the new node range matches the new
- // viewBox aspect exactly — no stretching when the SVG paints
- // into the refitted layer rectangle.
- const remapScale = 100 / newW;
- const remap = (p) => ({
- x: (p.x - minX) * remapScale,
- y: (p.y - minY) * remapScale,
- });
+ // Layer position shifts in inches by the bbox offset (in
+ // pixel-space) divided by 96 to convert back to inches.
+ layer.x = layer.x + minX / 96;
+ layer.y = layer.y + minY / 96;
+ // New layer dimensions in inches: pixel-space bbox / 96.
+ layer.w = Math.max(0.01, newW / 96);
+ layer.h = Math.max(0.01, newH / 96);
+ // Re-anchor nodes so the new bbox origin sits at (0, 0).
+ const remap = (p) => ({ x: p.x - minX, y: p.y - minY });
for (const n of nodes) {
const r = remap(n);
n.x = r.x; n.y = r.y;
@@ -10446,26 +10436,28 @@
width: Math.max(0.5, Number(sh.strokeWidth) || 6), dash: 'solid' };
layer.fill = { type: 'none' };
}
- // primitiveToPenNodes / parsePathDToNodes return nodes in
- // 0-100 × 0-100. The renderer paints pen paths with sx=100/vb.w,
- // sy=vbH/vb.h, where vbH = 100*layer.h/layer.w — so for any
- // non-square layer this would stretch the path. Remap node y
- // coordinates to 0..vbH so the resulting viewBox is the layer's
- // actual aspect, giving uniform sx==sy at render time.
+ // primitiveToPenNodes / parsePathDToNodes give nodes in 0-100×0-100.
+ // Pen paths now use a pixel-space viewBox (1 unit = 1px @ 96dpi)
+ // so stroke-width renders at a consistent device-pixel size across
+ // shapes (same pattern as the pencil tool). Map 0-100 → 0-(lw*96)
+ // along x and 0-100 → 0-(lh*96) along y; the resulting viewBox
+ // aspect matches the layer aspect, giving uniform scaling.
const lw = Number(layer.w) > 0 ? Number(layer.w) : 1;
const lh = Number(layer.h) > 0 ? Number(layer.h) : 1;
- const vbH = 100 * lh / lw;
+ const vbW = lw * 96;
+ const vbH = lh * 96;
+ const xScale = vbW / 100;
const yScale = vbH / 100;
const scaledNodes = result.nodes.map(n => ({
- x: n.x,
+ x: n.x * xScale,
y: n.y * yScale,
- hIn: n.hIn ? { x: n.hIn.x, y: n.hIn.y * yScale } : null,
- hOut: n.hOut ? { x: n.hOut.x, y: n.hOut.y * yScale } : null,
+ hIn: n.hIn ? { x: n.hIn.x * xScale, y: n.hIn.y * yScale } : null,
+ hOut: n.hOut ? { x: n.hOut.x * xScale, y: n.hOut.y * yScale } : null,
kind: n.kind || 'corner',
}));
layer.shape = {
kind: 'path',
- viewBox: { x: 0, y: 0, w: 100, h: vbH },
+ viewBox: { x: 0, y: 0, w: vbW, h: vbH },
pen: { closed: result.closed, tool: 'pen', nodes: scaledNodes },
d: penNodesToPathD(scaledNodes, result.closed)
};
@@ -10514,22 +10506,22 @@
if (n.hOut) allPts.push(n.hOut);
}
const bbox = pointsBbox(allPts);
- // Uniform scaling: divide both x and y by bbox.w so nodes land
- // in 0-100 × 0-(100*h/w). Then store shape.viewBox.h to match,
- // so the renderer (buildShapeSvgInner path branch, sx = 100/vb.w,
- // sy = H/vb.h) ends up with sx == sy = uniform scaling. The
- // earlier hardcoded viewBox.h = 100 with bbox-rescaled nodes
- // forced the renderer to stretch the path vertically into a
- // non-square layer rectangle, varying stroke widths around
- // curves.
- const scale = bbox.w > 0 ? 100 / bbox.w : 1;
- const remap = (pt) => ({ x: (pt.x - bbox.x) * scale, y: (pt.y - bbox.y) * scale });
+ // Store the path in a pixel-space viewBox (1 unit = 1px @ 96dpi).
+ // The pencil tool does the same thing (see commitPencilStroke) for
+ // exactly this reason: stroke-width becomes a true on-screen
+ // thickness regardless of how big the shape is — two pen paths
+ // with stroke "2" render at the same device-pixel width. The
+ // earlier 0-100 normalization made strokes scale with layer size,
+ // so identical stroke numbers visually differed across shapes.
+ const remap = (pt) => ({ x: (pt.x - bbox.x) * 96, y: (pt.y - bbox.y) * 96 });
const normNodes = nodes.map(n => ({
x: remap(n).x, y: remap(n).y,
hIn: n.hIn ? remap(n.hIn) : null,
- hOut: n.hOut ? remap(n.hOut) : null
+ hOut: n.hOut ? remap(n.hOut) : null,
+ kind: n.kind || 'corner',
}));
- const vbH = bbox.w > 0 ? 100 * bbox.h / bbox.w : 100;
+ const vbW = bbox.w * 96;
+ const vbH = bbox.h * 96;
const d = penNodesToPathD(normNodes, closed);
pushHistory('draw pen path');
const fill = (closed && _penToolOpts.penFill)
@@ -10543,7 +10535,7 @@
x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h,
rotate: 0, opacity: 1, z: nextLayerZ(),
shape: {
- kind: 'path', d, viewBox: { x: 0, y: 0, w: 100, h: vbH },
+ kind: 'path', d, viewBox: { x: 0, y: 0, w: vbW, h: vbH },
pen: { closed: !!closed, tool: 'pen', nodes: normNodes }
},
fill,
From 8ca7a2468a2b27602c88abad2c9a796516248912 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:08:06 -0400
Subject: [PATCH 05/23] Pen tool: select-mode anchor editing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Selecting a pen-shape layer with the Select tool now reveals its
anchors + handles for inline editing — no need to switch to the
Pen tool first. Drag anchors / handles to reshape; right-click
or Backspace to delete; Alt-click toggles corner ↔ smooth. Pen
mode keeps the richer affordances (segment-click to insert
anchor, click-to-create-new-path) since those would conflict with
the Select tool's normal layer-click behavior.
Three small changes:
- getEditablePenLayer drops the strict 'pen' tool requirement and
also accepts 'select'.
- syncPenOverlay keeps the overlay alive in select mode when a
pen-shape layer is selected; render() now re-syncs each frame
so the overlay appears / disappears as selection changes
(without requiring a tool change).
- renderPenEditHandles marks anchor + handle elements
pointer-events: all (with `cursor: pointer`) while the overlay
base stays pointer-events: none. Empty-space clicks fall
through to the layer's normal selection behavior; only clicks
on anchors / handles register as edit ops.
onPenPointerDown gains a select-mode branch that:
- Only handles anchor / handle hits; segment clicks pass through
to the layer (so dragging the path body still selects / moves
the layer as expected).
- preventDefault + stopPropagation on anchor / handle hits so
the underlying layer-drag handler doesn't also fire.
---
src/index.html | 55 ++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 53 insertions(+), 2 deletions(-)
diff --git a/src/index.html b/src/index.html
index e87dc08..cecb5f1 100644
--- a/src/index.html
+++ b/src/index.html
@@ -9485,7 +9485,14 @@
function syncPenOverlay() {
if (_penOverlay && _penOverlay.parentNode) _penOverlay.parentNode.removeChild(_penOverlay);
_penOverlay = null;
- if (_editorTool === 'select') { document.body.classList.remove('tool-drawing'); return; }
+ if (_editorTool === 'select') {
+ document.body.classList.remove('tool-drawing');
+ // Skip creating the overlay unless a pen-shape layer is
+ // selected. With one selected, the overlay renders anchor /
+ // handle glyphs so the user can drag-edit or right-click
+ // delete without switching to the pen tool.
+ if (!getEditablePenLayer()) return;
+ }
const host = currentCanvasHost();
if (!host) return;
const dims = currentCanvasDims();
@@ -9649,6 +9656,24 @@
}
function onPenPointerDown(evt) {
+ // In select mode the overlay base has pointer-events:none and
+ // events only reach this handler via anchor/handle elements
+ // with pointer-events:all. The pen-only branches (pencil /
+ // hand / shape / text / new-path) shouldn't run in select mode.
+ if (_editorTool === 'select') {
+ const editLayer = getEditablePenLayer();
+ if (!editLayer) return;
+ const hit = hitTestPenEdit(editLayer, evt);
+ if (!hit || hit.part === 'segment') return; // pass empty/segment clicks through to layer
+ evt.preventDefault();
+ evt.stopPropagation();
+ if (hit.part === 'anchor' && evt.altKey) {
+ togglePenAnchorKind(editLayer, hit.nodeIndex);
+ return;
+ }
+ startPenEditDrag(editLayer, hit, evt);
+ return;
+ }
evt.preventDefault();
if (_editorTool === 'pencil') { startPencilStroke(evt); return; }
if (_editorTool === 'hand') { startHandPan(evt); return; }
@@ -9818,7 +9843,13 @@
// ---- Phase D: editing an existing pen path's anchors / handles ----
function getEditablePenLayer() {
- if (_editorTool !== 'pen') return null;
+ // Anchors are exposed for editing in BOTH pen mode and select
+ // mode — select-mode editing lets the user drag / delete an
+ // anchor without having to switch tools first. Pen mode keeps
+ // its richer affordances (segment-click to insert anchor,
+ // Alt-click to toggle corner/smooth) since those would conflict
+ // with the select tool's normal layer-click behavior.
+ if (_editorTool !== 'pen' && _editorTool !== 'select') return null;
const layer = selectedLayer();
if (!layer || layer.type !== 'shape') return null;
const sh = layer.shape;
@@ -10134,6 +10165,11 @@
const lineW = unit * 0.0018;
const dotR = unit * 0.0075;
const nodes = layer.shape.pen.nodes;
+ // In select mode the overlay base passes pointer events through
+ // to the layer underneath (so clicks on empty canvas still
+ // select layers). Anchors / handles get explicit pointer-events
+ // to ensure they capture their own clicks regardless of the
+ // overlay-level setting.
nodes.forEach((n, i) => {
const a = penNodeToInch(layer, n);
for (const part of ['hIn', 'hOut']) {
@@ -10144,11 +10180,14 @@
hl.setAttribute('x2', h.x); hl.setAttribute('y2', h.y);
hl.setAttribute('stroke', '#3b82f6');
hl.setAttribute('stroke-width', String(lineW));
+ hl.setAttribute('pointer-events', 'none');
_penOverlay.appendChild(hl);
const hd = document.createElementNS(ns, 'circle');
hd.setAttribute('cx', h.x); hd.setAttribute('cy', h.y);
hd.setAttribute('r', String(dotR * 0.62));
hd.setAttribute('fill', '#3b82f6');
+ hd.setAttribute('pointer-events', 'all');
+ hd.style.cursor = 'pointer';
_penOverlay.appendChild(hd);
}
// Anchor glyph: smooth = circle, corner = square. Selected
@@ -10163,6 +10202,8 @@
dot.setAttribute('fill', '#ffffff');
dot.setAttribute('stroke', stroke);
dot.setAttribute('stroke-width', String(lineW * (isSelected ? 2.4 : 1.6)));
+ dot.setAttribute('pointer-events', 'all');
+ dot.style.cursor = 'pointer';
_penOverlay.appendChild(dot);
} else {
const sq = document.createElementNS(ns, 'rect');
@@ -10174,6 +10215,8 @@
sq.setAttribute('fill', '#ffffff');
sq.setAttribute('stroke', stroke);
sq.setAttribute('stroke-width', String(lineW * (isSelected ? 2.4 : 1.6)));
+ sq.setAttribute('pointer-events', 'all');
+ sq.style.cursor = 'pointer';
_penOverlay.appendChild(sq);
}
});
@@ -10669,6 +10712,14 @@
// After updateBodyClasses so the designer view is displayed/laid out —
// the disc image's cover-pan math needs the wrapper's real size.
renderDesignerDisc();
+ // In select mode the overlay is conditionally present based on
+ // whether an editable pen layer is selected — re-sync each
+ // render so the overlay appears / disappears with selection
+ // changes. Cheap (a single removeChild + recreate when
+ // needed). In tool-drawing modes setEditorTool already created
+ // the overlay; this is a no-op idempotent refresh.
+ const wantsOverlayNow = _editorTool !== 'select' || !!getEditablePenLayer();
+ if (wantsOverlayNow !== !!_penOverlay) syncPenOverlay();
refreshPenOverlayGeometry();
refreshPenOverlayContent();
if (_editorTool !== 'select') syncToolOptionInputs();
From c73aab2c209fb5d1cdf08d1e9e7338ca5181ee10 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:19:26 -0400
Subject: [PATCH 06/23] pen tool: Illustrator parity foundation (Direct Select
+ cursor + Shift constrain)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- New Direct Selection tool (A) as a separate palette button, distinct
from the regular Select tool. Anchors and handles only appear when
Pen or Direct Select is the effective tool, matching Illustrator's
V/A split.
- Cmd/Ctrl temporary toggle: holding the modifier from any non-Pen tool
flips the effective tool to Direct Selection so a quick anchor tweak
doesn't require dropping the current tool; release restores it.
- Hover cursor feedback on pen/direct-select: shows + when over the
end of an open path, - over an existing anchor (delete-adjacent),
✕ near the start to close, default crosshair otherwise.
- Shift constrains pen segments to 0/45/90 degrees while drawing and
while previewing the next click, matching Illustrator's constrain.
Foundation for the multi-anchor select + drag-segment + continue-path
endpoint work that follows.
---
src/index.html | 182 ++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 148 insertions(+), 34 deletions(-)
diff --git a/src/index.html b/src/index.html
index cecb5f1..c96d74a 100644
--- a/src/index.html
+++ b/src/index.html
@@ -4031,7 +4031,8 @@
-
+
+
@@ -9316,7 +9317,7 @@
const PEN_SEGMENT_HIT_TOL = 0.012; // segment-click tolerance (fraction of canvas unit) for insert-anchor
const PEN_MIN_DIST = 0.012; // min inch gap between pencil points
const PEN_CLOSE_HIT = 0.09; // inch radius to "click the first anchor"
- const EDITOR_TOOLS = ['select', 'hand', 'pen', 'pencil', 'shape', 'text'];
+ const EDITOR_TOOLS = ['select', 'direct-select', 'hand', 'pen', 'pencil', 'shape', 'text'];
// Tools with a contextual right-side options panel.
const EDITOR_TOOLS_WITH_PANEL = ['pen', 'pencil', 'shape', 'text'];
// Shape kinds the Shape tool can draw (excludes uploaded 'path'/'svg').
@@ -9367,8 +9368,13 @@
const btn = $(`tool-${t}`);
if (btn) btn.classList.toggle('active', t === _editorTool);
}
- const drawing = _editorTool !== 'select';
+ // "drawing" mode = the cursor is on the canvas overlay doing
+ // something other than layer-level selection. Direct Selection
+ // is layer-aware too (passes empty clicks through), so it
+ // doesn't claim the drawing class either.
+ const drawing = _editorTool !== 'select' && _editorTool !== 'direct-select';
document.body.classList.toggle('tool-drawing', drawing);
+ document.body.classList.toggle('tool-direct-select', _editorTool === 'direct-select');
document.body.classList.toggle('tool-hand', _editorTool === 'hand');
document.body.classList.toggle('tool-text', _editorTool === 'text');
if (_editorTool !== 'hand') document.body.classList.remove('tool-panning');
@@ -9485,12 +9491,19 @@
function syncPenOverlay() {
if (_penOverlay && _penOverlay.parentNode) _penOverlay.parentNode.removeChild(_penOverlay);
_penOverlay = null;
- if (_editorTool === 'select') {
+ const t = effectiveEditorTool();
+ // V tool stays layer-only — no anchor editing — to match
+ // Illustrator's V vs A split. Direct Selection (A) and Pen (P)
+ // are the anchor-editing tools.
+ if (t === 'select') {
document.body.classList.remove('tool-drawing');
- // Skip creating the overlay unless a pen-shape layer is
- // selected. With one selected, the overlay renders anchor /
- // handle glyphs so the user can drag-edit or right-click
- // delete without switching to the pen tool.
+ return;
+ }
+ if (t === 'direct-select') {
+ document.body.classList.remove('tool-drawing');
+ // Skip overlay unless a pen-shape layer is selected — the
+ // overlay renders anchor / handle glyphs for drag-edit /
+ // right-click delete. Falls through to creation below.
if (!getEditablePenLayer()) return;
}
const host = currentCanvasHost();
@@ -9612,11 +9625,15 @@
// ---- Pen (Bézier path authoring) ----
function penPointerDown(evt) {
- const p = overlayPointToInches(evt);
+ let p = overlayPointToInches(evt);
if (!p) return;
if (!_penDraft || _penDraft.tool !== 'pen') {
_penDraft = { tool: 'pen', nodes: [], cursor: p };
}
+ // Shift while clicking → constrain the new anchor to a
+ // 45°-multiple angle from the previous one. Same snap math
+ // as the hover preview so what-you-see-is-what-you-place.
+ p = _maybeConstrainAngle(p, _penDraft.nodes, evt.shiftKey);
// Clicking near the first node closes the path.
if (_penDraft.nodes.length >= 2) {
const first = _penDraft.nodes[0];
@@ -9656,15 +9673,17 @@
}
function onPenPointerDown(evt) {
- // In select mode the overlay base has pointer-events:none and
- // events only reach this handler via anchor/handle elements
- // with pointer-events:all. The pen-only branches (pencil /
- // hand / shape / text / new-path) shouldn't run in select mode.
- if (_editorTool === 'select') {
+ const t = effectiveEditorTool();
+ // Direct Selection (and the Cmd/Ctrl temporary toggle): the
+ // overlay base has pointer-events:none, so events only reach
+ // this handler when an anchor or handle is clicked. Segment
+ // clicks fall through to the layer; pen tool's segment-click
+ // insert lives below.
+ if (t === 'direct-select') {
const editLayer = getEditablePenLayer();
if (!editLayer) return;
const hit = hitTestPenEdit(editLayer, evt);
- if (!hit || hit.part === 'segment') return; // pass empty/segment clicks through to layer
+ if (!hit || hit.part === 'segment') return; // pass through to layer
evt.preventDefault();
evt.stopPropagation();
if (hit.part === 'anchor' && evt.altKey) {
@@ -9843,13 +9862,13 @@
// ---- Phase D: editing an existing pen path's anchors / handles ----
function getEditablePenLayer() {
- // Anchors are exposed for editing in BOTH pen mode and select
- // mode — select-mode editing lets the user drag / delete an
- // anchor without having to switch tools first. Pen mode keeps
- // its richer affordances (segment-click to insert anchor,
- // Alt-click to toggle corner/smooth) since those would conflict
- // with the select tool's normal layer-click behavior.
- if (_editorTool !== 'pen' && _editorTool !== 'select') return null;
+ // Anchor editing happens in Pen mode and Direct Selection mode
+ // (and during a Cmd/Ctrl temporary toggle from any tool — see
+ // _penTempDirectSelect). The plain Select tool stays
+ // layer-only to match Illustrator's V/A split, so users get a
+ // predictable mental model: V = whole layer, A = anchors.
+ const t = effectiveEditorTool();
+ if (t !== 'pen' && t !== 'direct-select') return null;
const layer = selectedLayer();
if (!layer || layer.type !== 'shape') return null;
const sh = layer.shape;
@@ -9859,6 +9878,15 @@
return layer;
}
+ // The "effective" tool may differ from _editorTool when the user
+ // is holding Cmd/Ctrl — Illustrator's convention temporarily
+ // promotes any tool into Direct Selection so you can tweak an
+ // anchor without dropping the current tool.
+ let _penTempDirectSelect = false;
+ function effectiveEditorTool() {
+ return _penTempDirectSelect ? 'direct-select' : _editorTool;
+ }
+
// Pen nodes live in a pixel-space viewBox (1 unit = 1px @ 96dpi).
// Layer dimensions are inches; map nodes ↔ canvas inches via /96.
// The viewBox dims (layer.w * 96, layer.h * 96) match the layer's
@@ -10509,11 +10537,74 @@
}
function onPenPointerHover(evt) {
- if (_editorTool !== 'pen' || !_penDraft || _penDraft.tool !== 'pen') return;
- const p = overlayPointToInches(evt);
- if (!p) return;
- _penDraft.cursor = p;
- renderPenDraft();
+ const t = effectiveEditorTool();
+ // Cursor feedback: hovering over an editable pen layer's
+ // anchors / segments / endpoints changes the pen cursor to
+ // hint what a click will do (Illustrator parity — −, +,
+ // continue-from-endpoint hints).
+ if (t === 'pen' || t === 'direct-select') {
+ _updatePenHoverCursor(evt);
+ }
+ // Live cursor preview during in-progress draft (with optional
+ // Shift-constrain snap to the last anchor).
+ if (_editorTool === 'pen' && _penDraft && _penDraft.tool === 'pen') {
+ const p = overlayPointToInches(evt);
+ if (!p) return;
+ _penDraft.cursor = _maybeConstrainAngle(p, _penDraft.nodes, evt.shiftKey);
+ renderPenDraft();
+ }
+ }
+
+ // Snap an in-progress cursor position to a 45°-multiple angle
+ // from the previous anchor when Shift is held. No previous
+ // anchor → return the point unchanged. Matches Illustrator's
+ // Shift-constrain behavior while drawing.
+ function _maybeConstrainAngle(pt, nodes, shiftHeld) {
+ if (!shiftHeld || !nodes?.length) return pt;
+ const a = nodes[nodes.length - 1];
+ const dx = pt.x - a.x, dy = pt.y - a.y;
+ const ang = Math.atan2(dy, dx);
+ const step = Math.PI / 4; // 45°
+ const snapped = Math.round(ang / step) * step;
+ const len = Math.hypot(dx, dy);
+ return { x: a.x + Math.cos(snapped) * len, y: a.y + Math.sin(snapped) * len };
+ }
+
+ // Pen / Direct Selection hover cursor. Inspects the hit-test
+ // and picks a cursor that matches Illustrator's pen sub-modes:
+ // anchor → "delete" (−)
+ // handle → "move"
+ // segment → "add anchor" (+) — pen tool only; DSel ignores
+ // open-end → "continue path" — pen tool only
+ // else → default (crosshair / move)
+ function _updatePenHoverCursor(evt) {
+ if (!_penOverlay) return;
+ const editLayer = getEditablePenLayer();
+ const t = effectiveEditorTool();
+ let cursor = (t === 'pen') ? 'crosshair' : 'default';
+ if (editLayer) {
+ const hit = hitTestPenEdit(editLayer, evt);
+ if (hit) {
+ if (hit.part === 'anchor') {
+ // Pen tool's "click anchor" deletes; DSel's "click anchor" selects/drags.
+ cursor = (t === 'pen') ? 'crosshair' : 'pointer';
+ // Endpoint of an open path → "continue from here" affordance
+ // in pen mode (we won't wire the continue behavior in this
+ // commit, but the cursor cue is what makes the affordance
+ // discoverable; the actual resume-drawing logic lands in
+ // the polish commit).
+ if (t === 'pen' && !editLayer.shape.pen.closed) {
+ const lastIdx = editLayer.shape.pen.nodes.length - 1;
+ if (hit.nodeIndex === 0 || hit.nodeIndex === lastIdx) cursor = 'pointer';
+ }
+ } else if (hit.part === 'hIn' || hit.part === 'hOut') {
+ cursor = 'move';
+ } else if (hit.part === 'segment') {
+ cursor = (t === 'pen') ? 'copy' : 'default'; // 'copy' is browsers' "+"-style cursor
+ }
+ }
+ }
+ _penOverlay.style.cursor = cursor;
}
// Build an SVG path d-string from pen nodes. `coords` are taken as-is;
@@ -10712,13 +10803,14 @@
// After updateBodyClasses so the designer view is displayed/laid out —
// the disc image's cover-pan math needs the wrapper's real size.
renderDesignerDisc();
- // In select mode the overlay is conditionally present based on
- // whether an editable pen layer is selected — re-sync each
- // render so the overlay appears / disappears with selection
- // changes. Cheap (a single removeChild + recreate when
- // needed). In tool-drawing modes setEditorTool already created
- // the overlay; this is a no-op idempotent refresh.
- const wantsOverlayNow = _editorTool !== 'select' || !!getEditablePenLayer();
+ // Direct Selection (A) shows the overlay only when an editable
+ // pen layer is selected; the V tool never shows the overlay
+ // (it stays layer-level per Illustrator's V/A split); the
+ // drawing tools always have it. Re-sync each render so the
+ // overlay appears / disappears as selection changes without
+ // requiring a tool change.
+ const tEff = effectiveEditorTool();
+ const wantsOverlayNow = tEff !== 'select' && (tEff !== 'direct-select' || !!getEditablePenLayer());
if (wantsOverlayNow !== !!_penOverlay) syncPenOverlay();
refreshPenOverlayGeometry();
refreshPenOverlayContent();
@@ -29405,6 +29497,27 @@
});
syncToolUI();
+ // Cmd/Ctrl temporary toggle to Direct Selection (Illustrator
+ // parity). While held, the effective tool flips to A so users
+ // can tweak an anchor without dropping the current tool;
+ // release returns to whatever was active. We flip a sentinel
+ // and re-sync the overlay so anchor handles appear / vanish.
+ const onModifierToggle = (held) => {
+ if (held === _penTempDirectSelect) return;
+ _penTempDirectSelect = held;
+ syncPenOverlay();
+ if (_penOverlay) refreshPenOverlayContent();
+ };
+ document.addEventListener('keydown', (e) => {
+ if ((e.key === 'Meta' || e.key === 'Control') && !isEditableFocus() && _editorTool !== 'pen') {
+ onModifierToggle(true);
+ }
+ });
+ document.addEventListener('keyup', (e) => {
+ if (e.key === 'Meta' || e.key === 'Control') onModifierToggle(false);
+ });
+ window.addEventListener('blur', () => onModifierToggle(false));
+
// Global undo/redo keyboard shortcuts. Bypass when an editable
// element is focused so the browser's native text-input undo runs.
document.addEventListener('keydown', (e) => {
@@ -29430,6 +29543,7 @@
// Tool shortcuts + layer keys — only when not typing and no modifier held.
if (!mod && !isEditableFocus()) {
if (k === 'v') { setEditorTool('select'); return; }
+ if (k === 'a') { setEditorTool('direct-select'); return; }
if (k === 'h') { setEditorTool('hand'); return; }
if (k === 'p') { setEditorTool('pen'); return; }
if (k === 'b') { setEditorTool('pencil'); return; }
From 547ff5d1c62794281fba7cd4983fb1bce53891a8 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:24:24 -0400
Subject: [PATCH 07/23] pen tool: Illustrator parity polish (multi-anchor +
drag-segment + marquee)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Multi-anchor selection. Shift-click an anchor to add/remove it from
the selection set; click without modifier selects just that anchor.
Dragging any selected anchor moves the whole set rigidly (anchors +
handles), so reshape operations affect groups in one gesture.
- Arrow keys nudge selected anchors. Direct Selection (or Pen) with at
least one anchor in the selection: ←/→/↑/↓ moves the anchors and
their handles by 1px (10px with Shift) in pixel-space; bbox refits
automatically. Falls back to the existing layer-nudge when no anchor
is selected.
- Marquee anchor box-select. In Direct Selection, drag from empty
space inside the layer to draw a dashed rectangle; on release, every
anchor whose center lies inside the box is selected. Shift+drag adds
to the existing selection.
- Drag a curve segment to bow it. Equal-split delta distributed
across the two inner control points so the cursor tracks the grabbed
point; smooth anchors on either end keep their handles mirrored. Lines
are auto-promoted to cubics on first bow.
- Alt-click a segment in Direct Selection now inserts an anchor at the
click (mirrors the Pen tool's segment-insert), and Alt-click an
anchor still toggles corner/smooth.
- Backspace deletes every selected anchor at once (bulk delete), with
the "path needs at least two anchors" guard applied to the resulting
size.
---
src/index.html | 250 +++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 232 insertions(+), 18 deletions(-)
diff --git a/src/index.html b/src/index.html
index c96d74a..2c6dccd 100644
--- a/src/index.html
+++ b/src/index.html
@@ -2495,6 +2495,9 @@
.toolOptHint { font-size:.7rem; color:var(--muted); margin:4px 0 0; line-height:1.35; }
.penOverlay { position:absolute; inset:0; z-index:998; pointer-events:none; }
body.tool-drawing .penOverlay { pointer-events:auto; cursor:crosshair; }
+ /* Direct Selection captures segment + marquee drags too, but
+ individual anchor / handle dots still set their own cursors. */
+ body.tool-direct-select .penOverlay { pointer-events:auto; cursor:default; }
body.tool-drawing .freeLayer { pointer-events:none !important; }
body.tool-hand .penOverlay { cursor:grab; }
body.tool-panning .penOverlay { cursor:grabbing; }
@@ -9313,7 +9316,23 @@
let _editorTool = 'select';
let _penOverlay = null; // active SVG overlay element
let _penDraft = null; // in-progress drawing state
- let _penSelectedAnchor = -1; // index of currently-selected anchor on the editable layer, or -1
+ let _penSelectedAnchor = -1; // primary (last-clicked) anchor index, or -1. Mirrors max of _penSelectedAnchors.
+ const _penSelectedAnchors = new Set(); // Set of indices for multi-anchor selection (Shift-click, marquee).
+ function setPenAnchorSelection(idx, additive) {
+ if (idx == null || idx < 0) {
+ if (!additive) _penSelectedAnchors.clear();
+ _penSelectedAnchor = _penSelectedAnchors.size ? Math.max(..._penSelectedAnchors) : -1;
+ return;
+ }
+ if (additive) {
+ if (_penSelectedAnchors.has(idx)) _penSelectedAnchors.delete(idx);
+ else _penSelectedAnchors.add(idx);
+ } else {
+ _penSelectedAnchors.clear();
+ _penSelectedAnchors.add(idx);
+ }
+ _penSelectedAnchor = _penSelectedAnchors.size ? (additive ? idx : idx) : -1;
+ }
const PEN_SEGMENT_HIT_TOL = 0.012; // segment-click tolerance (fraction of canvas unit) for insert-anchor
const PEN_MIN_DIST = 0.012; // min inch gap between pencil points
const PEN_CLOSE_HIT = 0.09; // inch radius to "click the first anchor"
@@ -9358,6 +9377,7 @@
if (_editorTool === tool) return;
cancelPenDraft();
_penSelectedAnchor = -1;
+ _penSelectedAnchors.clear();
_editorTool = tool;
syncToolUI();
syncPenOverlay();
@@ -9683,9 +9703,28 @@
const editLayer = getEditablePenLayer();
if (!editLayer) return;
const hit = hitTestPenEdit(editLayer, evt);
- if (!hit || hit.part === 'segment') return; // pass through to layer
+ if (!hit) {
+ // Empty miss: clear anchor selection + start a marquee
+ // box-select over anchors of the editable layer.
+ if (!evt.shiftKey) setPenAnchorSelection(-1, false);
+ startPenAnchorMarquee(editLayer, evt);
+ refreshPenOverlayContent();
+ return;
+ }
evt.preventDefault();
evt.stopPropagation();
+ if (hit.part === 'segment') {
+ if (evt.altKey) {
+ // Alt-click a segment inserts a new anchor at the hit
+ // point and starts dragging it — same as the Pen tool's
+ // segment-click behavior.
+ insertPenAnchorAt(editLayer, hit.segIndex, hit.t);
+ startPenEditDrag(editLayer, { nodeIndex: hit.segIndex + 1, part: 'anchor' }, evt);
+ return;
+ }
+ startPenSegmentBowDrag(editLayer, hit, evt);
+ return;
+ }
if (hit.part === 'anchor' && evt.altKey) {
togglePenAnchorKind(editLayer, hit.nodeIndex);
return;
@@ -10007,17 +10046,55 @@
pushHistory('edit pen path');
try { _penOverlay.setPointerCapture(evt.pointerId); } catch {}
const node = layer.shape.pen.nodes[hit.nodeIndex];
- _penSelectedAnchor = hit.nodeIndex;
+ // Anchor click: Shift-click toggles into the multi-selection;
+ // plain click resets to just this anchor. Handle clicks don't
+ // change selection (they only edit that handle's tangent).
+ if (hit.part === 'anchor') {
+ const shift = !!evt.shiftKey;
+ if (shift && _penSelectedAnchors.has(hit.nodeIndex)) {
+ // Already in set + Shift held → drag the whole group but
+ // don't drop this anchor out of the selection until pointer
+ // up with no movement.
+ } else {
+ setPenAnchorSelection(hit.nodeIndex, shift);
+ }
+ } else {
+ // For handle drags, ensure the parent anchor is at least the
+ // primary so the highlight is consistent with what's editing.
+ if (!_penSelectedAnchors.has(hit.nodeIndex)) {
+ setPenAnchorSelection(hit.nodeIndex, false);
+ } else {
+ _penSelectedAnchor = hit.nodeIndex;
+ }
+ }
+ // Snapshot pre-drag positions for every selected anchor so the
+ // whole selection moves rigidly together (anchors + handles).
+ const groupIdx = (hit.part === 'anchor') ? Array.from(_penSelectedAnchors) : [hit.nodeIndex];
+ const startPositions = groupIdx.map(i => {
+ const nn = layer.shape.pen.nodes[i];
+ return {
+ i,
+ x: nn.x, y: nn.y,
+ hIn: nn.hIn ? { x: nn.hIn.x, y: nn.hIn.y } : null,
+ hOut: nn.hOut ? { x: nn.hOut.x, y: nn.hOut.y } : null,
+ };
+ });
const move = (e) => {
const pt = overlayPointToInches(e);
if (!pt) return;
const local = inchToPenNode(layer, pt);
if (hit.part === 'anchor') {
- // Move the anchor; carry its handles along by the same delta.
- const dx = local.x - node.x, dy = local.y - node.y;
- node.x = local.x; node.y = local.y;
- if (node.hIn) { node.hIn.x += dx; node.hIn.y += dy; }
- if (node.hOut) { node.hOut.x += dx; node.hOut.y += dy; }
+ // Compute delta from the primary anchor's start position,
+ // then apply uniformly to every anchor in the selection.
+ const primary = startPositions.find(p => p.i === hit.nodeIndex) || startPositions[0];
+ const dx = local.x - primary.x;
+ const dy = local.y - primary.y;
+ for (const snap of startPositions) {
+ const nn = layer.shape.pen.nodes[snap.i];
+ nn.x = snap.x + dx; nn.y = snap.y + dy;
+ if (nn.hIn && snap.hIn) { nn.hIn.x = snap.hIn.x + dx; nn.hIn.y = snap.hIn.y + dy; }
+ if (nn.hOut && snap.hOut) { nn.hOut.x = snap.hOut.x + dx; nn.hOut.y = snap.hOut.y + dy; }
+ }
} else {
node[hit.part] = { x: local.x, y: local.y };
// Mirror the opposite handle if the anchor is smooth, so
@@ -10048,6 +10125,111 @@
_penOverlay.addEventListener('pointercancel', up);
}
+ // Drag a curve segment body to bow it. Distributes the drag
+ // delta equally across the two inner control points (a.hOut /
+ // b.hIn) so the curve at parameter t tracks the cursor. Lines
+ // (no handles) are promoted to a cubic on the fly so dragging
+ // works the same way. Adjacent anchors stay put — only their
+ // handles move. Anchors of kind 'smooth' on either end get their
+ // opposite handles re-mirrored so smoothness is preserved.
+ function startPenSegmentBowDrag(layer, hit, evt) {
+ pushHistory('bow pen segment');
+ try { _penOverlay.setPointerCapture(evt.pointerId); } catch {}
+ const nodes = layer.shape.pen.nodes;
+ const aIdx = hit.segIndex;
+ const bIdx = (hit.segIndex + 1) % nodes.length;
+ const a = nodes[aIdx], b = nodes[bIdx];
+ // Promote straight line to cubic: seed handles 1/3 and 2/3 along the line.
+ if (!a.hOut) a.hOut = { x: a.x + (b.x - a.x) / 3, y: a.y + (b.y - a.y) / 3 };
+ if (!b.hIn) b.hIn = { x: b.x - (b.x - a.x) / 3, y: b.y - (b.y - a.y) / 3 };
+ const t = Math.min(0.9, Math.max(0.1, hit.t));
+ const u = 1 - t;
+ // Equal-shift split: ΔC1 = ΔC2 = D / (3 u t). With both control
+ // points moved by Δ, the cubic at parameter t moves by 3 u t Δ,
+ // so the cursor tracks the curve at the grabbed point.
+ const k = 1 / (3 * u * t);
+ const startA = { hOut: { ...a.hOut } };
+ const startB = { hIn: { ...b.hIn } };
+ const startPt = overlayPointToInches(evt);
+ if (!startPt) return;
+ const startLocal = inchToPenNode(layer, startPt);
+ const move = (e) => {
+ const pt = overlayPointToInches(e);
+ if (!pt) return;
+ const local = inchToPenNode(layer, pt);
+ const dx = (local.x - startLocal.x) * k;
+ const dy = (local.y - startLocal.y) * k;
+ a.hOut = { x: startA.hOut.x + dx, y: startA.hOut.y + dy };
+ b.hIn = { x: startB.hIn.x + dx, y: startB.hIn.y + dy };
+ // Mirror opposite handles for smooth anchors so curves stay continuous.
+ if (a.kind === 'smooth' && a.hIn) a.hIn = { x: 2 * a.x - a.hOut.x, y: 2 * a.y - a.hOut.y };
+ if (b.kind === 'smooth' && b.hOut) b.hOut = { x: 2 * b.x - b.hIn.x, y: 2 * b.y - b.hIn.y };
+ refitPenLayerIfNeeded(layer);
+ regenPenLayerD(layer);
+ renderLayers();
+ renderPenEditHandles(layer);
+ };
+ const up = () => {
+ _penOverlay.removeEventListener('pointermove', move);
+ _penOverlay.removeEventListener('pointerup', up);
+ _penOverlay.removeEventListener('pointercancel', up);
+ render();
+ };
+ _penOverlay.addEventListener('pointermove', move);
+ _penOverlay.addEventListener('pointerup', up);
+ _penOverlay.addEventListener('pointercancel', up);
+ }
+
+ // Marquee box-select over anchors. Drag from empty space inside
+ // the overlay to draw a dashed rectangle; on pointer-up any anchor
+ // whose center falls inside the box is added to (or replaces, if
+ // Shift wasn't held at start) the selection.
+ function startPenAnchorMarquee(layer, evt) {
+ const startPt = overlayPointToInches(evt);
+ if (!startPt) return;
+ try { _penOverlay.setPointerCapture(evt.pointerId); } catch {}
+ const additive = !!evt.shiftKey;
+ const ns = 'http://www.w3.org/2000/svg';
+ const dims = currentCanvasDims();
+ const lw = Math.max(dims.w, dims.h) * 0.0018;
+ const rect = document.createElementNS(ns, 'rect');
+ rect.setAttribute('fill', 'rgba(59,130,246,0.10)');
+ rect.setAttribute('stroke', '#3b82f6');
+ rect.setAttribute('stroke-width', String(lw));
+ rect.setAttribute('stroke-dasharray', `${lw * 3} ${lw * 2}`);
+ rect.setAttribute('pointer-events', 'none');
+ _penOverlay.appendChild(rect);
+ let cur = startPt;
+ const draw = () => {
+ const x = Math.min(startPt.x, cur.x);
+ const y = Math.min(startPt.y, cur.y);
+ rect.setAttribute('x', x);
+ rect.setAttribute('y', y);
+ rect.setAttribute('width', Math.abs(cur.x - startPt.x));
+ rect.setAttribute('height', Math.abs(cur.y - startPt.y));
+ };
+ draw();
+ const move = (e) => { cur = overlayPointToInches(e) || cur; draw(); };
+ const up = () => {
+ _penOverlay.removeEventListener('pointermove', move);
+ _penOverlay.removeEventListener('pointerup', up);
+ _penOverlay.removeEventListener('pointercancel', up);
+ const x0 = Math.min(startPt.x, cur.x), x1 = Math.max(startPt.x, cur.x);
+ const y0 = Math.min(startPt.y, cur.y), y1 = Math.max(startPt.y, cur.y);
+ const nodes = layer.shape.pen.nodes;
+ if (!additive) _penSelectedAnchors.clear();
+ for (let i = 0; i < nodes.length; i++) {
+ const ap = penNodeToInch(layer, nodes[i]);
+ if (ap.x >= x0 && ap.x <= x1 && ap.y >= y0 && ap.y <= y1) _penSelectedAnchors.add(i);
+ }
+ _penSelectedAnchor = _penSelectedAnchors.size ? Math.max(..._penSelectedAnchors) : -1;
+ refreshPenOverlayContent();
+ };
+ _penOverlay.addEventListener('pointermove', move);
+ _penOverlay.addEventListener('pointerup', up);
+ _penOverlay.addEventListener('pointercancel', up);
+ }
+
// When an edit drag pushes any node or handle outside the
// 0–100 viewBox the path renders clipped at the layer's
// bounding rect. Detect that and re-normalize: compute the
@@ -10121,7 +10303,7 @@
kind: 'smooth',
});
}
- _penSelectedAnchor = segIndex + 1;
+ setPenAnchorSelection(segIndex + 1, false);
regenPenLayerD(layer);
render();
}
@@ -10131,16 +10313,23 @@
// node; if it was an endpoint, the path stays open with the
// new endpoint being the prior next-inward node.
function deletePenAnchor(layer, idx) {
+ deletePenAnchors(layer, [idx]);
+ }
+
+ function deletePenAnchors(layer, idxList) {
const nodes = layer.shape.pen.nodes;
- if (idx < 0 || idx >= nodes.length) return;
- // A path needs at least 2 nodes to survive (one node is just a point).
- if (nodes.length <= 2) {
+ const sorted = Array.from(new Set(idxList))
+ .filter(i => i >= 0 && i < nodes.length)
+ .sort((a, b) => b - a);
+ if (!sorted.length) return;
+ // A path needs at least 2 nodes to survive.
+ if (nodes.length - sorted.length < 2) {
toastError?.('Path needs at least two anchors. Delete the layer instead.');
return;
}
- pushHistory('delete pen anchor');
- nodes.splice(idx, 1);
- _penSelectedAnchor = -1;
+ pushHistory(sorted.length > 1 ? 'delete pen anchors' : 'delete pen anchor');
+ for (const i of sorted) nodes.splice(i, 1);
+ setPenAnchorSelection(-1, false);
regenPenLayerD(layer);
render();
}
@@ -10221,7 +10410,7 @@
// Anchor glyph: smooth = circle, corner = square. Selected
// gets a thicker stroke + accent color so the active anchor
// is obvious for Backspace-to-delete.
- const isSelected = i === _penSelectedAnchor;
+ const isSelected = _penSelectedAnchors.has(i);
const stroke = isSelected ? '#f97316' : '#3b82f6';
if (n.kind === 'smooth') {
const dot = document.createElementNS(ns, 'circle');
@@ -29556,15 +29745,40 @@
// not the whole layer. Keeps Backspace the standard
// "delete the selected thing" key in both modes.
const penEdit = getEditablePenLayer?.();
- if (penEdit && _penSelectedAnchor >= 0) {
+ if (penEdit && _penSelectedAnchors.size > 0) {
e.preventDefault();
- deletePenAnchor(penEdit, _penSelectedAnchor);
+ deletePenAnchors(penEdit, Array.from(_penSelectedAnchors));
return;
}
if (selectedLayerIds().length) { e.preventDefault(); deleteSelectedLayer(); }
return;
}
const layer = selectedLayer();
+ // Direct Selection / Pen with a multi-anchor selection: arrows
+ // nudge the anchors (and their handles) rather than the
+ // whole layer. Illustrator parity.
+ const penEdit = getEditablePenLayer?.();
+ if (penEdit && _penSelectedAnchors.size > 0 && k.startsWith('arrow')) {
+ e.preventDefault();
+ pushHistoryDebounced('nudgePenAnchor', 'nudge anchors');
+ const stepPx = e.shiftKey ? 10 : 1; // pixel-space (1 unit = 1px @ 96dpi)
+ let dx = 0, dy = 0;
+ if (k === 'arrowup') dy = -stepPx;
+ else if (k === 'arrowdown') dy = stepPx;
+ else if (k === 'arrowleft') dx = -stepPx;
+ else if (k === 'arrowright') dx = stepPx;
+ const nodes = penEdit.shape.pen.nodes;
+ for (const i of _penSelectedAnchors) {
+ const n = nodes[i]; if (!n) continue;
+ n.x += dx; n.y += dy;
+ if (n.hIn) { n.hIn.x += dx; n.hIn.y += dy; }
+ if (n.hOut) { n.hOut.x += dx; n.hOut.y += dy; }
+ }
+ refitPenLayerIfNeeded(penEdit);
+ regenPenLayerD(penEdit);
+ render();
+ return;
+ }
if (layer && k.startsWith('arrow')) {
e.preventDefault();
if (!layer.locked) {
From 1d64306a33d86125025927f19b63e25c7a7eca83 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:29:03 -0400
Subject: [PATCH 08/23] pen tool: continue an open path from its endpoint
(Illustrator parity)
With the Pen tool, clicking an endpoint of an open path resumes
drawing from that anchor. The source path is hidden for the duration
of the draft; on commit (Enter / click-back-to-start / Escape) the
old layer is dropped and the new (longer) path takes its place,
preserving the original z-order, target, fill and stroke. Clicking
the start anchor reverses the node order so the next click extends
in the natural direction. Cancelling the draft restores the source
path unchanged.
---
src/index.html | 108 +++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 95 insertions(+), 13 deletions(-)
diff --git a/src/index.html b/src/index.html
index 2c6dccd..7d8a8d2 100644
--- a/src/index.html
+++ b/src/index.html
@@ -9759,6 +9759,16 @@
startPenEditDrag(editLayer, { nodeIndex: hit.segIndex + 1, part: 'anchor' }, evt);
return;
}
+ // Pen tool + plain click on an endpoint of an open path →
+ // resume drawing from that endpoint. Illustrator parity.
+ if (hit.part === 'anchor' && !evt.shiftKey && !evt.altKey
+ && editLayer.shape?.pen?.closed === false) {
+ const last = editLayer.shape.pen.nodes.length - 1;
+ if (hit.nodeIndex === 0 || hit.nodeIndex === last) {
+ startPenContinuation(editLayer, hit.nodeIndex);
+ return;
+ }
+ }
startPenEditDrag(editLayer, hit, evt);
return;
}
@@ -10777,11 +10787,9 @@
if (hit.part === 'anchor') {
// Pen tool's "click anchor" deletes; DSel's "click anchor" selects/drags.
cursor = (t === 'pen') ? 'crosshair' : 'pointer';
- // Endpoint of an open path → "continue from here" affordance
- // in pen mode (we won't wire the continue behavior in this
- // commit, but the cursor cue is what makes the affordance
- // discoverable; the actual resume-drawing logic lands in
- // the polish commit).
+ // Endpoint of an open path in pen mode → "continue from
+ // here" affordance. Clicking seeds a draft from the
+ // existing nodes so the next click extends the path.
if (t === 'pen' && !editLayer.shape.pen.closed) {
const lastIdx = editLayer.shape.pen.nodes.length - 1;
if (hit.nodeIndex === 0 || hit.nodeIndex === lastIdx) cursor = 'pointer';
@@ -10818,9 +10826,19 @@
function finishPenPath(closed) {
if (!_penDraft || _penDraft.tool !== 'pen') { cancelPenDraft(); return; }
const nodes = _penDraft.nodes;
+ const continuationOf = _penDraft._continuationOf;
+ const inheritStyle = _penDraft._inheritStyle;
_penDraft = null;
clearPenDraftRender();
- if (nodes.length < 2) return;
+ if (nodes.length < 2) {
+ // Restore the source path if continuation aborted without committing.
+ if (continuationOf) {
+ const src = state.layers.find(l => l.id === continuationOf);
+ if (src) src._hiddenForContinuation = false;
+ render();
+ }
+ return;
+ }
// bbox includes anchor + handle points so curves that bow outward fit.
const allPts = [];
for (const n of nodes) {
@@ -10846,27 +10864,91 @@
const vbW = bbox.w * 96;
const vbH = bbox.h * 96;
const d = penNodesToPathD(normNodes, closed);
- pushHistory('draw pen path');
- const fill = (closed && _penToolOpts.penFill)
+ pushHistory(continuationOf ? 'extend pen path' : 'draw pen path');
+ // Continuation: drop the source layer, carry its z + target +
+ // style so the replacement reads as "the same path, longer."
+ let layerZ = nextLayerZ();
+ let layerTarget = currentDrawTarget();
+ let layerFill = (closed && _penToolOpts.penFill)
? { type: 'solid', color: _penToolOpts.penFillColor }
: { type: 'none' };
+ let layerStroke = { type: 'solid', color: _penToolOpts.penStrokeColor, width: _penToolOpts.penStrokeWidth, dash: 'solid' };
+ if (continuationOf) {
+ const idx = state.layers.findIndex(l => l.id === continuationOf);
+ if (idx >= 0) {
+ const src = state.layers[idx];
+ if (inheritStyle?.stroke) layerStroke = inheritStyle.stroke;
+ if (inheritStyle?.fill && (closed || inheritStyle.fill.type === 'none')) layerFill = inheritStyle.fill;
+ layerZ = src.z;
+ layerTarget = src.target;
+ state.layers.splice(idx, 1);
+ }
+ }
addLayer({
id: makeLayerId(),
type: 'shape',
name: closed ? 'Pen shape' : 'Pen path',
- target: currentDrawTarget(),
+ target: layerTarget,
x: bbox.x, y: bbox.y, w: bbox.w, h: bbox.h,
- rotate: 0, opacity: 1, z: nextLayerZ(),
+ rotate: 0, opacity: 1, z: layerZ,
shape: {
kind: 'path', d, viewBox: { x: 0, y: 0, w: vbW, h: vbH },
pen: { closed: !!closed, tool: 'pen', nodes: normNodes }
},
- fill,
- stroke: { type: 'solid', color: _penToolOpts.penStrokeColor, width: _penToolOpts.penStrokeWidth, dash: 'solid' }
+ fill: layerFill,
+ stroke: layerStroke
});
}
+ // Pen tool: resume drawing from an endpoint of an open path.
+ // Seeds _penDraft with the existing layer's nodes (converted
+ // back to inch-space), reversing the order if the user clicked
+ // the start so the clicked endpoint becomes the trailing anchor
+ // ready to extend on the next click. The source layer is hidden
+ // for the duration of the draft; on commit it's removed and the
+ // new (continued) path takes its place, preserving z + target.
+ function startPenContinuation(layer, endpointIdx) {
+ const srcNodes = layer.shape.pen.nodes;
+ if (!srcNodes || srcNodes.length === 0) return;
+ const reverse = endpointIdx === 0;
+ const draftNodes = srcNodes.map(n => ({
+ x: layer.x + n.x / 96,
+ y: layer.y + n.y / 96,
+ hIn: n.hIn ? { x: layer.x + n.hIn.x / 96, y: layer.y + n.hIn.y / 96 } : null,
+ hOut: n.hOut ? { x: layer.x + n.hOut.x / 96, y: layer.y + n.hOut.y / 96 } : null,
+ kind: n.kind || 'corner',
+ }));
+ if (reverse) {
+ draftNodes.reverse();
+ // After reversal, what was hIn becomes hOut and vice versa.
+ for (const n of draftNodes) { const tmp = n.hIn; n.hIn = n.hOut; n.hOut = tmp; }
+ }
+ const last = draftNodes[draftNodes.length - 1];
+ layer._hiddenForContinuation = true;
+ _penDraft = {
+ tool: 'pen',
+ nodes: draftNodes,
+ cursor: { x: last.x, y: last.y },
+ _continuationOf: layer.id,
+ };
+ // Carry over the source layer's stroke + fill so the committed
+ // replacement matches the original's appearance.
+ const srcStroke = layer.stroke;
+ const srcFill = layer.fill;
+ if (srcStroke || srcFill) {
+ _penDraft._inheritStyle = { stroke: srcStroke, fill: srcFill, z: layer.z, target: layer.target };
+ }
+ render();
+ renderPenDraft();
+ }
+
function cancelPenDraft() {
+ // Restore the hidden continuation source if the user backed out.
+ if (_penDraft && _penDraft._continuationOf) {
+ const src = state.layers.find(l => l.id === _penDraft._continuationOf);
+ if (src) src._hiddenForContinuation = false;
+ render();
+ }
_penDraft = null;
clearPenDraftRender();
}
@@ -16101,7 +16183,7 @@
// Only render layers whose target belongs to the current template mode
// (prevents leftover cover/disc/jewel layers from leaking into custom-art
// canvases and vice versa). Page-level layers render everywhere.
- if (layer.hidden) continue;
+ if (layer.hidden || layer._hiddenForContinuation) continue;
if (state.appMode !== 'designer' && !isLayerForCurrentMode(layer)) continue;
const layerSelected = isLayerSelected(layer.id);
From 0fc45ae9fc8b0776d4a8a03ba97a308288b121f1 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:35:21 -0400
Subject: [PATCH 09/23] pen tool: auto-add / auto-delete + convert +
handle-break (Illustrator parity)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Pen tool over an existing segment now auto-inserts an anchor at the
click with no modifier (was Alt-only). Pen over an anchor deletes it
on click — Illustrator's default "Auto Add/Delete Anchor" behavior.
- Drag (past a small threshold) on an existing anchor with the Pen tool
converts the anchor on the fly: pulls symmetric handles from the
drag, promoting corner → smooth. Alt-drag pulls only the outgoing
handle for an asymmetric corner.
- Alt-click an anchor in Pen mode still toggles corner ↔ smooth (drops
or pairs handles without a drag), preserving the existing modifier.
- Alt-drag a handle in Direct Selection breaks the smooth-anchor's
handle symmetry: the node becomes a corner so the opposite handle
stays put while the dragged one moves independently.
- Endpoint-of-open-path still wins over auto-delete: hovering the start
or last anchor of an open path shows the "continue" pointer cue, and
clicking resumes drawing rather than deleting.
- Hover cursors updated: − (delete) over a deletable anchor, + (add)
over a segment in Pen mode, grab over a segment in Direct Select.
---
src/index.html | 127 +++++++++++++++++++++++++++++++++++++++----------
1 file changed, 103 insertions(+), 24 deletions(-)
diff --git a/src/index.html b/src/index.html
index 7d8a8d2..8d3a3ff 100644
--- a/src/index.html
+++ b/src/index.html
@@ -9747,28 +9747,32 @@
if (editLayer) {
const hit = hitTestPenEdit(editLayer, evt);
if (hit) {
- if (hit.part === 'anchor' && evt.altKey) {
- // Alt-click an anchor → toggle corner / smooth.
- togglePenAnchorKind(editLayer, hit.nodeIndex);
- return;
- }
if (hit.part === 'segment') {
- // Click on a segment (between anchors) → insert anchor + grab it.
+ // Pen over a segment: auto-insert anchor at click + drag
+ // (Illustrator's default — no modifier needed).
insertPenAnchorAt(editLayer, hit.segIndex, hit.t);
- // Start dragging the new anchor immediately for ergonomics.
startPenEditDrag(editLayer, { nodeIndex: hit.segIndex + 1, part: 'anchor' }, evt);
return;
}
- // Pen tool + plain click on an endpoint of an open path →
- // resume drawing from that endpoint. Illustrator parity.
- if (hit.part === 'anchor' && !evt.shiftKey && !evt.altKey
- && editLayer.shape?.pen?.closed === false) {
- const last = editLayer.shape.pen.nodes.length - 1;
- if (hit.nodeIndex === 0 || hit.nodeIndex === last) {
- startPenContinuation(editLayer, hit.nodeIndex);
- return;
+ if (hit.part === 'anchor') {
+ // Endpoint of an open path → resume drawing from there.
+ // Takes priority over auto-delete (Illustrator shows the
+ // continue cursor on endpoint hover, not the − cursor).
+ if (!evt.shiftKey && !evt.altKey
+ && editLayer.shape?.pen?.closed === false) {
+ const last = editLayer.shape.pen.nodes.length - 1;
+ if (hit.nodeIndex === 0 || hit.nodeIndex === last) {
+ startPenContinuation(editLayer, hit.nodeIndex);
+ return;
+ }
}
+ // Interior anchor: click → auto-delete; drag → convert
+ // (pull symmetric handles, Alt-drag for asymmetric).
+ startPenAnchorClickOrConvert(editLayer, hit.nodeIndex, evt);
+ return;
}
+ // Handle hit (hIn / hOut): drag to edit the tangent; Alt
+ // breaks symmetry. Handled inside startPenEditDrag.
startPenEditDrag(editLayer, hit, evt);
return;
}
@@ -10107,6 +10111,11 @@
}
} else {
node[hit.part] = { x: local.x, y: local.y };
+ // Alt-drag a handle in Direct Selection breaks symmetry:
+ // node becomes corner so the opposite handle stays put.
+ // Illustrator parity (the Convert Anchor Point tool's
+ // Alt-drag-handle behavior).
+ if (e.altKey) node.kind = 'corner';
// Mirror the opposite handle if the anchor is smooth, so
// editing one side of a curve keeps the curve smooth on
// the other side too.
@@ -10135,6 +10144,68 @@
_penOverlay.addEventListener('pointercancel', up);
}
+ // Pen tool on an interior anchor: disambiguate click vs drag.
+ // Click without dragging deletes the anchor (auto-delete, the
+ // Pen tool's default Illustrator behavior over an existing
+ // anchor). Drag past a small threshold promotes the anchor to
+ // smooth and pulls symmetric handles, converting a corner into
+ // a smooth point on the fly. Alt-click toggles corner/smooth
+ // without a drag; Alt-drag pulls a single (outgoing) handle and
+ // leaves the incoming side empty, giving an asymmetric corner.
+ function startPenAnchorClickOrConvert(layer, idx, evt) {
+ const node = layer.shape.pen.nodes[idx];
+ if (!node) return;
+ const alt = !!evt.altKey;
+ try { _penOverlay.setPointerCapture(evt.pointerId); } catch {}
+ const startX = evt.clientX, startY = evt.clientY;
+ const THRESHOLD = 4;
+ let dragging = false;
+ let historyPushed = false;
+ const promote = () => {
+ if (historyPushed) return;
+ pushHistory('convert pen anchor');
+ historyPushed = true;
+ };
+ const move = (e) => {
+ if (!dragging) {
+ const dx = e.clientX - startX, dy = e.clientY - startY;
+ if (dx * dx + dy * dy < THRESHOLD * THRESHOLD) return;
+ dragging = true;
+ promote();
+ }
+ const pt = overlayPointToInches(e);
+ if (!pt) return;
+ const local = inchToPenNode(layer, pt);
+ node.hOut = { x: local.x, y: local.y };
+ if (alt) {
+ // Asymmetric: only the outgoing handle is pulled.
+ node.hIn = null;
+ node.kind = 'corner';
+ } else {
+ node.hIn = { x: 2 * node.x - local.x, y: 2 * node.y - local.y };
+ node.kind = 'smooth';
+ }
+ refitPenLayerIfNeeded(layer);
+ regenPenLayerD(layer);
+ renderLayers();
+ renderPenEditHandles(layer);
+ };
+ const up = () => {
+ _penOverlay.removeEventListener('pointermove', move);
+ _penOverlay.removeEventListener('pointerup', up);
+ _penOverlay.removeEventListener('pointercancel', up);
+ if (!dragging) {
+ if (alt) togglePenAnchorKind(layer, idx);
+ else deletePenAnchor(layer, idx);
+ } else {
+ render();
+ }
+ };
+ _penOverlay.addEventListener('pointermove', move);
+ _penOverlay.addEventListener('pointerup', up);
+ _penOverlay.addEventListener('pointercancel', up);
+ }
+
// Drag a curve segment body to bow it. Distributes the drag
// delta equally across the two inner control points (a.hOut /
// b.hIn) so the curve at parameter t tracks the cursor. Lines
@@ -10785,19 +10856,27 @@
const hit = hitTestPenEdit(editLayer, evt);
if (hit) {
if (hit.part === 'anchor') {
- // Pen tool's "click anchor" deletes; DSel's "click anchor" selects/drags.
- cursor = (t === 'pen') ? 'crosshair' : 'pointer';
- // Endpoint of an open path in pen mode → "continue from
- // here" affordance. Clicking seeds a draft from the
- // existing nodes so the next click extends the path.
- if (t === 'pen' && !editLayer.shape.pen.closed) {
- const lastIdx = editLayer.shape.pen.nodes.length - 1;
- if (hit.nodeIndex === 0 || hit.nodeIndex === lastIdx) cursor = 'pointer';
+ if (t === 'pen') {
+ // Pen over anchor = auto-delete (− cursor). The
+ // 'not-allowed' built-in cursor is the closest
+ // platform-agnostic "−" hint; the alternative would be a
+ // custom SVG cursor.
+ cursor = 'not-allowed';
+ // Endpoint of an open path → "continue from here". Wins
+ // over auto-delete since clicking there resumes drawing.
+ if (!editLayer.shape.pen.closed) {
+ const lastIdx = editLayer.shape.pen.nodes.length - 1;
+ if (hit.nodeIndex === 0 || hit.nodeIndex === lastIdx) cursor = 'pointer';
+ }
+ } else {
+ cursor = 'pointer';
}
} else if (hit.part === 'hIn' || hit.part === 'hOut') {
cursor = 'move';
} else if (hit.part === 'segment') {
- cursor = (t === 'pen') ? 'copy' : 'default'; // 'copy' is browsers' "+"-style cursor
+ // Pen over segment: + cursor (auto-add). Direct Select:
+ // grab to bow the curve.
+ cursor = (t === 'pen') ? 'copy' : 'grab';
}
}
}
From c3c58e67f588b06a6552369d6706b612e90c2935 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:36:05 -0400
Subject: [PATCH 10/23] pen tool: Spacebar reposition + Alt mid-draw asymmetric
(Illustrator parity)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
While the pen tool's click-drag is still down (the gesture that pulls
Bézier handles out of a freshly-placed anchor), two modifiers now
match Illustrator's pen behavior:
- Hold Alt during the drag and only the outgoing handle is pulled.
The incoming side stays at the anchor, giving an asymmetric corner
that's perfect for tangent-then-corner transitions without lifting
the pen.
- Hold Space and the drag stops growing handles and starts sliding
the anchor itself, carrying any handles along. Release Space to
resume handle-pulling from the anchor's new position. Mouse delta
is tracked from the moment each mode is entered, so toggling Space
on/off mid-drag doesn't introduce drift.
---
src/index.html | 61 +++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 58 insertions(+), 3 deletions(-)
diff --git a/src/index.html b/src/index.html
index 8d3a3ff..db50e97 100644
--- a/src/index.html
+++ b/src/index.html
@@ -9670,21 +9670,76 @@
_penDraft.cursor = p;
renderPenDraft();
// Drag from this anchor pulls out symmetric Bézier handles.
+ // Two modifier-driven variants (Illustrator parity):
+ // Alt held during drag → asymmetric (only outgoing handle is
+ // pulled; incoming side stays at the anchor for a corner
+ // transition into the next segment).
+ // Spacebar held during drag → reposition the anchor itself
+ // (and slide its handles along) instead of growing handles.
+ // Releasing Space resumes handle-pulling from the new anchor
+ // position.
try { _penOverlay.setPointerCapture(evt.pointerId); } catch {}
let dragged = false;
+ let spaceHeld = false;
+ let mode = 'handles'; // 'handles' | 'reposition'
+ let modeStartMouse = { x: p.x, y: p.y };
+ let modeStartNode = {
+ x: node.x, y: node.y,
+ hIn: node.hIn ? { ...node.hIn } : null,
+ hOut: node.hOut ? { ...node.hOut } : null,
+ };
+ const snapshotNode = (mousePt) => {
+ modeStartMouse = mousePt;
+ modeStartNode = {
+ x: node.x, y: node.y,
+ hIn: node.hIn ? { ...node.hIn } : null,
+ hOut: node.hOut ? { ...node.hOut } : null,
+ };
+ };
+ const onKey = (e) => {
+ if (e.code !== 'Space' && e.key !== ' ') return;
+ e.preventDefault();
+ const next = e.type === 'keydown';
+ if (next === spaceHeld) return;
+ spaceHeld = next;
+ };
+ document.addEventListener('keydown', onKey);
+ document.addEventListener('keyup', onKey);
const move = (e) => {
const pt = overlayPointToInches(e);
if (!pt) return;
dragged = true;
- node.hOut = { x: pt.x, y: pt.y };
- node.hIn = { x: node.x - (pt.x - node.x), y: node.y - (pt.y - node.y) };
- node.kind = 'smooth';
+ const wantMode = spaceHeld ? 'reposition' : 'handles';
+ if (wantMode !== mode) {
+ mode = wantMode;
+ snapshotNode(pt);
+ }
+ if (mode === 'reposition') {
+ const dx = pt.x - modeStartMouse.x;
+ const dy = pt.y - modeStartMouse.y;
+ node.x = modeStartNode.x + dx;
+ node.y = modeStartNode.y + dy;
+ if (modeStartNode.hIn) node.hIn = { x: modeStartNode.hIn.x + dx, y: modeStartNode.hIn.y + dy };
+ if (modeStartNode.hOut) node.hOut = { x: modeStartNode.hOut.x + dx, y: modeStartNode.hOut.y + dy };
+ } else {
+ node.hOut = { x: pt.x, y: pt.y };
+ if (e.altKey) {
+ // Asymmetric corner: only the outgoing handle is set.
+ node.hIn = null;
+ node.kind = 'corner';
+ } else {
+ node.hIn = { x: 2 * node.x - pt.x, y: 2 * node.y - pt.y };
+ node.kind = 'smooth';
+ }
+ }
renderPenDraft();
};
const up = () => {
_penOverlay.removeEventListener('pointermove', move);
_penOverlay.removeEventListener('pointerup', up);
_penOverlay.removeEventListener('pointercancel', up);
+ document.removeEventListener('keydown', onKey);
+ document.removeEventListener('keyup', onKey);
renderPenDraft();
};
_penOverlay.addEventListener('pointermove', move);
From 62f2a7592ef469818f9176964bfec54f2b0c988d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:37:11 -0400
Subject: [PATCH 11/23] pen tool: Join (Cmd+J) + Average (Cmd+Alt+J)
- Cmd+J closes an open path when both its endpoints are part of the
current anchor selection. No-op if the path is already closed or
the selection doesn't cover both endpoints; the user gets a hint
toast in that case.
- Cmd+Alt+J averages every selected anchor to the centroid of the
selection. Handles slide with their anchors so local curvature is
preserved. Requires at least two anchors selected.
---
src/index.html | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 63 insertions(+)
diff --git a/src/index.html b/src/index.html
index db50e97..8ccce58 100644
--- a/src/index.html
+++ b/src/index.html
@@ -10452,6 +10452,50 @@
deletePenAnchors(layer, [idx]);
}
+ // Close an open path when both its endpoints are part of the
+ // current selection. Illustrator's Cmd+J. Operates on the
+ // editable pen layer. No-op if the path is already closed or
+ // if the selection doesn't pick out both endpoints.
+ function joinSelectedPenAnchors(layer) {
+ const nodes = layer.shape?.pen?.nodes;
+ if (!nodes || layer.shape.pen.closed) return false;
+ if (_penSelectedAnchors.size !== 2) return false;
+ const last = nodes.length - 1;
+ if (!_penSelectedAnchors.has(0) || !_penSelectedAnchors.has(last)) return false;
+ pushHistory('join pen path');
+ layer.shape.pen.closed = true;
+ regenPenLayerD(layer);
+ render();
+ return true;
+ }
+
+ // Average the selected anchors. With no axis restriction every
+ // anchor (and its handles) slides to the centroid of the
+ // selection; the handles are translated by the same delta so
+ // the local curvature is preserved.
+ function averageSelectedPenAnchors(layer, axis) {
+ const nodes = layer.shape?.pen?.nodes;
+ if (!nodes || _penSelectedAnchors.size < 2) return false;
+ let sx = 0, sy = 0;
+ for (const i of _penSelectedAnchors) { sx += nodes[i].x; sy += nodes[i].y; }
+ const cx = sx / _penSelectedAnchors.size;
+ const cy = sy / _penSelectedAnchors.size;
+ pushHistory('average pen anchors');
+ for (const i of _penSelectedAnchors) {
+ const n = nodes[i];
+ const tx = (axis === 'vertical') ? n.x : cx;
+ const ty = (axis === 'horizontal') ? n.y : cy;
+ const dx = tx - n.x, dy = ty - n.y;
+ n.x = tx; n.y = ty;
+ if (n.hIn) { n.hIn.x += dx; n.hIn.y += dy; }
+ if (n.hOut) { n.hOut.x += dx; n.hOut.y += dy; }
+ }
+ refitPenLayerIfNeeded(layer);
+ regenPenLayerD(layer);
+ render();
+ return true;
+ }
+
function deletePenAnchors(layer, idxList) {
const nodes = layer.shape.pen.nodes;
const sorted = Array.from(new Set(idxList))
@@ -29944,6 +29988,25 @@
if (k === 'c' && selectedLayer()) { e.preventDefault(); copySelectedLayerToClipboard(); return; }
if (k === 'x' && selectedLayer()) { e.preventDefault(); cutSelectedLayerToClipboard(); return; }
if (k === 'v') { e.preventDefault(); pasteLayersFromClipboard(); return; }
+ // Pen path ops on the editable pen layer (Illustrator parity).
+ if (k === 'j') {
+ const penEdit = getEditablePenLayer?.();
+ if (penEdit) {
+ e.preventDefault();
+ if (e.altKey) {
+ // Cmd+Alt+J → average selected anchors to their centroid.
+ if (!averageSelectedPenAnchors(penEdit)) {
+ toastError?.('Select at least two anchors to average.');
+ }
+ } else {
+ // Cmd+J → close the path if both endpoints are selected.
+ if (!joinSelectedPenAnchors(penEdit)) {
+ toastError?.('Select both endpoints of an open path to join.');
+ }
+ }
+ return;
+ }
+ }
}
// Tool shortcuts + layer keys — only when not typing and no modifier held.
if (!mod && !isEditableFocus()) {
From d50284ecef8fb4d548141e3c68bbb2bdf366e619 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:42:48 -0400
Subject: [PATCH 12/23] =?UTF-8?q?pen=20tool:=20Alt-click=20anchor=20now=20?=
=?UTF-8?q?truly=20converts=20curved=20=E2=86=94=20pointed?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The presence of handles is now the source of truth for whether a
node is pointed or curved, so Alt-click does a full flip in either
direction:
- Anchor with any handle → drops both handles and demotes to a real
corner. The path renders straight segments through it.
- Anchor with no handles → grows symmetric Bézier handles aimed along
the chord through this anchor (using neighbouring anchors when they
exist, falling back to the single available neighbour for endpoints
of an open path). Handle length is 1/3 of the chord, so the curve
bends visibly without ballooning out.
Previously the toggle only flipped the kind flag without touching
handles, so a smooth-to-corner click left the curve looking unchanged
and a corner-with-no-handles click did nothing visible at all.
---
src/index.html | 67 ++++++++++++++++++++++++++++++++++----------------
1 file changed, 46 insertions(+), 21 deletions(-)
diff --git a/src/index.html b/src/index.html
index 8ccce58..e42823d 100644
--- a/src/index.html
+++ b/src/index.html
@@ -10514,40 +10514,65 @@
render();
}
- // Toggle a node between 'corner' (handles move independently)
- // and 'smooth' (handles mirror across the anchor). When
- // promoting to smooth, mirror hOut from hIn (or vice versa) so
- // the curve actually becomes continuous through this node.
+ // Flip an anchor between "pointed" and "curved" — Illustrator's
+ // Convert Anchor Point behavior. The presence of handles is the
+ // source of truth (kind is just metadata for how a smooth anchor
+ // mirrors its handles):
+ // has any handle → drop both handles → real pointed corner
+ // (path renders straight segments through it)
+ // no handles → grow symmetric handles aimed along the
+ // chord through this anchor → real curved
+ // smooth point. Handle length = 1/3 of the
+ // chord length to the nearest neighbour.
function togglePenAnchorKind(layer, idx) {
const nodes = layer.shape.pen.nodes;
const n = nodes[idx];
if (!n) return;
pushHistory('toggle pen anchor kind');
- if (n.kind === 'smooth') {
+ const hasHandles = !!(n.hIn || n.hOut);
+ if (hasHandles) {
n.kind = 'corner';
+ n.hIn = null;
+ n.hOut = null;
} else {
n.kind = 'smooth';
- if (n.hIn && !n.hOut) {
- n.hOut = { x: 2 * n.x - n.hIn.x, y: 2 * n.y - n.hIn.y };
- } else if (n.hOut && !n.hIn) {
- n.hIn = { x: 2 * n.x - n.hOut.x, y: 2 * n.y - n.hOut.y };
- } else if (n.hIn && n.hOut) {
- // Average direction and re-mirror so the smoothing is
- // visible (handles likely were independent before).
- const inLen = Math.hypot(n.hIn.x - n.x, n.hIn.y - n.y);
- const outLen = Math.hypot(n.hOut.x - n.x, n.hOut.y - n.y);
- const ax = (n.hOut.x - n.x) - (n.hIn.x - n.x);
- const ay = (n.hOut.y - n.y) - (n.hIn.y - n.y);
- const al = Math.hypot(ax, ay) || 1;
- const dirX = ax / al, dirY = ay / al;
- n.hOut = { x: n.x + dirX * outLen, y: n.y + dirY * outLen };
- n.hIn = { x: n.x - dirX * inLen, y: n.y - dirY * inLen };
- }
+ const grown = _defaultSmoothHandles(layer, idx);
+ n.hIn = grown.hIn;
+ n.hOut = grown.hOut;
}
regenPenLayerD(layer);
render();
}
+ // Compute default symmetric handles for a node that has none.
+ // Tangent direction = chord (prev → next) so the resulting curve
+ // bends naturally through this anchor. Endpoints of an open path
+ // use the single available neighbour. Isolated nodes get nothing.
+ function _defaultSmoothHandles(layer, idx) {
+ const nodes = layer.shape.pen.nodes;
+ const closed = !!layer.shape.pen.closed;
+ const n = nodes[idx];
+ const len = nodes.length;
+ let prev = null, next = null;
+ if (idx > 0) prev = nodes[idx - 1];
+ else if (closed && len > 1) prev = nodes[len - 1];
+ if (idx < len - 1) next = nodes[idx + 1];
+ else if (closed && len > 1) next = nodes[0];
+ let dx = 0, dy = 0;
+ if (prev && next) { dx = next.x - prev.x; dy = next.y - prev.y; }
+ else if (prev) { dx = n.x - prev.x; dy = n.y - prev.y; }
+ else if (next) { dx = next.x - n.x; dy = next.y - n.y; }
+ else return { hIn: null, hOut: null };
+ const chord = Math.hypot(dx, dy);
+ if (!chord) return { hIn: null, hOut: null };
+ const handleLen = chord / 3;
+ const ux = dx / chord, uy = dy / chord;
+ return {
+ hIn: { x: n.x - ux * handleLen, y: n.y - uy * handleLen },
+ hOut: { x: n.x + ux * handleLen, y: n.y + uy * handleLen },
+ };
+ }
+
function renderPenEditHandles(layer) {
if (!_penOverlay) return;
const ns = 'http://www.w3.org/2000/svg';
From 0cb028c3f11157f2e9c23456ff70aa8ae4c32263 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Mon, 1 Jun 2026 21:46:45 -0400
Subject: [PATCH 13/23] pen tool: side-panel "Path actions" block for anchor +
path ops
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A new "Path actions" block appears in the right-side tool options panel
whenever Pen (P) or Direct Selection (A) is active and an editable pen
layer is selected. The block surfaces the most useful anchor- / path-
level operations as buttons so they're discoverable without keyboard
shortcuts:
- Pointed corner / Curved smooth — flip the selected anchors (or all,
if none are selected) between handle-less corner and symmetric-handle
smooth, matching the Alt-click convert behavior.
- Delete anchor — bulk-delete the current anchor selection (Backspace
equivalent).
- Average — move selected anchors to their centroid (Cmd+Alt+J).
- Join endpoints — close an open path when both its endpoints are
selected (Cmd+J); button auto-disables otherwise.
- Close / Open path — toggle the path's closed flag.
- Reverse direction — flip the order anchors are drawn in.
A small hint at the top of the block reflects the current selection
size, so the user knows what an action will affect. The "Join" button
auto-disables when join would be a no-op; "Average" requires at least
two anchors. Direct Selection also gets a short panel reminding the
user how anchor editing works (multi-select via Shift / marquee, arrow
keys nudge selected anchors).
---
src/index.html | 153 ++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 151 insertions(+), 2 deletions(-)
diff --git a/src/index.html b/src/index.html
index e42823d..d403db9 100644
--- a/src/index.html
+++ b/src/index.html
@@ -4074,6 +4074,29 @@
+
+
Direct Selection
+
Click a path to reveal its anchors, then drag anchors / handles to reshape. Shift-click or drag a marquee to multi-select; arrow keys nudge selected anchors.
+
+
+
Path actions
+
Operations apply to the selected anchors on the editable path.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Pencil tool
@@ -9338,7 +9361,7 @@
const PEN_CLOSE_HIT = 0.09; // inch radius to "click the first anchor"
const EDITOR_TOOLS = ['select', 'direct-select', 'hand', 'pen', 'pencil', 'shape', 'text'];
// Tools with a contextual right-side options panel.
- const EDITOR_TOOLS_WITH_PANEL = ['pen', 'pencil', 'shape', 'text'];
+ const EDITOR_TOOLS_WITH_PANEL = ['pen', 'direct-select', 'pencil', 'shape', 'text'];
// Shape kinds the Shape tool can draw (excludes uploaded 'path'/'svg').
const SHAPE_TOOL_KINDS = ['rect', 'rounded-rect', 'circle', 'ellipse', 'triangle', 'polygon', 'star', 'line'];
@@ -9401,10 +9424,13 @@
const hasPanel = EDITOR_TOOLS_WITH_PANEL.includes(_editorTool);
const opts = $('toolOptions');
if (opts) opts.style.display = hasPanel ? '' : 'none';
- for (const [id, tool] of [['penToolOptions','pen'],['pencilToolOptions','pencil'],['shapeToolOptions','shape'],['textToolOptions','text']]) {
+ for (const [id, tool] of [['penToolOptions','pen'],['directSelectToolOptions','direct-select'],['pencilToolOptions','pencil'],['shapeToolOptions','shape'],['textToolOptions','text']]) {
const el = $(id);
if (el) el.style.display = _editorTool === tool ? '' : 'none';
}
+ // The shared path-actions block appears whenever an editable
+ // pen layer is selected and we're in a tool that can edit it.
+ syncPathActionsBlock();
if (hasPanel) syncToolOptionInputs();
}
@@ -10544,6 +10570,101 @@
render();
}
+ // Batch variants for the side-panel buttons. Operate on every
+ // currently-selected anchor; if no anchors are selected, treat
+ // every anchor in the path as selected (so the buttons still do
+ // something useful right after switching tools).
+ function _eachTargetAnchor(layer, fn) {
+ const nodes = layer.shape?.pen?.nodes;
+ if (!nodes) return 0;
+ const idxs = _penSelectedAnchors.size
+ ? Array.from(_penSelectedAnchors)
+ : nodes.map((_, i) => i);
+ for (const i of idxs) fn(nodes[i], i);
+ return idxs.length;
+ }
+ function convertSelectedAnchorsToPointed(layer) {
+ pushHistory('convert anchors to pointed');
+ const n = _eachTargetAnchor(layer, (node) => {
+ if (!node.hIn && !node.hOut) return;
+ node.kind = 'corner';
+ node.hIn = null;
+ node.hOut = null;
+ });
+ regenPenLayerD(layer);
+ render();
+ return n;
+ }
+ function convertSelectedAnchorsToCurved(layer) {
+ pushHistory('convert anchors to curved');
+ const n = _eachTargetAnchor(layer, (node, i) => {
+ if (node.hIn || node.hOut) return;
+ const grown = _defaultSmoothHandles(layer, i);
+ node.kind = 'smooth';
+ node.hIn = grown.hIn;
+ node.hOut = grown.hOut;
+ });
+ regenPenLayerD(layer);
+ render();
+ return n;
+ }
+ function reversePenPath(layer) {
+ const pen = layer.shape?.pen;
+ if (!pen?.nodes?.length) return;
+ pushHistory('reverse pen path');
+ pen.nodes.reverse();
+ // After reversal, what was hIn for each node becomes its hOut
+ // (the segment-direction relationship swaps).
+ for (const n of pen.nodes) { const tmp = n.hIn; n.hIn = n.hOut; n.hOut = tmp; }
+ regenPenLayerD(layer);
+ render();
+ }
+ function togglePenPathClosed(layer) {
+ const pen = layer.shape?.pen;
+ if (!pen) return;
+ if (!pen.closed && pen.nodes.length < 3) {
+ toastError?.('Path needs at least 3 anchors to close.');
+ return;
+ }
+ pushHistory(pen.closed ? 'open pen path' : 'close pen path');
+ pen.closed = !pen.closed;
+ regenPenLayerD(layer);
+ render();
+ }
+
+ // Show / hide the shared "Path actions" side-panel block. Visible
+ // whenever Pen or Direct Selection is the effective tool AND a
+ // pen path is selected; buttons stay enabled so the user can act
+ // on either all anchors (no selection) or a subset.
+ function syncPathActionsBlock() {
+ const block = document.getElementById('pathActionsBlock');
+ if (!block) return;
+ const t = (typeof effectiveEditorTool === 'function') ? effectiveEditorTool() : _editorTool;
+ const editable = (t === 'pen' || t === 'direct-select') ? getEditablePenLayer() : null;
+ block.style.display = editable ? '' : 'none';
+ if (!editable) return;
+ const closed = !!editable.shape.pen.closed;
+ const nodes = editable.shape.pen.nodes || [];
+ const selSize = _penSelectedAnchors.size;
+ const hint = document.getElementById('pathActionsHint');
+ if (hint) {
+ hint.textContent = selSize
+ ? `${selSize} anchor${selSize === 1 ? '' : 's'} selected. Buttons act on the selection.`
+ : `${nodes.length} anchor${nodes.length === 1 ? '' : 's'} on this path. Click anchors to select; buttons with no selection act on all.`;
+ }
+ // Join only makes sense for an open path with both endpoints in the selection.
+ const join = document.getElementById('pathActionJoin');
+ if (join) {
+ const last = nodes.length - 1;
+ const canJoin = !closed && selSize === 2 && _penSelectedAnchors.has(0) && _penSelectedAnchors.has(last);
+ join.disabled = !canJoin;
+ }
+ const avg = document.getElementById('pathActionAverage');
+ if (avg) avg.disabled = selSize < 2;
+ const tc = document.getElementById('pathActionToggleClose');
+ if (tc) tc.textContent = closed ? 'Open path' : 'Close path';
+ }
+
// Compute default symmetric handles for a node that has none.
// Tangent direction = chord (prev → next) so the resulting curve
// bends naturally through this anchor. Endpoints of an open path
@@ -10647,6 +10768,10 @@
// Decide what the overlay shows: an in-progress draft, edit handles for a
// selected pen path, or nothing.
function refreshPenOverlayContent() {
+ // Side-panel button enabled-state tracks anchor selection;
+ // refresh it whenever the overlay re-renders so the hint and
+ // disabled flags match what's actually selected.
+ try { syncPathActionsBlock(); } catch {}
if (!_penOverlay) return;
if (_penDraft) { renderPenDraft(); return; }
const editLayer = getEditablePenLayer();
@@ -29930,6 +30055,30 @@
}
on('penFinishBtn', 'click', () => { finishPenPath(false); setEditorTool('select'); });
on('penCancelBtn', 'click', () => setEditorTool('select'));
+ // Path actions side-panel buttons. Each grabs the currently
+ // editable pen layer and runs the corresponding batch op;
+ // the panel state refreshes after the action so the hint and
+ // enabled-flags reflect the new selection.
+ const _withPathLayer = (fn) => () => {
+ const layer = getEditablePenLayer();
+ if (!layer) { toastError?.('Select a pen path first.'); return; }
+ fn(layer);
+ syncPathActionsBlock();
+ };
+ on('pathActionPointed', 'click', _withPathLayer((layer) => convertSelectedAnchorsToPointed(layer)));
+ on('pathActionCurved', 'click', _withPathLayer((layer) => convertSelectedAnchorsToCurved(layer)));
+ on('pathActionDelete', 'click', _withPathLayer((layer) => {
+ if (_penSelectedAnchors.size === 0) { toastError?.('Select at least one anchor to delete.'); return; }
+ deletePenAnchors(layer, Array.from(_penSelectedAnchors));
+ }));
+ on('pathActionAverage', 'click', _withPathLayer((layer) => {
+ if (!averageSelectedPenAnchors(layer)) toastError?.('Select at least two anchors to average.');
+ }));
+ on('pathActionJoin', 'click', _withPathLayer((layer) => {
+ if (!joinSelectedPenAnchors(layer)) toastError?.('Select both endpoints of an open path to join.');
+ }));
+ on('pathActionToggleClose', 'click', _withPathLayer((layer) => togglePenPathClosed(layer)));
+ on('pathActionReverse', 'click', _withPathLayer((layer) => reversePenPath(layer)));
on('penMakeEditableBtn', 'click', () => {
const layer = selectedShapeLayer();
if (!layer) return;
From cb130b82cdb74f1d2c39cf7ba696668a69bbbed6 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 2 Jun 2026 08:41:06 -0400
Subject: [PATCH 14/23] =?UTF-8?q?eyedropper=20tool=20(I)=20=E2=80=94=20sam?=
=?UTF-8?q?ple=20colors=20via=20the=20native=20picker?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A new ⊙ button in the tool palette and the I keyboard shortcut open
the browser's window.EyeDropper picker so the user can sample any
pixel anywhere on screen (not just our canvas) and apply the
sampled hex to a smart target:
- Selected shape layer → fill color (Shift = stroke color).
- Selected text layer → text color.
- No layer selected → pen / shape tool's default fill (or
stroke), so the next thing drawn picks up the sampled hue.
The eyedropper is a transient action rather than a persistent tool —
invoking it doesn't change the active tool, so users can sample
mid-edit without losing their cursor. Falls back to a hint toast in
browsers that don't yet support window.EyeDropper (Safari).
---
src/index.html | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/src/index.html b/src/index.html
index d403db9..43004bd 100644
--- a/src/index.html
+++ b/src/index.html
@@ -4042,6 +4042,7 @@
+
@@ -9534,6 +9535,50 @@
render();
}
+ // Eyedropper: invoke the browser's native EyeDropper picker so
+ // the user can sample any pixel anywhere on screen (not just our
+ // canvas). Applies the picked hex to the most-relevant target:
+ // shape layer → fill (or stroke if Shift was held)
+ // text layer → text color
+ // nothing → the pen / shape tool's default fill colour so
+ // the next thing drawn picks up the sampled hue.
+ // Not supported in Safari yet — show a hint toast there.
+ async function invokeEyedropper(toStroke) {
+ if (typeof window.EyeDropper !== 'function') {
+ toastError?.('Eyedropper needs Chrome, Edge, or Firefox 124+. Safari support is still pending.');
+ return;
+ }
+ let hex = '';
+ try {
+ const res = await new window.EyeDropper().open();
+ hex = (res && res.sRGBHex) || '';
+ } catch (err) {
+ // User pressed Escape — silent abort.
+ return;
+ }
+ if (!hex) return;
+ const layer = selectedLayer?.();
+ if (layer && layer.type === 'shape') {
+ pushHistory('eyedropper');
+ if (toStroke) layer.stroke = { type: 'none', color: '#000000', width: 2, dash: 'solid', ...(layer.stroke || {}), type: 'solid', color: hex };
+ else layer.fill = { ...(layer.fill || {}), type: 'solid', color: hex };
+ render();
+ } else if (layer && layer.type === 'text') {
+ pushHistory('eyedropper');
+ layer.color = hex;
+ render();
+ } else {
+ // No layer selected — stash the color on the active drawing
+ // tool's defaults so the next stroke / fill uses it.
+ if (toStroke) _penToolOpts.penStrokeColor = hex;
+ else _penToolOpts.penFillColor = hex;
+ _shapeToolOpts.fillColor = toStroke ? _shapeToolOpts.fillColor : hex;
+ _shapeToolOpts.strokeColor = toStroke ? hex : _shapeToolOpts.strokeColor;
+ syncToolOptionInputs?.();
+ toast?.(`Sampled ${hex} — applied to current ${toStroke ? 'stroke' : 'fill'} color.`);
+ }
+ }
+
function syncPenOverlay() {
if (_penOverlay && _penOverlay.parentNode) _penOverlay.parentNode.removeChild(_penOverlay);
_penOverlay = null;
@@ -30053,6 +30098,11 @@
for (const t of EDITOR_TOOLS) {
on(`tool-${t}`, 'click', () => setEditorTool(t));
}
+ // Eyedropper is a transient action, not a persistent tool — its
+ // button immediately opens the native picker and the previous
+ // tool stays active. Shift-click samples into the stroke color
+ // instead of the fill.
+ on('tool-eyedropper', 'click', (e) => invokeEyedropper(!!e.shiftKey));
on('penFinishBtn', 'click', () => { finishPenPath(false); setEditorTool('select'); });
on('penCancelBtn', 'click', () => setEditorTool('select'));
// Path actions side-panel buttons. Each grabs the currently
@@ -30191,6 +30241,7 @@
if (k === 'b') { setEditorTool('pencil'); return; }
if (k === 's') { setEditorTool('shape'); return; }
if (k === 't') { setEditorTool('text'); return; }
+ if (k === 'i') { invokeEyedropper(e.shiftKey); return; }
if (k === 'enter' && _penDraft && _penDraft.tool === 'pen') { e.preventDefault(); finishPenPath(false); return; }
if (!_penDraft) {
if (k === 'delete' || k === 'backspace') {
From ce1023a14e78a41b27251e5a7513e93056e75ca1 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 2 Jun 2026 08:43:39 -0400
Subject: [PATCH 15/23] align + distribute panel for selected layers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A new "Align & distribute" details block in the layer-controls sidebar
surfaces eight ops: left / horizontal-centre / right and top / middle
/ bottom alignment, plus horizontal and vertical distribution.
- Multi-selection (2+ layers) aligns within the selection's own
bounding box, so the outer layers stay put and the rest snap into
line with them.
- Single-layer selection aligns to the layer's target bounds (page
panel / disc face / custom canvas), making it easy to centre or
pin a single asset without setting up a dummy second layer.
- Distribute requires three or more layers — the outermost two stay
pinned and the inner layers slide so consecutive centres are
evenly spaced along the chosen axis. Helpers toast a hint when the
selection is too small.
- Locked / generated-disc layers are skipped automatically.
Flip H / V were prototyped here but pulled out — they need a renderer
extension to honour per-layer scaleX/scaleY, which is a separate change.
---
src/index.html | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 84 insertions(+)
diff --git a/src/index.html b/src/index.html
index 43004bd..6955cf8 100644
--- a/src/index.html
+++ b/src/index.html
@@ -3704,6 +3704,25 @@
+
+
+ Align & distribute
+
Select 2+ layers to align to their bounding box, or 1 layer to align to the page bounds. 3+ layers needed to distribute.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Click a layer to select it. Shift/Ctrl/Cmd-click to select multiple layers, then drag any selected layer to move the set together. Text layers are editable in place.
@@ -18357,6 +18376,60 @@
);
}
+ // Align every editable layer in the selection to a reference
+ // bbox. Multi-selection uses the selection's own bbox so the
+ // outer layers stay put; a single layer aligns to its target
+ // (page / panel / disc) bounds so the user can centre or pin a
+ // single asset without setting up a second one.
+ function alignSelection(axis) {
+ const layers = selectedLayers().filter(l => !l?.locked && !l?._generatedDiscLayer);
+ if (!layers.length) { toastError?.('Select a layer first.'); return; }
+ let ref;
+ if (layers.length >= 2) {
+ ref = layersBounds(layers);
+ } else {
+ const single = layers[0];
+ const tb = targetBounds(single.target || (typeof currentDrawTarget === 'function' ? currentDrawTarget() : 'page'));
+ ref = { x: tb.x, y: tb.y, w: tb.w, h: tb.h };
+ }
+ if (!ref) return;
+ pushHistory(`align ${axis}`);
+ for (const l of layers) {
+ const w = Number(l.w) || 0;
+ const h = Number(l.h) || 0;
+ if (axis === 'left') l.x = ref.x;
+ else if (axis === 'right') l.x = ref.x + ref.w - w;
+ else if (axis === 'cx') l.x = ref.x + (ref.w - w) / 2;
+ else if (axis === 'top') l.y = ref.y;
+ else if (axis === 'bottom') l.y = ref.y + ref.h - h;
+ else if (axis === 'cy') l.y = ref.y + (ref.h - h) / 2;
+ }
+ render();
+ }
+
+ // Distribute selected layers so the spacing between consecutive
+ // centres is uniform along the chosen axis. Outer two layers stay
+ // pinned; inner ones slide. Requires at least three layers.
+ function distributeSelection(axis) {
+ const layers = selectedLayers().filter(l => !l?.locked && !l?._generatedDiscLayer);
+ if (layers.length < 3) { toastError?.('Select three or more layers to distribute.'); return; }
+ const dim = axis === 'x' ? 'w' : 'h';
+ const sorted = layers.slice().sort((a, b) => {
+ const ka = (Number(a[axis]) || 0) + (Number(a[dim]) || 0) / 2;
+ const kb = (Number(b[axis]) || 0) + (Number(b[dim]) || 0) / 2;
+ return ka - kb;
+ });
+ const firstCenter = (Number(sorted[0][axis]) || 0) + (Number(sorted[0][dim]) || 0) / 2;
+ const lastCenter = (Number(sorted.at(-1)[axis]) || 0) + (Number(sorted.at(-1)[dim]) || 0) / 2;
+ const step = (lastCenter - firstCenter) / (sorted.length - 1);
+ pushHistory(`distribute ${axis}`);
+ for (let i = 1; i < sorted.length - 1; i++) {
+ const l = sorted[i];
+ l[axis] = (firstCenter + step * i) - (Number(l[dim]) || 0) / 2;
+ }
+ render();
+ }
+
function layersBounds(layers) {
if (!layers.length) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
@@ -29792,6 +29865,17 @@
$('layerForward').addEventListener('click', () => moveLayerStep('forward'));
$('layerBackward').addEventListener('click', () => moveLayerStep('backward'));
+ // Align / distribute side-panel wiring. Each button targets the
+ // current layer selection; the helpers themselves toast a hint
+ // when the action would be a no-op (e.g. < 3 layers + distribute).
+ on('alignLeft', 'click', () => alignSelection('left'));
+ on('alignCX', 'click', () => alignSelection('cx'));
+ on('alignRight', 'click', () => alignSelection('right'));
+ on('alignTop', 'click', () => alignSelection('top'));
+ on('alignCY', 'click', () => alignSelection('cy'));
+ on('alignBottom', 'click', () => alignSelection('bottom'));
+ on('distributeX', 'click', () => distributeSelection('x'));
+ on('distributeY', 'click', () => distributeSelection('y'));
$('deleteLayer').addEventListener('click', deleteSelectedLayer);
From 9bb401bea2636f5babc016d6a5121c57ed15411a Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 2 Jun 2026 08:45:51 -0400
Subject: [PATCH 16/23] free transform: rotation handle on the selection bbox
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A 9th handle (round, accent-colored, sitting above the bounding box
on a thin connector line) joins the existing 8 scale handles on both
the single-layer overlay and the multi-layer selection box.
- Single layer: drag rotates the layer about its own centre, with
Shift snapping to 15° increments. The existing resize handles
already account for layer.rotate in their local-frame math, so
resizing keeps working at any rotation.
- Multi-layer selection: drag rotates each layer's centre around the
selection bbox's centre AND advances each layer's own rotation by
the same delta — the group spins as a rigid body. Pre-snapshotting
the centres + rotations keeps the math stable across long drags.
- Locked / generated-disc layers are filtered out as everywhere else.
---
src/index.html | 148 +++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 144 insertions(+), 4 deletions(-)
diff --git a/src/index.html b/src/index.html
index 6955cf8..ad47620 100644
--- a/src/index.html
+++ b/src/index.html
@@ -1720,6 +1720,29 @@
.lh-s { left: 50%; top: 100%; cursor: ns-resize; }
.lh-sw { left: 0; top: 100%; cursor: nesw-resize; }
.lh-w { left: 0; top: 50%; cursor: ew-resize; }
+ /* Free Transform rotation handle: sits above the bbox top edge,
+ connected by a thin line, with a circular pill so it's visually
+ distinct from the square scale handles. */
+ .lh-rot {
+ left: 50%; top: 0;
+ margin-top: -36px;
+ margin-left: -8px;
+ width: 15px; height: 15px;
+ border-radius: 50%;
+ background: var(--accent);
+ border-color: #fff;
+ cursor: grab;
+ }
+ .lh-rot:active { cursor: grabbing; }
+ .lh-rot::after {
+ content: '';
+ position: absolute;
+ left: 50%; top: 100%;
+ width: 1px; height: 23px;
+ background: var(--accent);
+ transform: translateX(-50%);
+ pointer-events: none;
+ }
.freeLayer img {
width: 100%;
@@ -16684,11 +16707,11 @@
// Drag-resize handles — only on the selected, unlocked layer.
if (layer.id === state.selectedLayerId && selectedLayerIds().length <= 1 && !layer.locked) {
- for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']) {
+ for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'rot']) {
const handle = document.createElement('div');
handle.className = `layerHandle lh-${dir}`;
handle.dataset.handle = dir;
- handle.addEventListener('pointerdown', startLayerResize);
+ handle.addEventListener('pointerdown', dir === 'rot' ? startLayerRotate : startLayerResize);
handle.addEventListener('click', (e) => e.stopPropagation());
el.appendChild(handle);
}
@@ -16723,11 +16746,11 @@
box.style.height = inches(bounds.h);
}
box.addEventListener('pointerdown', startLayerSelectionDrag);
- for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']) {
+ for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'rot']) {
const handle = document.createElement('div');
handle.className = `layerHandle lh-${dir}`;
handle.dataset.handle = dir;
- handle.addEventListener('pointerdown', startLayerSelectionResize);
+ handle.addEventListener('pointerdown', dir === 'rot' ? startLayerSelectionRotate : startLayerSelectionResize);
handle.addEventListener('click', (e) => e.stopPropagation());
box.appendChild(handle);
}
@@ -19861,6 +19884,123 @@
// Drag-resize a layer from one of its 8 handles. Resizes in the layer's
// own (rotated) frame and keeps the opposite edge/corner anchored.
// Text layers reflow inside the new box — fontSize is never touched.
+ // Free Transform rotation — drag the round pill above the bbox to
+ // spin the layer about its own centre. Shift snaps to 15°. The
+ // existing resize path already accounts for layer.rotate in its
+ // local-frame math, so other handles keep working at any angle.
+ function startLayerRotate(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ blurEditableFocus();
+ const handle = event.currentTarget;
+ const el = handle.parentElement;
+ const layer = getLayer(el?.dataset.layerId);
+ if (!layer || layer.locked) return;
+ setLayerSelection([layer.id], layer.id);
+
+ const isDesignerLayer = layer.target === 'designer-disc';
+ const dragHost = isDesignerLayer ? $('designerDiscWrap') : $('sheet');
+ const hostRect = dragHost.getBoundingClientRect();
+ const unitW = isDesignerLayer ? discTemplate.discD : sheetW();
+ const unitH = isDesignerLayer ? discTemplate.discD : sheetH();
+ const cxIn = (Number(layer.x) || 0) + (Number(layer.w) || 0) / 2;
+ const cyIn = (Number(layer.y) || 0) + (Number(layer.h) || 0) / 2;
+ const cxPx = hostRect.left + (cxIn / unitW) * hostRect.width;
+ const cyPx = hostRect.top + (cyIn / unitH) * hostRect.height;
+
+ const rot0 = Number(layer.rotate) || 0;
+ const ang0 = Math.atan2(event.clientY - cyPx, event.clientX - cxPx) * 180 / Math.PI;
+
+ let snapshotted = false;
+ document.body.classList.add('dragging-layer');
+ try { handle.setPointerCapture(event.pointerId); } catch {}
+ const move = (e) => {
+ if (!snapshotted) { pushHistory('rotate layer'); snapshotted = true; }
+ const ang = Math.atan2(e.clientY - cyPx, e.clientX - cxPx) * 180 / Math.PI;
+ let next = rot0 + (ang - ang0);
+ if (e.shiftKey) next = Math.round(next / 15) * 15;
+ // Normalize to (-180, 180] for cleaner numeric display.
+ next = ((next + 540) % 360) - 180;
+ layer.rotate = next;
+ render();
+ };
+ const up = () => {
+ document.body.classList.remove('dragging-layer');
+ handle.removeEventListener('pointermove', move);
+ handle.removeEventListener('pointerup', up);
+ handle.removeEventListener('pointercancel', up);
+ };
+ handle.addEventListener('pointermove', move);
+ handle.addEventListener('pointerup', up);
+ handle.addEventListener('pointercancel', up);
+ }
+
+ // Multi-layer rotation: each selected layer orbits the selection's
+ // bbox centre, and its own rotation increments by the same delta,
+ // so the group spins as a rigid body.
+ function startLayerSelectionRotate(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ blurEditableFocus();
+ const handle = event.currentTarget;
+ const box = handle.parentElement;
+ const isDesignerLayer = !!box.closest('#designerLayerOverlay');
+ const layers = selectionTransformLayers(isDesignerLayer);
+ if (layers.length < 1) return;
+ const bounds = layersBounds(layers);
+ if (!bounds) return;
+
+ const dragHost = isDesignerLayer ? $('designerDiscWrap') : $('sheet');
+ const hostRect = dragHost.getBoundingClientRect();
+ const unitW = isDesignerLayer ? discTemplate.discD : sheetW();
+ const unitH = isDesignerLayer ? discTemplate.discD : sheetH();
+ const cxIn = bounds.x + bounds.w / 2;
+ const cyIn = bounds.y + bounds.h / 2;
+ const cxPx = hostRect.left + (cxIn / unitW) * hostRect.width;
+ const cyPx = hostRect.top + (cyIn / unitH) * hostRect.height;
+
+ const ang0 = Math.atan2(event.clientY - cyPx, event.clientX - cxPx) * 180 / Math.PI;
+ // Snapshot each layer's centre + rotation so the delta is
+ // applied from a stable starting state rather than accumulating.
+ const snaps = layers.map(l => ({
+ layer: l,
+ cx: (Number(l.x) || 0) + (Number(l.w) || 0) / 2,
+ cy: (Number(l.y) || 0) + (Number(l.h) || 0) / 2,
+ rot: Number(l.rotate) || 0,
+ }));
+
+ let snapshotted = false;
+ document.body.classList.add('dragging-layer');
+ try { handle.setPointerCapture(event.pointerId); } catch {}
+ const move = (e) => {
+ if (!snapshotted) { pushHistory('rotate selection'); snapshotted = true; }
+ const ang = Math.atan2(e.clientY - cyPx, e.clientX - cxPx) * 180 / Math.PI;
+ let delta = ang - ang0;
+ if (e.shiftKey) delta = Math.round(delta / 15) * 15;
+ const rad = delta * Math.PI / 180;
+ const cos = Math.cos(rad), sin = Math.sin(rad);
+ for (const s of snaps) {
+ const dx = s.cx - cxIn, dy = s.cy - cyIn;
+ const nx = dx * cos - dy * sin + cxIn;
+ const ny = dx * sin + dy * cos + cyIn;
+ const w = Number(s.layer.w) || 0, h = Number(s.layer.h) || 0;
+ s.layer.x = nx - w / 2;
+ s.layer.y = ny - h / 2;
+ s.layer.rotate = ((s.rot + delta + 540) % 360) - 180;
+ }
+ render();
+ };
+ const up = () => {
+ document.body.classList.remove('dragging-layer');
+ handle.removeEventListener('pointermove', move);
+ handle.removeEventListener('pointerup', up);
+ handle.removeEventListener('pointercancel', up);
+ };
+ handle.addEventListener('pointermove', move);
+ handle.addEventListener('pointerup', up);
+ handle.addEventListener('pointercancel', up);
+ }
+
function startLayerResize(event) {
event.preventDefault();
event.stopPropagation();
From eb9aec19d809bda9a4c2d39fc9255f0faf72e705 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Tue, 2 Jun 2026 08:54:26 -0400
Subject: [PATCH 17/23] pathfinder: Unite / Subtract / Intersect / Exclude on
shape selection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Boolean ops on 2+ selected shape layers, backed by the vendored
polygon-clipping library (29 KB minified, MIT) loaded from
src/vendor/. Adds a new "Pathfinder" details block in the sidebar
under Align & distribute.
- Unite combines all selected shapes into one outline.
- Subtract removes upper shapes from the bottom-most shape.
- Intersect keeps only the overlapping region.
- Exclude keeps only the non-overlapping regions (XOR).
Pipeline: each selected layer's pen path is sampled to a polygon
(16 samples per Bézier segment, in world inches), the chosen op
runs, and the result is rebuilt as a new pen layer that inherits
the bottom-most input layer's z-order, target, fill and stroke —
matching Illustrator's Pathfinder convention. Non-pen primitives
(rect, circle, etc.) are auto-converted to pen paths first.
Limitations baked in:
- Rotated layers aren't supported yet (we'd need to rotate the
sampled polygon before clipping). The op refuses with a toast.
- Curves are flattened — the result is a polygon approximation
with `fill-rule: evenodd` so multi-ring outputs (donut from a
subtraction) render correctly. The renderer learned to honor
shape.fillRule for this commit.
- Multi-polygon results stay rendered correctly but only the
first polygon's outer ring is preserved in pen.nodes, so
Direct Select editing post-op is partial.
---
src/index.html | 184 ++++++++++++++++++++++++++++-
src/vendor/polygon-clipping.min.js | 24 ++++
2 files changed, 207 insertions(+), 1 deletion(-)
create mode 100644 src/vendor/polygon-clipping.min.js
diff --git a/src/index.html b/src/index.html
index ad47620..3f3f74e 100644
--- a/src/index.html
+++ b/src/index.html
@@ -3746,6 +3746,19 @@
+
+
+ Pathfinder
+
Boolean ops on 2+ selected shape layers. Result is a polygon approximation (curves get flattened). Non-pen primitives are auto-converted; rotated shapes aren’t supported yet.
+
+
+
+
+
+
+
+
+
Click a layer to select it. Shift/Ctrl/Cmd-click to select multiple layers, then drag any selected layer to move the set together. Text layers are editable in place.