-
+
+
@@ -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; }