@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.
package/README.md CHANGED
@@ -962,7 +962,7 @@ What this editor will never be. Each one is a defensive perimeter for the princi
962
962
  - **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.)
963
963
  - **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
964
964
  - **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
965
- - **Not a customizable selection policy.** What a pick or a marquee selects (the marquee shadow rule, meta routing, additive) is a fixed product decision, owned by the labeled policy layer (`src/selection/`, spec [`docs/marquee-selection.md`](./docs/marquee-selection.md)). No host hook, provider, or registry swaps it; the opinion lives above the engine, never inside it, and never as a public knob.
965
+ - **Not a customizable selection policy.** What a pick or a marquee selects (the marquee shadow rule, meta routing, additive, and the group-first targeting that lifts a click to its container) is a fixed product decision, owned by the labeled policy layer (`src/selection/`, specs [`docs/marquee-selection.md`](./docs/marquee-selection.md) and [`docs/group-first-targeting.md`](./docs/group-first-targeting.md)). No host hook, provider, or registry swaps it; the opinion lives above the engine, never inside it, and never as a public knob.
966
966
  - **Not a private IR.** SVG is the source of truth. The editor does not maintain an alternative on-disk format, and the bytes are not projected from any in-memory canonical store. (The internal typed element IR described under [Paradigm § Element IR (internal)](#element-ir-internal) is a typed view over the parsed AST, not a store the file is derived from — the AST and the file are the source of truth, and the IR is rebuilt from them on each load.)
967
967
  - **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
968
968
  - **Not an input-interception hook.** The pick/tap observation (`subscribe_pick`) reports a click that already happened; it cannot prevent, delay, or replace the editor's own selection and gesture handling. A host that needs to intercept input owns the container and splices its own layer in (the DOM escape hatch) — it does not get a veto through the observation surface.
@@ -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_cmath = require("@grida/cmath");
3
3
  _grida_cmath = require_model.__toESM(_grida_cmath);
4
4
  let _grida_svg_parse = require("@grida/svg/parse");
@@ -788,6 +788,139 @@ let marquee_selection;
788
788
  _marquee_selection.resolve = resolve;
789
789
  })(marquee_selection || (marquee_selection = {}));
790
790
  //#endregion
791
+ //#region src/selection/targeting.ts
792
+ let targeting;
793
+ (function(_targeting) {
794
+ /** Siblings are treated as closer than parent/child (distance 1) so a tap
795
+ * into a sibling subtree resolves to the sibling at the focus depth without
796
+ * a tie-breaker. Mirrors Grida Canvas `weights: { sibling: 0.9 }`. */
797
+ const SIBLING_WEIGHT = .9;
798
+ function resolve_target(hits, tree, opts) {
799
+ if (hits.length === 0) return null;
800
+ const depth_cache = /* @__PURE__ */ new Map();
801
+ const depth = (id) => {
802
+ const cached = depth_cache.get(id);
803
+ if (cached !== void 0) return cached;
804
+ let d = 0;
805
+ const seen = new Set([id]);
806
+ let cur = tree.parent_of(id);
807
+ while (cur !== null && !seen.has(cur)) {
808
+ d++;
809
+ seen.add(cur);
810
+ cur = tree.parent_of(cur);
811
+ }
812
+ depth_cache.set(id, d);
813
+ return d;
814
+ };
815
+ const ancestors_inclusive = (id) => {
816
+ const set = /* @__PURE__ */ new Set();
817
+ let cur = id;
818
+ while (cur !== null && !set.has(cur)) {
819
+ set.add(cur);
820
+ cur = tree.parent_of(cur);
821
+ }
822
+ return set;
823
+ };
824
+ /** Lowest common ancestor, or `null` if the two ids are not connected
825
+ * (e.g. a stale selection id from a different/old tree). */
826
+ const lca = (a, b) => {
827
+ const a_anc = ancestors_inclusive(a);
828
+ const seen = /* @__PURE__ */ new Set();
829
+ let cur = b;
830
+ while (cur !== null && !seen.has(cur)) {
831
+ if (a_anc.has(cur)) return cur;
832
+ seen.add(cur);
833
+ cur = tree.parent_of(cur);
834
+ }
835
+ return null;
836
+ };
837
+ /** Is `a` a (strict) descendant of `b`? */
838
+ const is_descendant_of = (a, b) => {
839
+ const seen = /* @__PURE__ */ new Set();
840
+ let cur = tree.parent_of(a);
841
+ while (cur !== null && !seen.has(cur)) {
842
+ if (cur === b) return true;
843
+ seen.add(cur);
844
+ cur = tree.parent_of(cur);
845
+ }
846
+ return false;
847
+ };
848
+ const graph_distance = (a, b) => {
849
+ if (a === b) return 0;
850
+ const m = lca(a, b);
851
+ if (m === null) return Infinity;
852
+ return depth(a) + depth(b) - 2 * depth(m);
853
+ };
854
+ const weighted_distance = (a, b) => {
855
+ if (a === b) return 0;
856
+ const pa = tree.parent_of(a);
857
+ if (pa !== null && pa === tree.parent_of(b)) return SIBLING_WEIGHT;
858
+ return graph_distance(a, b);
859
+ };
860
+ if (opts.deepest) {
861
+ let best = null;
862
+ let best_depth = -Infinity;
863
+ for (const id of hits) {
864
+ const d = depth(id);
865
+ if (d > best_depth) {
866
+ best_depth = d;
867
+ best = id;
868
+ }
869
+ }
870
+ return best;
871
+ }
872
+ const by_depth = [...hits].sort((a, b) => {
873
+ const dd = depth(a) - depth(b);
874
+ return dd !== 0 ? dd : hits.indexOf(a) - hits.indexOf(b);
875
+ });
876
+ const leaf = hits[0];
877
+ const selection = opts.selection.filter((s) => lca(leaf, s) !== null);
878
+ const find_nearest = (candidates, sel, use_sibling_weight, prefer_children) => {
879
+ if (candidates.length === 0 || sel.length === 0) return null;
880
+ const dist = use_sibling_weight ? weighted_distance : graph_distance;
881
+ const scored = candidates.map((c) => {
882
+ let min = Infinity;
883
+ for (const s of sel) {
884
+ const d = dist(c, s);
885
+ if (d < min) min = d;
886
+ }
887
+ return {
888
+ id: c,
889
+ distance: min,
890
+ is_child: prefer_children ? sel.some((s) => is_descendant_of(c, s)) : false,
891
+ depth: depth(c)
892
+ };
893
+ });
894
+ scored.sort((a, b) => {
895
+ if (a.distance !== b.distance) return a.distance - b.distance;
896
+ if (prefer_children) {
897
+ if (a.is_child && !b.is_child) return -1;
898
+ if (!a.is_child && b.is_child) return 1;
899
+ }
900
+ return a.depth - b.depth;
901
+ });
902
+ return scored[0]?.id ?? null;
903
+ };
904
+ if (selection.length > 0) {
905
+ if (opts.nested_first) {
906
+ const descendants = by_depth.filter((c) => !selection.includes(c) && selection.some((s) => is_descendant_of(c, s)));
907
+ if (descendants.length > 0) {
908
+ const r = find_nearest(descendants, selection, false, false);
909
+ if (r !== null) return r;
910
+ }
911
+ }
912
+ const focus_ancestors = /* @__PURE__ */ new Set();
913
+ for (const s of selection) for (const a of ancestors_inclusive(s)) focus_ancestors.add(a);
914
+ for (const s of selection) focus_ancestors.delete(s);
915
+ const lateral = by_depth.filter((c) => !focus_ancestors.has(c));
916
+ const nearest = find_nearest(lateral.length > 0 ? lateral : by_depth, selection, true, opts.nested_first);
917
+ if (nearest !== null) return nearest;
918
+ }
919
+ return by_depth[0] ?? null;
920
+ }
921
+ _targeting.resolve_target = resolve_target;
922
+ })(targeting || (targeting = {}));
923
+ //#endregion
791
924
  //#region src/gestures/gestures.ts
792
925
  /**
793
926
  * Sibling to `Keymap`. Owns a list of installed gesture bindings; each
@@ -1783,6 +1916,7 @@ var DomSurface = class DomSurface {
1783
1916
  this.last_pointer_valid = false;
1784
1917
  this.resize_observer = null;
1785
1918
  this.redraw_raf_id = null;
1919
+ this.hover_repick_raf_id = null;
1786
1920
  this._geometry_provider = null;
1787
1921
  this._hit_shapes = null;
1788
1922
  this._z_order_cache = [];
@@ -1999,6 +2133,7 @@ var DomSurface = class DomSurface {
1999
2133
  });
2000
2134
  this.teardown.push(unsub);
2001
2135
  this.teardown.push(editor.subscribe_geometry(() => this.request_redraw()));
2136
+ this.teardown.push(editor.subscribe_with_selector((s) => s.selection, () => this.request_hover_repick(), require_model.array_shallow_equal));
2002
2137
  if (typeof ResizeObserver !== "undefined") {
2003
2138
  this.resize_observer = new ResizeObserver(() => this.sync_canvas_size());
2004
2139
  this.resize_observer.observe(container);
@@ -2089,7 +2224,62 @@ var DomSurface = class DomSurface {
2089
2224
  }
2090
2225
  hit_test(x, y) {
2091
2226
  if (this.vector_edit) return null;
2092
- return this.pick_at(x, y, false);
2227
+ const leaf = this.pick_at(x, y, false);
2228
+ if (leaf === null) return null;
2229
+ return this.resolve_group_first(leaf, { nested_first: false });
2230
+ }
2231
+ /** Group-first targeting over the raw leaf's ancestor chain — the host's
2232
+ * selection POLICY (pure rules in `src/selection/targeting.ts`). The
2233
+ * current selection defines the focus depth: a tap moves laterally /
2234
+ * sibling-aware at that depth, meta/ctrl jumps to the leaf, and
2235
+ * `nested_first` (double-click) descends one level. Returns the raw `leaf`
2236
+ * unchanged if the document is momentarily unavailable. */
2237
+ resolve_group_first(leaf, opts) {
2238
+ const doc = (() => {
2239
+ try {
2240
+ return this.editor_internal().doc;
2241
+ } catch {
2242
+ return null;
2243
+ }
2244
+ })();
2245
+ if (!doc) return leaf;
2246
+ const root = doc.root;
2247
+ const hits = [];
2248
+ const seen = /* @__PURE__ */ new Set();
2249
+ let cur = leaf;
2250
+ while (cur !== null && cur !== root && !seen.has(cur)) {
2251
+ hits.push(cur);
2252
+ seen.add(cur);
2253
+ cur = doc.parent_of(cur);
2254
+ }
2255
+ const mods = this.hud.modifiers();
2256
+ return targeting.resolve_target(hits, { parent_of: (id) => doc.parent_of(id) }, {
2257
+ selection: this.editor.state.selection,
2258
+ deepest: mods.meta || mods.ctrl,
2259
+ nested_first: opts.nested_first
2260
+ });
2261
+ }
2262
+ /** Double-click behaviour, as two distinct phases over the hierarchy:
2263
+ *
2264
+ * 1. **Descend** — resolve one level deeper toward `leaf` (relative to the
2265
+ * current focus, via `resolve_group_first` with `nested_first`). A
2266
+ * double-click that lands on a node deeper than the selection just
2267
+ * SELECTS it.
2268
+ * 2. **Enter content-edit** — only when there is nowhere deeper to go: the
2269
+ * resolved target is already the sole selection. Then (and only then) a
2270
+ * double-click opens content-edit, when the node is editable.
2271
+ *
2272
+ * Descending and editing never collide: progressive double-clicks peel
2273
+ * `G1 → G2 → … → leaf`, and one more double-click on the now-focused leaf
2274
+ * edits it. A top-level editable node (no container to peel) is selected by
2275
+ * the double-click's own first press, so its second press lands in the
2276
+ * edit phase — it still edits on a single double-click. */
2277
+ descend_or_enter_content_edit(leaf) {
2278
+ const target = this.resolve_group_first(leaf, { nested_first: true });
2279
+ if (target === null) return;
2280
+ const selection = this.editor.state.selection;
2281
+ if (selection.length === 1 && selection[0] === target) this.editor.enter_content_edit(target);
2282
+ else this.editor.commands.select(target);
2093
2283
  }
2094
2284
  /** Element-walk under (x, y) → first ancestor with `ID_ATTR`. When
2095
2285
  * `allow_root` is `false`, root hits are rejected (returns `null`) so
@@ -2129,9 +2319,12 @@ var DomSurface = class DomSurface {
2129
2319
  * `allow_root` controls whether the root `<svg>` may be returned:
2130
2320
  * selection HUD passes `false`, measurement HUD passes `true`.
2131
2321
  *
2132
- * Used by both `pick_at` (HUD hover / measurement) and
2133
- * `SvgGeometryDriver.node_at_point` (core editor selection) so one
2134
- * source of truth governs every click that resolves to a node. */
2322
+ * This is the RAW (leaf-first) picker one source of truth for "what
2323
+ * element is under this point." Used by `pick_at` (HUD hover / measurement)
2324
+ * and `SvgGeometryDriver.node_at_point` (core geometry queries). Selection
2325
+ * targeting policy (group-first / meta-deepest / focus-depth) is applied on
2326
+ * top of the raw leaf by `hit_test` → `resolve_group_first`, NOT here, so
2327
+ * measurement and geometry queries stay leaf-exact. */
2135
2328
  _pick_node_at_world(p, allow_root) {
2136
2329
  const root_id = this.editor.tree().root;
2137
2330
  const tol_px = this.editor.style.hit_tolerance_px;
@@ -2215,6 +2408,10 @@ var DomSurface = class DomSurface {
2215
2408
  (this.container.ownerDocument.defaultView ?? window).cancelAnimationFrame(this.redraw_raf_id);
2216
2409
  this.redraw_raf_id = null;
2217
2410
  }
2411
+ if (this.hover_repick_raf_id !== null) {
2412
+ (this.container.ownerDocument.defaultView ?? window).cancelAnimationFrame(this.hover_repick_raf_id);
2413
+ this.hover_repick_raf_id = null;
2414
+ }
2218
2415
  for (const fn of this.teardown) fn();
2219
2416
  this.teardown = [];
2220
2417
  this.hud.dispose();
@@ -2392,6 +2589,53 @@ var DomSurface = class DomSurface {
2392
2589
  });
2393
2590
  }
2394
2591
  /**
2592
+ * Re-pick the hover / target id at the RESTING pointer and return what
2593
+ * changed. The hover target is a pure function of (pointer, modifiers,
2594
+ * selection, tree) resolved through `hit_test` → group-first targeting;
2595
+ * every input OTHER than the pointer changes off the pointer path, so the
2596
+ * stored hover goes stale until the next real move. Replaying a synthetic
2597
+ * idle pointer-move re-runs `pick` under the current state, keeping the
2598
+ * highlight a faithful preview of what the next click would select.
2599
+ *
2600
+ * Idle-only: a synthetic move opens / cancels no gesture and leaves any tap
2601
+ * candidate / pending press untouched. No-op (all-false) when the pointer
2602
+ * has never been observed, has left the canvas, or a text / vector edit owns
2603
+ * it. Pushes nothing and redraws nothing — the caller reflects the flags.
2604
+ */
2605
+ repick_hover_at_rest() {
2606
+ if (!this.last_pointer_valid || this.hud.gesture().kind !== "idle" || this.text_edit || this.vector_edit) return {
2607
+ needsRedraw: false,
2608
+ cursorChanged: false,
2609
+ hoverChanged: false
2610
+ };
2611
+ return this.hud.dispatch({
2612
+ kind: "pointer_move",
2613
+ x: this.last_pointer.x,
2614
+ y: this.last_pointer.y,
2615
+ mods: this.hud.modifiers()
2616
+ });
2617
+ }
2618
+ /**
2619
+ * Schedule an idle hover re-pick on the next frame (RAF-coalesced, like
2620
+ * `request_redraw`). DEFERRED on purpose: the trigger — a selection change —
2621
+ * frequently fires from inside `hud.dispatch` (a double-click drill resolves
2622
+ * its new selection via `onIntent`, mid-dispatch), and re-picking there would
2623
+ * re-enter the HUD. By next frame the dispatch has unwound and the gesture
2624
+ * has settled, so `repick_hover_at_rest` runs against stable state. Multiple
2625
+ * changes within a frame fold into one re-pick.
2626
+ */
2627
+ request_hover_repick() {
2628
+ if (this.hover_repick_raf_id !== null) return;
2629
+ const win = this.container.ownerDocument.defaultView ?? window;
2630
+ this.hover_repick_raf_id = win.requestAnimationFrame(() => {
2631
+ this.hover_repick_raf_id = null;
2632
+ const moved = this.repick_hover_at_rest();
2633
+ if (moved.needsRedraw || moved.hoverChanged) this.redraw();
2634
+ if (moved.cursorChanged) this.sync_cursor();
2635
+ if (moved.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
2636
+ });
2637
+ }
2638
+ /**
2395
2639
  * Build the host-fed measurement guide for the current frame, or
2396
2640
  * `undefined` if no guide should be drawn.
2397
2641
  *
@@ -2952,9 +3196,16 @@ var DomSurface = class DomSurface {
2952
3196
  if ((prev.shift !== next.shift || prev.alt !== next.alt) && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
2953
3197
  if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
2954
3198
  if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
3199
+ let cursor_changed = response.cursorChanged;
3200
+ let hover_changed = response.hoverChanged;
3201
+ if (prev.meta !== next.meta || prev.ctrl !== next.ctrl) {
3202
+ const moved = this.repick_hover_at_rest();
3203
+ if (moved.cursorChanged) cursor_changed = true;
3204
+ if (moved.hoverChanged) hover_changed = true;
3205
+ }
2955
3206
  this.redraw();
2956
- if (response.cursorChanged) this.sync_cursor();
2957
- if (response.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
3207
+ if (cursor_changed) this.sync_cursor();
3208
+ if (hover_changed) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
2958
3209
  }
2959
3210
  dispatch_pointer(e, kind) {
2960
3211
  if (this.text_edit) {
@@ -3236,10 +3487,16 @@ var DomSurface = class DomSurface {
3236
3487
  case "set_endpoint":
3237
3488
  this.handle_set_endpoint(intent);
3238
3489
  return;
3239
- case "enter_content_edit":
3240
- this.editor.commands.select(intent.id);
3241
- this.editor.enter_content_edit(intent.id);
3490
+ case "enter_content_edit": {
3491
+ const leaf = this.last_pointer_valid ? this.pick_at(this.last_pointer.x, this.last_pointer.y, false) : null;
3492
+ if (leaf === null) {
3493
+ this.editor.commands.select(intent.id);
3494
+ this.editor.enter_content_edit(intent.id);
3495
+ return;
3496
+ }
3497
+ this.descend_or_enter_content_edit(leaf);
3242
3498
  return;
3499
+ }
3243
3500
  case "exit_content_edit":
3244
3501
  this.exit_vector_edit();
3245
3502
  return;
@@ -3291,10 +3548,12 @@ var DomSurface = class DomSurface {
3291
3548
  * Shift axis-lock for point-level drags (gridaco/grida#848) — the
3292
3549
  * vertex / endpoint counterpart of whole-object translate's
3293
3550
  * `axis_lock: "by_dominance"` (see `current_translate_modifiers`).
3294
- * Collapses the lesser axis of a local-frame delta when Shift is held,
3295
- * via the same cmath rule the translate pipeline's `axis_lock` stage
3296
- * uses; identity when Shift is up. Pull-at-consume from the HUD modifier
3297
- * store so a mid-drag Shift press/release reflects on the next frame.
3551
+ * Collapses the lesser axis of a delta when Shift is held, via the same
3552
+ * cmath rule the translate pipeline's `axis_lock` stage uses; identity when
3553
+ * Shift is up. Space-agnostic: the point path applies it to the raw screen
3554
+ * delta (before snap), so dominance reflects the user's drag intent. Pull-
3555
+ * at-consume from the HUD modifier store so a mid-drag Shift press/release
3556
+ * reflects on the next frame.
3298
3557
  */
3299
3558
  axis_lock_point_delta(dx, dy) {
3300
3559
  if (!this.hud.modifiers().shift) return [dx, dy];
@@ -3307,19 +3566,21 @@ var DomSurface = class DomSurface {
3307
3566
  * delta so it aligns with (or lands on) a sibling point: a path's other
3308
3567
  * vertices, or a `<line>`'s opposite endpoint.
3309
3568
  *
3310
- * Runs entirely in the element's **local frame**: agents (the moving
3311
- * point[s]) and neighbors (the static sibling points) come straight from
3312
- * the path's vector network / line attributes no projection, no separate
3313
- * neighbor source. The corrected delta is returned in that same local frame
3314
- * so the caller applies it exactly where it already applies the local drag
3315
- * delta. Only the snap GUIDE is projected to screen (via the element CTM)
3316
- * for HUD rendering.
3569
+ * Runs in **screen space** (container CSS-px, the HUD's identity frame). The
3570
+ * caller projects the moving point[s] (agents) and the static sibling points
3571
+ * (neighbors) through the element CTM first, and feeds the drag delta in the
3572
+ * same space. Screen space is the right frame because the threshold and the
3573
+ * rendered guide are both screen-pixel concepts: the configured
3574
+ * `snap_threshold_px` stays user-visible pixels on BOTH axes for any element
3575
+ * CTM even a non-uniform / rotated one, which a single area-scale would
3576
+ * distort (gridaco/grida#844 review) — and the guide needs no reprojection.
3577
+ * The caller converts the returned screen delta back to local for the write.
3317
3578
  *
3318
- * Honors the global snap toggle (`style.snap_enabled`) and threshold
3319
- * (`style.snap_threshold_px`, converted to local units by the CTM scale) —
3579
+ * Honors the global snap toggle (`style.snap_enabled`) and threshold
3320
3580
  * snap off ⇒ free point dragging, per the issue. Bypassed when
3321
- * `suppress_point_snap` is set (keyboard nudge). Identity when there are no
3322
- * neighbors / agents.
3581
+ * `suppress_point_snap` is set (keyboard nudge) or the point hasn't moved
3582
+ * (a pure tap / select must never relocate a control). Identity when there
3583
+ * are no neighbors / agents.
3323
3584
  *
3324
3585
  * The shared `SnapSession` drops 0-area rects (its empty-`<g>` "jerk to
3325
3586
  * origin" defense), so each point is modeled as a sub-pixel square centered
@@ -3327,14 +3588,12 @@ var DomSurface = class DomSurface {
3327
3588
  * matched same-edge offset carries the same ±eps on both sides), so eps
3328
3589
  * magnitude is immaterial as long as it stays far below the threshold.
3329
3590
  */
3330
- snap_local_point_delta(raw_dx, raw_dy, agents_local, neighbors_local, ctm) {
3591
+ snap_point_delta_screen(raw_dx, raw_dy, agents_screen, neighbors_screen) {
3331
3592
  this.point_snap_guide = void 0;
3332
3593
  const style = this.editor.style;
3333
- 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];
3334
- const det = ctm.a * ctm.d - ctm.c * ctm.b;
3335
- const scale = Math.sqrt(Math.abs(det)) || 1;
3336
- const threshold_local = style.snap_threshold_px / scale;
3337
- const eps = threshold_local * 1e-6 || 1e-9;
3594
+ 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];
3595
+ const threshold = style.snap_threshold_px;
3596
+ const eps = threshold * 1e-6 || 1e-9;
3338
3597
  const to_rect = (p) => ({
3339
3598
  x: p[0] - eps / 2,
3340
3599
  y: p[1] - eps / 2,
@@ -3342,23 +3601,23 @@ var DomSurface = class DomSurface {
3342
3601
  height: eps
3343
3602
  });
3344
3603
  const session = new SnapSession({
3345
- agents: agents_local.map(to_rect),
3346
- neighbors: neighbors_local.map(to_rect)
3604
+ agents: agents_screen.map(to_rect),
3605
+ neighbors: neighbors_screen.map(to_rect)
3347
3606
  });
3348
3607
  const { delta, guide } = session.snap({
3349
3608
  x: raw_dx,
3350
3609
  y: raw_dy
3351
3610
  }, {
3352
3611
  enabled: true,
3353
- threshold_px: threshold_local
3612
+ threshold_px: threshold
3354
3613
  });
3355
3614
  session.dispose();
3356
- if (guide) this.point_snap_guide = this.project_local_guide_to_screen(guide, ctm, this.container_offset());
3615
+ this.point_snap_guide = guide;
3357
3616
  return [delta.x, delta.y];
3358
3617
  }
3359
3618
  /** Split a path's baseline vertices into the snap agents (the moving
3360
- * sub-selection) and neighbors (everything else) for
3361
- * {@link snap_local_point_delta}. Both in path-local space. */
3619
+ * sub-selection) and neighbors (everything else) for the point-snap pass.
3620
+ * Both in path-local space; the caller projects them to screen. */
3362
3621
  vertex_snap_points(model, moving) {
3363
3622
  const verts = model.snapshot().vertices;
3364
3623
  const moving_set = new Set(moving);
@@ -3370,31 +3629,6 @@ var DomSurface = class DomSurface {
3370
3629
  neighbors
3371
3630
  };
3372
3631
  }
3373
- /** Project a point-snap guide from an element's local frame to screen
3374
- * CSS-px (the HUD canvas's identity coordinate system), via the element
3375
- * CTM + container offset — the same projection `vector_of` uses for
3376
- * vertex chrome. The local-frame analog of `project_guide_to_screen`
3377
- * (which projects world-space orchestrator guides via the camera). */
3378
- project_local_guide_to_screen(g, ctm, container_offset) {
3379
- return {
3380
- lines: g.lines.map((l) => {
3381
- const [x1, y1] = project_point_through_ctm(l.x1, l.y1, ctm, container_offset);
3382
- const [x2, y2] = project_point_through_ctm(l.x2, l.y2, ctm, container_offset);
3383
- return {
3384
- ...l,
3385
- x1,
3386
- y1,
3387
- x2,
3388
- y2
3389
- };
3390
- }),
3391
- points: g.points.map(([x, y]) => project_point_through_ctm(x, y, ctm, container_offset)),
3392
- rules: g.rules.map(([axis, value]) => {
3393
- const [px, py] = project_point_through_ctm(axis === "x" ? value : 0, axis === "x" ? 0 : value, ctm, container_offset);
3394
- return [axis, axis === "x" ? px : py];
3395
- })
3396
- };
3397
- }
3398
3632
  /** Translation from screen/page CSS-px to the HUD's container-identity
3399
3633
  * space: subtract the container's page offset, add its scroll. Used at
3400
3634
  * every `getScreenCTM` projection boundary (chrome, marquee, point snap)
@@ -3404,24 +3638,45 @@ var DomSurface = class DomSurface {
3404
3638
  return [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3405
3639
  }
3406
3640
  /**
3641
+ * Local-frame drag delta for a point sub-selection (vertices or a `<line>`
3642
+ * endpoint), composed exactly like whole-object translate: **axis-lock then
3643
+ * snap** (translate-pipeline stage order `axis_lock → snap`). The raw delta
3644
+ * and the agent / neighbor points are all in screen space (container
3645
+ * CSS-px):
3646
+ *
3647
+ * 1. Shift axis-lock collapses the lesser axis of the RAW screen delta —
3648
+ * dominance is the user's intent, decided before snap can perturb it
3649
+ * (gridaco/grida#844 review: locking the snapped delta could flip the
3650
+ * axis). The lock is screen-aligned, matching the drag and the guides.
3651
+ * 2. Point snap aligns the locked delta to the sibling points.
3652
+ * 3. The result projects back into the element's local frame via the
3653
+ * inverse CTM for `translateVertices` / the endpoint write.
3654
+ *
3655
+ * `agents_local` / `neighbors_local` are projected to screen here so the
3656
+ * snap threshold stays user-visible pixels under any element CTM.
3657
+ */
3658
+ point_drag_local_delta(ctm, raw_screen_dx, raw_screen_dy, agents_local, neighbors_local) {
3659
+ let [sx, sy] = this.axis_lock_point_delta(raw_screen_dx, raw_screen_dy);
3660
+ const offset = this.container_offset();
3661
+ const to_screen = (p) => project_point_through_ctm(p[0], p[1], ctm, offset);
3662
+ [sx, sy] = this.snap_point_delta_screen(sx, sy, agents_local.map(to_screen), neighbors_local.map(to_screen));
3663
+ return ctm.a * ctm.d - ctm.c * ctm.b !== 0 ? project_delta_inverse_ctm(sx, sy, ctm) : [sx, sy];
3664
+ }
3665
+ /**
3407
3666
  * Local-frame drag delta for a vertex sub-selection, from the HUD's
3408
- * container-space `dx/dy`. Inverse-projects through the element CTM (so a
3409
- * path under a scaled `<g>` / nested viewport tracks the cursor 1:1) then
3410
- * point-snaps to the path's other vertices (#844) before axis-lock, so
3411
- * the lock keeps final say on a constrained axis. Identity when the element
3412
- * has no usable CTM. A tangent-only drag (empty `indices`) yields no snap
3413
- * agents, so snap is a no-op. Shared by the two vertex-translate handlers;
3414
- * the caller applies axis-lock and feeds the result to `translateVertices`.
3667
+ * container-space `dx/dy`. Resolves the element CTM and the snap neighbor
3668
+ * set (the path's other vertices), then defers to `point_drag_local_delta`
3669
+ * for the axis-lock snap project composition. Falls back to a plain
3670
+ * axis-lock of the raw delta when the element has no usable CTM. A
3671
+ * tangent-only drag (empty `indices`) has no vertex agents, so snap is a
3672
+ * no-op. Shared by the two vertex-translate handlers.
3415
3673
  */
3416
3674
  vertex_drag_local_delta(node_id, baseline_model, indices, dx, dy) {
3417
3675
  const el = this.element_index.get(node_id);
3418
3676
  const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
3419
- if (!ctm) return [dx, dy];
3420
- let local_dx = dx;
3421
- let local_dy = dy;
3422
- if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(dx, dy, ctm);
3677
+ if (!ctm) return this.axis_lock_point_delta(dx, dy);
3423
3678
  const { agents, neighbors } = this.vertex_snap_points(baseline_model, indices);
3424
- return this.snap_local_point_delta(local_dx, local_dy, agents, neighbors, ctm);
3679
+ return this.point_drag_local_delta(ctm, dx, dy, agents, neighbors);
3425
3680
  }
3426
3681
  /** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
3427
3682
  * Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
@@ -3531,17 +3786,21 @@ var DomSurface = class DomSurface {
3531
3786
  const target_y = pos_own.y;
3532
3787
  const base_x = endpoint === "p1" ? initial.x1 : initial.x2;
3533
3788
  const base_y = endpoint === "p1" ? initial.y1 : initial.y2;
3534
- let dx = target_x - base_x;
3535
- let dy = target_y - base_y;
3536
3789
  const el = this.element_index.get(id);
3537
3790
  const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
3791
+ let final_x;
3792
+ let final_y;
3538
3793
  if (ctm) {
3794
+ const base_screen = project_point_through_ctm(base_x, base_y, ctm, this.container_offset());
3539
3795
  const other = endpoint === "p1" ? [initial.x2, initial.y2] : [initial.x1, initial.y1];
3540
- [dx, dy] = this.snap_local_point_delta(dx, dy, [[base_x, base_y]], [other], ctm);
3796
+ 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]);
3797
+ final_x = base_x + ldx;
3798
+ final_y = base_y + ldy;
3799
+ } else {
3800
+ const [ldx, ldy] = this.axis_lock_point_delta(target_x - base_x, target_y - base_y);
3801
+ final_x = base_x + ldx;
3802
+ final_y = base_y + ldy;
3541
3803
  }
3542
- const [locked_dx, locked_dy] = this.axis_lock_point_delta(dx, dy);
3543
- const final_x = base_x + locked_dx;
3544
- const final_y = base_y + locked_dy;
3545
3804
  const apply = () => {
3546
3805
  if (endpoint === "p1") {
3547
3806
  doc.set_attr(id, "x1", String(final_x));
@@ -4015,6 +4274,7 @@ var DomSurface = class DomSurface {
4015
4274
  }
4016
4275
  this.vector_edit = null;
4017
4276
  this.vector_edit_region_baseline = null;
4277
+ this.point_snap_guide = void 0;
4018
4278
  this.hud.setVectorSelection(null);
4019
4279
  if (this.current_tool.type === "lasso" || this.current_tool.type === "bend") this.editor.set_tool({ type: "cursor" });
4020
4280
  this.editor.commands.set_mode("select");
@@ -4286,8 +4546,7 @@ var DomSurface = class DomSurface {
4286
4546
  }
4287
4547
  const baseline_d = this.active_preview.initial_d;
4288
4548
  const indices = this.active_preview.indices;
4289
- let [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4290
- [local_dx, local_dy] = this.axis_lock_point_delta(local_dx, local_dy);
4549
+ const [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4291
4550
  const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
4292
4551
  const target_d = preview_model.toSvgPathD();
4293
4552
  this.active_preview.preview_model = preview_model;
@@ -4379,8 +4638,7 @@ var DomSurface = class DomSurface {
4379
4638
  };
4380
4639
  }
4381
4640
  const baseline_d = this.active_preview.initial_d;
4382
- let [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4383
- [local_dx, local_dy] = this.axis_lock_point_delta(local_dx, local_dy);
4641
+ const [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4384
4642
  const baseline_model = this.active_preview.baseline_model;
4385
4643
  const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
4386
4644
  let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;