mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
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:
1 parent
c73aab2c20
commit
547ff5d1c6
1 file changed
+232
-18
+232
-18
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user