@grida/svg-editor 1.0.0-alpha.13 → 1.0.0-alpha.15

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,7 +1,10 @@
1
- const require_insertions = require("./insertions-BJ-6o6o5.js");
1
+ const require_model = require("./model-CJ1Ctq14.js");
2
2
  let _grida_cmath = require("@grida/cmath");
3
- _grida_cmath = require_insertions.__toESM(_grida_cmath);
3
+ _grida_cmath = require_model.__toESM(_grida_cmath);
4
4
  let _grida_svg_parse = require("@grida/svg/parse");
5
+ let _grida_svg_pathdata = require("@grida/svg/pathdata");
6
+ let _grida_vn = require("@grida/vn");
7
+ _grida_vn = require_model.__toESM(_grida_vn);
5
8
  let _grida_text_editor_dom = require("@grida/text-editor/dom");
6
9
  let _grida_hud = require("@grida/hud");
7
10
  let _grida_hud_cursors = require("@grida/hud/cursors");
@@ -296,7 +299,7 @@ function transform_equal(a, b) {
296
299
  //#region src/core/geometry.ts
297
300
  /**
298
301
  * Caches `bounds_of` results keyed on `NodeId`; full-clears on either
299
- * `structure_version` or `geometry_version` bump. See docs/wg/feat-svg-editor/geometry.md for
302
+ * `structure_version` or `geometry_version` bump. See ../../docs/geometry.md for
300
303
  * why the cache is load-bearing under the surface's per-tick re-render.
301
304
  */
302
305
  var MemoizedGeometryProvider = class {
@@ -342,6 +345,12 @@ var MemoizedGeometryProvider = class {
342
345
  node_at_point(p) {
343
346
  return this.driver.node_at_point(p);
344
347
  }
348
+ /** Pass-through. Frame projection depends on live layout, not on the
349
+ * bounds cache, so there is nothing to memoize. Falls back to the raw
350
+ * delta when the driver can't resolve a frame. */
351
+ world_delta_to_local(id, delta) {
352
+ return this.driver.world_delta_to_local?.(id, delta) ?? delta;
353
+ }
345
354
  /** Unsubscribe from both signals. Call on surface detach. */
346
355
  dispose() {
347
356
  for (const unsub of this.unsubscribers) unsub();
@@ -671,7 +680,7 @@ function is_self_rendered(doc, id) {
671
680
  function collect_rendered_subtree(doc, parent, out) {
672
681
  for (const child of doc.element_children_of(parent)) {
673
682
  if (!is_self_rendered(doc, child)) continue;
674
- if (!require_insertions.STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(child))) continue;
683
+ if (!require_model.group.STRUCTURAL_GRAPHICS.has(doc.tag_of(child))) continue;
675
684
  out.add(child);
676
685
  if (doc.tag_of(child) === "g") collect_rendered_subtree(doc, child, out);
677
686
  }
@@ -707,10 +716,10 @@ function compute_neighborhood(doc, dragged) {
707
716
  for (const id of dragged) {
708
717
  const parent = doc.parent_of(id);
709
718
  if (parent === null) continue;
710
- if (!excluded.has(parent) && require_insertions.STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(parent)) && is_self_rendered(doc, parent)) out.add(parent);
719
+ if (!excluded.has(parent) && require_model.group.STRUCTURAL_GRAPHICS.has(doc.tag_of(parent)) && is_self_rendered(doc, parent)) out.add(parent);
711
720
  for (const sib of doc.element_children_of(parent)) {
712
721
  if (excluded.has(sib)) continue;
713
- if (!require_insertions.STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(sib))) continue;
722
+ if (!require_model.group.STRUCTURAL_GRAPHICS.has(doc.tag_of(sib))) continue;
714
723
  if (!is_self_rendered(doc, sib)) continue;
715
724
  for (const inner of snap_descent(doc, sib)) {
716
725
  if (excluded.has(inner)) continue;
@@ -727,565 +736,28 @@ const DEFAULT_SNAP_OPTIONS = {
727
736
  threshold_px: 6
728
737
  };
729
738
  //#endregion
730
- //#region src/core/resize-pipeline/pipeline.ts
731
- /** The funnel. Threads `plan` through `stages` in order; aggregates guide
732
- * emissions. Pure: same inputs → same outputs. */
733
- function run_resize_pipeline(init, stages, ctx) {
734
- let plan = init;
735
- const guides = [];
736
- for (const stage of stages) {
737
- const out = stage.run(plan, ctx);
738
- plan = out.plan;
739
- if (out.emit?.guide) guides.push(out.emit.guide);
740
- }
741
- return {
742
- plan,
743
- guides
744
- };
745
- }
746
- //#endregion
747
- //#region src/core/resize-capability.ts
748
- function direction_mask(dir) {
749
- const has_n = dir === "n" || dir === "ne" || dir === "nw";
750
- const has_s = dir === "s" || dir === "se" || dir === "sw";
751
- const has_e = dir === "e" || dir === "ne" || dir === "se";
752
- const has_w = dir === "w" || dir === "nw" || dir === "sw";
753
- return {
754
- affects_x: has_e || has_w,
755
- affects_y: has_n || has_s,
756
- x_edge: has_e ? "right" : has_w ? "left" : null,
757
- y_edge: has_n ? "top" : has_s ? "bottom" : null
758
- };
759
- }
760
- /** Is this direction one of the four corners (vs. an edge handle)? */
761
- function is_corner_direction(dir) {
762
- return dir === "nw" || dir === "ne" || dir === "se" || dir === "sw";
763
- }
764
- /**
765
- * Apply per-element resize constraints to a gesture's proposed `(sx, sy)`.
766
- *
767
- * The constraint mirrors `apply_resize` exactly so that the *effective
768
- * rect* the caller computes from `(sx, sy)` matches what attribute writes
769
- * actually produce. This is the keystone of resize snap: snapping on the
770
- * gesture-proposed rect would lie about where the geometry lands when an
771
- * element-type constraint kicks in.
772
- *
773
- * Constraints:
774
- * - `rect` / `image` / `use` / `ellipse` / `line` / `polyline` /
775
- * `polygon` / `path`: free per-axis. Identity.
776
- * - `circle`: uniform. `s = min(sx, sy)`.
777
- * - `text`: uniform on corner drags; no-op on edge drags. Mirrors the
778
- * `isCorner = sx !== 1 && sy !== 1` check in `apply_resize`.
779
- * - `unsupported`: no-op.
780
- */
781
- function resize_constraint(baseline, dir, sx_gesture, sy_gesture) {
782
- switch (baseline.attrs.kind) {
783
- case "rect":
784
- case "image":
785
- case "use":
786
- case "ellipse":
787
- case "line":
788
- case "polyline":
789
- case "polygon":
790
- case "path": return {
791
- sx: sx_gesture,
792
- sy: sy_gesture,
793
- no_op: false,
794
- uniform: false
795
- };
796
- case "circle": {
797
- const s = Math.min(sx_gesture, sy_gesture);
798
- return {
799
- sx: s,
800
- sy: s,
801
- no_op: false,
802
- uniform: true
803
- };
804
- }
805
- case "text": {
806
- if (!is_corner_direction(dir)) return {
807
- sx: 1,
808
- sy: 1,
809
- no_op: true,
810
- uniform: true
811
- };
812
- const s = Math.min(sx_gesture, sy_gesture);
813
- return {
814
- sx: s,
815
- sy: s,
816
- no_op: false,
817
- uniform: true
818
- };
819
- }
820
- case "unsupported": return {
821
- sx: 1,
822
- sy: 1,
823
- no_op: true,
824
- uniform: false
825
- };
826
- }
827
- }
828
- /** Position of a bbox corner / edge midpoint by direction. */
829
- function corner_of_rect(r, dir) {
830
- switch (dir) {
831
- case "nw": return {
832
- x: r.x,
833
- y: r.y
834
- };
835
- case "n": return {
836
- x: r.x + r.width / 2,
837
- y: r.y
838
- };
839
- case "ne": return {
840
- x: r.x + r.width,
841
- y: r.y
842
- };
843
- case "e": return {
844
- x: r.x + r.width,
845
- y: r.y + r.height / 2
846
- };
847
- case "se": return {
848
- x: r.x + r.width,
849
- y: r.y + r.height
850
- };
851
- case "s": return {
852
- x: r.x + r.width / 2,
853
- y: r.y + r.height
854
- };
855
- case "sw": return {
856
- x: r.x,
857
- y: r.y + r.height
858
- };
859
- case "w": return {
860
- x: r.x,
861
- y: r.y + r.height / 2
862
- };
863
- }
864
- }
865
- /** The fixed-origin corner for a drag (opposite the moving corner). */
866
- function origin_of_direction(r, dir) {
867
- switch (dir) {
868
- case "nw": return {
869
- x: r.x + r.width,
870
- y: r.y + r.height
871
- };
872
- case "n": return {
873
- x: r.x + r.width / 2,
874
- y: r.y + r.height
875
- };
876
- case "ne": return {
877
- x: r.x,
878
- y: r.y + r.height
879
- };
880
- case "e": return {
881
- x: r.x,
882
- y: r.y + r.height / 2
883
- };
884
- case "se": return {
885
- x: r.x,
886
- y: r.y
887
- };
888
- case "s": return {
889
- x: r.x + r.width / 2,
890
- y: r.y
891
- };
892
- case "sw": return {
893
- x: r.x + r.width,
894
- y: r.y
895
- };
896
- case "w": return {
897
- x: r.x + r.width,
898
- y: r.y + r.height / 2
899
- };
900
- }
901
- }
739
+ //#region src/core/text-edit.ts
902
740
  /**
903
- * Compute the effective rect that `apply_resize` would write for the
904
- * given gesture. This is what snap operates on.
741
+ * Decide what happens when inline text content-editing exits.
905
742
  *
906
- * The rect is computed by scaling `baseline.bbox` around `origin` by the
907
- * constrained `(sx, sy)`. For free elements this matches the gesture
908
- * exactly; for circle / text-on-corner it collapses to a uniform-scale
909
- * rect; for text-on-edge it returns the baseline rect unchanged.
910
- */
911
- function effective_resize(baseline, dir, sx_gesture, sy_gesture) {
912
- const bbox = baseline.bbox;
913
- const origin = origin_of_direction(bbox, dir);
914
- const c = resize_constraint(baseline, dir, sx_gesture, sy_gesture);
915
- const mask = direction_mask(dir);
916
- const rect = {
917
- x: origin.x + (bbox.x - origin.x) * c.sx,
918
- y: origin.y + (bbox.y - origin.y) * c.sy,
919
- width: bbox.width * c.sx,
920
- height: bbox.height * c.sy
921
- };
922
- const moving_corner = corner_of_rect(rect, dir);
923
- return {
924
- rect,
925
- sx: c.sx,
926
- sy: c.sy,
927
- moving_corner,
928
- origin,
929
- no_op: c.no_op,
930
- uniform: c.uniform,
931
- mask
932
- };
933
- }
934
- //#endregion
935
- //#region src/core/resize-pipeline/stages.ts
936
- function pipeline_baseline(plan) {
937
- return plan.baseline;
938
- }
939
- /** Collapse `(dx, dy)` to a uniform scale when Shift-drag is active.
940
- * Element-driven uniform (circle / text-on-corner) is enforced inside
941
- * `resize_constraint`, not here — this stage is *only* the user-visible
942
- * modifier. No-op on edge handles (uniform across one axis isn't a
943
- * meaningful constraint). */
944
- const stage_aspect_lock = {
945
- name: "aspect_lock",
946
- run(plan, ctx) {
947
- if (ctx.modifiers.aspect_lock !== "uniform") return { plan };
948
- if (!is_corner_direction(plan.direction)) return { plan };
949
- const pbase = pipeline_baseline(plan);
950
- const locked = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, true);
951
- const bbox = pbase.bbox;
952
- const Hx_base = (() => {
953
- switch (plan.direction) {
954
- case "nw":
955
- case "w":
956
- case "sw": return bbox.x;
957
- case "ne":
958
- case "e":
959
- case "se": return bbox.x + bbox.width;
960
- case "n":
961
- case "s": return bbox.x + bbox.width / 2;
962
- }
963
- })();
964
- const Hy_base = (() => {
965
- switch (plan.direction) {
966
- case "nw":
967
- case "n":
968
- case "ne": return bbox.y;
969
- case "sw":
970
- case "s":
971
- case "se": return bbox.y + bbox.height;
972
- case "e":
973
- case "w": return bbox.y + bbox.height / 2;
974
- }
975
- })();
976
- const new_Hx = locked.origin.x + (Hx_base - locked.origin.x) * locked.sx;
977
- const new_Hy = locked.origin.y + (Hy_base - locked.origin.y) * locked.sy;
978
- return { plan: {
979
- ...plan,
980
- dx: new_Hx - Hx_base,
981
- dy: new_Hy - Hy_base
982
- } };
983
- }
984
- };
985
- /** Consults `ctx.snap_session` for moving-edge alignment correction.
986
- * Identity on `force_disable_snap`, missing session, or
987
- * `snap_enabled === false`. */
988
- const stage_snap = {
989
- name: "snap",
990
- run(plan, ctx) {
991
- if (ctx.modifiers.force_disable_snap) return { plan };
992
- if (!ctx.snap_session) return { plan };
993
- if (!ctx.options.snap_enabled) return { plan };
994
- const pbase = pipeline_baseline(plan);
995
- const f = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
996
- const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
997
- if (eff.no_op) return { plan };
998
- const r = ctx.snap_session.snap_resize(eff.rect, {
999
- x: eff.mask.x_edge,
1000
- y: eff.mask.y_edge
1001
- }, {
1002
- enabled: true,
1003
- threshold_px: ctx.options.snap_threshold_px
1004
- });
1005
- if (r.dx === 0 && r.dy === 0) return {
1006
- plan,
1007
- emit: r.guide ? { guide: r.guide } : void 0
1008
- };
1009
- if (eff.uniform) {
1010
- const bbox = pbase.bbox;
1011
- const new_Hx = eff.moving_corner.x + r.dx;
1012
- const new_Hy = eff.moving_corner.y + r.dy;
1013
- const sx_from_x = eff.mask.x_edge !== null && r.dx !== 0 && bbox.width !== 0 ? (new_Hx - eff.origin.x) / (eff.moving_corner.x - eff.origin.x) * eff.sx : null;
1014
- const sy_from_y = eff.mask.y_edge !== null && r.dy !== 0 && bbox.height !== 0 ? (new_Hy - eff.origin.y) / (eff.moving_corner.y - eff.origin.y) * eff.sy : null;
1015
- let s = eff.sx;
1016
- if (sx_from_x !== null && sy_from_y !== null) s = Math.min(sx_from_x, sy_from_y);
1017
- else if (sx_from_x !== null) s = sx_from_x;
1018
- else if (sy_from_y !== null) s = sy_from_y;
1019
- const Hx_base = corner_x_of(bbox, plan.direction);
1020
- const Hy_base = corner_y_of(bbox, plan.direction);
1021
- const target_Hx = eff.origin.x + (Hx_base - eff.origin.x) * s;
1022
- const target_Hy = eff.origin.y + (Hy_base - eff.origin.y) * s;
1023
- return {
1024
- plan: {
1025
- ...plan,
1026
- dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
1027
- dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
1028
- },
1029
- emit: r.guide ? { guide: r.guide } : void 0
1030
- };
1031
- }
1032
- return {
1033
- plan: {
1034
- ...plan,
1035
- dx: eff.mask.affects_x ? plan.dx + r.dx : plan.dx,
1036
- dy: eff.mask.affects_y ? plan.dy + r.dy : plan.dy
1037
- },
1038
- emit: r.guide ? { guide: r.guide } : void 0
1039
- };
1040
- }
1041
- };
1042
- /** Quantize the moving corner's *post-resize* position to integer
1043
- * multiples of `pixel_grid_quantum` in own-frame space. Identity when
1044
- * quantum is `null` / `<= 0` or the gesture is a no-op. */
1045
- const stage_pixel_grid = {
1046
- name: "pixel_grid",
1047
- run(plan, ctx) {
1048
- const q = ctx.options.pixel_grid_quantum;
1049
- if (q === null || q <= 0) return { plan };
1050
- const pbase = pipeline_baseline(plan);
1051
- const f = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
1052
- const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
1053
- if (eff.no_op) return { plan };
1054
- const target_Hx = eff.mask.affects_x ? Math.round(eff.moving_corner.x / q) * q : eff.moving_corner.x;
1055
- const target_Hy = eff.mask.affects_y ? Math.round(eff.moving_corner.y / q) * q : eff.moving_corner.y;
1056
- const bbox = pbase.bbox;
1057
- const Hx_base = corner_x_of(bbox, plan.direction);
1058
- const Hy_base = corner_y_of(bbox, plan.direction);
1059
- return { plan: {
1060
- ...plan,
1061
- dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
1062
- dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
1063
- } };
1064
- }
1065
- };
1066
- function corner_x_of(r, dir) {
1067
- switch (dir) {
1068
- case "nw":
1069
- case "w":
1070
- case "sw": return r.x;
1071
- case "ne":
1072
- case "e":
1073
- case "se": return r.x + r.width;
1074
- case "n":
1075
- case "s": return r.x + r.width / 2;
1076
- }
1077
- }
1078
- function corner_y_of(r, dir) {
1079
- switch (dir) {
1080
- case "nw":
1081
- case "n":
1082
- case "ne": return r.y;
1083
- case "sw":
1084
- case "s":
1085
- case "se": return r.y + r.height;
1086
- case "e":
1087
- case "w": return r.y + r.height / 2;
1088
- }
1089
- }
1090
- /** Default stage list for HUD-driven resize gestures (drag).
1091
- * No NUDGE / RPC analogs at v1 (no `commands.resize` per README). */
1092
- const STAGES_DEFAULT = Object.freeze([
1093
- stage_aspect_lock,
1094
- stage_snap,
1095
- stage_pixel_grid
1096
- ]);
1097
- //#endregion
1098
- //#region src/core/resize-pipeline/apply.ts
1099
- function applyResizePlan(doc, plan, phase = "commit") {
1100
- const f = require_insertions.compute_resize_factors(plan.baseline, plan.direction, plan.dx, plan.dy, false);
1101
- const members = plan.members ?? [{
1102
- id: plan.id,
1103
- baseline: plan.baseline
1104
- }];
1105
- for (const m of members) require_insertions.apply_resize(doc, m.id, m.baseline, f.sx, f.sy, f.origin, phase);
1106
- }
1107
- function revertResizePlan(doc, plan) {
1108
- const f = require_insertions.compute_resize_factors(plan.baseline, plan.direction, 0, 0, false);
1109
- const members = plan.members ?? [{
1110
- id: plan.id,
1111
- baseline: plan.baseline
1112
- }];
1113
- for (const m of members) require_insertions.apply_resize(doc, m.id, m.baseline, 1, 1, f.origin, "preview");
1114
- }
1115
- /**
1116
- * Synthesize a "group" baseline over an arbitrary union rect. The attrs
1117
- * carrier is `rect`-kind so the pipeline math (snap / pixel-grid)
1118
- * treats the group as free per-axis — per-member constraints (circle
1119
- * uniform, text edge no-op) kick in at apply time against each
1120
- * member's own captured baseline.
743
+ * `result` is the text that should remain the typed text on commit, the
744
+ * original on cancel. "Empty" means zero-length: a space is authored
745
+ * content and is kept. The rule is unconditional an empty result deletes
746
+ * the node however it got there (freshly placed and never typed, cleared by
747
+ * the author, or already empty on entry).
1121
748
  *
1122
- * For single-member groups callers should pass the member's own
1123
- * baseline directly so the per-element snap correction (`eff.uniform`
1124
- * branch) fires correctly.
749
+ * See docs/wg/feat-svg-editor/text-tool.md.
1125
750
  */
1126
- function synthesize_group_baseline(union) {
1127
- return {
1128
- bbox: {
1129
- x: union.x,
1130
- y: union.y,
1131
- width: union.width,
1132
- height: union.height
1133
- },
1134
- attrs: {
1135
- kind: "rect",
1136
- x: union.x,
1137
- y: union.y,
1138
- w: union.width,
1139
- h: union.height
1140
- }
1141
- };
1142
- }
1143
- //#endregion
1144
- //#region src/core/resize-pipeline/orchestrator.ts
1145
- const PROVIDER_ID = "svg-editor";
1146
- /** West/north-anchor flips invert the corresponding world delta so a
1147
- * positive value always grows the moving edge outward — the convention
1148
- * `compute_resize_factors` consumes. */
1149
- function sign_adjust(dir, dx_world, dy_world) {
1150
- return {
1151
- dx: dir === "w" || dir === "nw" || dir === "sw" ? -dx_world : dx_world,
1152
- dy: dir === "n" || dir === "ne" || dir === "nw" ? -dy_world : dy_world
751
+ function resolve_text_exit(input) {
752
+ const is_empty = input.result.length === 0;
753
+ if (input.origin === "fresh") return is_empty ? { kind: "discard_insert" } : { kind: "commit_insert" };
754
+ if (is_empty) return { kind: "remove" };
755
+ if (input.result !== input.original) return {
756
+ kind: "set_text",
757
+ value: input.result
1153
758
  };
759
+ return { kind: "noop" };
1154
760
  }
1155
- /** Stable, order-independent key for an id set — used by `is_active_for`
1156
- * to decide whether the current session targets the same group. */
1157
- function ids_key(ids) {
1158
- return [...ids].sort().join("\0");
1159
- }
1160
- var ResizeOrchestrator = class {
1161
- constructor(deps) {
1162
- this.deps = deps;
1163
- this.active = null;
1164
- this._last_guides = [];
1165
- }
1166
- /** Guides emitted by the most recent pipeline run. Cleared on
1167
- * cancel/dispose. */
1168
- get last_guides() {
1169
- return this._last_guides;
1170
- }
1171
- has_active_session() {
1172
- return this.active !== null;
1173
- }
1174
- /** Is the gesture currently targeting `ids` with `direction`? Used by
1175
- * the HUD dispatch to decide whether to reset the session on a new
1176
- * handle / target. Order-independent. */
1177
- is_active_for(ids, direction) {
1178
- return this.active !== null && this.active.direction === direction && this.active.ids_key === ids_key(ids);
1179
- }
1180
- /** Per-frame drive. Opens a session lazily on the first call. The
1181
- * HUD passes its gesture-target rect dimensions in **world space**;
1182
- * the orchestrator derives the signed world-frame delta against its
1183
- * captured `baseline.bbox`. The DOM adapter is responsible for the
1184
- * CSS-px → world conversion at the intent boundary. */
1185
- drive(input, modifiers, opts) {
1186
- if (input.ids.length === 0) return null;
1187
- const doc = this.deps.get_doc();
1188
- for (const id of input.ids) if (!require_insertions.is_resizable_node(doc, id)) return null;
1189
- const key = ids_key(input.ids);
1190
- if (this.active && (this.active.ids_key !== key || this.active.direction !== input.direction)) {
1191
- this.active.preview.discard();
1192
- this.dispose_session();
1193
- }
1194
- if (this.active === null) this.active = this.open(input.ids, input.direction, opts.snap, opts.label ?? "resize");
1195
- const session = this.active;
1196
- const bbox = session.baseline.bbox;
1197
- const dx_world = input.target_width - bbox.width;
1198
- const dy_world = input.target_height - bbox.height;
1199
- const d = sign_adjust(input.direction, dx_world, dy_world);
1200
- const stages = opts.stages ?? STAGES_DEFAULT;
1201
- const result = this.run_pass(session, d.dx, d.dy, modifiers, stages);
1202
- session.last_dx = d.dx;
1203
- session.last_dy = d.dy;
1204
- session.last_stages = stages;
1205
- this.write_preview(session, result.plan, opts.phase);
1206
- if (opts.phase === "commit") {
1207
- session.preview.commit();
1208
- this.dispose_session();
1209
- }
1210
- return result;
1211
- }
1212
- /** Re-run the current preview frame with new modifiers. */
1213
- redrive_modifiers(modifiers) {
1214
- if (!this.active) return null;
1215
- const session = this.active;
1216
- const result = this.run_pass(session, session.last_dx, session.last_dy, modifiers, session.last_stages);
1217
- this.write_preview(session, result.plan, "preview");
1218
- return result;
1219
- }
1220
- cancel() {
1221
- if (!this.active) return;
1222
- this.active.preview.discard();
1223
- this.dispose_session();
1224
- }
1225
- run_pass(session, dx, dy, modifiers, stages) {
1226
- const result = run_resize_pipeline({
1227
- id: session.primary_id,
1228
- baseline: session.baseline,
1229
- members: session.members,
1230
- direction: session.direction,
1231
- dx,
1232
- dy
1233
- }, stages, {
1234
- input: {
1235
- id: session.primary_id,
1236
- direction: session.direction,
1237
- dx,
1238
- dy
1239
- },
1240
- modifiers,
1241
- options: this.deps.options(),
1242
- snap_session: session.snap
1243
- });
1244
- this._last_guides = result.guides;
1245
- return result;
1246
- }
1247
- open(ids, direction, snap, label) {
1248
- const doc = this.deps.get_doc();
1249
- const members = ids.map((id) => ({
1250
- id,
1251
- baseline: require_insertions.capture_resize_baseline(doc, id, this.deps.bbox_world(id))
1252
- }));
1253
- const baseline = members.length === 1 ? members[0].baseline : synthesize_group_baseline(_grida_cmath.default.rect.union(members.map((m) => m.baseline.bbox)));
1254
- return {
1255
- ids_key: ids_key(ids),
1256
- primary_id: members[0].id,
1257
- direction,
1258
- members,
1259
- baseline,
1260
- snap: snap ? this.deps.open_snap(ids) : null,
1261
- preview: this.deps.open_preview(label),
1262
- last_dx: 0,
1263
- last_dy: 0,
1264
- last_stages: STAGES_DEFAULT
1265
- };
1266
- }
1267
- write_preview(session, plan, phase) {
1268
- const doc = this.deps.get_doc();
1269
- const emit = this.deps.emit;
1270
- session.preview.set({
1271
- providerId: PROVIDER_ID,
1272
- apply: () => {
1273
- applyResizePlan(doc, plan, phase);
1274
- emit();
1275
- },
1276
- revert: () => {
1277
- revertResizePlan(doc, plan);
1278
- emit();
1279
- }
1280
- });
1281
- }
1282
- dispose_session() {
1283
- if (!this.active) return;
1284
- this.active.snap?.dispose();
1285
- this.active = null;
1286
- this._last_guides = [];
1287
- }
1288
- };
1289
761
  //#endregion
1290
762
  //#region src/gestures/gestures.ts
1291
763
  /**
@@ -1421,7 +893,7 @@ const DEFAULT_GESTURE_BINDINGS = [
1421
893
  WHEEL_PAN_ZOOM,
1422
894
  {
1423
895
  id: "space-drag-pan",
1424
- install({ container, camera }) {
896
+ install({ container, camera, is_attended }) {
1425
897
  let space_held = false;
1426
898
  let prev_cursor = null;
1427
899
  const set_cursor = (next) => {
@@ -1431,7 +903,8 @@ const DEFAULT_GESTURE_BINDINGS = [
1431
903
  };
1432
904
  const on_keydown = (e) => {
1433
905
  if (e.code !== "Space" || e.repeat) return;
1434
- if (require_insertions.is_text_input_focused()) return;
906
+ if (require_model.is_text_input_focused()) return;
907
+ if (!is_attended()) return;
1435
908
  space_held = true;
1436
909
  set_cursor("grab");
1437
910
  e.preventDefault();
@@ -1484,12 +957,11 @@ const DEFAULT_GESTURE_BINDINGS = [
1484
957
  },
1485
958
  {
1486
959
  id: "keyboard-zoom",
1487
- install({ container, camera }) {
960
+ install({ container, camera, is_attended }) {
1488
961
  const owner_doc = container.ownerDocument;
1489
962
  const on_keydown = (e) => {
1490
- const active = owner_doc.activeElement;
1491
- if (active && active !== owner_doc.body && !container.contains(active)) return;
1492
- if (require_insertions.is_text_input_focused()) return;
963
+ if (!is_attended()) return;
964
+ if (require_model.is_text_input_focused()) return;
1493
965
  const mod = e.metaKey || e.ctrlKey;
1494
966
  if (e.shiftKey && !mod && (e.code === "Digit0" || e.code === "Numpad0")) {
1495
967
  camera.reset();
@@ -1518,6 +990,39 @@ function applyDefaultGestures(gestures) {
1518
990
  for (const b of DEFAULT_GESTURE_BINDINGS) gestures.bind(b);
1519
991
  }
1520
992
  //#endregion
993
+ //#region src/util/attention.ts
994
+ /**
995
+ * Install pointer-tracking listeners on `container` and return the
996
+ * read-side handle. The tracker is owned by the surface and disposed
997
+ * alongside it; gesture bindings that need to consult it receive the
998
+ * read-only `is_attended` predicate through `GestureContext`.
999
+ */
1000
+ 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;
1007
+ };
1008
+ container.addEventListener("pointerenter", on_enter);
1009
+ container.addEventListener("pointerleave", on_leave);
1010
+ const is_attended = () => {
1011
+ const owner = container.ownerDocument;
1012
+ if (!owner) return pointer_over;
1013
+ const active = owner.activeElement;
1014
+ if (active && active !== owner.body && container.contains(active)) return true;
1015
+ return pointer_over;
1016
+ };
1017
+ return {
1018
+ is_attended,
1019
+ dispose: () => {
1020
+ container.removeEventListener("pointerenter", on_enter);
1021
+ container.removeEventListener("pointerleave", on_leave);
1022
+ }
1023
+ };
1024
+ }
1025
+ //#endregion
1521
1026
  //#region src/text-surface.ts
1522
1027
  const SVG_NS = "http://www.w3.org/2000/svg";
1523
1028
  const XML_NS = "http://www.w3.org/XML/1998/namespace";
@@ -1677,6 +1182,423 @@ var SvgTextSurface = class {
1677
1182
  }
1678
1183
  };
1679
1184
  //#endregion
1185
+ //#region src/core/vector-edit/session.ts
1186
+ function tangents_equal(a, b) {
1187
+ return a[0] === b[0] && a[1] === b[1];
1188
+ }
1189
+ /**
1190
+ * Shallow equality over the three sub-selection arrays. Order-sensitive
1191
+ * (mirrors how the host stores them). Used by the orchestrator to skip
1192
+ * pushing a no-op undo entry when a selection handler resolves to the
1193
+ * same state — e.g. clicking an already-selected vertex in `replace`
1194
+ * mode.
1195
+ */
1196
+ function sub_selection_equal(a, b) {
1197
+ if (a === b) return true;
1198
+ if (a.vertices.length !== b.vertices.length) return false;
1199
+ if (a.segments.length !== b.segments.length) return false;
1200
+ if (a.tangents.length !== b.tangents.length) return false;
1201
+ for (let i = 0; i < a.vertices.length; i++) if (a.vertices[i] !== b.vertices[i]) return false;
1202
+ for (let i = 0; i < a.segments.length; i++) if (a.segments[i] !== b.segments[i]) return false;
1203
+ for (let i = 0; i < a.tangents.length; i++) if (!tangents_equal(a.tangents[i], b.tangents[i])) return false;
1204
+ return true;
1205
+ }
1206
+ /**
1207
+ * Host-side state for vector content-edit (vertex / segment / tangent
1208
+ * gestures) on the supported source tags. "Vector" here names the
1209
+ * editing mode — NOT a `vn.VectorNetwork` wrapper. The session holds
1210
+ * the source tag's authored attrs plus a path-form session-d, and
1211
+ * delegates geometry to {@link PathModel} via the apply.ts shim.
1212
+ */
1213
+ var VectorEditSession = class {
1214
+ constructor(node_id, source, session_d) {
1215
+ this.node_id = node_id;
1216
+ this._source = source;
1217
+ this._source_before_promotion = null;
1218
+ this._session_d = session_d;
1219
+ this._last_seen_d = session_d;
1220
+ this._selected_vertices = [];
1221
+ this._selected_segments = [];
1222
+ this._selected_tangents = [];
1223
+ this._hovered_control = null;
1224
+ }
1225
+ /** Source tag the session currently projects through. See `_source`. */
1226
+ get source() {
1227
+ return this._source;
1228
+ }
1229
+ /**
1230
+ * Flip the source to `path` after the underlying element was promoted
1231
+ * (rect / circle / ellipse → `<path>`). Idempotent: a second call while
1232
+ * already promoted does nothing, so the pre-promotion source captured by
1233
+ * the first flip is never clobbered.
1234
+ */
1235
+ promote_source_to_path() {
1236
+ if (this._source_before_promotion !== null) return;
1237
+ if (this._source.kind === "path") return;
1238
+ this._source_before_promotion = this._source;
1239
+ this._source = {
1240
+ kind: "path",
1241
+ d: this._session_d
1242
+ };
1243
+ }
1244
+ /** Reverse a {@link promote_source_to_path} (gesture undo). No-op if the
1245
+ * source was never promoted. */
1246
+ restore_source() {
1247
+ if (this._source_before_promotion === null) return;
1248
+ this._source = this._source_before_promotion;
1249
+ this._source_before_promotion = null;
1250
+ }
1251
+ /**
1252
+ * Re-sync the source to the document's current tag, outright. Unlike
1253
+ * {@link promote_source_to_path} / {@link restore_source} (which manage a
1254
+ * single primitive→path flip within one gesture), this sets the source to
1255
+ * an authoritative value derived from the live document and clears the
1256
+ * promotion bookkeeping.
1257
+ *
1258
+ * The host calls this when an undo/redo re-types the node out from under a
1259
+ * *different* live session object than the one that performed the original
1260
+ * flip (exit + undo-exit creates a fresh session; the captured session's
1261
+ * `restore_source` then no-ops). Without it the live session could keep
1262
+ * `source.kind === "path"` while the node is back to a primitive, and the
1263
+ * next gesture would write a stray `d` onto the native tag. Re-deriving
1264
+ * from the document keeps the live session authoritative.
1265
+ */
1266
+ sync_source(source) {
1267
+ this._source = source;
1268
+ this._source_before_promotion = null;
1269
+ }
1270
+ /** The session's current PathModel-form `d`. Gesture handlers read
1271
+ * this instead of `doc.get_attr(node_id, "d")` so they stay tag-
1272
+ * oblivious (non-path sources have no `d` on the document). */
1273
+ get current_d() {
1274
+ return this._session_d;
1275
+ }
1276
+ /** Update the session's view after a write produced `next_d`. Caller
1277
+ * is `apply_session_d` (or the gesture handler that called it). */
1278
+ update_session_d(next_d) {
1279
+ this._session_d = next_d;
1280
+ }
1281
+ get last_seen_d() {
1282
+ return this._last_seen_d;
1283
+ }
1284
+ get selected_vertices() {
1285
+ return this._selected_vertices;
1286
+ }
1287
+ get selected_segments() {
1288
+ return this._selected_segments;
1289
+ }
1290
+ get selected_tangents() {
1291
+ return this._selected_tangents;
1292
+ }
1293
+ get hovered_control() {
1294
+ return this._hovered_control;
1295
+ }
1296
+ /**
1297
+ * Record that the host's most recent gesture write produced `d`.
1298
+ * Updates both the session-d (the in-session canonical form) and the
1299
+ * last-seen mark. The next state-change tick uses last_seen to
1300
+ * distinguish "we wrote this" from "the document changed under us".
1301
+ */
1302
+ mark_seen(d) {
1303
+ this._session_d = d;
1304
+ this._last_seen_d = d;
1305
+ }
1306
+ /**
1307
+ * The session's response to a detected external mutation of `d`
1308
+ * (undo / redo / programmatic / collab). Atomically (a) advances
1309
+ * `last_seen_d` to the now-current value and (b) drops sub-selection
1310
+ * — selection indices reference vertices and segments by ordinal
1311
+ * position, and an external mutation may have shifted or removed
1312
+ * them.
1313
+ *
1314
+ * Exposed as a single method so callers cannot get the two halves
1315
+ * out of order. Doing `clear_selection` without `mark_seen` would
1316
+ * leave us "stuck dirty" — the next tick would reconcile again.
1317
+ * Doing `mark_seen` without `clear_selection` would leave stale
1318
+ * indices pointing into a geometry that no longer matches.
1319
+ */
1320
+ reconcile_after_external_mutation(d) {
1321
+ this.mark_seen(d);
1322
+ this.clear_selection();
1323
+ }
1324
+ select_vertex(index, mode) {
1325
+ switch (mode) {
1326
+ case "replace":
1327
+ this._selected_vertices = [index];
1328
+ break;
1329
+ case "add":
1330
+ if (!this._selected_vertices.includes(index)) this._selected_vertices = [...this._selected_vertices, index];
1331
+ break;
1332
+ case "toggle":
1333
+ this._selected_vertices = this._selected_vertices.includes(index) ? this._selected_vertices.filter((v) => v !== index) : [...this._selected_vertices, index];
1334
+ break;
1335
+ }
1336
+ if (mode === "replace") {
1337
+ this._selected_segments = [];
1338
+ this._selected_tangents = [];
1339
+ }
1340
+ }
1341
+ select_segment(index, mode) {
1342
+ switch (mode) {
1343
+ case "replace":
1344
+ this._selected_segments = [index];
1345
+ break;
1346
+ case "add":
1347
+ if (!this._selected_segments.includes(index)) this._selected_segments = [...this._selected_segments, index];
1348
+ break;
1349
+ case "toggle":
1350
+ this._selected_segments = this._selected_segments.includes(index) ? this._selected_segments.filter((s) => s !== index) : [...this._selected_segments, index];
1351
+ break;
1352
+ }
1353
+ if (mode === "replace") {
1354
+ this._selected_vertices = [];
1355
+ this._selected_tangents = [];
1356
+ }
1357
+ }
1358
+ select_tangent(ref, mode) {
1359
+ const has = this._selected_tangents.some((t) => tangents_equal(t, ref));
1360
+ switch (mode) {
1361
+ case "replace":
1362
+ this._selected_tangents = [ref];
1363
+ break;
1364
+ case "add":
1365
+ if (!has) this._selected_tangents = [...this._selected_tangents, ref];
1366
+ break;
1367
+ case "toggle":
1368
+ this._selected_tangents = has ? this._selected_tangents.filter((t) => !tangents_equal(t, ref)) : [...this._selected_tangents, ref];
1369
+ break;
1370
+ }
1371
+ if (mode === "replace") {
1372
+ this._selected_vertices = [];
1373
+ this._selected_segments = [];
1374
+ }
1375
+ }
1376
+ /**
1377
+ * Replace the entire sub-selection at once. Useful for marquee /
1378
+ * lasso results, which compute the full set up-front.
1379
+ */
1380
+ set_selection(next) {
1381
+ this._selected_vertices = [...next.vertices];
1382
+ this._selected_segments = [...next.segments];
1383
+ this._selected_tangents = next.tangents.map((t) => [t[0], t[1]]);
1384
+ }
1385
+ /**
1386
+ * Capture the current sub-selection as a frozen triple. The
1387
+ * orchestrator closes over snapshots in gesture deltas (so undo
1388
+ * restores selection alongside geometry) and in standalone selection
1389
+ * deltas (so a click on a vertex is itself undoable).
1390
+ *
1391
+ * Returned arrays are fresh copies — safe to retain across
1392
+ * subsequent mutations of the session.
1393
+ */
1394
+ snapshot_selection() {
1395
+ return Object.freeze({
1396
+ vertices: Object.freeze([...this._selected_vertices]),
1397
+ segments: Object.freeze([...this._selected_segments]),
1398
+ tangents: Object.freeze(this._selected_tangents.map((t) => [t[0], t[1]]))
1399
+ });
1400
+ }
1401
+ /**
1402
+ * Restore a previously-captured sub-selection. Counterpart to
1403
+ * {@link snapshot_selection}. Equivalent to calling
1404
+ * {@link set_selection} with the snapshot's contents.
1405
+ */
1406
+ restore_selection(snap) {
1407
+ this.set_selection(snap);
1408
+ }
1409
+ clear_selection() {
1410
+ if (this._selected_vertices.length === 0 && this._selected_segments.length === 0 && this._selected_tangents.length === 0) return;
1411
+ this._selected_vertices = [];
1412
+ this._selected_segments = [];
1413
+ this._selected_tangents = [];
1414
+ }
1415
+ };
1416
+ //#endregion
1417
+ //#region src/core/vector-edit/apply.ts
1418
+ /**
1419
+ * Build the in-session path-form `d` ("session-d") for a freshly-
1420
+ * entered vector-edit. For `<path>` this is the verbatim authored
1421
+ * string; for the two vertex-chain tags we route through
1422
+ * `vn.fromPolyline` / `vn.fromPolygon` and emit via `vn.toSVGPathData`
1423
+ * — same zero-tangent `M`/`L` sequence the gesture handlers'
1424
+ * `PathModel.toSvgPathD()` will produce on subsequent commits.
1425
+ *
1426
+ * The returned string is internal to the session — it is never written
1427
+ * to the document on its own. Native-attr writeback happens through
1428
+ * {@link apply_session_d} (which calls {@link PathModel.toNativeAttrs}
1429
+ * to project the path-form geometry back to source-tag attrs).
1430
+ */
1431
+ function source_to_session_d(source) {
1432
+ switch (source.kind) {
1433
+ case "path": return source.d;
1434
+ case "line": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromPolyline([[source.x1, source.y1], [source.x2, source.y2]]));
1435
+ case "polyline": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromPolyline(source.points.map((p) => [p[0], p[1]])));
1436
+ case "polygon": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromPolygon(source.points.map((p) => [p[0], p[1]])));
1437
+ case "circle": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromEllipse({
1438
+ x: source.cx - source.r,
1439
+ y: source.cy - source.r,
1440
+ width: source.r * 2,
1441
+ height: source.r * 2
1442
+ }));
1443
+ case "ellipse": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromEllipse({
1444
+ x: source.cx - source.rx,
1445
+ y: source.cy - source.ry,
1446
+ width: source.rx * 2,
1447
+ height: source.ry * 2
1448
+ }));
1449
+ case "rect": return _grida_svg_pathdata.SVGShapes.createRect(source.x, source.y, source.width, source.height, source.rx, source.ry).encode();
1450
+ }
1451
+ }
1452
+ /**
1453
+ * Native-attribute writeback. Given a new path-data `d` from a gesture,
1454
+ * project it back into the source tag's native attrs and write those —
1455
+ * `<path>` takes `d` directly; `<line>` takes `x1/y1/x2/y2`;
1456
+ * `<polyline>` / `<polygon>` take `points`.
1457
+ *
1458
+ * Returns `true` if the geometry was written natively. Returns `false`
1459
+ * when the source tag cannot express the geometry — a curve was introduced
1460
+ * or the topology left the tag's canonical form, OR the source is a
1461
+ * geometry primitive (rect / circle / ellipse) which has no native vector
1462
+ * form at all. A `false` return is the re-type-to-`<path>` signal; the
1463
+ * caller ({@link vector_apply}) handles it. This function never re-types.
1464
+ *
1465
+ * Symmetric across apply / revert: callers use it for both the in-flight
1466
+ * write and the undo-revert (both are just "set the geometry to this d").
1467
+ */
1468
+ function apply_session_d(doc, node_id, source, d) {
1469
+ if (source.kind === "path") {
1470
+ doc.set_attr(node_id, "d", d);
1471
+ return true;
1472
+ }
1473
+ const native = require_model.PathModel.fromSvgPathD(d).toNativeAttrs(source.kind);
1474
+ if (native === null) return false;
1475
+ if (native.kind === "line") {
1476
+ doc.set_attr(node_id, "x1", String(native.x1));
1477
+ doc.set_attr(node_id, "y1", String(native.y1));
1478
+ doc.set_attr(node_id, "x2", String(native.x2));
1479
+ doc.set_attr(node_id, "y2", String(native.y2));
1480
+ return true;
1481
+ }
1482
+ const points = native.points.map((p) => `${p[0]},${p[1]}`).join(" ");
1483
+ doc.set_attr(node_id, "points", points);
1484
+ return true;
1485
+ }
1486
+ /**
1487
+ * Session-aware geometry write — the single commit chokepoint the DOM
1488
+ * gesture handlers call so re-typing stays in one place rather than being
1489
+ * reimplemented per gesture. One uniform rule across every source:
1490
+ *
1491
+ * 1. Try native writeback ({@link apply_session_d}). For `<path>` and for
1492
+ * a vertex tag (`line` / `polyline` / `polygon`) whose edit still fits
1493
+ * its native form, this writes and we're done — the element keeps its
1494
+ * tag.
1495
+ * 2. If native writeback refused (a curve was introduced, the topology
1496
+ * escaped the canonical chain, or the source is a geometry primitive
1497
+ * with no native form), re-type the element to `<path>` via
1498
+ * {@link SvgDocument.retype_to_path} and flip the session source to
1499
+ * `path` (so every downstream reader — overlay, gates, the
1500
+ * external-mutation reconciler — behaves correctly).
1501
+ *
1502
+ * Returns the {@link RetypeRecord} token iff this call performed a re-type
1503
+ * (so the caller can pair it with the edit in one history bracket and hand
1504
+ * it to {@link vector_revert} on undo); otherwise `null`.
1505
+ */
1506
+ function vector_apply(doc, session, d) {
1507
+ if (apply_session_d(doc, session.node_id, session.source, d)) return null;
1508
+ const token = doc.retype_to_path(session.node_id, d);
1509
+ if (token) {
1510
+ session.promote_source_to_path();
1511
+ return token;
1512
+ }
1513
+ doc.set_attr(session.node_id, "d", d);
1514
+ return null;
1515
+ }
1516
+ /**
1517
+ * Counterpart to {@link vector_apply}. If this gesture re-typed the element
1518
+ * (a non-null `promotion` token), restore the original tag/attrs and the
1519
+ * session source — the re-type and the edit undo as one step. Otherwise
1520
+ * re-write the baseline geometry natively; for a geometry primitive that
1521
+ * never re-typed, {@link apply_session_d} writes nothing (a correct no-op).
1522
+ */
1523
+ function vector_revert(doc, session, baseline_d, promotion) {
1524
+ if (promotion) {
1525
+ doc.revert_retype(session.node_id, promotion);
1526
+ session.restore_source();
1527
+ return;
1528
+ }
1529
+ apply_session_d(doc, session.node_id, session.source, baseline_d);
1530
+ }
1531
+ //#endregion
1532
+ //#region src/core/vector-edit/marquee.ts
1533
+ let marquee;
1534
+ (function(_marquee) {
1535
+ function points_in_rect(candidates, rect) {
1536
+ const hits = [];
1537
+ for (const c of candidates) if (_grida_cmath.default.rect.containsPoint(rect, c.pos)) hits.push(c.key);
1538
+ return hits;
1539
+ }
1540
+ _marquee.points_in_rect = points_in_rect;
1541
+ function points_in_polygon(candidates, polygon) {
1542
+ const hits = [];
1543
+ const poly = polygon;
1544
+ for (const c of candidates) if (_grida_cmath.default.polygon.pointInPolygon(c.pos, poly)) hits.push(c.key);
1545
+ return hits;
1546
+ }
1547
+ _marquee.points_in_polygon = points_in_polygon;
1548
+ function subpath_select_candidates(model, selection, to_doc = identity_proj) {
1549
+ const snap = model.snapshot();
1550
+ const vertices = snap.vertices.map((v, i) => ({
1551
+ key: i,
1552
+ pos: to_doc(v)
1553
+ }));
1554
+ const neigh_set = new Set(model.neighbouringVertices(selection));
1555
+ const tangents = [];
1556
+ for (let si = 0; si < snap.segments.length; si++) {
1557
+ const s = snap.segments[si];
1558
+ if (neigh_set.has(s.a)) {
1559
+ const va = snap.vertices[s.a];
1560
+ tangents.push({
1561
+ key: [s.a, 0],
1562
+ pos: to_doc([va[0] + s.ta[0], va[1] + s.ta[1]])
1563
+ });
1564
+ }
1565
+ if (neigh_set.has(s.b)) {
1566
+ const vb = snap.vertices[s.b];
1567
+ tangents.push({
1568
+ key: [s.b, 1],
1569
+ pos: to_doc([vb[0] + s.tb[0], vb[1] + s.tb[1]])
1570
+ });
1571
+ }
1572
+ }
1573
+ return {
1574
+ vertices,
1575
+ tangents,
1576
+ segments: Array.from({ length: snap.segments.length }, (_, i) => i)
1577
+ };
1578
+ }
1579
+ _marquee.subpath_select_candidates = subpath_select_candidates;
1580
+ function identity_proj(p) {
1581
+ return p;
1582
+ }
1583
+ function merge_subpath_hits(prev, hits, additive) {
1584
+ if (!additive) return {
1585
+ vertices: [...hits.vertices],
1586
+ segments: [...hits.segments],
1587
+ tangents: hits.tangents.map((t) => [t[0], t[1]])
1588
+ };
1589
+ const vertices = Array.from(new Set([...prev.vertices, ...hits.vertices]));
1590
+ const segments = Array.from(new Set([...prev.segments, ...hits.segments]));
1591
+ const tangents = prev.tangents.map((t) => [t[0], t[1]]);
1592
+ for (const t of hits.tangents) if (!tangents.some((x) => x[0] === t[0] && x[1] === t[1])) tangents.push(t);
1593
+ return {
1594
+ vertices,
1595
+ segments,
1596
+ tangents
1597
+ };
1598
+ }
1599
+ _marquee.merge_subpath_hits = merge_subpath_hits;
1600
+ })(marquee || (marquee = {}));
1601
+ //#endregion
1680
1602
  //#region src/dom.ts
1681
1603
  /** Stamped on every rendered SVG element by `render()` so external
1682
1604
  * tooling (host inspectors, the layers panel, snapshot tests) can map
@@ -1758,14 +1680,20 @@ var DomSurface = class DomSurface {
1758
1680
  this.text_edit = null;
1759
1681
  this.text_edit_target = null;
1760
1682
  this.text_edit_original = "";
1761
- this.current_tool = require_insertions.TOOL_CURSOR;
1683
+ this.pending_text_insert = null;
1684
+ this.vector_edit = null;
1685
+ this.vector_edit_region_baseline = null;
1686
+ this.current_tool = require_model.TOOL_CURSOR;
1762
1687
  this.pending_insert = null;
1763
1688
  this.editor_hover_internal = null;
1764
1689
  this.container = options.container;
1765
1690
  const container = this.container;
1766
1691
  this.fit_on_attach = options.fit === true;
1692
+ this.attention = create_attention_tracker(container);
1693
+ this.teardown.push(() => this.attention.dispose());
1767
1694
  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.");
1768
1695
  if (getComputedStyle(container).position === "static") container.style.position = "relative";
1696
+ container.style.overflow = "hidden";
1769
1697
  container.style.userSelect = "none";
1770
1698
  container.style.webkitUserSelect = "none";
1771
1699
  const translate_options = () => {
@@ -1777,12 +1705,13 @@ var DomSurface = class DomSurface {
1777
1705
  snap_threshold_px: style.snap_threshold_px / zoom
1778
1706
  };
1779
1707
  };
1780
- this.translate_orchestrator = new require_insertions.TranslateOrchestrator({
1708
+ this.translate_orchestrator = new require_model.TranslateOrchestrator({
1781
1709
  get_doc: () => this.editor_internal().doc,
1782
1710
  emit: () => this.editor_internal().emit(),
1783
1711
  open_preview: (label) => this.editor_internal().history.preview(label),
1784
1712
  open_snap: (ids) => this.open_snap_session_for(ids),
1785
- options: translate_options
1713
+ options: translate_options,
1714
+ project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d
1786
1715
  });
1787
1716
  const resize_options = () => {
1788
1717
  const style = this.editor.style;
@@ -1793,7 +1722,7 @@ var DomSurface = class DomSurface {
1793
1722
  snap_threshold_px: style.snap_threshold_px / zoom
1794
1723
  };
1795
1724
  };
1796
- this.resize_orchestrator = new ResizeOrchestrator({
1725
+ this.resize_orchestrator = new require_model.ResizeOrchestrator({
1797
1726
  get_doc: () => this.editor_internal().doc,
1798
1727
  emit: () => this.editor_internal().emit(),
1799
1728
  open_preview: (label) => this.editor_internal().history.preview(label),
@@ -1807,7 +1736,7 @@ var DomSurface = class DomSurface {
1807
1736
  }
1808
1737
  });
1809
1738
  const rotate_options = () => ({ angle_snap_step_radians: this.editor.style.angle_snap_step_radians });
1810
- this.rotate_orchestrator = new require_insertions.RotateOrchestrator({
1739
+ this.rotate_orchestrator = new require_model.RotateOrchestrator({
1811
1740
  get_doc: () => this.editor_internal().doc,
1812
1741
  emit: () => this.editor_internal().emit(),
1813
1742
  open_preview: (label) => this.editor_internal().history.preview(label),
@@ -1829,7 +1758,7 @@ var DomSurface = class DomSurface {
1829
1758
  },
1830
1759
  subscribe_translate_commit: (cb) => this.editor_internal().subscribe_translate_commit(cb)
1831
1760
  };
1832
- this.nudge_dwell_watcher = new require_insertions.NudgeDwellWatcher({
1761
+ this.nudge_dwell_watcher = new require_model.NudgeDwellWatcher({
1833
1762
  editor: editor_for_watcher,
1834
1763
  open_snap: (ids) => this.open_snap_session_for(ids),
1835
1764
  options: translate_options,
@@ -1848,6 +1777,7 @@ var DomSurface = class DomSurface {
1848
1777
  this.hud = new _grida_hud.Surface(this.hud_canvas, {
1849
1778
  pick: (p) => this.hit_test(p[0], p[1]),
1850
1779
  shapeOf: (id) => this.shape_of(id),
1780
+ vectorOf: (id) => this.vector_of(id),
1851
1781
  onIntent: (i) => this.commit_intent(i),
1852
1782
  style: {
1853
1783
  chromeColor: editor.style.chrome_color,
@@ -1900,11 +1830,14 @@ var DomSurface = class DomSurface {
1900
1830
  hud_canvas: this.hud_canvas,
1901
1831
  camera: this.camera,
1902
1832
  editor,
1903
- handle: { detach: () => {} }
1833
+ handle: { detach: () => {} },
1834
+ is_attended: () => this.attention.is_attended()
1904
1835
  });
1905
1836
  if (options.gestures !== false) applyDefaultGestures(this.gestures);
1906
1837
  const unsub = editor.subscribe(() => {
1907
1838
  this.current_tool = editor.state.tool;
1839
+ this.hud.setVectorSelectionMode(this.current_tool.type === "lasso" ? "lasso" : "marquee");
1840
+ this.hud.setVectorBendMode(this.current_tool.type === "bend" ? "always" : "auto");
1908
1841
  this.render();
1909
1842
  this.sync_surface_selection();
1910
1843
  this.hud.setPixelGrid({
@@ -1921,6 +1854,23 @@ var DomSurface = class DomSurface {
1921
1854
  this.pending_insert = null;
1922
1855
  }
1923
1856
  }
1857
+ if (this.vector_edit && !this.active_preview) {
1858
+ const ses = this.vector_edit;
1859
+ const live_source = this.editor_internal().doc.is_vector_edit_target(ses.node_id);
1860
+ const had_selection = ses.selected_vertices.length > 0 || ses.selected_segments.length > 0 || ses.selected_tangents.length > 0;
1861
+ if (live_source === null || live_source.kind !== ses.source.kind) {
1862
+ if (had_selection) {
1863
+ ses.clear_selection();
1864
+ this.sync_selection_mirror();
1865
+ }
1866
+ } else {
1867
+ const live_d = source_to_session_d(live_source);
1868
+ if (live_d !== ses.last_seen_d) {
1869
+ ses.reconcile_after_external_mutation(live_d);
1870
+ if (had_selection) this.sync_selection_mirror();
1871
+ }
1872
+ }
1873
+ }
1924
1874
  this.request_redraw();
1925
1875
  });
1926
1876
  this.teardown.push(unsub);
@@ -1954,7 +1904,7 @@ var DomSurface = class DomSurface {
1954
1904
  if (computed === "") return null;
1955
1905
  return {
1956
1906
  computed,
1957
- resolved_paint: require_insertions.parse_paint(computed)
1907
+ resolved_paint: require_model.paint.parse(computed)
1958
1908
  };
1959
1909
  }
1960
1910
  });
@@ -2006,8 +1956,8 @@ var DomSurface = class DomSurface {
2006
1956
  });
2007
1957
  this.teardown.push(() => internal.set_surface_hover_override_driver(null));
2008
1958
  }
2009
- paint(_snapshot) {}
2010
1959
  hit_test(x, y) {
1960
+ if (this.vector_edit) return null;
2011
1961
  return this.pick_at(x, y, false);
2012
1962
  }
2013
1963
  /** Element-walk under (x, y) → first ancestor with `ID_ATTR`. When
@@ -2035,7 +1985,7 @@ var DomSurface = class DomSurface {
2035
1985
  * `<text>` / `<use>` / transformed nodes). Tolerance is screen-
2036
1986
  * CSS-px, converted to world units via `camera.zoom` so the band
2037
1987
  * stays the same width on screen regardless of zoom. Has known
2038
- * issues — see `docs/wg/feat-svg-editor/hit-test.md`.
1988
+ * issues — see https://grida.co/docs/wg/feat-svg-editor/hit-test.
2039
1989
  *
2040
1990
  * - **`<= 0` (legacy elementFromPoint)** — opt-out of the cmath
2041
1991
  * picker. Uses the browser's painted-pixel hit-test plus a
@@ -2113,15 +2063,17 @@ var DomSurface = class DomSurface {
2113
2063
  if (this._z_order_cache.length > 0 && this._z_order_cache[0] === root_id) return this._z_order_cache.slice(1);
2114
2064
  return this._z_order_cache.filter((id) => id !== root_id);
2115
2065
  }
2116
- on_input(_listener) {
2117
- return () => {};
2118
- }
2119
2066
  dispose() {
2120
2067
  if (this.text_edit) {
2121
2068
  this.text_edit.cancel();
2122
2069
  this.text_edit = null;
2123
2070
  this.text_edit_target = null;
2124
2071
  }
2072
+ if (this.vector_edit) {
2073
+ this.vector_edit = null;
2074
+ this.vector_edit_region_baseline = null;
2075
+ this.hud.setVectorSelection(null);
2076
+ }
2125
2077
  this.gestures._dispose();
2126
2078
  this.translate_orchestrator.cancel();
2127
2079
  this.resize_orchestrator.cancel();
@@ -2189,6 +2141,17 @@ var DomSurface = class DomSurface {
2189
2141
  style.left = "0";
2190
2142
  style.top = "0";
2191
2143
  style.transformOrigin = "0 0";
2144
+ const vb = this.svg_root.getAttribute("viewBox");
2145
+ if (vb) {
2146
+ const parts = vb.split(/[\s,]+/).map(Number);
2147
+ if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
2148
+ const [, , w, h] = parts;
2149
+ if (w > 0 && h > 0) {
2150
+ style.width = `${w}px`;
2151
+ style.height = `${h}px`;
2152
+ }
2153
+ }
2154
+ }
2192
2155
  }
2193
2156
  /**
2194
2157
  * Push the current camera transform to the SVG as a CSS `matrix(...)`.
@@ -2488,14 +2451,15 @@ var DomSurface = class DomSurface {
2488
2451
  const neighbor_ids = compute_neighborhood(doc, ids);
2489
2452
  const agent_id_set = /* @__PURE__ */ new Set();
2490
2453
  for (const id of ids) for (const inner of snap_descent(doc, id)) agent_id_set.add(inner);
2454
+ const bounds_of = (id) => this._geometry_provider?.bounds_of(id) ?? null;
2491
2455
  const agents = [];
2492
2456
  for (const id of agent_id_set) {
2493
- const r = this.bbox_world_for_snap(id);
2457
+ const r = bounds_of(id);
2494
2458
  if (r) agents.push(r);
2495
2459
  }
2496
2460
  const neighbors = [];
2497
2461
  for (const id of neighbor_ids) {
2498
- const r = this.bbox_world_for_snap(id);
2462
+ const r = bounds_of(id);
2499
2463
  if (r) neighbors.push(r);
2500
2464
  }
2501
2465
  return new SnapSession({
@@ -2836,6 +2800,17 @@ var DomSurface = class DomSurface {
2836
2800
  }
2837
2801
  }
2838
2802
  }
2803
+ if (tool.type === "insert-text") {
2804
+ if (kind === "pointer_down" && e.button === 0) {
2805
+ const world = this.camera.screen_to_world({
2806
+ x,
2807
+ y
2808
+ });
2809
+ this.editor.set_tool({ type: "cursor" });
2810
+ this.begin_text_insert(world);
2811
+ return;
2812
+ }
2813
+ }
2839
2814
  const button = e.button === 0 ? "primary" : e.button === 2 ? "secondary" : "middle";
2840
2815
  let event;
2841
2816
  if (kind === "pointer_move") event = {
@@ -2894,7 +2869,7 @@ var DomSurface = class DomSurface {
2894
2869
  }
2895
2870
  /** Transition `armed` → `drawing`: open `insert_preview` + snap session. */
2896
2871
  arm_to_draw(armed) {
2897
- const session = this.editor.commands.insert_preview(armed.tag, require_insertions.initial_attrs(armed.tag, armed.anchor));
2872
+ const session = this.editor.commands.insert_preview(armed.tag, require_model.insertions.initial_attrs(armed.tag, armed.anchor));
2898
2873
  const snap_session = this.editor.style.snap_enabled && (armed.tag === "rect" || armed.tag === "ellipse") ? this.open_snap_session_for([session.id]) : null;
2899
2874
  this.pending_insert = {
2900
2875
  phase: "drawing",
@@ -2916,7 +2891,7 @@ var DomSurface = class DomSurface {
2916
2891
  shift: mods.shift,
2917
2892
  alt: mods.alt
2918
2893
  };
2919
- drawing.session.update(require_insertions.compute_drag_attrs(drawing.tag, drawing.anchor, world, dm));
2894
+ drawing.session.update(require_model.insertions.compute_drag_attrs(drawing.tag, drawing.anchor, world, dm));
2920
2895
  }
2921
2896
  /** Commit on pointer-up. `armed` → one-shot `commands.insert` with
2922
2897
  * `default_attrs` (click-no-drag, never touches the IR mid-gesture).
@@ -2924,7 +2899,7 @@ var DomSurface = class DomSurface {
2924
2899
  commit_insert_gesture(screen_pt, mods) {
2925
2900
  const cur = this.pending_insert;
2926
2901
  if (!cur) return;
2927
- if (cur.phase === "armed") this.editor.commands.insert(cur.tag, require_insertions.default_attrs(cur.tag, cur.anchor));
2902
+ if (cur.phase === "armed") this.editor.commands.insert(cur.tag, require_model.insertions.default_attrs(cur.tag, cur.anchor));
2928
2903
  else {
2929
2904
  this.push_drawing_frame(cur, screen_pt, mods);
2930
2905
  cur.session.commit();
@@ -2975,11 +2950,20 @@ var DomSurface = class DomSurface {
2975
2950
  this.container.style.cursor = "crosshair";
2976
2951
  return;
2977
2952
  }
2953
+ if (this.current_tool.type === "insert-text") {
2954
+ this.container.style.cursor = "text";
2955
+ return;
2956
+ }
2978
2957
  this.container.style.cursor = this.hud.cursorCss();
2979
2958
  }
2980
2959
  on_keydown(e) {
2981
2960
  if (this.text_edit) return;
2982
- if (e.code === "Escape") this.cancel_in_flight();
2961
+ if (e.code !== "Escape" && !this.attention.is_attended()) return;
2962
+ if (e.code === "Escape") {
2963
+ const canceled = this.cancel_in_flight();
2964
+ if (!this.attention.is_attended()) return;
2965
+ if (!canceled && this.vector_edit) this.exit_vector_edit();
2966
+ }
2983
2967
  if ((this.active_preview || this.translate_orchestrator.has_active_session() || this.resize_orchestrator.has_active_session() || this.rotate_orchestrator.has_active_session() || this.pending_insert) && e.code !== "Escape") return;
2984
2968
  if (this.editor.keymap.claims(e)) e.preventDefault();
2985
2969
  this.editor.keymap.dispatch(e);
@@ -2992,6 +2976,15 @@ var DomSurface = class DomSurface {
2992
2976
  case "deselect_all":
2993
2977
  this.editor.commands.deselect();
2994
2978
  return;
2979
+ case "clear_vector_selection": {
2980
+ if (!this.vector_edit) return;
2981
+ const before = this.vector_edit.snapshot_selection();
2982
+ this.vector_edit.clear_selection();
2983
+ this.sync_selection_mirror();
2984
+ this.redraw();
2985
+ this.record_vector_selection_change(before, "clear vector selection");
2986
+ return;
2987
+ }
2995
2988
  case "translate":
2996
2989
  this.handle_translate(intent);
2997
2990
  return;
@@ -3004,6 +2997,9 @@ var DomSurface = class DomSurface {
3004
2997
  case "marquee_select":
3005
2998
  this.handle_marquee(intent);
3006
2999
  return;
3000
+ case "lasso_select":
3001
+ this.handle_lasso_select(intent);
3002
+ return;
3007
3003
  case "set_endpoint":
3008
3004
  this.handle_set_endpoint(intent);
3009
3005
  return;
@@ -3011,6 +3007,33 @@ var DomSurface = class DomSurface {
3011
3007
  this.editor.commands.select(intent.id);
3012
3008
  this.editor.enter_content_edit(intent.id);
3013
3009
  return;
3010
+ case "exit_content_edit":
3011
+ this.exit_vector_edit();
3012
+ return;
3013
+ case "select_vertex":
3014
+ this.handle_select_vertex(intent);
3015
+ return;
3016
+ case "translate_vertices":
3017
+ this.handle_translate_vertices(intent);
3018
+ return;
3019
+ case "translate_vector_selection":
3020
+ this.handle_translate_vector_selection(intent);
3021
+ return;
3022
+ case "select_segment":
3023
+ this.handle_select_segment(intent);
3024
+ return;
3025
+ case "select_tangent":
3026
+ this.handle_select_tangent(intent);
3027
+ return;
3028
+ case "set_tangent":
3029
+ this.handle_set_tangent_intent(intent);
3030
+ return;
3031
+ case "split_segment":
3032
+ this.handle_split_segment(intent);
3033
+ return;
3034
+ case "bend_segment":
3035
+ this.handle_bend_segment(intent);
3036
+ return;
3014
3037
  case "cancel_gesture":
3015
3038
  this.cancel_in_flight();
3016
3039
  return;
@@ -3050,7 +3073,7 @@ var DomSurface = class DomSurface {
3050
3073
  }
3051
3074
  handle_resize(intent) {
3052
3075
  if (intent.ids.length === 0) return;
3053
- for (const id of intent.ids) if (!require_insertions.is_resizable_node(this.editor.document, id)) return;
3076
+ for (const id of intent.ids) if (!require_model.resize_pipeline.intent.is_resizable_node(this.editor.document, id)) return;
3054
3077
  const dir = intent.anchor;
3055
3078
  let target_width;
3056
3079
  let target_height;
@@ -3098,9 +3121,7 @@ var DomSurface = class DomSurface {
3098
3121
  for (const v of verdicts.values()) {
3099
3122
  if (v.kind !== "refuse") continue;
3100
3123
  const message = v.reason === "non-trivial-transform" ? "Cannot rotate cleanly — element has a composite transform. Use Flatten Transform first." : v.reason === "text-with-glyph-rotate" ? "Cannot rotate — text has per-glyph rotation. Edit `rotate=` or remove it first." : v.reason === "css-property-transform" ? "Cannot rotate — transform is set via CSS. Move the declaration to the `transform` attribute first." : "Cannot rotate — element has an animated transform. Remove `<animateTransform>` first.";
3101
- const hud = this.hud;
3102
- if (typeof hud.setTransientToast === "function") hud.setTransientToast(message);
3103
- else console.warn(`[svg-editor] ${message}`);
3124
+ console.warn(`[svg-editor] ${message}`);
3104
3125
  return;
3105
3126
  }
3106
3127
  }
@@ -3185,7 +3206,28 @@ var DomSurface = class DomSurface {
3185
3206
  y: t.y
3186
3207
  };
3187
3208
  }
3209
+ /**
3210
+ * Capture the vector-edit sub-selection on the first region-gesture
3211
+ * preview, and reuse it for every subsequent preview + the commit.
3212
+ * Anchors additive merging and tangent-candidate eligibility to the
3213
+ * gesture-start state so they don't shift mid-drag. Shared by marquee
3214
+ * and lasso — both gestures emit a preview-per-move, both consume the
3215
+ * same baseline. See `vector_edit_region_baseline` doc-comment for the
3216
+ * full rationale. Caller must have a non-null `this.vector_edit`.
3217
+ */
3218
+ ensure_region_baseline() {
3219
+ if (!this.vector_edit_region_baseline) this.vector_edit_region_baseline = {
3220
+ vertices: this.vector_edit.selected_vertices.slice(),
3221
+ segments: this.vector_edit.selected_segments.slice(),
3222
+ tangents: this.vector_edit.selected_tangents.map((t) => [t[0], t[1]])
3223
+ };
3224
+ return this.vector_edit_region_baseline;
3225
+ }
3188
3226
  handle_marquee(intent) {
3227
+ if (this.vector_edit) {
3228
+ this.handle_marquee_vectors(intent);
3229
+ return;
3230
+ }
3189
3231
  if (intent.phase !== "commit") return;
3190
3232
  const ids = [];
3191
3233
  for (const id of this.element_index.keys()) {
@@ -3200,7 +3242,170 @@ var DomSurface = class DomSurface {
3200
3242
  }
3201
3243
  this.editor.commands.select(ids, { mode: intent.additive ? "add" : "replace" });
3202
3244
  }
3203
- enter_content_edit(id) {
3245
+ /**
3246
+ * Vector marquee predicate — applies the **vertex-priority precedence
3247
+ * rule** ported from the main editor:
3248
+ *
3249
+ * 1. Vertices: keep those whose container-space position falls inside
3250
+ * the marquee rect.
3251
+ * 2. Tangents: keep only those at the (new) selection's neighbouring
3252
+ * vertices whose control point falls inside the rect.
3253
+ * 3. Segments: keep only segments **fully contained** in the rect AND
3254
+ * whose endpoints are NOT among the selected vertices. (If a vertex
3255
+ * is selected, the segments meeting it would double-credit the
3256
+ * drag, so they're dropped — "vertex priority.")
3257
+ *
3258
+ * The rect comes in container CSS-px (HUD's frame). We project it back
3259
+ * to path-local by applying the inverse-CTM linear part to its size and
3260
+ * shifting its position via `project_delta_inverse_ctm` against the
3261
+ * top-left in doc-space — but a cleaner approach is to project each
3262
+ * vertex/tangent/segment-sample into doc-space and test against the rect
3263
+ * directly. For consistency with how `vector_of` projects, we do the
3264
+ * "project geometry to doc-space, test against doc-space rect" approach.
3265
+ */
3266
+ handle_marquee_vectors(intent) {
3267
+ if (!this.vector_edit) return;
3268
+ const node_id = this.vector_edit.node_id;
3269
+ const el = this.element_index.get(node_id);
3270
+ if (!(el instanceof SVGGraphicsElement)) return;
3271
+ if (typeof el.getScreenCTM !== "function") return;
3272
+ const ctm = el.getScreenCTM();
3273
+ if (!ctm) return;
3274
+ const cr = this.container.getBoundingClientRect();
3275
+ const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3276
+ const model = this.session_model();
3277
+ if (!model) return;
3278
+ const rect = intent.rect;
3279
+ const baseline = this.ensure_region_baseline();
3280
+ const pre_selection = {
3281
+ vertices: baseline.vertices,
3282
+ segments: baseline.segments,
3283
+ tangents: baseline.tangents
3284
+ };
3285
+ const candidates = marquee.subpath_select_candidates(model, pre_selection, (p) => project_point_through_ctm(p[0], p[1], ctm, offset));
3286
+ const vertex_hits = marquee.points_in_rect(candidates.vertices, rect);
3287
+ const tangent_hits = marquee.points_in_rect(candidates.tangents, rect);
3288
+ const rect_local = inverse_project_rect(rect, ctm, offset);
3289
+ const vertex_hit_set = new Set(vertex_hits);
3290
+ const segment_hits = [];
3291
+ if (rect_local) {
3292
+ const segs = model.snapshot().segments;
3293
+ for (const sid of candidates.segments) {
3294
+ const s = segs[sid];
3295
+ if (vertex_hit_set.has(s.a) || vertex_hit_set.has(s.b)) continue;
3296
+ if (model.segmentContainedByRect(sid, rect_local)) segment_hits.push(sid);
3297
+ }
3298
+ }
3299
+ const merged = marquee.merge_subpath_hits(pre_selection, {
3300
+ vertices: vertex_hits,
3301
+ segments: segment_hits,
3302
+ tangents: tangent_hits
3303
+ }, intent.additive);
3304
+ this.vector_edit.set_selection(merged);
3305
+ this.sync_selection_mirror();
3306
+ if (intent.phase === "commit") {
3307
+ const baseline_snapshot = Object.freeze({
3308
+ vertices: baseline.vertices,
3309
+ segments: baseline.segments,
3310
+ tangents: baseline.tangents
3311
+ });
3312
+ this.vector_edit_region_baseline = null;
3313
+ this.record_vector_selection_change(baseline_snapshot, "marquee select");
3314
+ }
3315
+ this.redraw();
3316
+ }
3317
+ /**
3318
+ * Lasso (freeform polygon) sub-selection — vector analogue of
3319
+ * `handle_marquee`. Per the main editor's decision
3320
+ * (editor/grida-canvas/reducers/methods/vector.ts:163–291 +
3321
+ * event-target.reducer.ts:629–641) lasso targets **vertices and
3322
+ * tangents only** — segments are NOT tested against the polygon. The
3323
+ * segment-vs-region test is rect-only and lives in the marquee path.
3324
+ *
3325
+ * Lifecycle / baseline behaviour matches marquee: snapshot on first
3326
+ * preview, reuse for additive merge and tangent-eligibility, clear on
3327
+ * commit and on vector-edit exit. Scene (non-vector-edit) lasso is a
3328
+ * follow-up.
3329
+ */
3330
+ handle_lasso_select(intent) {
3331
+ if (!this.vector_edit) return;
3332
+ const node_id = this.vector_edit.node_id;
3333
+ const el = this.element_index.get(node_id);
3334
+ if (!(el instanceof SVGGraphicsElement)) return;
3335
+ if (typeof el.getScreenCTM !== "function") return;
3336
+ const ctm = el.getScreenCTM();
3337
+ if (!ctm) return;
3338
+ const cr = this.container.getBoundingClientRect();
3339
+ const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3340
+ const model = this.session_model();
3341
+ if (!model) return;
3342
+ const polygon = intent.polygon;
3343
+ if (polygon.length < 3) return;
3344
+ const baseline = this.ensure_region_baseline();
3345
+ const pre_selection = {
3346
+ vertices: baseline.vertices,
3347
+ segments: baseline.segments,
3348
+ tangents: baseline.tangents
3349
+ };
3350
+ const candidates = marquee.subpath_select_candidates(model, pre_selection, (p) => project_point_through_ctm(p[0], p[1], ctm, offset));
3351
+ const vertex_hits = marquee.points_in_polygon(candidates.vertices, polygon);
3352
+ const tangent_hits = marquee.points_in_polygon(candidates.tangents, polygon);
3353
+ const merged = marquee.merge_subpath_hits(pre_selection, {
3354
+ vertices: vertex_hits,
3355
+ segments: [],
3356
+ tangents: tangent_hits
3357
+ }, intent.additive);
3358
+ this.vector_edit.set_selection(merged);
3359
+ this.sync_selection_mirror();
3360
+ if (intent.phase === "commit") {
3361
+ const baseline_snapshot = Object.freeze({
3362
+ vertices: baseline.vertices,
3363
+ segments: baseline.segments,
3364
+ tangents: baseline.tangents
3365
+ });
3366
+ this.vector_edit_region_baseline = null;
3367
+ this.record_vector_selection_change(baseline_snapshot, "lasso select");
3368
+ }
3369
+ this.redraw();
3370
+ }
3371
+ /**
3372
+ * Dispatched by the editor when `enter_content_edit(id)` is called. The
3373
+ * editor has already gated on text-OR-path eligibility; this method
3374
+ * routes on the actual tag.
3375
+ */
3376
+ enter_content_edit(id) {
3377
+ if (this.text_edit || this.vector_edit) return false;
3378
+ const tag = this.tag_of(id);
3379
+ if (tag === "text" || tag === "tspan") return this.enter_text_edit(id);
3380
+ if (this.editor_internal().doc.is_vector_edit_target(id) !== null) return this.enter_vector_edit(id);
3381
+ return false;
3382
+ }
3383
+ /**
3384
+ * Place a new single-line `<text>` at `world` and immediately enter
3385
+ * content-edit on it. Creation + first edit are bracketed in one history
3386
+ * preview (via `insert_text_preview`): committing with content is one
3387
+ * undo step, exiting empty discards the node entirely (empty-equals-
3388
+ * delete). The bracket is finalized in {@link enter_text_edit}'s
3389
+ * commit/cancel callbacks via `this.pending_text_insert`.
3390
+ *
3391
+ * Default font appearance lives in `core/insertions.ts`
3392
+ * (`default_text_attrs`), alongside the shape insertion defaults — not
3393
+ * hard-coded here — so the per-element insert semantics stay in core (P3).
3394
+ */
3395
+ begin_text_insert(world) {
3396
+ if (this.text_edit || this.vector_edit) return;
3397
+ const session = this.editor_internal().insert_text_preview(require_model.insertions.default_text_attrs(world));
3398
+ this.pending_text_insert = {
3399
+ id: session.id,
3400
+ session
3401
+ };
3402
+ this.editor.enter_content_edit(session.id);
3403
+ if (this.text_edit_target !== session.id) {
3404
+ this.pending_text_insert = null;
3405
+ session.discard();
3406
+ }
3407
+ }
3408
+ enter_text_edit(id) {
3204
3409
  if (this.text_edit) return false;
3205
3410
  const el = this.element_index.get(id);
3206
3411
  if (!(el instanceof SVGElement)) return false;
@@ -3216,15 +3421,6 @@ var DomSurface = class DomSurface {
3216
3421
  const text_surface = new SvgTextSurface(this.element_index.get(id) ?? el);
3217
3422
  const is_mac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
3218
3423
  let settled = false;
3219
- const cleanup_after_commit_or_cancel = () => {
3220
- this.editor.commands.set_mode("select");
3221
- this.render();
3222
- this.sync_surface_selection();
3223
- this.sync_cursor();
3224
- this.redraw();
3225
- this.text_edit = null;
3226
- this.text_edit_target = null;
3227
- };
3228
3424
  this.text_edit = (0, _grida_text_editor_dom.createTextEditor)({
3229
3425
  container: this.container,
3230
3426
  initialText: this.text_edit_original,
@@ -3240,16 +3436,12 @@ var DomSurface = class DomSurface {
3240
3436
  onCommit: (final_text) => {
3241
3437
  if (settled) return;
3242
3438
  settled = true;
3243
- doc.doc.set_text(id, this.text_edit_original);
3244
- cleanup_after_commit_or_cancel();
3245
- if (final_text !== this.text_edit_original) this.editor.commands.set_text(final_text);
3439
+ this.finalize_text_exit(id, final_text);
3246
3440
  },
3247
3441
  onCancel: () => {
3248
3442
  if (settled) return;
3249
3443
  settled = true;
3250
- doc.doc.set_text(id, this.text_edit_original);
3251
- cleanup_after_commit_or_cancel();
3252
- doc.emit();
3444
+ this.finalize_text_exit(id, this.text_edit_original);
3253
3445
  },
3254
3446
  onUndoFallthrough: () => {
3255
3447
  this.text_edit?.commit();
@@ -3263,6 +3455,726 @@ var DomSurface = class DomSurface {
3263
3455
  });
3264
3456
  return true;
3265
3457
  }
3458
+ /**
3459
+ * Exit inline text-edit back to select mode. P4 — observers should see a
3460
+ * consistent post-edit state, so do all observable mutations + surface
3461
+ * syncs first, then clear the text-edit handles last (so anything polling
3462
+ * "is text-edit active?" still says yes until the world is settled).
3463
+ */
3464
+ cleanup_text_edit() {
3465
+ this.editor.commands.set_mode("select");
3466
+ this.render();
3467
+ this.sync_surface_selection();
3468
+ this.sync_cursor();
3469
+ this.redraw();
3470
+ this.text_edit = null;
3471
+ this.text_edit_target = null;
3472
+ }
3473
+ /**
3474
+ * Realize the result of a text content-edit session and exit. `result`
3475
+ * is the text that should remain — the typed text on commit, the original
3476
+ * on cancel. Implements the empty-equals-delete rule (design:
3477
+ * docs/wg/feat-svg-editor/text-tool.md): an empty result removes the node.
3478
+ * (see test/svg-editor-text-empty-delete.md)
3479
+ *
3480
+ * The empty-equals-delete decision is pure and lives in
3481
+ * {@link resolve_text_exit} (`core/text-edit.ts`) — tested headlessly.
3482
+ * This method is the thin dispatcher that realizes each action against
3483
+ * the surface + editor.
3484
+ */
3485
+ finalize_text_exit(id, result) {
3486
+ const internal = this.editor_internal();
3487
+ const insert = this.pending_text_insert;
3488
+ this.pending_text_insert = null;
3489
+ const action = resolve_text_exit({
3490
+ origin: insert && insert.id === id ? "fresh" : "existing",
3491
+ result,
3492
+ original: this.text_edit_original
3493
+ });
3494
+ if (action.kind === "commit_insert" || action.kind === "discard_insert") {
3495
+ if (!insert) return;
3496
+ if (action.kind === "discard_insert") {
3497
+ this.cleanup_text_edit();
3498
+ insert.session.discard();
3499
+ } else {
3500
+ this.cleanup_text_edit();
3501
+ insert.session.commit();
3502
+ }
3503
+ return;
3504
+ }
3505
+ internal.doc.set_text(id, this.text_edit_original);
3506
+ this.cleanup_text_edit();
3507
+ switch (action.kind) {
3508
+ case "remove":
3509
+ this.editor.commands.select(id);
3510
+ this.editor.commands.remove();
3511
+ break;
3512
+ case "set_text":
3513
+ this.editor.commands.set_text(action.value);
3514
+ break;
3515
+ case "noop":
3516
+ internal.emit();
3517
+ break;
3518
+ default:
3519
+ }
3520
+ }
3521
+ /**
3522
+ * Enter path-vertex-edit mode. Mirrors `enter_text_edit` shape: capture
3523
+ * the original `d`, flip the editor mode, push a vector-selection mirror
3524
+ * to the HUD, and start serving `vectorOf` from the live session.
3525
+ *
3526
+ * Exit happens via `exit_vector_edit` (Esc / `set_mode("select")` /
3527
+ * dblclick away). On exit, any in-flight preview is discarded; committed
3528
+ * intermediate states stay (each gesture's commit was its own history
3529
+ * entry, per the gesture-bracketed history doctrine).
3530
+ *
3531
+ * The enter is itself a history step (tagged `vector-mode-enter`) so
3532
+ * undo from inside vector-edit reverts the entry — symmetric to the
3533
+ * `vector-mode-exit` push in {@link exit_vector_edit}. Both deltas
3534
+ * delegate to the unchecked {@link _do_enter_vector_edit} /
3535
+ * {@link _do_exit_vector_edit} helpers; the public wrappers below own
3536
+ * the history side, the unchecked helpers own the state mutation.
3537
+ */
3538
+ enter_vector_edit(id) {
3539
+ if (this.vector_edit) return false;
3540
+ if (!this._do_enter_vector_edit(id)) return false;
3541
+ const internal = this.editor_internal();
3542
+ const node_id = id;
3543
+ const preview = internal.history.preview("enter vector edit");
3544
+ preview.set({
3545
+ providerId: "svg-editor",
3546
+ descriptor: { kind: "vector-mode-enter" },
3547
+ apply: () => {
3548
+ this._do_enter_vector_edit(node_id);
3549
+ },
3550
+ revert: () => {
3551
+ this._do_exit_vector_edit();
3552
+ }
3553
+ });
3554
+ preview.commit();
3555
+ return true;
3556
+ }
3557
+ /**
3558
+ * Discard any in-flight preview, clear the vector-edit session, and return
3559
+ * the editor to select mode. Safe to call when no vector-edit is active
3560
+ * (no-op). Idempotent.
3561
+ *
3562
+ * Pushes a `vector-mode-exit` history step that closes over the
3563
+ * session's `node_id` and its final sub-selection, so undo re-enters
3564
+ * the same path and restores the selection the user was about to
3565
+ * leave. Pairs with {@link enter_vector_edit}.
3566
+ */
3567
+ exit_vector_edit() {
3568
+ if (!this.vector_edit) return;
3569
+ const node_id = this.vector_edit.node_id;
3570
+ const final_selection = this.vector_edit.snapshot_selection();
3571
+ this._do_exit_vector_edit();
3572
+ const preview = this.editor_internal().history.preview("exit vector edit");
3573
+ preview.set({
3574
+ providerId: "svg-editor",
3575
+ descriptor: { kind: "vector-mode-exit" },
3576
+ apply: () => {
3577
+ this._do_exit_vector_edit();
3578
+ },
3579
+ revert: () => {
3580
+ if (this._do_enter_vector_edit(node_id)) {
3581
+ this.vector_edit?.restore_selection(final_selection);
3582
+ this.sync_selection_mirror();
3583
+ this.redraw();
3584
+ }
3585
+ }
3586
+ });
3587
+ preview.commit();
3588
+ }
3589
+ /**
3590
+ * Unchecked enter — performs the mode flip + HUD wiring without
3591
+ * pushing to history. Called by {@link enter_vector_edit} (user-facing,
3592
+ * which pushes the delta) and by the exit-delta's revert (history-
3593
+ * driven re-entry on undo). Returns `false` if the node has no usable
3594
+ * `d` attribute, leaving editor state untouched.
3595
+ */
3596
+ _do_enter_vector_edit(id) {
3597
+ if (this.vector_edit) return false;
3598
+ const source = this.editor_internal().doc.is_vector_edit_target(id);
3599
+ if (source === null) return false;
3600
+ const session_d = source_to_session_d(source);
3601
+ this.vector_edit = new VectorEditSession(id, source, session_d);
3602
+ this.editor.commands.set_mode("edit-content");
3603
+ this.sync_selection_mirror();
3604
+ this.sync_surface_selection();
3605
+ this.sync_cursor();
3606
+ this.redraw();
3607
+ return true;
3608
+ }
3609
+ /**
3610
+ * Unchecked exit — counterpart to {@link _do_enter_vector_edit}. No
3611
+ * history push. Safe to call when no session is active.
3612
+ */
3613
+ _do_exit_vector_edit() {
3614
+ if (!this.vector_edit) return;
3615
+ if (this.active_preview && (this.active_preview.kind === "vector_vertex_translate" || this.active_preview.kind === "vector_set_tangent" || this.active_preview.kind === "vector_bend_segment" || this.active_preview.kind === "vector_translate_selection")) {
3616
+ this.active_preview.session.discard();
3617
+ this.active_preview = null;
3618
+ }
3619
+ this.vector_edit = null;
3620
+ this.vector_edit_region_baseline = null;
3621
+ this.hud.setVectorSelection(null);
3622
+ if (this.current_tool.type === "lasso" || this.current_tool.type === "bend") this.editor.set_tool({ type: "cursor" });
3623
+ this.editor.commands.set_mode("select");
3624
+ this.sync_surface_selection();
3625
+ this.sync_cursor();
3626
+ this.redraw();
3627
+ }
3628
+ /**
3629
+ * `vectorOf` provider for the HUD. Returns the live PathSnapshot for the
3630
+ * named node when a vector-edit session is active for it; otherwise null.
3631
+ * HUD calls this each frame; cheap enough to recompute (snapshot just
3632
+ * copies the underlying network's arrays via the model's getter).
3633
+ */
3634
+ vector_of(id) {
3635
+ if (!this.vector_edit || this.vector_edit.node_id !== id) return null;
3636
+ const model = this.active_preview_model_for(id) ?? this.session_model();
3637
+ if (!model) return null;
3638
+ const snap = model.snapshot();
3639
+ const el = this.element_index.get(id);
3640
+ if (!(el instanceof SVGGraphicsElement)) return null;
3641
+ if (typeof el.getScreenCTM !== "function") return null;
3642
+ const ctm = el.getScreenCTM();
3643
+ if (!ctm) return null;
3644
+ const cr = this.container.getBoundingClientRect();
3645
+ const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3646
+ return {
3647
+ vertices: Array.from({ length: snap.vertices.length }, (_, i) => {
3648
+ const v = snap.vertices[i];
3649
+ return project_point_through_ctm(v[0], v[1], ctm, offset);
3650
+ }),
3651
+ segments: snap.segments.map((s) => {
3652
+ const va = snap.vertices[s.a];
3653
+ const vb = snap.vertices[s.b];
3654
+ const a_ctrl_local = [va[0] + s.ta[0], va[1] + s.ta[1]];
3655
+ const b_ctrl_local = [vb[0] + s.tb[0], vb[1] + s.tb[1]];
3656
+ return {
3657
+ a: s.a,
3658
+ b: s.b,
3659
+ a_control: project_point_through_ctm(a_ctrl_local[0], a_ctrl_local[1], ctm, offset),
3660
+ b_control: project_point_through_ctm(b_ctrl_local[0], b_ctrl_local[1], ctm, offset)
3661
+ };
3662
+ }),
3663
+ neighbours: model.neighbouringVertices({
3664
+ vertices: this.vector_edit.selected_vertices,
3665
+ segments: this.vector_edit.selected_segments,
3666
+ tangents: this.vector_edit.selected_tangents
3667
+ }),
3668
+ origin: [0, 0]
3669
+ };
3670
+ }
3671
+ /**
3672
+ * The session's in-memory PathModel-form `d`. For `<path>` sources
3673
+ * this stays in lock-step with `doc.get_attr(id, "d")`; for
3674
+ * `<line>` / `<polyline>` / `<polygon>` sources the document holds
3675
+ * native attrs and the session-d is the lingua-franca view that
3676
+ * gesture handlers parse from and write back to via
3677
+ * {@link apply_session_d}. Returns `null` if no session is active.
3678
+ */
3679
+ read_session_d() {
3680
+ if (!this.vector_edit) return null;
3681
+ return this.vector_edit.current_d;
3682
+ }
3683
+ /**
3684
+ * Derive a fresh `PathModel` from the current `d` for the path under
3685
+ * edit. Computed on read — there is no cached copy held on the session.
3686
+ * See `vector-edit/session.ts` for the doctrine ("d is the live store").
3687
+ */
3688
+ session_model() {
3689
+ const d = this.read_session_d();
3690
+ if (d === null) return null;
3691
+ return require_model.PathModel.fromSvgPathD(d);
3692
+ }
3693
+ /**
3694
+ * Republish the vector-edit sub-selection to the HUD. No-op when no
3695
+ * session is open. Every selection-changing handler ends with this so
3696
+ * the surface mirror stays in lock-step with `this.vector_edit` — the
3697
+ * inline `setVectorSelection({ node_id, vertices, segments, tangents })`
3698
+ * block was repeated at 6+ sites before this collapse.
3699
+ */
3700
+ sync_selection_mirror() {
3701
+ if (!this.vector_edit) return;
3702
+ this.hud.setVectorSelection({
3703
+ node_id: this.vector_edit.node_id,
3704
+ vertices: this.vector_edit.selected_vertices,
3705
+ segments: this.vector_edit.selected_segments,
3706
+ tangents: this.vector_edit.selected_tangents
3707
+ });
3708
+ }
3709
+ /**
3710
+ * Replay the session-side effects of a committed vector-edit delta onto
3711
+ * the LIVE `this.vector_edit` session — but only if it is still aimed at
3712
+ * the same node. Geometry restoration is the closure's `commit(d)` job
3713
+ * (document-level, always safe); this method handles the bits the
3714
+ * geometry write cannot reach on its own: advancing `last_seen_d` so
3715
+ * the external-mutation watcher doesn't pounce, and re-installing the
3716
+ * captured sub-selection.
3717
+ *
3718
+ * Closures used to call `session.mark_seen(...)` / `session.restore_selection(...)`
3719
+ * on a `const session = this.vector_edit` captured at gesture start.
3720
+ * After exit + undo-exit + undo-geometry, that capture pointed at the
3721
+ * disposed session while the live session was a fresh one — geometry
3722
+ * still restored correctly (via `commit`), but sub-selection didn't,
3723
+ * and the live session's stale `last_seen_d` would then cause the
3724
+ * watcher to clear the new session's selection on its next tick.
3725
+ *
3726
+ * Pass `d = null` for selection-only deltas (no geometry change → no
3727
+ * watermark advance).
3728
+ */
3729
+ replay_vector_session_state(target_node_id, d, selection) {
3730
+ const cur = this.vector_edit;
3731
+ if (!cur || cur.node_id !== target_node_id) return;
3732
+ if (d !== null) {
3733
+ cur.mark_seen(d);
3734
+ const live_source = this.editor_internal().doc.is_vector_edit_target(cur.node_id);
3735
+ if (live_source) cur.sync_source(live_source);
3736
+ }
3737
+ cur.restore_selection(selection);
3738
+ this.sync_selection_mirror();
3739
+ }
3740
+ /**
3741
+ * Build the `{ apply, revert }` history step for a vector-edit geometry
3742
+ * delta — the single chokepoint that routes the write through
3743
+ * {@link vector_apply} / {@link vector_revert} so promote-to-path of a
3744
+ * primitive source (rect / circle / ellipse) is handled in one place
3745
+ * rather than per gesture.
3746
+ *
3747
+ * `promo` is the per-gesture token holder (shared by reference across an
3748
+ * `active_preview`'s preview frames and its committed step) so the
3749
+ * promotion that fires on the first frame is the one undo reverses —
3750
+ * promotion + first edit collapse into a single undo step. On redo after
3751
+ * an undo-demote, `apply` re-promotes and refreshes the token.
3752
+ *
3753
+ * `after_selection` / `before_selection` drive sub-selection replay;
3754
+ * pass `null` (preview frames) to skip it.
3755
+ */
3756
+ vector_geometry_step(node_id, target_d, baseline_d, promo, after_selection, before_selection) {
3757
+ const internal = this.editor_internal();
3758
+ const doc = internal.doc;
3759
+ const emit = internal.emit;
3760
+ const session = this.vector_edit;
3761
+ return {
3762
+ providerId: "svg-editor",
3763
+ apply: () => {
3764
+ if (session) {
3765
+ const tok = vector_apply(doc, session, target_d);
3766
+ if (tok) promo.token = tok;
3767
+ }
3768
+ if (after_selection) this.replay_vector_session_state(node_id, target_d, after_selection);
3769
+ emit();
3770
+ },
3771
+ revert: () => {
3772
+ if (session) vector_revert(doc, session, baseline_d, promo.token);
3773
+ promo.token = null;
3774
+ if (before_selection) this.replay_vector_session_state(node_id, baseline_d, before_selection);
3775
+ emit();
3776
+ }
3777
+ };
3778
+ }
3779
+ /**
3780
+ * Push a standalone vector sub-selection change as one history entry.
3781
+ *
3782
+ * Called by selection-only handlers (vertex / segment / tangent click,
3783
+ * marquee / lasso commit, clear-vector-selection) AFTER the
3784
+ * `VectorEditSession` has been mutated to the new state. `before` is the
3785
+ * snapshot captured before the mutation; the current session state is
3786
+ * captured here as `after`.
3787
+ *
3788
+ * Tagged with `descriptor: { kind: "vector-selection" }` so hosts that
3789
+ * want Figma-style "skip selection on undo" can filter on the
3790
+ * descriptor without inspecting closure internals. Default behavior:
3791
+ * standalone vector-selection IS undoable.
3792
+ *
3793
+ * No-op when the snapshot is unchanged — avoids spamming the stack
3794
+ * with entries from clicks that resolve to the same state (e.g.
3795
+ * clicking an already-selected vertex in `replace` mode).
3796
+ */
3797
+ record_vector_selection_change(before, label) {
3798
+ if (!this.vector_edit) return;
3799
+ const after = this.vector_edit.snapshot_selection();
3800
+ if (sub_selection_equal(before, after)) return;
3801
+ const target_node_id = this.vector_edit.node_id;
3802
+ const preview = this.editor_internal().history.preview(label);
3803
+ preview.set({
3804
+ providerId: "svg-editor",
3805
+ descriptor: { kind: "vector-selection" },
3806
+ apply: () => {
3807
+ this.replay_vector_session_state(target_node_id, null, after);
3808
+ this.redraw();
3809
+ },
3810
+ revert: () => {
3811
+ this.replay_vector_session_state(target_node_id, null, before);
3812
+ this.redraw();
3813
+ }
3814
+ });
3815
+ preview.commit();
3816
+ }
3817
+ /** Resolve the in-flight `PathModel` for the named node id when a
3818
+ * vector-preview is active; null otherwise. */
3819
+ active_preview_model_for(id) {
3820
+ const ap = this.active_preview;
3821
+ if (!ap) return null;
3822
+ switch (ap.kind) {
3823
+ case "vector_vertex_translate": return ap.node_id === id ? ap.preview_model : null;
3824
+ case "vector_set_tangent": return ap.node_id === id ? ap.preview_model : null;
3825
+ case "vector_bend_segment": return ap.node_id === id ? ap.preview_model : null;
3826
+ case "vector_translate_selection": return ap.node_id === id ? ap.preview_model : null;
3827
+ default: return null;
3828
+ }
3829
+ }
3830
+ /**
3831
+ * Apply a `select_vertex` intent. Updates the vector-edit session's sub-
3832
+ * selection and pushes a fresh mirror to the HUD.
3833
+ */
3834
+ handle_select_vertex(intent) {
3835
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3836
+ const before = this.vector_edit.snapshot_selection();
3837
+ this.vector_edit.select_vertex(intent.index, intent.mode);
3838
+ this.sync_selection_mirror();
3839
+ this.redraw();
3840
+ this.record_vector_selection_change(before, "select vertex");
3841
+ }
3842
+ /**
3843
+ * Apply a `select_segment` intent. Mirrors {@link handle_select_vertex}.
3844
+ * Fired when the user clicks a segment OFF the ghost insertion knob —
3845
+ * clicking the ghost itself fires `split_segment` instead.
3846
+ */
3847
+ handle_select_segment(intent) {
3848
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3849
+ const before = this.vector_edit.snapshot_selection();
3850
+ this.vector_edit.select_segment(intent.segment, intent.mode);
3851
+ this.sync_selection_mirror();
3852
+ this.redraw();
3853
+ this.record_vector_selection_change(before, "select segment");
3854
+ }
3855
+ /**
3856
+ * Apply a `translate_vertices` intent. Mirrors the `set_endpoint` flow:
3857
+ * - First frame opens a `history.preview` session capturing the original `d`.
3858
+ * - Each subsequent preview frame applies a fresh translation FROM the
3859
+ * original baseline (so the cumulative delta on the intent stays correct
3860
+ * and we don't accumulate drift across frames).
3861
+ * - The commit frame finalizes the preview; the session keeps its updated
3862
+ * model for the next gesture.
3863
+ *
3864
+ * The vector-edit session's `model` is updated to reflect the committed
3865
+ * state (preview model is computed on the fly each frame from the
3866
+ * baseline; only commit writes back into session.model).
3867
+ */
3868
+ handle_translate_vertices(intent) {
3869
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3870
+ const internal = this.editor_internal();
3871
+ const node_id = intent.node_id;
3872
+ if (!this.active_preview || this.active_preview.kind !== "vector_vertex_translate" || this.active_preview.node_id !== node_id || !require_model.array_shallow_equal(this.active_preview.indices, intent.indices)) {
3873
+ if (this.active_preview) {
3874
+ if ("session" in this.active_preview) this.active_preview.session.discard();
3875
+ }
3876
+ const initial_d = this.read_session_d();
3877
+ if (initial_d === null) return;
3878
+ const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
3879
+ this.active_preview = {
3880
+ kind: "vector_vertex_translate",
3881
+ node_id,
3882
+ promo: { token: null },
3883
+ indices: [...intent.indices],
3884
+ initial_d,
3885
+ baseline_model,
3886
+ before_selection: this.vector_edit.snapshot_selection(),
3887
+ preview_model: baseline_model,
3888
+ session: internal.history.preview("vector/translate-vertex")
3889
+ };
3890
+ }
3891
+ const baseline_d = this.active_preview.initial_d;
3892
+ const indices = this.active_preview.indices;
3893
+ let local_dx = intent.dx;
3894
+ let local_dy = intent.dy;
3895
+ const el = this.element_index.get(node_id);
3896
+ if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
3897
+ const ctm = el.getScreenCTM();
3898
+ if (ctm) {
3899
+ if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
3900
+ }
3901
+ }
3902
+ const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
3903
+ const target_d = preview_model.toSvgPathD();
3904
+ this.active_preview.preview_model = preview_model;
3905
+ if (intent.phase === "commit") {
3906
+ const before_selection = this.active_preview.before_selection;
3907
+ const after_selection = this.vector_edit.snapshot_selection();
3908
+ this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
3909
+ this.active_preview.session.commit();
3910
+ this.active_preview = null;
3911
+ } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
3912
+ }
3913
+ /**
3914
+ * `translate_vector_selection` — the sub-selection-aware delta-translate.
3915
+ *
3916
+ * Mirrors main editor's `translate-vector-controls`
3917
+ * (`editor/grida-canvas/reducers/tools/event-target.cem-vector.reducer.ts:667-675`).
3918
+ * Translates the union of:
3919
+ *
3920
+ * - selected vertices (authoritative sub-selection)
3921
+ * - endpoints of selected segments (segment selection implies
3922
+ * its two endpoints translate)
3923
+ * - intent.additional_vertex_indices (carried by segment drag so
3924
+ * endpoints translate even
3925
+ * when the segment isn't yet
3926
+ * in sub-selection — the
3927
+ * deferred select_segment was
3928
+ * canceled by drag promotion)
3929
+ *
3930
+ * AND delta-translates selected tangents, EXCLUDING tangents whose parent
3931
+ * vertex is already in the translated set (mirrors `vector.ts:39-42`'s
3932
+ * `getUXNeighbouringVertices`-style exclusion: the vertex move already
3933
+ * carries its tangent controls, so double-applying would shift them
3934
+ * twice). Mirror policy pinned to `"none"` during multi-translate; mirror
3935
+ * behavior is reserved for the singleton-tangent curve gesture.
3936
+ *
3937
+ * Opens a dedicated `vector_translate_selection` preview so the
3938
+ * vertex translate AND tangent delta apply atomically per frame.
3939
+ */
3940
+ handle_translate_vector_selection(intent) {
3941
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3942
+ const internal = this.editor_internal();
3943
+ const node_id = intent.node_id;
3944
+ const ses = this.vector_edit;
3945
+ const current_d = this.read_session_d();
3946
+ if (current_d === null) return;
3947
+ const resolved_model = require_model.PathModel.fromSvgPathD(current_d);
3948
+ const vertex_count = resolved_model.vertexCount();
3949
+ const segment_snapshot = resolved_model.snapshot().segments;
3950
+ const indices_set = /* @__PURE__ */ new Set();
3951
+ const add_if_valid = (i) => {
3952
+ if (i >= 0 && i < vertex_count) indices_set.add(i);
3953
+ };
3954
+ for (const v of ses.selected_vertices) add_if_valid(v);
3955
+ for (const s of ses.selected_segments) {
3956
+ const seg = segment_snapshot[s];
3957
+ if (!seg) continue;
3958
+ add_if_valid(seg.a);
3959
+ add_if_valid(seg.b);
3960
+ }
3961
+ for (const v of intent.additional_vertex_indices) add_if_valid(v);
3962
+ const tangent_refs = [];
3963
+ for (const ref of ses.selected_tangents) {
3964
+ if (indices_set.has(ref[0])) continue;
3965
+ tangent_refs.push(ref);
3966
+ }
3967
+ if (indices_set.size === 0 && tangent_refs.length === 0) return;
3968
+ const indices = Array.from(indices_set).sort((a, b) => a - b);
3969
+ if (!this.active_preview || this.active_preview.kind !== "vector_translate_selection" || this.active_preview.node_id !== node_id || !require_model.array_shallow_equal(this.active_preview.indices, indices) || !sameTangentRefs(this.active_preview.tangent_refs, tangent_refs)) {
3970
+ if (this.active_preview) {
3971
+ if ("session" in this.active_preview) this.active_preview.session.discard();
3972
+ }
3973
+ const initial_d = this.read_session_d();
3974
+ if (initial_d === null) return;
3975
+ const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
3976
+ const baseline_tangent_abs = tangent_refs.map((ref) => baseline_model.tangentAbsolute(ref, [0, 0]));
3977
+ this.active_preview = {
3978
+ kind: "vector_translate_selection",
3979
+ node_id,
3980
+ promo: { token: null },
3981
+ indices: [...indices],
3982
+ tangent_refs: [...tangent_refs],
3983
+ initial_d,
3984
+ before_selection: this.vector_edit.snapshot_selection(),
3985
+ preview_model: baseline_model,
3986
+ baseline_model,
3987
+ baseline_tangent_abs,
3988
+ session: internal.history.preview("vector/translate-selection")
3989
+ };
3990
+ }
3991
+ const baseline_d = this.active_preview.initial_d;
3992
+ let local_dx = intent.dx;
3993
+ let local_dy = intent.dy;
3994
+ const el = this.element_index.get(node_id);
3995
+ if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
3996
+ const ctm = el.getScreenCTM();
3997
+ if (ctm) {
3998
+ if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
3999
+ }
4000
+ }
4001
+ const baseline_model = this.active_preview.baseline_model;
4002
+ const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
4003
+ let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;
4004
+ for (let i = 0; i < tangent_refs.length; i++) {
4005
+ const baseline_abs = baseline_tangent_abs[i];
4006
+ if (baseline_abs === null) continue;
4007
+ preview_model = preview_model.setTangent(tangent_refs[i], [baseline_abs[0] + local_dx, baseline_abs[1] + local_dy], "none");
4008
+ }
4009
+ const target_d = preview_model.toSvgPathD();
4010
+ this.active_preview.preview_model = preview_model;
4011
+ if (intent.phase === "commit") {
4012
+ const before_selection = this.active_preview.before_selection;
4013
+ const after_selection = this.vector_edit.snapshot_selection();
4014
+ this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
4015
+ this.active_preview.session.commit();
4016
+ this.active_preview = null;
4017
+ } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
4018
+ }
4019
+ /** Mirror handler for `select_tangent` (analogous to `select_vertex`). */
4020
+ handle_select_tangent(intent) {
4021
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
4022
+ const before = this.vector_edit.snapshot_selection();
4023
+ this.vector_edit.select_tangent(intent.tangent, intent.mode);
4024
+ this.sync_selection_mirror();
4025
+ this.redraw();
4026
+ this.record_vector_selection_change(before, "select tangent");
4027
+ }
4028
+ /**
4029
+ * Tangent drag handler. Mirrors `handle_translate_vertices`:
4030
+ *
4031
+ * - First frame opens a `history.preview` session, captures `original_d`.
4032
+ * - Each preview frame replays setTangent from the baseline model so
4033
+ * cumulative drift never accumulates.
4034
+ * - Commit finalizes the preview and reseeds the session.
4035
+ *
4036
+ * `intent.pos` arrives in container CSS-px (HUD's doc-space). We project
4037
+ * it back through the inverse-CTM to path-local before calling
4038
+ * `PathModel.setTangent`.
4039
+ */
4040
+ handle_set_tangent_intent(intent) {
4041
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
4042
+ const internal = this.editor_internal();
4043
+ const node_id = intent.node_id;
4044
+ if (!this.active_preview || this.active_preview.kind !== "vector_set_tangent" || this.active_preview.node_id !== node_id || this.active_preview.tangent[0] !== intent.tangent[0] || this.active_preview.tangent[1] !== intent.tangent[1]) {
4045
+ if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
4046
+ const initial_d = this.read_session_d();
4047
+ if (initial_d === null) return;
4048
+ const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
4049
+ this.active_preview = {
4050
+ kind: "vector_set_tangent",
4051
+ node_id,
4052
+ promo: { token: null },
4053
+ tangent: [intent.tangent[0], intent.tangent[1]],
4054
+ initial_d,
4055
+ baseline_model,
4056
+ before_selection: this.vector_edit.snapshot_selection(),
4057
+ preview_model: baseline_model,
4058
+ session: internal.history.preview("vector/set-tangent")
4059
+ };
4060
+ }
4061
+ const baseline_d = this.active_preview.initial_d;
4062
+ const local_pos = this.project_doc_point_to_local(node_id, intent.pos);
4063
+ if (!local_pos) return;
4064
+ const preview_model = this.active_preview.baseline_model.setTangent(intent.tangent, local_pos, intent.mirror);
4065
+ const target_d = preview_model.toSvgPathD();
4066
+ this.active_preview.preview_model = preview_model;
4067
+ if (intent.phase === "commit") {
4068
+ const before_selection = this.active_preview.before_selection;
4069
+ const after_selection = this.vector_edit.snapshot_selection();
4070
+ this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
4071
+ this.active_preview.session.commit();
4072
+ this.active_preview = null;
4073
+ } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
4074
+ }
4075
+ /**
4076
+ * Split a segment at parametric position `t`. One-shot atomic edit; no
4077
+ * preview phase. After the split, the new vertex is auto-selected — this
4078
+ * matches Figma's add-anchor behavior and prepares the user to immediately
4079
+ * drag the newly inserted anchor.
4080
+ */
4081
+ handle_split_segment(intent) {
4082
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
4083
+ const node_id = intent.node_id;
4084
+ const baseline_d = this.read_session_d();
4085
+ if (baseline_d === null) return;
4086
+ const { model: next_model, new_vertex } = require_model.PathModel.fromSvgPathD(baseline_d).splitSegment(intent.segment, intent.t);
4087
+ const target_d = next_model.toSvgPathD();
4088
+ const internal = this.editor_internal();
4089
+ const before_selection = this.vector_edit.snapshot_selection();
4090
+ const after_selection = Object.freeze({
4091
+ vertices: Object.freeze([new_vertex]),
4092
+ segments: Object.freeze([]),
4093
+ tangents: Object.freeze([])
4094
+ });
4095
+ const promo = { token: null };
4096
+ const split_session = internal.history.preview("vector/split-segment");
4097
+ split_session.set(this.vector_geometry_step(node_id, target_d, baseline_d, promo, after_selection, before_selection));
4098
+ split_session.commit();
4099
+ this.redraw();
4100
+ }
4101
+ /**
4102
+ * Bend a segment by dragging an interior point. Mirrors set_tangent —
4103
+ * preview session opened on first frame, replays from baseline each
4104
+ * frame, commits / reseeds on phase===commit.
4105
+ *
4106
+ * `frozen` is captured ONCE at session open from the baseline model;
4107
+ * `vne.bendSegment` solves for new tangents against this snapshot, so
4108
+ * the cumulative drag delta is correct without per-frame drift.
4109
+ */
4110
+ handle_bend_segment(intent) {
4111
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
4112
+ const internal = this.editor_internal();
4113
+ const node_id = intent.node_id;
4114
+ if (!this.active_preview || this.active_preview.kind !== "vector_bend_segment" || this.active_preview.node_id !== node_id || this.active_preview.segment !== intent.segment || this.active_preview.ca !== intent.ca) {
4115
+ if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
4116
+ const initial_d = this.read_session_d();
4117
+ if (initial_d === null) return;
4118
+ const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
4119
+ const snap = baseline_model.snapshot();
4120
+ const s = snap.segments[intent.segment];
4121
+ if (!s) return;
4122
+ const va = snap.vertices[s.a];
4123
+ const vb = snap.vertices[s.b];
4124
+ this.active_preview = {
4125
+ kind: "vector_bend_segment",
4126
+ node_id,
4127
+ promo: { token: null },
4128
+ segment: intent.segment,
4129
+ ca: intent.ca,
4130
+ frozen: {
4131
+ a: [va[0], va[1]],
4132
+ b: [vb[0], vb[1]],
4133
+ ta: [s.ta[0], s.ta[1]],
4134
+ tb: [s.tb[0], s.tb[1]]
4135
+ },
4136
+ initial_d,
4137
+ baseline_model,
4138
+ before_selection: this.vector_edit.snapshot_selection(),
4139
+ preview_model: baseline_model,
4140
+ session: internal.history.preview("vector/bend-segment")
4141
+ };
4142
+ }
4143
+ const baseline_d = this.active_preview.initial_d;
4144
+ const frozen = this.active_preview.frozen;
4145
+ const local_cb = this.project_doc_point_to_local(node_id, intent.cb);
4146
+ if (!local_cb) return;
4147
+ const preview_model = this.active_preview.baseline_model.bendSegment(intent.segment, intent.ca, local_cb, frozen);
4148
+ const target_d = preview_model.toSvgPathD();
4149
+ this.active_preview.preview_model = preview_model;
4150
+ if (intent.phase === "commit") {
4151
+ const before_selection = this.active_preview.before_selection;
4152
+ const after_selection = this.vector_edit.snapshot_selection();
4153
+ this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
4154
+ this.active_preview.session.commit();
4155
+ this.active_preview = null;
4156
+ } else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
4157
+ }
4158
+ /**
4159
+ * Project a doc-space point (HUD's container CSS-px frame) back to the
4160
+ * named element's local frame via inverse-CTM. Returns null if no CTM
4161
+ * is available or the CTM is singular.
4162
+ */
4163
+ project_doc_point_to_local(id, p) {
4164
+ const el = this.element_index.get(id);
4165
+ if (!(el instanceof SVGGraphicsElement)) return null;
4166
+ if (typeof el.getScreenCTM !== "function") return null;
4167
+ const ctm = el.getScreenCTM();
4168
+ if (!ctm) return null;
4169
+ const cr = this.container.getBoundingClientRect();
4170
+ const offset_x = -cr.left + this.container.scrollLeft;
4171
+ const offset_y = -cr.top + this.container.scrollTop;
4172
+ const det = ctm.a * ctm.d - ctm.c * ctm.b;
4173
+ if (det === 0) return null;
4174
+ const px = p[0] - offset_x;
4175
+ const py = p[1] - offset_y;
4176
+ return [(ctm.d * (px - ctm.e) - ctm.c * (py - ctm.f)) / det, (-ctm.b * (px - ctm.e) + ctm.a * (py - ctm.f)) / det];
4177
+ }
3266
4178
  tag_of(id) {
3267
4179
  return this.editor.tree().nodes.get(id)?.tag ?? "";
3268
4180
  }
@@ -3295,25 +4207,8 @@ var DomSurface = class DomSurface {
3295
4207
  bbox_world(id) {
3296
4208
  const local = this.bbox_local(id);
3297
4209
  if (!local) return null;
3298
- return require_insertions.project_local_bbox(local, this.editor.document.get_attr(id, "transform"));
3299
- }
3300
- /** World-space rect for snap purposes. Differs from `bbox_world` for
3301
- * `<svg>` viewport-establishing elements: `getBBox()` on an `<svg>`
3302
- * reports the union of descendant geometry (SVG 2 §4.6.4), which —
3303
- * when the dragged element is a descendant — silently turns the
3304
- * dragged element's own pre-gesture position into a snap target via
3305
- * the parent's edges. Use the viewport extent instead so the root
3306
- * SVG's snap edges represent the canvas boundary, not "wherever the
3307
- * children happen to be right now". */
3308
- bbox_world_for_snap(id) {
3309
- if (this.tag_of(id) === "svg") {
3310
- const el = this.element_index.get(id);
3311
- if (el instanceof SVGSVGElement) {
3312
- const vp = svg_viewport_bounds(el);
3313
- if (vp) return vp;
3314
- }
3315
- }
3316
- return this.bbox_world(id);
4210
+ const transform_str = this.editor.document.get_attr(id, "transform");
4211
+ return require_model.transform.project(local, transform_str);
3317
4212
  }
3318
4213
  editor_internal() {
3319
4214
  return this.editor._internal;
@@ -3322,13 +4217,92 @@ var DomSurface = class DomSurface {
3322
4217
  function numAttr(doc, id, name) {
3323
4218
  return _grida_svg_parse.svg_parse.parse_number(doc.get_attr(id, name));
3324
4219
  }
4220
+ /** Order-sensitive shallow equality for tangent-ref arrays. */
4221
+ function sameTangentRefs(a, b) {
4222
+ if (a.length !== b.length) return false;
4223
+ for (let i = 0; i < a.length; i++) if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
4224
+ return true;
4225
+ }
4226
+ /**
4227
+ * Affine projection of a point through a 2×3 CTM, then offset by a
4228
+ * container origin (in page CSS-px). Mirrors how `line_endpoints_in_container`
4229
+ * and `shape_of` (transformed branch) bridge from local SVG coords to the
4230
+ * HUD's container-CSS-px space (HUD keeps its own transform at identity;
4231
+ * the SVG carries the camera as a CSS transform, which getScreenCTM
4232
+ * folds in).
4233
+ *
4234
+ * Exported for headless test coverage — pure function, no DOM types.
4235
+ */
4236
+ function project_point_through_ctm(px, py, ctm, container_offset) {
4237
+ const [sx, sy] = _grida_cmath.default.vector2.transform([px, py], [[
4238
+ ctm.a,
4239
+ ctm.c,
4240
+ ctm.e
4241
+ ], [
4242
+ ctm.b,
4243
+ ctm.d,
4244
+ ctm.f
4245
+ ]]);
4246
+ return [sx + container_offset[0], sy + container_offset[1]];
4247
+ }
4248
+ /**
4249
+ * Inverse of the CTM's linear part applied to a delta vector. Drops
4250
+ * translation. Used to convert a HUD-reported container-space drag delta
4251
+ * back to the path's local coord space for `PathModel.translateVertices`.
4252
+ *
4253
+ * Throws on a degenerate (det = 0) matrix — the caller is expected to
4254
+ * have a non-singular CTM for any visible element.
4255
+ */
4256
+ function project_delta_inverse_ctm(dx, dy, ctm) {
4257
+ if (ctm.a * ctm.d - ctm.c * ctm.b === 0) throw new Error("project_delta_inverse_ctm: singular CTM linear part");
4258
+ const inv = _grida_cmath.default.transform.invert([[
4259
+ ctm.a,
4260
+ ctm.c,
4261
+ 0
4262
+ ], [
4263
+ ctm.b,
4264
+ ctm.d,
4265
+ 0
4266
+ ]]);
4267
+ return _grida_cmath.default.vector2.transform([dx, dy], inv);
4268
+ }
4269
+ /**
4270
+ * Inverse-project a doc-space rect through a CTM + container offset back
4271
+ * into the element's local frame. The output is the AABB of the four
4272
+ * inverse-projected corners — when the CTM has a rotation component the
4273
+ * AABB is an approximation, but it matches what the user visually
4274
+ * expects from a screen-aligned marquee drag.
4275
+ *
4276
+ * Returns `null` when the CTM's linear part is singular (degenerate
4277
+ * camera) — the caller should skip any test that needs the local rect.
4278
+ */
4279
+ function inverse_project_rect(rect, ctm, offset) {
4280
+ if (ctm.a * ctm.d - ctm.c * ctm.b === 0) return null;
4281
+ const inv = _grida_cmath.default.transform.invert([[
4282
+ ctm.a,
4283
+ ctm.c,
4284
+ ctm.e
4285
+ ], [
4286
+ ctm.b,
4287
+ ctm.d,
4288
+ ctm.f
4289
+ ]]);
4290
+ const to_local = (px, py) => _grida_cmath.default.vector2.transform([px - offset[0], py - offset[1]], inv);
4291
+ const corners = [
4292
+ to_local(rect.x, rect.y),
4293
+ to_local(rect.x + rect.width, rect.y),
4294
+ to_local(rect.x, rect.y + rect.height),
4295
+ to_local(rect.x + rect.width, rect.y + rect.height)
4296
+ ];
4297
+ return _grida_cmath.default.rect.fromPoints(corners);
4298
+ }
3325
4299
  /** World-space viewport rect of an `<svg>` element. Prefers `viewBox`
3326
4300
  * (the declared user-space rect — what the user perceives as canvas),
3327
4301
  * falls back to `width`/`height` at (0,0). For nested `<svg>` with a
3328
4302
  * positional `x`/`y`, the declared viewBox/(0,0) is in the nested
3329
4303
  * element's OWN user space; callers are responsible for CTM
3330
4304
  * projection if a different frame is desired. v1 nested-svg story is
3331
- * documented in docs/wg/feat-svg-editor/geometry.md as out of scope. */
4305
+ * documented in ../docs/geometry.md as out of scope. */
3332
4306
  function svg_viewport_bounds(el) {
3333
4307
  const vb = el.getAttribute("viewBox");
3334
4308
  if (vb) {
@@ -3490,6 +4464,36 @@ var SvgGeometryDriver = class {
3490
4464
  node_at_point(p) {
3491
4465
  return this.accessors.pick_at_world(p, true);
3492
4466
  }
4467
+ /** World→local delta projection. The frame an element's position is
4468
+ * written in is its PARENT user-space: a `<rect>`'s `x`/`y` and the
4469
+ * leading `translate(...)` composed onto a `<g>`/transformed node are
4470
+ * both interpreted there. We take the parent element's frame (not the
4471
+ * element's own) so that translating a node whose OWN transform has a
4472
+ * scale/rotation is not double-counted.
4473
+ *
4474
+ * Camera-free: `inv(root.getScreenCTM) ∘ parent.getScreenCTM` maps
4475
+ * parent user-space → root world-space, cancelling the shared CSS /
4476
+ * camera transform. Inverting its linear part turns a world delta into
4477
+ * the local delta. Identity (→ delta unchanged) for flat frames,
4478
+ * top-level nodes, and any degenerate / unavailable matrix. */
4479
+ world_delta_to_local(id, delta) {
4480
+ const parent = this.accessors.element_for(id)?.parentNode;
4481
+ const root = this.accessors.root();
4482
+ if (!(parent instanceof SVGGraphicsElement) || !root) return delta;
4483
+ if (parent === root) return delta;
4484
+ if (typeof parent.getScreenCTM !== "function" || typeof root.getScreenCTM !== "function") return delta;
4485
+ const parent_ctm = parent.getScreenCTM();
4486
+ const root_ctm = root.getScreenCTM();
4487
+ if (!parent_ctm || !root_ctm) return delta;
4488
+ const m = root_ctm.inverse().multiply(parent_ctm);
4489
+ const det = m.a * m.d - m.c * m.b;
4490
+ if (!Number.isFinite(det) || det === 0) return delta;
4491
+ const [x, y] = project_delta_inverse_ctm(delta.x, delta.y, m);
4492
+ return {
4493
+ x,
4494
+ y
4495
+ };
4496
+ }
3493
4497
  };
3494
4498
  var SvgHitShapeDriver = class {
3495
4499
  constructor(accessors) {
@@ -3498,9 +4502,9 @@ var SvgHitShapeDriver = class {
3498
4502
  hit_shape_of(id) {
3499
4503
  const doc = this.accessors.doc();
3500
4504
  if (!doc) return null;
3501
- const intrinsic = require_insertions.hit_shape_of_doc(doc, id);
4505
+ const intrinsic = require_model.hit_shape_svg.of_doc(doc, id);
3502
4506
  if (intrinsic) return intrinsic;
3503
- if (require_insertions.is_transparent_tag(doc.tag_of(id))) return null;
4507
+ if (require_model.hit_shape_svg.is_transparent_tag(doc.tag_of(id))) return null;
3504
4508
  const bounds = this.accessors.bounds_of(id);
3505
4509
  if (!bounds) return null;
3506
4510
  return {
@@ -3543,3 +4547,21 @@ Object.defineProperty(exports, "attach_dom_surface", {
3543
4547
  return attach_dom_surface;
3544
4548
  }
3545
4549
  });
4550
+ Object.defineProperty(exports, "inverse_project_rect", {
4551
+ enumerable: true,
4552
+ get: function() {
4553
+ return inverse_project_rect;
4554
+ }
4555
+ });
4556
+ Object.defineProperty(exports, "project_delta_inverse_ctm", {
4557
+ enumerable: true,
4558
+ get: function() {
4559
+ return project_delta_inverse_ctm;
4560
+ }
4561
+ });
4562
+ Object.defineProperty(exports, "project_point_through_ctm", {
4563
+ enumerable: true,
4564
+ get: function() {
4565
+ return project_point_through_ctm;
4566
+ }
4567
+ });