@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
- 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, x as array_shallow_equal } from "./model-DU0GOMwM.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-DS5MxDrd.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";
@@ -757,6 +757,35 @@ function resolve_text_exit(input) {
757
757
  return { kind: "noop" };
758
758
  }
759
759
  //#endregion
760
+ //#region src/selection/marquee.ts
761
+ let marquee_selection;
762
+ (function(_marquee_selection) {
763
+ function hits(boxes, rect) {
764
+ const touched = boxes.filter(([, box]) => cmath.rect.intersects(box, rect));
765
+ const front = touched.length - 1;
766
+ const out = [];
767
+ for (let i = 0; i < touched.length; i++) {
768
+ const [id, box] = touched[i];
769
+ if (i !== front && cmath.rect.contains(box, rect)) continue;
770
+ out.push(id);
771
+ }
772
+ return out;
773
+ }
774
+ _marquee_selection.hits = hits;
775
+ function resolve(boxes, rect, baseline, opts = {}) {
776
+ const h = hits(boxes, rect);
777
+ if (!opts.additive) return h;
778
+ const out = [...baseline];
779
+ const seen = new Set(baseline);
780
+ for (const id of h) if (!seen.has(id)) {
781
+ seen.add(id);
782
+ out.push(id);
783
+ }
784
+ return out;
785
+ }
786
+ _marquee_selection.resolve = resolve;
787
+ })(marquee_selection || (marquee_selection = {}));
788
+ //#endregion
760
789
  //#region src/gestures/gestures.ts
761
790
  /**
762
791
  * Sibling to `Keymap`. Owns a list of installed gesture bindings; each
@@ -993,33 +1022,68 @@ function applyDefaultGestures(gestures) {
993
1022
  * Install pointer-tracking listeners on `container` and return the
994
1023
  * read-side handle. The tracker is owned by the surface and disposed
995
1024
  * alongside it; gesture bindings that need to consult it receive the
996
- * read-only `is_attended` predicate through `GestureContext`.
1025
+ * read-only `is_attended` predicate through `GestureContext`. Hosts
1026
+ * extend the scope through `handle.attention` (`dom.ts`), which fronts
1027
+ * {@link AttentionTracker.add} / {@link AttentionTracker.remove}.
997
1028
  */
998
1029
  function create_attention_tracker(container) {
999
- let pointer_over = false;
1000
- const on_enter = () => {
1001
- pointer_over = true;
1002
- };
1003
- const on_leave = () => {
1004
- pointer_over = false;
1030
+ /** Elements of the scope the pointer is currently over. Per-element
1031
+ * membership (not a single boolean) so crossing from the container
1032
+ * onto overlapping registered chrome — `leave` and `enter` firing in
1033
+ * either order — never reads as a gap in attention. */
1034
+ const hovered = /* @__PURE__ */ new Set();
1035
+ /** Registered extras → their hover-tracking teardown. */
1036
+ const extras = /* @__PURE__ */ new Map();
1037
+ let disposed = false;
1038
+ /** Start hover-tracking `element`; returns the exact undo. */
1039
+ const track = (element) => {
1040
+ const enter = () => {
1041
+ hovered.add(element);
1042
+ };
1043
+ const leave = () => {
1044
+ hovered.delete(element);
1045
+ };
1046
+ element.addEventListener("pointerenter", enter);
1047
+ element.addEventListener("pointerleave", leave);
1048
+ return () => {
1049
+ element.removeEventListener("pointerenter", enter);
1050
+ element.removeEventListener("pointerleave", leave);
1051
+ hovered.delete(element);
1052
+ };
1005
1053
  };
1006
- container.addEventListener("pointerenter", on_enter);
1007
- container.addEventListener("pointerleave", on_leave);
1054
+ const untrack_container = track(container);
1008
1055
  const is_focus_within = () => {
1009
1056
  const owner = container.ownerDocument;
1010
1057
  if (!owner) return false;
1011
1058
  const active = owner.activeElement;
1012
- return !!active && active !== owner.body && container.contains(active);
1059
+ if (!active || active === owner.body) return false;
1060
+ if (container.contains(active)) return true;
1061
+ for (const element of extras.keys()) if (element.contains(active)) return true;
1062
+ return false;
1013
1063
  };
1014
1064
  const is_attended = () => {
1015
- return is_focus_within() || pointer_over;
1065
+ return hovered.size > 0 || is_focus_within();
1016
1066
  };
1017
1067
  return {
1018
1068
  is_attended,
1019
1069
  is_focus_within,
1070
+ add: (element) => {
1071
+ if (disposed) return;
1072
+ if (element === container || extras.has(element)) return;
1073
+ extras.set(element, track(element));
1074
+ if (typeof element.matches === "function" && element.matches(":hover")) hovered.add(element);
1075
+ },
1076
+ remove: (element) => {
1077
+ const untrack = extras.get(element);
1078
+ if (!untrack) return;
1079
+ extras.delete(element);
1080
+ untrack();
1081
+ },
1020
1082
  dispose: () => {
1021
- container.removeEventListener("pointerenter", on_enter);
1022
- container.removeEventListener("pointerleave", on_leave);
1083
+ disposed = true;
1084
+ untrack_container();
1085
+ for (const untrack of extras.values()) untrack();
1086
+ extras.clear();
1023
1087
  }
1024
1088
  };
1025
1089
  }
@@ -1699,7 +1763,8 @@ function attach_dom_surface(editor, options) {
1699
1763
  inner.detach();
1700
1764
  },
1701
1765
  camera: surface.camera,
1702
- gestures: surface.gestures
1766
+ gestures: surface.gestures,
1767
+ attention: surface.attention_scope
1703
1768
  };
1704
1769
  }
1705
1770
  var DomSurface = class DomSurface {
@@ -1726,7 +1791,10 @@ var DomSurface = class DomSurface {
1726
1791
  this.text_edit_original = "";
1727
1792
  this.pending_text_insert = null;
1728
1793
  this.vector_edit = null;
1794
+ this.point_snap_guide = void 0;
1795
+ this.suppress_point_snap = false;
1729
1796
  this.vector_edit_region_baseline = null;
1797
+ this.scene_marquee_baseline = null;
1730
1798
  this.current_tool = TOOL_CURSOR;
1731
1799
  this.pending_insert = null;
1732
1800
  this.editor_hover_internal = null;
@@ -1735,6 +1803,10 @@ var DomSurface = class DomSurface {
1735
1803
  this.fit_on_attach = options.fit === true;
1736
1804
  this.clipboard_enabled = options.clipboard !== false;
1737
1805
  this.attention = create_attention_tracker(container);
1806
+ this.attention_scope = {
1807
+ add: (element) => this.attention.add(element),
1808
+ remove: (element) => this.attention.remove(element)
1809
+ };
1738
1810
  this.teardown.push(() => this.attention.dispose());
1739
1811
  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.");
1740
1812
  if (getComputedStyle(container).position === "static") container.style.position = "relative";
@@ -1942,6 +2014,8 @@ var DomSurface = class DomSurface {
1942
2014
  this.editor_hover_internal = internal;
1943
2015
  internal.set_content_edit_driver((id) => this.enter_content_edit(id));
1944
2016
  this.teardown.push(() => internal.set_content_edit_driver(null));
2017
+ internal.register_command("transform.nudge", (args) => this.handle_nudge_command(args));
2018
+ this.teardown.push(() => internal.register_command("transform.nudge", default_nudge_handler(this.editor)));
1945
2019
  internal.set_computed_resolver({
1946
2020
  computed_property: (id, name) => {
1947
2021
  this.flush_dom();
@@ -2129,6 +2203,7 @@ var DomSurface = class DomSurface {
2129
2203
  this.vector_edit_region_baseline = null;
2130
2204
  this.hud.setVectorSelection(null);
2131
2205
  }
2206
+ this.scene_marquee_baseline = null;
2132
2207
  this.gestures._dispose();
2133
2208
  this.translate_orchestrator.cancel();
2134
2209
  this.resize_orchestrator.cancel();
@@ -2460,6 +2535,7 @@ var DomSurface = class DomSurface {
2460
2535
  return rects.length > 0 ? { rects } : void 0;
2461
2536
  }
2462
2537
  compute_snap_extra() {
2538
+ if (this.point_snap_guide) return snapGuideToHUDDraw(this.point_snap_guide, this.editor.style.measurement_color);
2463
2539
  const insert_guide = this.pending_insert?.phase === "drawing" ? this.pending_insert.snap_session?.last_guide : void 0;
2464
2540
  if (insert_guide) return snapGuideToHUDDraw(this.project_guide_to_screen(insert_guide), this.editor.style.measurement_color);
2465
2541
  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;
@@ -2547,6 +2623,10 @@ var DomSurface = class DomSurface {
2547
2623
  * was canceled. */
2548
2624
  cancel_in_flight() {
2549
2625
  let canceled = false;
2626
+ if (this.scene_marquee_baseline) {
2627
+ this.scene_marquee_baseline = null;
2628
+ canceled = true;
2629
+ }
2550
2630
  if (this.translate_orchestrator.has_active_session()) {
2551
2631
  this.translate_orchestrator.cancel();
2552
2632
  canceled = true;
@@ -2562,6 +2642,7 @@ var DomSurface = class DomSurface {
2562
2642
  if (this.active_preview) {
2563
2643
  this.active_preview.session.discard();
2564
2644
  this.active_preview = null;
2645
+ this.point_snap_guide = void 0;
2565
2646
  canceled = true;
2566
2647
  }
2567
2648
  if (this.pending_insert) {
@@ -3204,6 +3285,142 @@ var DomSurface = class DomSurface {
3204
3285
  });
3205
3286
  if (intent.phase === "commit") this.request_redraw();
3206
3287
  }
3288
+ /**
3289
+ * Shift axis-lock for point-level drags (gridaco/grida#848) — the
3290
+ * vertex / endpoint counterpart of whole-object translate's
3291
+ * `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.
3296
+ */
3297
+ axis_lock_point_delta(dx, dy) {
3298
+ if (!this.hud.modifiers().shift) return [dx, dy];
3299
+ const locked = cmath.ext.movement.axisLockedByDominance([dx, dy]);
3300
+ return cmath.ext.movement.normalize(locked);
3301
+ }
3302
+ /**
3303
+ * Point-level snap (gridaco/grida#844) — the vertex / endpoint counterpart
3304
+ * of whole-object translate's edge/center snap. Snaps a dragged point's
3305
+ * delta so it aligns with (or lands on) a sibling point: a path's other
3306
+ * vertices, or a `<line>`'s opposite endpoint.
3307
+ *
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.
3315
+ *
3316
+ * Honors the global snap toggle (`style.snap_enabled`) and threshold
3317
+ * (`style.snap_threshold_px`, converted to local units by the CTM scale) —
3318
+ * 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.
3321
+ *
3322
+ * The shared `SnapSession` drops 0-area rects (its empty-`<g>` "jerk to
3323
+ * origin" defense), so each point is modeled as a sub-pixel square centered
3324
+ * on it. Symmetric inflation cancels in the corrected delta (the engine's
3325
+ * matched same-edge offset carries the same ±eps on both sides), so eps
3326
+ * magnitude is immaterial as long as it stays far below the threshold.
3327
+ */
3328
+ snap_local_point_delta(raw_dx, raw_dy, agents_local, neighbors_local, ctm) {
3329
+ this.point_snap_guide = void 0;
3330
+ 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;
3336
+ const to_rect = (p) => ({
3337
+ x: p[0] - eps / 2,
3338
+ y: p[1] - eps / 2,
3339
+ width: eps,
3340
+ height: eps
3341
+ });
3342
+ const session = new SnapSession({
3343
+ agents: agents_local.map(to_rect),
3344
+ neighbors: neighbors_local.map(to_rect)
3345
+ });
3346
+ const { delta, guide } = session.snap({
3347
+ x: raw_dx,
3348
+ y: raw_dy
3349
+ }, {
3350
+ enabled: true,
3351
+ threshold_px: threshold_local
3352
+ });
3353
+ session.dispose();
3354
+ if (guide) this.point_snap_guide = this.project_local_guide_to_screen(guide, ctm, this.container_offset());
3355
+ return [delta.x, delta.y];
3356
+ }
3357
+ /** 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. */
3360
+ vertex_snap_points(model, moving) {
3361
+ const verts = model.snapshot().vertices;
3362
+ const moving_set = new Set(moving);
3363
+ const agents = [];
3364
+ const neighbors = [];
3365
+ for (let i = 0; i < verts.length; i++) (moving_set.has(i) ? agents : neighbors).push(verts[i]);
3366
+ return {
3367
+ agents,
3368
+ neighbors
3369
+ };
3370
+ }
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
+ /** Translation from screen/page CSS-px to the HUD's container-identity
3397
+ * space: subtract the container's page offset, add its scroll. Used at
3398
+ * every `getScreenCTM` projection boundary (chrome, marquee, point snap)
3399
+ * so they share one definition of "where the container's origin is". */
3400
+ container_offset() {
3401
+ const cr = this.container.getBoundingClientRect();
3402
+ return [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3403
+ }
3404
+ /**
3405
+ * 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`.
3413
+ */
3414
+ vertex_drag_local_delta(node_id, baseline_model, indices, dx, dy) {
3415
+ const el = this.element_index.get(node_id);
3416
+ 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);
3421
+ const { agents, neighbors } = this.vertex_snap_points(baseline_model, indices);
3422
+ return this.snap_local_point_delta(local_dx, local_dy, agents, neighbors, ctm);
3423
+ }
3207
3424
  /** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
3208
3425
  * Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
3209
3426
  * read live so mid-drag Shift press/release reflects on the next pass. */
@@ -3310,13 +3527,26 @@ var DomSurface = class DomSurface {
3310
3527
  if (!pos_own) return;
3311
3528
  const target_x = pos_own.x;
3312
3529
  const target_y = pos_own.y;
3530
+ const base_x = endpoint === "p1" ? initial.x1 : initial.x2;
3531
+ const base_y = endpoint === "p1" ? initial.y1 : initial.y2;
3532
+ let dx = target_x - base_x;
3533
+ let dy = target_y - base_y;
3534
+ const el = this.element_index.get(id);
3535
+ const ctm = el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function" ? el.getScreenCTM() : null;
3536
+ if (ctm) {
3537
+ 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);
3539
+ }
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;
3313
3543
  const apply = () => {
3314
3544
  if (endpoint === "p1") {
3315
- doc.set_attr(id, "x1", String(target_x));
3316
- doc.set_attr(id, "y1", String(target_y));
3545
+ doc.set_attr(id, "x1", String(final_x));
3546
+ doc.set_attr(id, "y1", String(final_y));
3317
3547
  } else {
3318
- doc.set_attr(id, "x2", String(target_x));
3319
- doc.set_attr(id, "y2", String(target_y));
3548
+ doc.set_attr(id, "x2", String(final_x));
3549
+ doc.set_attr(id, "y2", String(final_y));
3320
3550
  }
3321
3551
  emit();
3322
3552
  };
@@ -3333,6 +3563,7 @@ var DomSurface = class DomSurface {
3333
3563
  revert
3334
3564
  });
3335
3565
  if (intent.phase === "commit") {
3566
+ this.point_snap_guide = void 0;
3336
3567
  this.active_preview.session.commit();
3337
3568
  this.active_preview = null;
3338
3569
  }
@@ -3380,19 +3611,33 @@ var DomSurface = class DomSurface {
3380
3611
  this.handle_marquee_vectors(intent);
3381
3612
  return;
3382
3613
  }
3383
- if (intent.phase !== "commit") return;
3384
- const ids = [];
3385
- for (const id of this.element_index.keys()) {
3386
- if (id === this.editor.tree().root) continue;
3387
- const box = this.container_box(id);
3388
- if (!box) continue;
3389
- if (cmath.rect.intersects(box, intent.rect)) ids.push(id);
3390
- }
3391
- if (ids.length === 0) {
3392
- if (!intent.additive) this.editor.commands.deselect();
3393
- return;
3614
+ if (!this.scene_marquee_baseline) {
3615
+ const cr = this.container.getBoundingClientRect();
3616
+ const root = this.editor.tree().root;
3617
+ const boxes = [];
3618
+ for (const id of this._ensure_z_order(false, root)) {
3619
+ const box = this.container_box(id, cr);
3620
+ if (box) boxes.push([id, box]);
3621
+ }
3622
+ this.scene_marquee_baseline = {
3623
+ boxes,
3624
+ selection: this.editor.state.selection
3625
+ };
3394
3626
  }
3395
- this.editor.commands.select(ids, { mode: intent.additive ? "add" : "replace" });
3627
+ const next = this.resolve_scene_marquee(intent.rect, intent.additive);
3628
+ this.editor.commands.select(next, { mode: "replace" });
3629
+ if (intent.phase === "commit") this.scene_marquee_baseline = null;
3630
+ }
3631
+ /** Resolve the scene marquee selection for one frame from the frozen,
3632
+ * paint-ordered box snapshot. The rule (shadow + additive) lives in the
3633
+ * headless `marquee_selection` policy (`src/selection/marquee.ts`, spec
3634
+ * `docs/marquee-selection.md`); the surface only supplies the boxes and
3635
+ * the gesture-start baseline. Selection is deterministic in (marquee rect,
3636
+ * gesture-start selection, shift) — meta is a gesture-routing modifier
3637
+ * only (it decides that a drag IS a marquee), not a resolution input. */
3638
+ resolve_scene_marquee(rect, additive) {
3639
+ const { boxes, selection } = this.scene_marquee_baseline;
3640
+ return marquee_selection.resolve(boxes, rect, selection, { additive });
3396
3641
  }
3397
3642
  /**
3398
3643
  * Vector marquee predicate — applies the **vertex-priority precedence
@@ -3423,8 +3668,7 @@ var DomSurface = class DomSurface {
3423
3668
  if (typeof el.getScreenCTM !== "function") return;
3424
3669
  const ctm = el.getScreenCTM();
3425
3670
  if (!ctm) return;
3426
- const cr = this.container.getBoundingClientRect();
3427
- const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3671
+ const offset = this.container_offset();
3428
3672
  const model = this.session_model();
3429
3673
  if (!model) return;
3430
3674
  const rect = intent.rect;
@@ -3487,8 +3731,7 @@ var DomSurface = class DomSurface {
3487
3731
  if (typeof el.getScreenCTM !== "function") return;
3488
3732
  const ctm = el.getScreenCTM();
3489
3733
  if (!ctm) return;
3490
- const cr = this.container.getBoundingClientRect();
3491
- const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3734
+ const offset = this.container_offset();
3492
3735
  const model = this.session_model();
3493
3736
  if (!model) return;
3494
3737
  const polygon = intent.polygon;
@@ -3793,8 +4036,7 @@ var DomSurface = class DomSurface {
3793
4036
  if (typeof el.getScreenCTM !== "function") return null;
3794
4037
  const ctm = el.getScreenCTM();
3795
4038
  if (!ctm) return null;
3796
- const cr = this.container.getBoundingClientRect();
3797
- const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
4039
+ const offset = this.container_offset();
3798
4040
  return {
3799
4041
  vertices: Array.from({ length: snap.vertices.length }, (_, i) => {
3800
4042
  const v = snap.vertices[i];
@@ -4042,19 +4284,13 @@ var DomSurface = class DomSurface {
4042
4284
  }
4043
4285
  const baseline_d = this.active_preview.initial_d;
4044
4286
  const indices = this.active_preview.indices;
4045
- let local_dx = intent.dx;
4046
- let local_dy = intent.dy;
4047
- const el = this.element_index.get(node_id);
4048
- if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
4049
- const ctm = el.getScreenCTM();
4050
- if (ctm) {
4051
- if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
4052
- }
4053
- }
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);
4054
4289
  const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
4055
4290
  const target_d = preview_model.toSvgPathD();
4056
4291
  this.active_preview.preview_model = preview_model;
4057
4292
  if (intent.phase === "commit") {
4293
+ this.point_snap_guide = void 0;
4058
4294
  const before_selection = this.active_preview.before_selection;
4059
4295
  const after_selection = this.vector_edit.snapshot_selection();
4060
4296
  this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
@@ -4141,15 +4377,8 @@ var DomSurface = class DomSurface {
4141
4377
  };
4142
4378
  }
4143
4379
  const baseline_d = this.active_preview.initial_d;
4144
- let local_dx = intent.dx;
4145
- let local_dy = intent.dy;
4146
- const el = this.element_index.get(node_id);
4147
- if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
4148
- const ctm = el.getScreenCTM();
4149
- if (ctm) {
4150
- if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
4151
- }
4152
- }
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);
4153
4382
  const baseline_model = this.active_preview.baseline_model;
4154
4383
  const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
4155
4384
  let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;
@@ -4161,6 +4390,7 @@ var DomSurface = class DomSurface {
4161
4390
  const target_d = preview_model.toSvgPathD();
4162
4391
  this.active_preview.preview_model = preview_model;
4163
4392
  if (intent.phase === "commit") {
4393
+ this.point_snap_guide = void 0;
4164
4394
  const before_selection = this.active_preview.before_selection;
4165
4395
  const after_selection = this.vector_edit.snapshot_selection();
4166
4396
  this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
@@ -4168,6 +4398,63 @@ var DomSurface = class DomSurface {
4168
4398
  this.active_preview = null;
4169
4399
  } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
4170
4400
  }
4401
+ /**
4402
+ * Surface-aware `transform.nudge` handler — registered over the headless
4403
+ * default on attach (gridaco/grida#849). In vector content-edit, arrow
4404
+ * keys nudge the path sub-selection (the keyboard counterpart of
4405
+ * dragging vertices / segments / tangents) rather than leaking to a
4406
+ * whole-element move (the bug). This mirrors text-edit, where the inline
4407
+ * editor owns the arrow keys. Outside content-edit it delegates to the
4408
+ * headless `default_nudge_handler` (whole-element nudge) — the same
4409
+ * handler the registry restores on detach, so the override and the
4410
+ * default can never drift.
4411
+ *
4412
+ * While a vector session is open the arrows are OWNED by it: an empty
4413
+ * sub-selection is a consumed no-op, never a whole-element nudge — same
4414
+ * "content-edit captures arrows" contract as text-edit.
4415
+ */
4416
+ handle_nudge_command(args) {
4417
+ const { dx, dy } = args;
4418
+ if (this.editor.state.mode === "edit-content" && this.vector_edit) {
4419
+ this.nudge_vector_selection(dx, dy);
4420
+ return true;
4421
+ }
4422
+ return default_nudge_handler(this.editor)(args) === true;
4423
+ }
4424
+ /**
4425
+ * Discrete keyboard nudge of the vector sub-selection. Reuses the drag
4426
+ * path ({@link handle_translate_vector_selection}) by synthesizing a
4427
+ * single commit-phase intent, so a nudge is byte-identical to a one-step
4428
+ * drag of the same points: same union resolution (selected vertices ∪
4429
+ * selected-segment endpoints ∪ tangents), same parent-vertex tangent
4430
+ * exclusion, same history bracket + sub-selection capture.
4431
+ *
4432
+ * `dx`/`dy` arrive in world units (1px, or 10px with Shift — already
4433
+ * resolved by the keymap binding). `handle_translate_vector_selection`
4434
+ * expects container CSS-px (it projects back through the inverse
4435
+ * screen-CTM), so scale by the camera zoom on the way in; the projection
4436
+ * recovers the world delta in the path's local frame. A no-sub-selection
4437
+ * session resolves to a no-op inside the handler.
4438
+ */
4439
+ nudge_vector_selection(dx, dy) {
4440
+ const ses = this.vector_edit;
4441
+ if (!ses) return;
4442
+ const zoom = this.camera.zoom || 1;
4443
+ this.suppress_point_snap = true;
4444
+ try {
4445
+ this.handle_translate_vector_selection({
4446
+ kind: "translate_vector_selection",
4447
+ node_id: ses.node_id,
4448
+ additional_vertex_indices: [],
4449
+ dx: dx * zoom,
4450
+ dy: dy * zoom,
4451
+ phase: "commit"
4452
+ });
4453
+ } finally {
4454
+ this.suppress_point_snap = false;
4455
+ }
4456
+ this.request_redraw();
4457
+ }
4171
4458
  /** Mirror handler for `select_tangent` (analogous to `select_vertex`). */
4172
4459
  handle_select_tangent(intent) {
4173
4460
  if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
@@ -1,7 +1,56 @@
1
- import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-KqpIW1qm.mjs";
1
+ import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-CcW4BVth.mjs";
2
2
  import cmath from "@grida/cmath";
3
3
  import { guide } from "@grida/cmath/_snap";
4
4
 
5
+ //#region src/util/attention.d.ts
6
+ /** The runtime handle returned by `create_attention_tracker`. */
7
+ interface AttentionTracker {
8
+ /**
9
+ * `true` iff focus is inside the attention scope (the container's
10
+ * subtree or a registered element's subtree) OR the pointer is
11
+ * currently over any element of the scope. See module doc for the
12
+ * rationale.
13
+ *
14
+ * Pure read; no DOM mutation. Cheap enough to call once per keydown.
15
+ */
16
+ is_attended(): boolean;
17
+ /**
18
+ * `true` iff focus is inside the attention scope — the focus arm of
19
+ * {@link is_attended} alone, WITHOUT the pointer-over arm.
20
+ *
21
+ * Exists for the native clipboard gate (`dom.ts`): pointer-over is a
22
+ * sufficient signal to claim a keystroke (worst case: a stolen scroll)
23
+ * but NOT a copy/cut/paste gesture (worst case: destroying what the
24
+ * user believed they copied, or routing a paste meant for a host text
25
+ * field into the document). See docs/wg/feat-svg-editor/clipboard.md
26
+ * §Transport "Gating the native events".
27
+ */
28
+ is_focus_within(): boolean;
29
+ /**
30
+ * Register a host-chrome element into the attention scope. Editor-
31
+ * adjacent chrome — an inspector, a toolbar, a zoom menu; anything that
32
+ * drives `commands.*` — is a DOM *sibling* of the container (the
33
+ * container is exclusively surface-owned), so without registration the
34
+ * tracker cannot tell it apart from unrelated page surface: clicking
35
+ * its buttons moves focus out of the container, and hovering it fires
36
+ * the container's `pointerleave`, blacking out the whole keymap.
37
+ * Registered elements count for both arms — focus-within and
38
+ * pointer-over. Idempotent; re-adding a registered element is a no-op.
39
+ */
40
+ add(element: Element): void;
41
+ /**
42
+ * Unregister an element added via {@link add}. Also clears any
43
+ * still-latched pointer-over contribution from it (the element may be
44
+ * unmounted mid-hover — e.g. a popover closing under the cursor).
45
+ * Unknown elements are a no-op. The container itself cannot be
46
+ * removed; it is the scope's fixed root, not a registered extra.
47
+ */
48
+ remove(element: Element): void;
49
+ /** Detach the internal pointer-tracking listeners (container and every
50
+ * registered element). */
51
+ dispose(): void;
52
+ }
53
+ //#endregion
5
54
  //#region src/core/snap/options.d.ts
6
55
  type SnapOptions = {
7
56
  /** When false, snap behavior and snap-guide rendering are both off. */enabled: boolean;
@@ -88,17 +137,39 @@ type DomSurfaceOptions = {
88
137
  */
89
138
  font_load_source?: EventTarget;
90
139
  };
140
+ /**
141
+ * Host-extendable attention scope — public via `handle.attention`.
142
+ *
143
+ * The document-level keymap (undo / redo / delete / tool keys) is gated on
144
+ * attention: focus inside the scope, or pointer over it. The scope starts
145
+ * as the container alone, which makes editor-adjacent host chrome — an
146
+ * inspector, a toolbar, a zoom menu; anything that drives `commands.*` —
147
+ * indistinguishable from unrelated page surface (chrome must be a DOM
148
+ * *sibling* of the container, never a child). Registering a chrome element
149
+ * keeps the full keymap live while the user works in it, with
150
+ * text-input focus still excluded by the keymap's own guard. The native
151
+ * clipboard gate (deliberately stricter: focus-only, never pointer-over)
152
+ * honors the registered set's focus arm the same way.
153
+ *
154
+ * `add` is idempotent; `remove` of an unregistered element is a no-op.
155
+ * Registrations live for the surface's lifetime — `detach()` drops them
156
+ * with the tracker. Hosts with mounting/unmounting chrome (popovers,
157
+ * panels) pair `add` on mount with `remove` on unmount.
158
+ */
159
+ type AttentionScope = Pick<AttentionTracker, "add" | "remove">;
91
160
  /**
92
161
  * Surface handle for the DOM surface. Extends the editor's core
93
- * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`)
94
- * and pointer/wheel/keyboard gesture bindings (`gestures`).
162
+ * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`),
163
+ * pointer/wheel/keyboard gesture bindings (`gestures`), and the
164
+ * host-extendable attention scope (`attention`).
95
165
  *
96
- * Camera + gestures are **surface-scoped**: detaching the surface drops
97
- * both. They never appear on the headless `SvgEditor`.
166
+ * Camera, gestures, and attention are **surface-scoped**: detaching the
167
+ * surface drops all three. They never appear on the headless `SvgEditor`.
98
168
  */
99
169
  type DomSurfaceHandle = SurfaceHandle & {
100
170
  camera: Camera;
101
171
  gestures: Gestures;
172
+ attention: AttentionScope;
102
173
  };
103
174
  /**
104
175
  * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
@@ -165,4 +236,4 @@ declare function inverse_project_rect(rect: {
165
236
  f: number;
166
237
  }, offset: readonly [number, number]): cmath.Rectangle | null;
167
238
  //#endregion
168
- export { inverse_project_rect as a, DEFAULT_SNAP_OPTIONS as c, install_font_load_geometry_bump as i, SnapOptions as l, DomSurfaceOptions as n, project_delta_inverse_ctm as o, attach_dom_surface as r, project_point_through_ctm as s, DomSurfaceHandle as t };
239
+ export { install_font_load_geometry_bump as a, project_point_through_ctm as c, attach_dom_surface as i, DEFAULT_SNAP_OPTIONS as l, DomSurfaceHandle as n, inverse_project_rect as o, DomSurfaceOptions as r, project_delta_inverse_ctm as s, AttentionScope as t, SnapOptions as u };