@grida/svg-editor 1.0.0-alpha.20 → 1.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { S as is_text_input_focused, a as paint, c as hit_shape_svg, d as NudgeDwellWatcher, f as TranslateOrchestrator, h as transform, i as TOOL_CURSOR, l as RotateOrchestrator, m as group, n as insertions, o as ResizeOrchestrator, s as resize_pipeline, t as PathModel, w as default_nudge_handler, x as array_shallow_equal } from "./model-DS5MxDrd.mjs";
1
+ import { S as is_text_input_focused, a as paint, c as hit_shape_svg, d as NudgeDwellWatcher, f as TranslateOrchestrator, h as transform, i as TOOL_CURSOR, l as RotateOrchestrator, m as group, n as insertions, o as ResizeOrchestrator, s as resize_pipeline, t as PathModel, w as default_nudge_handler, x as array_shallow_equal } from "./model-C6jCFK_p.mjs";
2
2
  import cmath from "@grida/cmath";
3
3
  import { svg_parse } from "@grida/svg/parse";
4
4
  import { SVGShapes } from "@grida/svg/pathdata";
@@ -786,6 +786,139 @@ let marquee_selection;
786
786
  _marquee_selection.resolve = resolve;
787
787
  })(marquee_selection || (marquee_selection = {}));
788
788
  //#endregion
789
+ //#region src/selection/targeting.ts
790
+ let targeting;
791
+ (function(_targeting) {
792
+ /** Siblings are treated as closer than parent/child (distance 1) so a tap
793
+ * into a sibling subtree resolves to the sibling at the focus depth without
794
+ * a tie-breaker. Mirrors Grida Canvas `weights: { sibling: 0.9 }`. */
795
+ const SIBLING_WEIGHT = .9;
796
+ function resolve_target(hits, tree, opts) {
797
+ if (hits.length === 0) return null;
798
+ const depth_cache = /* @__PURE__ */ new Map();
799
+ const depth = (id) => {
800
+ const cached = depth_cache.get(id);
801
+ if (cached !== void 0) return cached;
802
+ let d = 0;
803
+ const seen = new Set([id]);
804
+ let cur = tree.parent_of(id);
805
+ while (cur !== null && !seen.has(cur)) {
806
+ d++;
807
+ seen.add(cur);
808
+ cur = tree.parent_of(cur);
809
+ }
810
+ depth_cache.set(id, d);
811
+ return d;
812
+ };
813
+ const ancestors_inclusive = (id) => {
814
+ const set = /* @__PURE__ */ new Set();
815
+ let cur = id;
816
+ while (cur !== null && !set.has(cur)) {
817
+ set.add(cur);
818
+ cur = tree.parent_of(cur);
819
+ }
820
+ return set;
821
+ };
822
+ /** Lowest common ancestor, or `null` if the two ids are not connected
823
+ * (e.g. a stale selection id from a different/old tree). */
824
+ const lca = (a, b) => {
825
+ const a_anc = ancestors_inclusive(a);
826
+ const seen = /* @__PURE__ */ new Set();
827
+ let cur = b;
828
+ while (cur !== null && !seen.has(cur)) {
829
+ if (a_anc.has(cur)) return cur;
830
+ seen.add(cur);
831
+ cur = tree.parent_of(cur);
832
+ }
833
+ return null;
834
+ };
835
+ /** Is `a` a (strict) descendant of `b`? */
836
+ const is_descendant_of = (a, b) => {
837
+ const seen = /* @__PURE__ */ new Set();
838
+ let cur = tree.parent_of(a);
839
+ while (cur !== null && !seen.has(cur)) {
840
+ if (cur === b) return true;
841
+ seen.add(cur);
842
+ cur = tree.parent_of(cur);
843
+ }
844
+ return false;
845
+ };
846
+ const graph_distance = (a, b) => {
847
+ if (a === b) return 0;
848
+ const m = lca(a, b);
849
+ if (m === null) return Infinity;
850
+ return depth(a) + depth(b) - 2 * depth(m);
851
+ };
852
+ const weighted_distance = (a, b) => {
853
+ if (a === b) return 0;
854
+ const pa = tree.parent_of(a);
855
+ if (pa !== null && pa === tree.parent_of(b)) return SIBLING_WEIGHT;
856
+ return graph_distance(a, b);
857
+ };
858
+ if (opts.deepest) {
859
+ let best = null;
860
+ let best_depth = -Infinity;
861
+ for (const id of hits) {
862
+ const d = depth(id);
863
+ if (d > best_depth) {
864
+ best_depth = d;
865
+ best = id;
866
+ }
867
+ }
868
+ return best;
869
+ }
870
+ const by_depth = [...hits].sort((a, b) => {
871
+ const dd = depth(a) - depth(b);
872
+ return dd !== 0 ? dd : hits.indexOf(a) - hits.indexOf(b);
873
+ });
874
+ const leaf = hits[0];
875
+ const selection = opts.selection.filter((s) => lca(leaf, s) !== null);
876
+ const find_nearest = (candidates, sel, use_sibling_weight, prefer_children) => {
877
+ if (candidates.length === 0 || sel.length === 0) return null;
878
+ const dist = use_sibling_weight ? weighted_distance : graph_distance;
879
+ const scored = candidates.map((c) => {
880
+ let min = Infinity;
881
+ for (const s of sel) {
882
+ const d = dist(c, s);
883
+ if (d < min) min = d;
884
+ }
885
+ return {
886
+ id: c,
887
+ distance: min,
888
+ is_child: prefer_children ? sel.some((s) => is_descendant_of(c, s)) : false,
889
+ depth: depth(c)
890
+ };
891
+ });
892
+ scored.sort((a, b) => {
893
+ if (a.distance !== b.distance) return a.distance - b.distance;
894
+ if (prefer_children) {
895
+ if (a.is_child && !b.is_child) return -1;
896
+ if (!a.is_child && b.is_child) return 1;
897
+ }
898
+ return a.depth - b.depth;
899
+ });
900
+ return scored[0]?.id ?? null;
901
+ };
902
+ if (selection.length > 0) {
903
+ if (opts.nested_first) {
904
+ const descendants = by_depth.filter((c) => !selection.includes(c) && selection.some((s) => is_descendant_of(c, s)));
905
+ if (descendants.length > 0) {
906
+ const r = find_nearest(descendants, selection, false, false);
907
+ if (r !== null) return r;
908
+ }
909
+ }
910
+ const focus_ancestors = /* @__PURE__ */ new Set();
911
+ for (const s of selection) for (const a of ancestors_inclusive(s)) focus_ancestors.add(a);
912
+ for (const s of selection) focus_ancestors.delete(s);
913
+ const lateral = by_depth.filter((c) => !focus_ancestors.has(c));
914
+ const nearest = find_nearest(lateral.length > 0 ? lateral : by_depth, selection, true, opts.nested_first);
915
+ if (nearest !== null) return nearest;
916
+ }
917
+ return by_depth[0] ?? null;
918
+ }
919
+ _targeting.resolve_target = resolve_target;
920
+ })(targeting || (targeting = {}));
921
+ //#endregion
789
922
  //#region src/gestures/gestures.ts
790
923
  /**
791
924
  * Sibling to `Keymap`. Owns a list of installed gesture bindings; each
@@ -1781,6 +1914,7 @@ var DomSurface = class DomSurface {
1781
1914
  this.last_pointer_valid = false;
1782
1915
  this.resize_observer = null;
1783
1916
  this.redraw_raf_id = null;
1917
+ this.hover_repick_raf_id = null;
1784
1918
  this._geometry_provider = null;
1785
1919
  this._hit_shapes = null;
1786
1920
  this._z_order_cache = [];
@@ -1997,6 +2131,7 @@ var DomSurface = class DomSurface {
1997
2131
  });
1998
2132
  this.teardown.push(unsub);
1999
2133
  this.teardown.push(editor.subscribe_geometry(() => this.request_redraw()));
2134
+ this.teardown.push(editor.subscribe_with_selector((s) => s.selection, () => this.request_hover_repick(), array_shallow_equal));
2000
2135
  if (typeof ResizeObserver !== "undefined") {
2001
2136
  this.resize_observer = new ResizeObserver(() => this.sync_canvas_size());
2002
2137
  this.resize_observer.observe(container);
@@ -2087,7 +2222,62 @@ var DomSurface = class DomSurface {
2087
2222
  }
2088
2223
  hit_test(x, y) {
2089
2224
  if (this.vector_edit) return null;
2090
- return this.pick_at(x, y, false);
2225
+ const leaf = this.pick_at(x, y, false);
2226
+ if (leaf === null) return null;
2227
+ return this.resolve_group_first(leaf, { nested_first: false });
2228
+ }
2229
+ /** Group-first targeting over the raw leaf's ancestor chain — the host's
2230
+ * selection POLICY (pure rules in `src/selection/targeting.ts`). The
2231
+ * current selection defines the focus depth: a tap moves laterally /
2232
+ * sibling-aware at that depth, meta/ctrl jumps to the leaf, and
2233
+ * `nested_first` (double-click) descends one level. Returns the raw `leaf`
2234
+ * unchanged if the document is momentarily unavailable. */
2235
+ resolve_group_first(leaf, opts) {
2236
+ const doc = (() => {
2237
+ try {
2238
+ return this.editor_internal().doc;
2239
+ } catch {
2240
+ return null;
2241
+ }
2242
+ })();
2243
+ if (!doc) return leaf;
2244
+ const root = doc.root;
2245
+ const hits = [];
2246
+ const seen = /* @__PURE__ */ new Set();
2247
+ let cur = leaf;
2248
+ while (cur !== null && cur !== root && !seen.has(cur)) {
2249
+ hits.push(cur);
2250
+ seen.add(cur);
2251
+ cur = doc.parent_of(cur);
2252
+ }
2253
+ const mods = this.hud.modifiers();
2254
+ return targeting.resolve_target(hits, { parent_of: (id) => doc.parent_of(id) }, {
2255
+ selection: this.editor.state.selection,
2256
+ deepest: mods.meta || mods.ctrl,
2257
+ nested_first: opts.nested_first
2258
+ });
2259
+ }
2260
+ /** Double-click behaviour, as two distinct phases over the hierarchy:
2261
+ *
2262
+ * 1. **Descend** — resolve one level deeper toward `leaf` (relative to the
2263
+ * current focus, via `resolve_group_first` with `nested_first`). A
2264
+ * double-click that lands on a node deeper than the selection just
2265
+ * SELECTS it.
2266
+ * 2. **Enter content-edit** — only when there is nowhere deeper to go: the
2267
+ * resolved target is already the sole selection. Then (and only then) a
2268
+ * double-click opens content-edit, when the node is editable.
2269
+ *
2270
+ * Descending and editing never collide: progressive double-clicks peel
2271
+ * `G1 → G2 → … → leaf`, and one more double-click on the now-focused leaf
2272
+ * edits it. A top-level editable node (no container to peel) is selected by
2273
+ * the double-click's own first press, so its second press lands in the
2274
+ * edit phase — it still edits on a single double-click. */
2275
+ descend_or_enter_content_edit(leaf) {
2276
+ const target = this.resolve_group_first(leaf, { nested_first: true });
2277
+ if (target === null) return;
2278
+ const selection = this.editor.state.selection;
2279
+ if (selection.length === 1 && selection[0] === target) this.editor.enter_content_edit(target);
2280
+ else this.editor.commands.select(target);
2091
2281
  }
2092
2282
  /** Element-walk under (x, y) → first ancestor with `ID_ATTR`. When
2093
2283
  * `allow_root` is `false`, root hits are rejected (returns `null`) so
@@ -2127,9 +2317,12 @@ var DomSurface = class DomSurface {
2127
2317
  * `allow_root` controls whether the root `<svg>` may be returned:
2128
2318
  * selection HUD passes `false`, measurement HUD passes `true`.
2129
2319
  *
2130
- * Used by both `pick_at` (HUD hover / measurement) and
2131
- * `SvgGeometryDriver.node_at_point` (core editor selection) so one
2132
- * source of truth governs every click that resolves to a node. */
2320
+ * This is the RAW (leaf-first) picker one source of truth for "what
2321
+ * element is under this point." Used by `pick_at` (HUD hover / measurement)
2322
+ * and `SvgGeometryDriver.node_at_point` (core geometry queries). Selection
2323
+ * targeting policy (group-first / meta-deepest / focus-depth) is applied on
2324
+ * top of the raw leaf by `hit_test` → `resolve_group_first`, NOT here, so
2325
+ * measurement and geometry queries stay leaf-exact. */
2133
2326
  _pick_node_at_world(p, allow_root) {
2134
2327
  const root_id = this.editor.tree().root;
2135
2328
  const tol_px = this.editor.style.hit_tolerance_px;
@@ -2213,6 +2406,10 @@ var DomSurface = class DomSurface {
2213
2406
  (this.container.ownerDocument.defaultView ?? window).cancelAnimationFrame(this.redraw_raf_id);
2214
2407
  this.redraw_raf_id = null;
2215
2408
  }
2409
+ if (this.hover_repick_raf_id !== null) {
2410
+ (this.container.ownerDocument.defaultView ?? window).cancelAnimationFrame(this.hover_repick_raf_id);
2411
+ this.hover_repick_raf_id = null;
2412
+ }
2216
2413
  for (const fn of this.teardown) fn();
2217
2414
  this.teardown = [];
2218
2415
  this.hud.dispose();
@@ -2390,6 +2587,53 @@ var DomSurface = class DomSurface {
2390
2587
  });
2391
2588
  }
2392
2589
  /**
2590
+ * Re-pick the hover / target id at the RESTING pointer and return what
2591
+ * changed. The hover target is a pure function of (pointer, modifiers,
2592
+ * selection, tree) resolved through `hit_test` → group-first targeting;
2593
+ * every input OTHER than the pointer changes off the pointer path, so the
2594
+ * stored hover goes stale until the next real move. Replaying a synthetic
2595
+ * idle pointer-move re-runs `pick` under the current state, keeping the
2596
+ * highlight a faithful preview of what the next click would select.
2597
+ *
2598
+ * Idle-only: a synthetic move opens / cancels no gesture and leaves any tap
2599
+ * candidate / pending press untouched. No-op (all-false) when the pointer
2600
+ * has never been observed, has left the canvas, or a text / vector edit owns
2601
+ * it. Pushes nothing and redraws nothing — the caller reflects the flags.
2602
+ */
2603
+ repick_hover_at_rest() {
2604
+ if (!this.last_pointer_valid || this.hud.gesture().kind !== "idle" || this.text_edit || this.vector_edit) return {
2605
+ needsRedraw: false,
2606
+ cursorChanged: false,
2607
+ hoverChanged: false
2608
+ };
2609
+ return this.hud.dispatch({
2610
+ kind: "pointer_move",
2611
+ x: this.last_pointer.x,
2612
+ y: this.last_pointer.y,
2613
+ mods: this.hud.modifiers()
2614
+ });
2615
+ }
2616
+ /**
2617
+ * Schedule an idle hover re-pick on the next frame (RAF-coalesced, like
2618
+ * `request_redraw`). DEFERRED on purpose: the trigger — a selection change —
2619
+ * frequently fires from inside `hud.dispatch` (a double-click drill resolves
2620
+ * its new selection via `onIntent`, mid-dispatch), and re-picking there would
2621
+ * re-enter the HUD. By next frame the dispatch has unwound and the gesture
2622
+ * has settled, so `repick_hover_at_rest` runs against stable state. Multiple
2623
+ * changes within a frame fold into one re-pick.
2624
+ */
2625
+ request_hover_repick() {
2626
+ if (this.hover_repick_raf_id !== null) return;
2627
+ const win = this.container.ownerDocument.defaultView ?? window;
2628
+ this.hover_repick_raf_id = win.requestAnimationFrame(() => {
2629
+ this.hover_repick_raf_id = null;
2630
+ const moved = this.repick_hover_at_rest();
2631
+ if (moved.needsRedraw || moved.hoverChanged) this.redraw();
2632
+ if (moved.cursorChanged) this.sync_cursor();
2633
+ if (moved.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
2634
+ });
2635
+ }
2636
+ /**
2393
2637
  * Build the host-fed measurement guide for the current frame, or
2394
2638
  * `undefined` if no guide should be drawn.
2395
2639
  *
@@ -2950,9 +3194,16 @@ var DomSurface = class DomSurface {
2950
3194
  if ((prev.shift !== next.shift || prev.alt !== next.alt) && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
2951
3195
  if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
2952
3196
  if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
3197
+ let cursor_changed = response.cursorChanged;
3198
+ let hover_changed = response.hoverChanged;
3199
+ if (prev.meta !== next.meta || prev.ctrl !== next.ctrl) {
3200
+ const moved = this.repick_hover_at_rest();
3201
+ if (moved.cursorChanged) cursor_changed = true;
3202
+ if (moved.hoverChanged) hover_changed = true;
3203
+ }
2953
3204
  this.redraw();
2954
- if (response.cursorChanged) this.sync_cursor();
2955
- if (response.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
3205
+ if (cursor_changed) this.sync_cursor();
3206
+ if (hover_changed) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
2956
3207
  }
2957
3208
  dispatch_pointer(e, kind) {
2958
3209
  if (this.text_edit) {
@@ -3234,10 +3485,16 @@ var DomSurface = class DomSurface {
3234
3485
  case "set_endpoint":
3235
3486
  this.handle_set_endpoint(intent);
3236
3487
  return;
3237
- case "enter_content_edit":
3238
- this.editor.commands.select(intent.id);
3239
- this.editor.enter_content_edit(intent.id);
3488
+ case "enter_content_edit": {
3489
+ const leaf = this.last_pointer_valid ? this.pick_at(this.last_pointer.x, this.last_pointer.y, false) : null;
3490
+ if (leaf === null) {
3491
+ this.editor.commands.select(intent.id);
3492
+ this.editor.enter_content_edit(intent.id);
3493
+ return;
3494
+ }
3495
+ this.descend_or_enter_content_edit(leaf);
3240
3496
  return;
3497
+ }
3241
3498
  case "exit_content_edit":
3242
3499
  this.exit_vector_edit();
3243
3500
  return;
@@ -3289,10 +3546,12 @@ var DomSurface = class DomSurface {
3289
3546
  * Shift axis-lock for point-level drags (gridaco/grida#848) — the
3290
3547
  * vertex / endpoint counterpart of whole-object translate's
3291
3548
  * `axis_lock: "by_dominance"` (see `current_translate_modifiers`).
3292
- * Collapses the lesser axis of a local-frame delta when Shift is held,
3293
- * via the same cmath rule the translate pipeline's `axis_lock` stage
3294
- * uses; identity when Shift is up. Pull-at-consume from the HUD modifier
3295
- * store so a mid-drag Shift press/release reflects on the next frame.
3549
+ * Collapses the lesser axis of a delta when Shift is held, via the same
3550
+ * cmath rule the translate pipeline's `axis_lock` stage uses; identity when
3551
+ * Shift is up. Space-agnostic: the point path applies it to the raw screen
3552
+ * delta (before snap), so dominance reflects the user's drag intent. Pull-
3553
+ * at-consume from the HUD modifier store so a mid-drag Shift press/release
3554
+ * reflects on the next frame.
3296
3555
  */
3297
3556
  axis_lock_point_delta(dx, dy) {
3298
3557
  if (!this.hud.modifiers().shift) return [dx, dy];
@@ -3305,19 +3564,21 @@ var DomSurface = class DomSurface {
3305
3564
  * delta so it aligns with (or lands on) a sibling point: a path's other
3306
3565
  * vertices, or a `<line>`'s opposite endpoint.
3307
3566
  *
3308
- * Runs entirely in the element's **local frame**: agents (the moving
3309
- * point[s]) and neighbors (the static sibling points) come straight from
3310
- * the path's vector network / line attributes no projection, no separate
3311
- * neighbor source. The corrected delta is returned in that same local frame
3312
- * so the caller applies it exactly where it already applies the local drag
3313
- * delta. Only the snap GUIDE is projected to screen (via the element CTM)
3314
- * for HUD rendering.
3567
+ * Runs in **screen space** (container CSS-px, the HUD's identity frame). The
3568
+ * caller projects the moving point[s] (agents) and the static sibling points
3569
+ * (neighbors) through the element CTM first, and feeds the drag delta in the
3570
+ * same space. Screen space is the right frame because the threshold and the
3571
+ * rendered guide are both screen-pixel concepts: the configured
3572
+ * `snap_threshold_px` stays user-visible pixels on BOTH axes for any element
3573
+ * CTM even a non-uniform / rotated one, which a single area-scale would
3574
+ * distort (gridaco/grida#844 review) — and the guide needs no reprojection.
3575
+ * The caller converts the returned screen delta back to local for the write.
3315
3576
  *
3316
- * Honors the global snap toggle (`style.snap_enabled`) and threshold
3317
- * (`style.snap_threshold_px`, converted to local units by the CTM scale) —
3577
+ * Honors the global snap toggle (`style.snap_enabled`) and threshold
3318
3578
  * snap off ⇒ free point dragging, per the issue. Bypassed when
3319
- * `suppress_point_snap` is set (keyboard nudge). Identity when there are no
3320
- * neighbors / agents.
3579
+ * `suppress_point_snap` is set (keyboard nudge) or the point hasn't moved
3580
+ * (a pure tap / select must never relocate a control). Identity when there
3581
+ * are no neighbors / agents.
3321
3582
  *
3322
3583
  * The shared `SnapSession` drops 0-area rects (its empty-`<g>` "jerk to
3323
3584
  * origin" defense), so each point is modeled as a sub-pixel square centered
@@ -3325,14 +3586,12 @@ var DomSurface = class DomSurface {
3325
3586
  * matched same-edge offset carries the same ±eps on both sides), so eps
3326
3587
  * magnitude is immaterial as long as it stays far below the threshold.
3327
3588
  */
3328
- snap_local_point_delta(raw_dx, raw_dy, agents_local, neighbors_local, ctm) {
3589
+ snap_point_delta_screen(raw_dx, raw_dy, agents_screen, neighbors_screen) {
3329
3590
  this.point_snap_guide = void 0;
3330
3591
  const style = this.editor.style;
3331
- if (this.suppress_point_snap || !style.snap_enabled || agents_local.length === 0 || neighbors_local.length === 0 || raw_dx === 0 && raw_dy === 0) return [raw_dx, raw_dy];
3332
- const det = ctm.a * ctm.d - ctm.c * ctm.b;
3333
- const scale = Math.sqrt(Math.abs(det)) || 1;
3334
- const threshold_local = style.snap_threshold_px / scale;
3335
- const eps = threshold_local * 1e-6 || 1e-9;
3592
+ if (this.suppress_point_snap || !style.snap_enabled || agents_screen.length === 0 || neighbors_screen.length === 0 || raw_dx === 0 && raw_dy === 0) return [raw_dx, raw_dy];
3593
+ const threshold = style.snap_threshold_px;
3594
+ const eps = threshold * 1e-6 || 1e-9;
3336
3595
  const to_rect = (p) => ({
3337
3596
  x: p[0] - eps / 2,
3338
3597
  y: p[1] - eps / 2,
@@ -3340,23 +3599,23 @@ var DomSurface = class DomSurface {
3340
3599
  height: eps
3341
3600
  });
3342
3601
  const session = new SnapSession({
3343
- agents: agents_local.map(to_rect),
3344
- neighbors: neighbors_local.map(to_rect)
3602
+ agents: agents_screen.map(to_rect),
3603
+ neighbors: neighbors_screen.map(to_rect)
3345
3604
  });
3346
3605
  const { delta, guide } = session.snap({
3347
3606
  x: raw_dx,
3348
3607
  y: raw_dy
3349
3608
  }, {
3350
3609
  enabled: true,
3351
- threshold_px: threshold_local
3610
+ threshold_px: threshold
3352
3611
  });
3353
3612
  session.dispose();
3354
- if (guide) this.point_snap_guide = this.project_local_guide_to_screen(guide, ctm, this.container_offset());
3613
+ this.point_snap_guide = guide;
3355
3614
  return [delta.x, delta.y];
3356
3615
  }
3357
3616
  /** Split a path's baseline vertices into the snap agents (the moving
3358
- * sub-selection) and neighbors (everything else) for
3359
- * {@link snap_local_point_delta}. Both in path-local space. */
3617
+ * sub-selection) and neighbors (everything else) for the point-snap pass.
3618
+ * Both in path-local space; the caller projects them to screen. */
3360
3619
  vertex_snap_points(model, moving) {
3361
3620
  const verts = model.snapshot().vertices;
3362
3621
  const moving_set = new Set(moving);
@@ -3368,31 +3627,6 @@ var DomSurface = class DomSurface {
3368
3627
  neighbors
3369
3628
  };
3370
3629
  }
3371
- /** Project a point-snap guide from an element's local frame to screen
3372
- * CSS-px (the HUD canvas's identity coordinate system), via the element
3373
- * CTM + container offset — the same projection `vector_of` uses for
3374
- * vertex chrome. The local-frame analog of `project_guide_to_screen`
3375
- * (which projects world-space orchestrator guides via the camera). */
3376
- project_local_guide_to_screen(g, ctm, container_offset) {
3377
- return {
3378
- lines: g.lines.map((l) => {
3379
- const [x1, y1] = project_point_through_ctm(l.x1, l.y1, ctm, container_offset);
3380
- const [x2, y2] = project_point_through_ctm(l.x2, l.y2, ctm, container_offset);
3381
- return {
3382
- ...l,
3383
- x1,
3384
- y1,
3385
- x2,
3386
- y2
3387
- };
3388
- }),
3389
- points: g.points.map(([x, y]) => project_point_through_ctm(x, y, ctm, container_offset)),
3390
- rules: g.rules.map(([axis, value]) => {
3391
- const [px, py] = project_point_through_ctm(axis === "x" ? value : 0, axis === "x" ? 0 : value, ctm, container_offset);
3392
- return [axis, axis === "x" ? px : py];
3393
- })
3394
- };
3395
- }
3396
3630
  /** Translation from screen/page CSS-px to the HUD's container-identity
3397
3631
  * space: subtract the container's page offset, add its scroll. Used at
3398
3632
  * every `getScreenCTM` projection boundary (chrome, marquee, point snap)
@@ -3402,24 +3636,45 @@ var DomSurface = class DomSurface {
3402
3636
  return [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3403
3637
  }
3404
3638
  /**
3639
+ * Local-frame drag delta for a point sub-selection (vertices or a `<line>`
3640
+ * endpoint), composed exactly like whole-object translate: **axis-lock then
3641
+ * snap** (translate-pipeline stage order `axis_lock → snap`). The raw delta
3642
+ * and the agent / neighbor points are all in screen space (container
3643
+ * CSS-px):
3644
+ *
3645
+ * 1. Shift axis-lock collapses the lesser axis of the RAW screen delta —
3646
+ * dominance is the user's intent, decided before snap can perturb it
3647
+ * (gridaco/grida#844 review: locking the snapped delta could flip the
3648
+ * axis). The lock is screen-aligned, matching the drag and the guides.
3649
+ * 2. Point snap aligns the locked delta to the sibling points.
3650
+ * 3. The result projects back into the element's local frame via the
3651
+ * inverse CTM for `translateVertices` / the endpoint write.
3652
+ *
3653
+ * `agents_local` / `neighbors_local` are projected to screen here so the
3654
+ * snap threshold stays user-visible pixels under any element CTM.
3655
+ */
3656
+ point_drag_local_delta(ctm, raw_screen_dx, raw_screen_dy, agents_local, neighbors_local) {
3657
+ let [sx, sy] = this.axis_lock_point_delta(raw_screen_dx, raw_screen_dy);
3658
+ const offset = this.container_offset();
3659
+ const to_screen = (p) => project_point_through_ctm(p[0], p[1], ctm, offset);
3660
+ [sx, sy] = this.snap_point_delta_screen(sx, sy, agents_local.map(to_screen), neighbors_local.map(to_screen));
3661
+ return ctm.a * ctm.d - ctm.c * ctm.b !== 0 ? project_delta_inverse_ctm(sx, sy, ctm) : [sx, sy];
3662
+ }
3663
+ /**
3405
3664
  * Local-frame drag delta for a vertex sub-selection, from the HUD's
3406
- * container-space `dx/dy`. Inverse-projects through the element CTM (so a
3407
- * path under a scaled `<g>` / nested viewport tracks the cursor 1:1) then
3408
- * point-snaps to the path's other vertices (#844) before axis-lock, so
3409
- * the lock keeps final say on a constrained axis. Identity when the element
3410
- * has no usable CTM. A tangent-only drag (empty `indices`) yields no snap
3411
- * agents, so snap is a no-op. Shared by the two vertex-translate handlers;
3412
- * the caller applies axis-lock and feeds the result to `translateVertices`.
3665
+ * container-space `dx/dy`. Resolves the element CTM and the snap neighbor
3666
+ * set (the path's other vertices), then defers to `point_drag_local_delta`
3667
+ * for the axis-lock snap project composition. Falls back to a plain
3668
+ * axis-lock of the raw delta when the element has no usable CTM. A
3669
+ * tangent-only drag (empty `indices`) has no vertex agents, so snap is a
3670
+ * no-op. Shared by the two vertex-translate handlers.
3413
3671
  */
3414
3672
  vertex_drag_local_delta(node_id, baseline_model, indices, dx, dy) {
3415
3673
  const el = this.element_index.get(node_id);
3416
3674
  const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
3417
- if (!ctm) return [dx, dy];
3418
- let local_dx = dx;
3419
- let local_dy = dy;
3420
- if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(dx, dy, ctm);
3675
+ if (!ctm) return this.axis_lock_point_delta(dx, dy);
3421
3676
  const { agents, neighbors } = this.vertex_snap_points(baseline_model, indices);
3422
- return this.snap_local_point_delta(local_dx, local_dy, agents, neighbors, ctm);
3677
+ return this.point_drag_local_delta(ctm, dx, dy, agents, neighbors);
3423
3678
  }
3424
3679
  /** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
3425
3680
  * Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
@@ -3529,17 +3784,21 @@ var DomSurface = class DomSurface {
3529
3784
  const target_y = pos_own.y;
3530
3785
  const base_x = endpoint === "p1" ? initial.x1 : initial.x2;
3531
3786
  const base_y = endpoint === "p1" ? initial.y1 : initial.y2;
3532
- let dx = target_x - base_x;
3533
- let dy = target_y - base_y;
3534
3787
  const el = this.element_index.get(id);
3535
3788
  const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
3789
+ let final_x;
3790
+ let final_y;
3536
3791
  if (ctm) {
3792
+ const base_screen = project_point_through_ctm(base_x, base_y, ctm, this.container_offset());
3537
3793
  const other = endpoint === "p1" ? [initial.x2, initial.y2] : [initial.x1, initial.y1];
3538
- [dx, dy] = this.snap_local_point_delta(dx, dy, [[base_x, base_y]], [other], ctm);
3794
+ const [ldx, ldy] = this.point_drag_local_delta(ctm, intent.pos[0] - base_screen[0], intent.pos[1] - base_screen[1], [[base_x, base_y]], [other]);
3795
+ final_x = base_x + ldx;
3796
+ final_y = base_y + ldy;
3797
+ } else {
3798
+ const [ldx, ldy] = this.axis_lock_point_delta(target_x - base_x, target_y - base_y);
3799
+ final_x = base_x + ldx;
3800
+ final_y = base_y + ldy;
3539
3801
  }
3540
- const [locked_dx, locked_dy] = this.axis_lock_point_delta(dx, dy);
3541
- const final_x = base_x + locked_dx;
3542
- const final_y = base_y + locked_dy;
3543
3802
  const apply = () => {
3544
3803
  if (endpoint === "p1") {
3545
3804
  doc.set_attr(id, "x1", String(final_x));
@@ -4013,6 +4272,7 @@ var DomSurface = class DomSurface {
4013
4272
  }
4014
4273
  this.vector_edit = null;
4015
4274
  this.vector_edit_region_baseline = null;
4275
+ this.point_snap_guide = void 0;
4016
4276
  this.hud.setVectorSelection(null);
4017
4277
  if (this.current_tool.type === "lasso" || this.current_tool.type === "bend") this.editor.set_tool({ type: "cursor" });
4018
4278
  this.editor.commands.set_mode("select");
@@ -4284,8 +4544,7 @@ var DomSurface = class DomSurface {
4284
4544
  }
4285
4545
  const baseline_d = this.active_preview.initial_d;
4286
4546
  const indices = this.active_preview.indices;
4287
- let [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4288
- [local_dx, local_dy] = this.axis_lock_point_delta(local_dx, local_dy);
4547
+ const [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4289
4548
  const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
4290
4549
  const target_d = preview_model.toSvgPathD();
4291
4550
  this.active_preview.preview_model = preview_model;
@@ -4377,8 +4636,7 @@ var DomSurface = class DomSurface {
4377
4636
  };
4378
4637
  }
4379
4638
  const baseline_d = this.active_preview.initial_d;
4380
- let [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4381
- [local_dx, local_dy] = this.axis_lock_point_delta(local_dx, local_dy);
4639
+ const [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4382
4640
  const baseline_model = this.active_preview.baseline_model;
4383
4641
  const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
4384
4642
  let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;
package/dist/dom.js CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_dom = require("./dom-CfYDV311.js");
2
+ const require_dom = require("./dom-CuK0LFUY.js");
3
3
  exports.Camera = require_dom.Camera;
4
4
  exports.DEFAULT_SNAP_OPTIONS = require_dom.DEFAULT_SNAP_OPTIONS;
5
5
  exports.Gestures = require_dom.Gestures;
package/dist/dom.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as project_point_through_ctm, c as MemoizedGeometryProvider, i as project_delta_inverse_ctm, l as Camera, n as install_font_load_geometry_bump, o as Gestures, r as inverse_project_rect, s as DEFAULT_SNAP_OPTIONS, t as attach_dom_surface } from "./dom-Dub-TMoN.mjs";
1
+ import { a as project_point_through_ctm, c as MemoizedGeometryProvider, i as project_delta_inverse_ctm, l as Camera, n as install_font_load_geometry_bump, o as Gestures, r as inverse_project_rect, s as DEFAULT_SNAP_OPTIONS, t as attach_dom_surface } from "./dom-DHaTIObb.mjs";
2
2
  export { Camera, DEFAULT_SNAP_OPTIONS, Gestures, MemoizedGeometryProvider, attach_dom_surface, install_font_load_geometry_bump, inverse_project_rect, project_delta_inverse_ctm, project_point_through_ctm };
@@ -1,4 +1,4 @@
1
- const require_model = require("./model-CzL6_zId.js");
1
+ const require_model = require("./model-DVwjrVYp.js");
2
2
  let _grida_history = require("@grida/history");
3
3
  let _grida_keybinding = require("@grida/keybinding");
4
4
  let _grida_cmath = require("@grida/cmath");
@@ -1206,15 +1206,16 @@ function _create_svg_editor_internal(opts) {
1206
1206
  });
1207
1207
  }
1208
1208
  function set_selection(next) {
1209
- if (next.length === selection.length) {
1209
+ const pruned = doc.prune_nested_nodes(next);
1210
+ if (pruned.length === selection.length) {
1210
1211
  let same = true;
1211
- for (let i = 0; i < next.length; i++) if (next[i] !== selection[i]) {
1212
+ for (let i = 0; i < pruned.length; i++) if (pruned[i] !== selection[i]) {
1212
1213
  same = false;
1213
1214
  break;
1214
1215
  }
1215
1216
  if (same) return;
1216
1217
  }
1217
- selection = Object.freeze([...next]);
1218
+ selection = Object.freeze([...pruned]);
1218
1219
  emit();
1219
1220
  }
1220
1221
  function select(target, opts) {
@@ -1,4 +1,4 @@
1
- import { C as TOOL_SET, S as is_text_input_focused, T as registerDefaultCommands, _ as SVG_NS, a as paint, b as XMLNS_NS, g as subtree, h as transform, i as TOOL_CURSOR, m as group, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline, v as SvgDocument, x as array_shallow_equal, y as WELL_KNOWN_NS_PREFIXES } from "./model-DS5MxDrd.mjs";
1
+ import { C as TOOL_SET, S as is_text_input_focused, T as registerDefaultCommands, _ as SVG_NS, a as paint, b as XMLNS_NS, g as subtree, h as transform, i as TOOL_CURSOR, m as group, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline, v as SvgDocument, x as array_shallow_equal, y as WELL_KNOWN_NS_PREFIXES } from "./model-C6jCFK_p.mjs";
2
2
  import { HistoryImpl } from "@grida/history";
3
3
  import { KeyCode, M, chunkKey, eventToChunk, getKeyboardOS, kb, keybindingsToKeyCodes } from "@grida/keybinding";
4
4
  import cmath from "@grida/cmath";
@@ -1205,15 +1205,16 @@ function _create_svg_editor_internal(opts) {
1205
1205
  });
1206
1206
  }
1207
1207
  function set_selection(next) {
1208
- if (next.length === selection.length) {
1208
+ const pruned = doc.prune_nested_nodes(next);
1209
+ if (pruned.length === selection.length) {
1209
1210
  let same = true;
1210
- for (let i = 0; i < next.length; i++) if (next[i] !== selection[i]) {
1211
+ for (let i = 0; i < pruned.length; i++) if (pruned[i] !== selection[i]) {
1211
1212
  same = false;
1212
1213
  break;
1213
1214
  }
1214
1215
  if (same) return;
1215
1216
  }
1216
- selection = Object.freeze([...next]);
1217
+ selection = Object.freeze([...pruned]);
1217
1218
  emit();
1218
1219
  }
1219
1220
  function select(target, opts) {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_model = require("./model-CzL6_zId.js");
3
- const require_editor = require("./editor-4U9LAZ6r.js");
2
+ const require_model = require("./model-DVwjrVYp.js");
3
+ const require_editor = require("./editor-BlByfVyF.js");
4
4
  exports.DEFAULT_STYLE = require_model.DEFAULT_STYLE;
5
5
  exports.PathModel = require_model.PathModel;
6
6
  exports.TOOL_CURSOR = require_model.TOOL_CURSOR;