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

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
- 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-zMPCOVAr.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
  *
@@ -2948,11 +3192,18 @@ var DomSurface = class DomSurface {
2948
3192
  mods: next
2949
3193
  });
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
- if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
3195
+ if ((prev.shift !== next.shift || prev.alt !== next.alt) && 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`),
@@ -3435,8 +3690,10 @@ var DomSurface = class DomSurface {
3435
3690
  /** Snapshot of HUD modifier state mapped to `ResizeModifiers`. Same
3436
3691
  * pull-at-consume discipline as `current_translate_modifiers`. */
3437
3692
  current_resize_modifiers() {
3693
+ const mods = this.hud.modifiers();
3438
3694
  return {
3439
- aspect_lock: this.hud.modifiers().shift ? "uniform" : "off",
3695
+ aspect_lock: mods.shift ? "uniform" : "off",
3696
+ from_center: mods.alt,
3440
3697
  force_disable_snap: false
3441
3698
  };
3442
3699
  }
@@ -3529,17 +3786,21 @@ var DomSurface = class DomSurface {
3529
3786
  const target_y = pos_own.y;
3530
3787
  const base_x = endpoint === "p1" ? initial.x1 : initial.x2;
3531
3788
  const base_y = endpoint === "p1" ? initial.y1 : initial.y2;
3532
- let dx = target_x - base_x;
3533
- let dy = target_y - base_y;
3534
3789
  const el = this.element_index.get(id);
3535
3790
  const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
3791
+ let final_x;
3792
+ let final_y;
3536
3793
  if (ctm) {
3794
+ const base_screen = project_point_through_ctm(base_x, base_y, ctm, this.container_offset());
3537
3795
  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);
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;
3539
3803
  }
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
3804
  const apply = () => {
3544
3805
  if (endpoint === "p1") {
3545
3806
  doc.set_attr(id, "x1", String(final_x));
@@ -4013,6 +4274,7 @@ var DomSurface = class DomSurface {
4013
4274
  }
4014
4275
  this.vector_edit = null;
4015
4276
  this.vector_edit_region_baseline = null;
4277
+ this.point_snap_guide = void 0;
4016
4278
  this.hud.setVectorSelection(null);
4017
4279
  if (this.current_tool.type === "lasso" || this.current_tool.type === "bend") this.editor.set_tool({ type: "cursor" });
4018
4280
  this.editor.commands.set_mode("select");
@@ -4284,8 +4546,7 @@ var DomSurface = class DomSurface {
4284
4546
  }
4285
4547
  const baseline_d = this.active_preview.initial_d;
4286
4548
  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);
4549
+ const [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4289
4550
  const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
4290
4551
  const target_d = preview_model.toSvgPathD();
4291
4552
  this.active_preview.preview_model = preview_model;
@@ -4377,8 +4638,7 @@ var DomSurface = class DomSurface {
4377
4638
  };
4378
4639
  }
4379
4640
  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);
4641
+ const [local_dx, local_dy] = this.vertex_drag_local_delta(node_id, this.active_preview.baseline_model, indices, intent.dx, intent.dy);
4382
4642
  const baseline_model = this.active_preview.baseline_model;
4383
4643
  const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
4384
4644
  let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;