pen tool: Illustrator parity polish (multi-anchor + drag-segment + marquee)

- 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.
This commit is contained in:
thePR0M3TH3AN committed 2026-06-01 21:24:24 -04:00
1 parent c73aab2c20
commit 547ff5d1c6
1 file changed
+232 -18
+232 -18
View File
@@ -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) {