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

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
- const require_model = require("./model-BLhMJZKJ.js");
1
+ const require_model = require("./model-CzL6_zId.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");
@@ -759,6 +759,35 @@ function resolve_text_exit(input) {
759
759
  return { kind: "noop" };
760
760
  }
761
761
  //#endregion
762
+ //#region src/selection/marquee.ts
763
+ let marquee_selection;
764
+ (function(_marquee_selection) {
765
+ function hits(boxes, rect) {
766
+ const touched = boxes.filter(([, box]) => _grida_cmath.default.rect.intersects(box, rect));
767
+ const front = touched.length - 1;
768
+ const out = [];
769
+ for (let i = 0; i < touched.length; i++) {
770
+ const [id, box] = touched[i];
771
+ if (i !== front && _grida_cmath.default.rect.contains(box, rect)) continue;
772
+ out.push(id);
773
+ }
774
+ return out;
775
+ }
776
+ _marquee_selection.hits = hits;
777
+ function resolve(boxes, rect, baseline, opts = {}) {
778
+ const h = hits(boxes, rect);
779
+ if (!opts.additive) return h;
780
+ const out = [...baseline];
781
+ const seen = new Set(baseline);
782
+ for (const id of h) if (!seen.has(id)) {
783
+ seen.add(id);
784
+ out.push(id);
785
+ }
786
+ return out;
787
+ }
788
+ _marquee_selection.resolve = resolve;
789
+ })(marquee_selection || (marquee_selection = {}));
790
+ //#endregion
762
791
  //#region src/gestures/gestures.ts
763
792
  /**
764
793
  * Sibling to `Keymap`. Owns a list of installed gesture bindings; each
@@ -995,33 +1024,68 @@ function applyDefaultGestures(gestures) {
995
1024
  * Install pointer-tracking listeners on `container` and return the
996
1025
  * read-side handle. The tracker is owned by the surface and disposed
997
1026
  * alongside it; gesture bindings that need to consult it receive the
998
- * read-only `is_attended` predicate through `GestureContext`.
1027
+ * read-only `is_attended` predicate through `GestureContext`. Hosts
1028
+ * extend the scope through `handle.attention` (`dom.ts`), which fronts
1029
+ * {@link AttentionTracker.add} / {@link AttentionTracker.remove}.
999
1030
  */
1000
1031
  function create_attention_tracker(container) {
1001
- let pointer_over = false;
1002
- const on_enter = () => {
1003
- pointer_over = true;
1004
- };
1005
- const on_leave = () => {
1006
- pointer_over = false;
1032
+ /** Elements of the scope the pointer is currently over. Per-element
1033
+ * membership (not a single boolean) so crossing from the container
1034
+ * onto overlapping registered chrome — `leave` and `enter` firing in
1035
+ * either order — never reads as a gap in attention. */
1036
+ const hovered = /* @__PURE__ */ new Set();
1037
+ /** Registered extras → their hover-tracking teardown. */
1038
+ const extras = /* @__PURE__ */ new Map();
1039
+ let disposed = false;
1040
+ /** Start hover-tracking `element`; returns the exact undo. */
1041
+ const track = (element) => {
1042
+ const enter = () => {
1043
+ hovered.add(element);
1044
+ };
1045
+ const leave = () => {
1046
+ hovered.delete(element);
1047
+ };
1048
+ element.addEventListener("pointerenter", enter);
1049
+ element.addEventListener("pointerleave", leave);
1050
+ return () => {
1051
+ element.removeEventListener("pointerenter", enter);
1052
+ element.removeEventListener("pointerleave", leave);
1053
+ hovered.delete(element);
1054
+ };
1007
1055
  };
1008
- container.addEventListener("pointerenter", on_enter);
1009
- container.addEventListener("pointerleave", on_leave);
1056
+ const untrack_container = track(container);
1010
1057
  const is_focus_within = () => {
1011
1058
  const owner = container.ownerDocument;
1012
1059
  if (!owner) return false;
1013
1060
  const active = owner.activeElement;
1014
- return !!active && active !== owner.body && container.contains(active);
1061
+ if (!active || active === owner.body) return false;
1062
+ if (container.contains(active)) return true;
1063
+ for (const element of extras.keys()) if (element.contains(active)) return true;
1064
+ return false;
1015
1065
  };
1016
1066
  const is_attended = () => {
1017
- return is_focus_within() || pointer_over;
1067
+ return hovered.size > 0 || is_focus_within();
1018
1068
  };
1019
1069
  return {
1020
1070
  is_attended,
1021
1071
  is_focus_within,
1072
+ add: (element) => {
1073
+ if (disposed) return;
1074
+ if (element === container || extras.has(element)) return;
1075
+ extras.set(element, track(element));
1076
+ if (typeof element.matches === "function" && element.matches(":hover")) hovered.add(element);
1077
+ },
1078
+ remove: (element) => {
1079
+ const untrack = extras.get(element);
1080
+ if (!untrack) return;
1081
+ extras.delete(element);
1082
+ untrack();
1083
+ },
1022
1084
  dispose: () => {
1023
- container.removeEventListener("pointerenter", on_enter);
1024
- container.removeEventListener("pointerleave", on_leave);
1085
+ disposed = true;
1086
+ untrack_container();
1087
+ for (const untrack of extras.values()) untrack();
1088
+ extras.clear();
1025
1089
  }
1026
1090
  };
1027
1091
  }
@@ -1701,7 +1765,8 @@ function attach_dom_surface(editor, options) {
1701
1765
  inner.detach();
1702
1766
  },
1703
1767
  camera: surface.camera,
1704
- gestures: surface.gestures
1768
+ gestures: surface.gestures,
1769
+ attention: surface.attention_scope
1705
1770
  };
1706
1771
  }
1707
1772
  var DomSurface = class DomSurface {
@@ -1728,7 +1793,10 @@ var DomSurface = class DomSurface {
1728
1793
  this.text_edit_original = "";
1729
1794
  this.pending_text_insert = null;
1730
1795
  this.vector_edit = null;
1796
+ this.point_snap_guide = void 0;
1797
+ this.suppress_point_snap = false;
1731
1798
  this.vector_edit_region_baseline = null;
1799
+ this.scene_marquee_baseline = null;
1732
1800
  this.current_tool = require_model.TOOL_CURSOR;
1733
1801
  this.pending_insert = null;
1734
1802
  this.editor_hover_internal = null;
@@ -1737,6 +1805,10 @@ var DomSurface = class DomSurface {
1737
1805
  this.fit_on_attach = options.fit === true;
1738
1806
  this.clipboard_enabled = options.clipboard !== false;
1739
1807
  this.attention = create_attention_tracker(container);
1808
+ this.attention_scope = {
1809
+ add: (element) => this.attention.add(element),
1810
+ remove: (element) => this.attention.remove(element)
1811
+ };
1740
1812
  this.teardown.push(() => this.attention.dispose());
1741
1813
  if (process.env.NODE_ENV !== "production" && container.children.length > 0) console.warn("@grida/svg-editor: surface container is not empty at attach time. Render chrome (toolbars, layer lists, inspectors) as siblings of the container, not children — otherwise clicks on those children will silently break. See README §Surface.");
1742
1814
  if (getComputedStyle(container).position === "static") container.style.position = "relative";
@@ -1944,6 +2016,8 @@ var DomSurface = class DomSurface {
1944
2016
  this.editor_hover_internal = internal;
1945
2017
  internal.set_content_edit_driver((id) => this.enter_content_edit(id));
1946
2018
  this.teardown.push(() => internal.set_content_edit_driver(null));
2019
+ internal.register_command("transform.nudge", (args) => this.handle_nudge_command(args));
2020
+ this.teardown.push(() => internal.register_command("transform.nudge", require_model.default_nudge_handler(this.editor)));
1947
2021
  internal.set_computed_resolver({
1948
2022
  computed_property: (id, name) => {
1949
2023
  this.flush_dom();
@@ -2131,6 +2205,7 @@ var DomSurface = class DomSurface {
2131
2205
  this.vector_edit_region_baseline = null;
2132
2206
  this.hud.setVectorSelection(null);
2133
2207
  }
2208
+ this.scene_marquee_baseline = null;
2134
2209
  this.gestures._dispose();
2135
2210
  this.translate_orchestrator.cancel();
2136
2211
  this.resize_orchestrator.cancel();
@@ -2462,6 +2537,7 @@ var DomSurface = class DomSurface {
2462
2537
  return rects.length > 0 ? { rects } : void 0;
2463
2538
  }
2464
2539
  compute_snap_extra() {
2540
+ if (this.point_snap_guide) return (0, _grida_hud.snapGuideToHUDDraw)(this.point_snap_guide, this.editor.style.measurement_color);
2465
2541
  const insert_guide = this.pending_insert?.phase === "drawing" ? this.pending_insert.snap_session?.last_guide : void 0;
2466
2542
  if (insert_guide) return (0, _grida_hud.snapGuideToHUDDraw)(this.project_guide_to_screen(insert_guide), this.editor.style.measurement_color);
2467
2543
  const guides = this.translate_orchestrator.last_guides.length > 0 ? this.translate_orchestrator.last_guides : this.resize_orchestrator.last_guides.length > 0 ? this.resize_orchestrator.last_guides : this.nudge_dwell_watcher.guides;
@@ -2549,6 +2625,10 @@ var DomSurface = class DomSurface {
2549
2625
  * was canceled. */
2550
2626
  cancel_in_flight() {
2551
2627
  let canceled = false;
2628
+ if (this.scene_marquee_baseline) {
2629
+ this.scene_marquee_baseline = null;
2630
+ canceled = true;
2631
+ }
2552
2632
  if (this.translate_orchestrator.has_active_session()) {
2553
2633
  this.translate_orchestrator.cancel();
2554
2634
  canceled = true;
@@ -2564,6 +2644,7 @@ var DomSurface = class DomSurface {
2564
2644
  if (this.active_preview) {
2565
2645
  this.active_preview.session.discard();
2566
2646
  this.active_preview = null;
2647
+ this.point_snap_guide = void 0;
2567
2648
  canceled = true;
2568
2649
  }
2569
2650
  if (this.pending_insert) {
@@ -3206,6 +3287,142 @@ var DomSurface = class DomSurface {
3206
3287
  });
3207
3288
  if (intent.phase === "commit") this.request_redraw();
3208
3289
  }
3290
+ /**
3291
+ * Shift axis-lock for point-level drags (gridaco/grida#848) — the
3292
+ * vertex / endpoint counterpart of whole-object translate's
3293
+ * `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.
3298
+ */
3299
+ axis_lock_point_delta(dx, dy) {
3300
+ if (!this.hud.modifiers().shift) return [dx, dy];
3301
+ const locked = _grida_cmath.default.ext.movement.axisLockedByDominance([dx, dy]);
3302
+ return _grida_cmath.default.ext.movement.normalize(locked);
3303
+ }
3304
+ /**
3305
+ * Point-level snap (gridaco/grida#844) — the vertex / endpoint counterpart
3306
+ * of whole-object translate's edge/center snap. Snaps a dragged point's
3307
+ * delta so it aligns with (or lands on) a sibling point: a path's other
3308
+ * vertices, or a `<line>`'s opposite endpoint.
3309
+ *
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.
3317
+ *
3318
+ * Honors the global snap toggle (`style.snap_enabled`) and threshold
3319
+ * (`style.snap_threshold_px`, converted to local units by the CTM scale) —
3320
+ * 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.
3323
+ *
3324
+ * The shared `SnapSession` drops 0-area rects (its empty-`<g>` "jerk to
3325
+ * origin" defense), so each point is modeled as a sub-pixel square centered
3326
+ * on it. Symmetric inflation cancels in the corrected delta (the engine's
3327
+ * matched same-edge offset carries the same ±eps on both sides), so eps
3328
+ * magnitude is immaterial as long as it stays far below the threshold.
3329
+ */
3330
+ snap_local_point_delta(raw_dx, raw_dy, agents_local, neighbors_local, ctm) {
3331
+ this.point_snap_guide = void 0;
3332
+ 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;
3338
+ const to_rect = (p) => ({
3339
+ x: p[0] - eps / 2,
3340
+ y: p[1] - eps / 2,
3341
+ width: eps,
3342
+ height: eps
3343
+ });
3344
+ const session = new SnapSession({
3345
+ agents: agents_local.map(to_rect),
3346
+ neighbors: neighbors_local.map(to_rect)
3347
+ });
3348
+ const { delta, guide } = session.snap({
3349
+ x: raw_dx,
3350
+ y: raw_dy
3351
+ }, {
3352
+ enabled: true,
3353
+ threshold_px: threshold_local
3354
+ });
3355
+ session.dispose();
3356
+ if (guide) this.point_snap_guide = this.project_local_guide_to_screen(guide, ctm, this.container_offset());
3357
+ return [delta.x, delta.y];
3358
+ }
3359
+ /** 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. */
3362
+ vertex_snap_points(model, moving) {
3363
+ const verts = model.snapshot().vertices;
3364
+ const moving_set = new Set(moving);
3365
+ const agents = [];
3366
+ const neighbors = [];
3367
+ for (let i = 0; i < verts.length; i++) (moving_set.has(i) ? agents : neighbors).push(verts[i]);
3368
+ return {
3369
+ agents,
3370
+ neighbors
3371
+ };
3372
+ }
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
+ /** Translation from screen/page CSS-px to the HUD's container-identity
3399
+ * space: subtract the container's page offset, add its scroll. Used at
3400
+ * every `getScreenCTM` projection boundary (chrome, marquee, point snap)
3401
+ * so they share one definition of "where the container's origin is". */
3402
+ container_offset() {
3403
+ const cr = this.container.getBoundingClientRect();
3404
+ return [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3405
+ }
3406
+ /**
3407
+ * 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`.
3415
+ */
3416
+ vertex_drag_local_delta(node_id, baseline_model, indices, dx, dy) {
3417
+ const el = this.element_index.get(node_id);
3418
+ 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);
3423
+ const { agents, neighbors } = this.vertex_snap_points(baseline_model, indices);
3424
+ return this.snap_local_point_delta(local_dx, local_dy, agents, neighbors, ctm);
3425
+ }
3209
3426
  /** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
3210
3427
  * Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
3211
3428
  * read live so mid-drag Shift press/release reflects on the next pass. */
@@ -3312,13 +3529,26 @@ var DomSurface = class DomSurface {
3312
3529
  if (!pos_own) return;
3313
3530
  const target_x = pos_own.x;
3314
3531
  const target_y = pos_own.y;
3532
+ const base_x = endpoint === "p1" ? initial.x1 : initial.x2;
3533
+ const base_y = endpoint === "p1" ? initial.y1 : initial.y2;
3534
+ let dx = target_x - base_x;
3535
+ let dy = target_y - base_y;
3536
+ const el = this.element_index.get(id);
3537
+ const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
3538
+ if (ctm) {
3539
+ 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);
3541
+ }
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;
3315
3545
  const apply = () => {
3316
3546
  if (endpoint === "p1") {
3317
- doc.set_attr(id, "x1", String(target_x));
3318
- doc.set_attr(id, "y1", String(target_y));
3547
+ doc.set_attr(id, "x1", String(final_x));
3548
+ doc.set_attr(id, "y1", String(final_y));
3319
3549
  } else {
3320
- doc.set_attr(id, "x2", String(target_x));
3321
- doc.set_attr(id, "y2", String(target_y));
3550
+ doc.set_attr(id, "x2", String(final_x));
3551
+ doc.set_attr(id, "y2", String(final_y));
3322
3552
  }
3323
3553
  emit();
3324
3554
  };
@@ -3335,6 +3565,7 @@ var DomSurface = class DomSurface {
3335
3565
  revert
3336
3566
  });
3337
3567
  if (intent.phase === "commit") {
3568
+ this.point_snap_guide = void 0;
3338
3569
  this.active_preview.session.commit();
3339
3570
  this.active_preview = null;
3340
3571
  }
@@ -3382,19 +3613,33 @@ var DomSurface = class DomSurface {
3382
3613
  this.handle_marquee_vectors(intent);
3383
3614
  return;
3384
3615
  }
3385
- if (intent.phase !== "commit") return;
3386
- const ids = [];
3387
- for (const id of this.element_index.keys()) {
3388
- if (id === this.editor.tree().root) continue;
3389
- const box = this.container_box(id);
3390
- if (!box) continue;
3391
- if (_grida_cmath.default.rect.intersects(box, intent.rect)) ids.push(id);
3392
- }
3393
- if (ids.length === 0) {
3394
- if (!intent.additive) this.editor.commands.deselect();
3395
- return;
3616
+ if (!this.scene_marquee_baseline) {
3617
+ const cr = this.container.getBoundingClientRect();
3618
+ const root = this.editor.tree().root;
3619
+ const boxes = [];
3620
+ for (const id of this._ensure_z_order(false, root)) {
3621
+ const box = this.container_box(id, cr);
3622
+ if (box) boxes.push([id, box]);
3623
+ }
3624
+ this.scene_marquee_baseline = {
3625
+ boxes,
3626
+ selection: this.editor.state.selection
3627
+ };
3396
3628
  }
3397
- this.editor.commands.select(ids, { mode: intent.additive ? "add" : "replace" });
3629
+ const next = this.resolve_scene_marquee(intent.rect, intent.additive);
3630
+ this.editor.commands.select(next, { mode: "replace" });
3631
+ if (intent.phase === "commit") this.scene_marquee_baseline = null;
3632
+ }
3633
+ /** Resolve the scene marquee selection for one frame from the frozen,
3634
+ * paint-ordered box snapshot. The rule (shadow + additive) lives in the
3635
+ * headless `marquee_selection` policy (`src/selection/marquee.ts`, spec
3636
+ * `docs/marquee-selection.md`); the surface only supplies the boxes and
3637
+ * the gesture-start baseline. Selection is deterministic in (marquee rect,
3638
+ * gesture-start selection, shift) — meta is a gesture-routing modifier
3639
+ * only (it decides that a drag IS a marquee), not a resolution input. */
3640
+ resolve_scene_marquee(rect, additive) {
3641
+ const { boxes, selection } = this.scene_marquee_baseline;
3642
+ return marquee_selection.resolve(boxes, rect, selection, { additive });
3398
3643
  }
3399
3644
  /**
3400
3645
  * Vector marquee predicate — applies the **vertex-priority precedence
@@ -3425,8 +3670,7 @@ var DomSurface = class DomSurface {
3425
3670
  if (typeof el.getScreenCTM !== "function") return;
3426
3671
  const ctm = el.getScreenCTM();
3427
3672
  if (!ctm) return;
3428
- const cr = this.container.getBoundingClientRect();
3429
- const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3673
+ const offset = this.container_offset();
3430
3674
  const model = this.session_model();
3431
3675
  if (!model) return;
3432
3676
  const rect = intent.rect;
@@ -3489,8 +3733,7 @@ var DomSurface = class DomSurface {
3489
3733
  if (typeof el.getScreenCTM !== "function") return;
3490
3734
  const ctm = el.getScreenCTM();
3491
3735
  if (!ctm) return;
3492
- const cr = this.container.getBoundingClientRect();
3493
- const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3736
+ const offset = this.container_offset();
3494
3737
  const model = this.session_model();
3495
3738
  if (!model) return;
3496
3739
  const polygon = intent.polygon;
@@ -3795,8 +4038,7 @@ var DomSurface = class DomSurface {
3795
4038
  if (typeof el.getScreenCTM !== "function") return null;
3796
4039
  const ctm = el.getScreenCTM();
3797
4040
  if (!ctm) return null;
3798
- const cr = this.container.getBoundingClientRect();
3799
- const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
4041
+ const offset = this.container_offset();
3800
4042
  return {
3801
4043
  vertices: Array.from({ length: snap.vertices.length }, (_, i) => {
3802
4044
  const v = snap.vertices[i];
@@ -4044,19 +4286,13 @@ var DomSurface = class DomSurface {
4044
4286
  }
4045
4287
  const baseline_d = this.active_preview.initial_d;
4046
4288
  const indices = this.active_preview.indices;
4047
- let local_dx = intent.dx;
4048
- let local_dy = intent.dy;
4049
- const el = this.element_index.get(node_id);
4050
- if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
4051
- const ctm = el.getScreenCTM();
4052
- if (ctm) {
4053
- if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
4054
- }
4055
- }
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);
4056
4291
  const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
4057
4292
  const target_d = preview_model.toSvgPathD();
4058
4293
  this.active_preview.preview_model = preview_model;
4059
4294
  if (intent.phase === "commit") {
4295
+ this.point_snap_guide = void 0;
4060
4296
  const before_selection = this.active_preview.before_selection;
4061
4297
  const after_selection = this.vector_edit.snapshot_selection();
4062
4298
  this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
@@ -4143,15 +4379,8 @@ var DomSurface = class DomSurface {
4143
4379
  };
4144
4380
  }
4145
4381
  const baseline_d = this.active_preview.initial_d;
4146
- let local_dx = intent.dx;
4147
- let local_dy = intent.dy;
4148
- const el = this.element_index.get(node_id);
4149
- if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
4150
- const ctm = el.getScreenCTM();
4151
- if (ctm) {
4152
- if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
4153
- }
4154
- }
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);
4155
4384
  const baseline_model = this.active_preview.baseline_model;
4156
4385
  const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
4157
4386
  let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;
@@ -4163,6 +4392,7 @@ var DomSurface = class DomSurface {
4163
4392
  const target_d = preview_model.toSvgPathD();
4164
4393
  this.active_preview.preview_model = preview_model;
4165
4394
  if (intent.phase === "commit") {
4395
+ this.point_snap_guide = void 0;
4166
4396
  const before_selection = this.active_preview.before_selection;
4167
4397
  const after_selection = this.vector_edit.snapshot_selection();
4168
4398
  this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
@@ -4170,6 +4400,63 @@ var DomSurface = class DomSurface {
4170
4400
  this.active_preview = null;
4171
4401
  } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
4172
4402
  }
4403
+ /**
4404
+ * Surface-aware `transform.nudge` handler — registered over the headless
4405
+ * default on attach (gridaco/grida#849). In vector content-edit, arrow
4406
+ * keys nudge the path sub-selection (the keyboard counterpart of
4407
+ * dragging vertices / segments / tangents) rather than leaking to a
4408
+ * whole-element move (the bug). This mirrors text-edit, where the inline
4409
+ * editor owns the arrow keys. Outside content-edit it delegates to the
4410
+ * headless `default_nudge_handler` (whole-element nudge) — the same
4411
+ * handler the registry restores on detach, so the override and the
4412
+ * default can never drift.
4413
+ *
4414
+ * While a vector session is open the arrows are OWNED by it: an empty
4415
+ * sub-selection is a consumed no-op, never a whole-element nudge — same
4416
+ * "content-edit captures arrows" contract as text-edit.
4417
+ */
4418
+ handle_nudge_command(args) {
4419
+ const { dx, dy } = args;
4420
+ if (this.editor.state.mode === "edit-content" && this.vector_edit) {
4421
+ this.nudge_vector_selection(dx, dy);
4422
+ return true;
4423
+ }
4424
+ return require_model.default_nudge_handler(this.editor)(args) === true;
4425
+ }
4426
+ /**
4427
+ * Discrete keyboard nudge of the vector sub-selection. Reuses the drag
4428
+ * path ({@link handle_translate_vector_selection}) by synthesizing a
4429
+ * single commit-phase intent, so a nudge is byte-identical to a one-step
4430
+ * drag of the same points: same union resolution (selected vertices ∪
4431
+ * selected-segment endpoints ∪ tangents), same parent-vertex tangent
4432
+ * exclusion, same history bracket + sub-selection capture.
4433
+ *
4434
+ * `dx`/`dy` arrive in world units (1px, or 10px with Shift — already
4435
+ * resolved by the keymap binding). `handle_translate_vector_selection`
4436
+ * expects container CSS-px (it projects back through the inverse
4437
+ * screen-CTM), so scale by the camera zoom on the way in; the projection
4438
+ * recovers the world delta in the path's local frame. A no-sub-selection
4439
+ * session resolves to a no-op inside the handler.
4440
+ */
4441
+ nudge_vector_selection(dx, dy) {
4442
+ const ses = this.vector_edit;
4443
+ if (!ses) return;
4444
+ const zoom = this.camera.zoom || 1;
4445
+ this.suppress_point_snap = true;
4446
+ try {
4447
+ this.handle_translate_vector_selection({
4448
+ kind: "translate_vector_selection",
4449
+ node_id: ses.node_id,
4450
+ additional_vertex_indices: [],
4451
+ dx: dx * zoom,
4452
+ dy: dy * zoom,
4453
+ phase: "commit"
4454
+ });
4455
+ } finally {
4456
+ this.suppress_point_snap = false;
4457
+ }
4458
+ this.request_redraw();
4459
+ }
4173
4460
  /** Mirror handler for `select_tangent` (analogous to `select_vertex`). */
4174
4461
  handle_select_tangent(intent) {
4175
4462
  if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;