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