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) {