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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,9 @@
1
- const require_insertions = require("./insertions-BJ-6o6o5.js");
1
+ const require_model = require("./model-DqGqV1H4.js");
2
2
  let _grida_cmath = require("@grida/cmath");
3
- _grida_cmath = require_insertions.__toESM(_grida_cmath);
3
+ _grida_cmath = require_model.__toESM(_grida_cmath);
4
4
  let _grida_svg_parse = require("@grida/svg/parse");
5
+ let _grida_vn = require("@grida/vn");
6
+ _grida_vn = require_model.__toESM(_grida_vn);
5
7
  let _grida_text_editor_dom = require("@grida/text-editor/dom");
6
8
  let _grida_hud = require("@grida/hud");
7
9
  let _grida_hud_cursors = require("@grida/hud/cursors");
@@ -296,7 +298,7 @@ function transform_equal(a, b) {
296
298
  //#region src/core/geometry.ts
297
299
  /**
298
300
  * Caches `bounds_of` results keyed on `NodeId`; full-clears on either
299
- * `structure_version` or `geometry_version` bump. See docs/wg/feat-svg-editor/geometry.md for
301
+ * `structure_version` or `geometry_version` bump. See ../../docs/geometry.md for
300
302
  * why the cache is load-bearing under the surface's per-tick re-render.
301
303
  */
302
304
  var MemoizedGeometryProvider = class {
@@ -671,7 +673,7 @@ function is_self_rendered(doc, id) {
671
673
  function collect_rendered_subtree(doc, parent, out) {
672
674
  for (const child of doc.element_children_of(parent)) {
673
675
  if (!is_self_rendered(doc, child)) continue;
674
- if (!require_insertions.STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(child))) continue;
676
+ if (!require_model.group.STRUCTURAL_GRAPHICS.has(doc.tag_of(child))) continue;
675
677
  out.add(child);
676
678
  if (doc.tag_of(child) === "g") collect_rendered_subtree(doc, child, out);
677
679
  }
@@ -707,10 +709,10 @@ function compute_neighborhood(doc, dragged) {
707
709
  for (const id of dragged) {
708
710
  const parent = doc.parent_of(id);
709
711
  if (parent === null) continue;
710
- if (!excluded.has(parent) && require_insertions.STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(parent)) && is_self_rendered(doc, parent)) out.add(parent);
712
+ if (!excluded.has(parent) && require_model.group.STRUCTURAL_GRAPHICS.has(doc.tag_of(parent)) && is_self_rendered(doc, parent)) out.add(parent);
711
713
  for (const sib of doc.element_children_of(parent)) {
712
714
  if (excluded.has(sib)) continue;
713
- if (!require_insertions.STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(sib))) continue;
715
+ if (!require_model.group.STRUCTURAL_GRAPHICS.has(doc.tag_of(sib))) continue;
714
716
  if (!is_self_rendered(doc, sib)) continue;
715
717
  for (const inner of snap_descent(doc, sib)) {
716
718
  if (excluded.has(inner)) continue;
@@ -727,565 +729,28 @@ const DEFAULT_SNAP_OPTIONS = {
727
729
  threshold_px: 6
728
730
  };
729
731
  //#endregion
730
- //#region src/core/resize-pipeline/pipeline.ts
731
- /** The funnel. Threads `plan` through `stages` in order; aggregates guide
732
- * emissions. Pure: same inputs → same outputs. */
733
- function run_resize_pipeline(init, stages, ctx) {
734
- let plan = init;
735
- const guides = [];
736
- for (const stage of stages) {
737
- const out = stage.run(plan, ctx);
738
- plan = out.plan;
739
- if (out.emit?.guide) guides.push(out.emit.guide);
740
- }
741
- return {
742
- plan,
743
- guides
744
- };
745
- }
746
- //#endregion
747
- //#region src/core/resize-capability.ts
748
- function direction_mask(dir) {
749
- const has_n = dir === "n" || dir === "ne" || dir === "nw";
750
- const has_s = dir === "s" || dir === "se" || dir === "sw";
751
- const has_e = dir === "e" || dir === "ne" || dir === "se";
752
- const has_w = dir === "w" || dir === "nw" || dir === "sw";
753
- return {
754
- affects_x: has_e || has_w,
755
- affects_y: has_n || has_s,
756
- x_edge: has_e ? "right" : has_w ? "left" : null,
757
- y_edge: has_n ? "top" : has_s ? "bottom" : null
758
- };
759
- }
760
- /** Is this direction one of the four corners (vs. an edge handle)? */
761
- function is_corner_direction(dir) {
762
- return dir === "nw" || dir === "ne" || dir === "se" || dir === "sw";
763
- }
764
- /**
765
- * Apply per-element resize constraints to a gesture's proposed `(sx, sy)`.
766
- *
767
- * The constraint mirrors `apply_resize` exactly so that the *effective
768
- * rect* the caller computes from `(sx, sy)` matches what attribute writes
769
- * actually produce. This is the keystone of resize snap: snapping on the
770
- * gesture-proposed rect would lie about where the geometry lands when an
771
- * element-type constraint kicks in.
772
- *
773
- * Constraints:
774
- * - `rect` / `image` / `use` / `ellipse` / `line` / `polyline` /
775
- * `polygon` / `path`: free per-axis. Identity.
776
- * - `circle`: uniform. `s = min(sx, sy)`.
777
- * - `text`: uniform on corner drags; no-op on edge drags. Mirrors the
778
- * `isCorner = sx !== 1 && sy !== 1` check in `apply_resize`.
779
- * - `unsupported`: no-op.
780
- */
781
- function resize_constraint(baseline, dir, sx_gesture, sy_gesture) {
782
- switch (baseline.attrs.kind) {
783
- case "rect":
784
- case "image":
785
- case "use":
786
- case "ellipse":
787
- case "line":
788
- case "polyline":
789
- case "polygon":
790
- case "path": return {
791
- sx: sx_gesture,
792
- sy: sy_gesture,
793
- no_op: false,
794
- uniform: false
795
- };
796
- case "circle": {
797
- const s = Math.min(sx_gesture, sy_gesture);
798
- return {
799
- sx: s,
800
- sy: s,
801
- no_op: false,
802
- uniform: true
803
- };
804
- }
805
- case "text": {
806
- if (!is_corner_direction(dir)) return {
807
- sx: 1,
808
- sy: 1,
809
- no_op: true,
810
- uniform: true
811
- };
812
- const s = Math.min(sx_gesture, sy_gesture);
813
- return {
814
- sx: s,
815
- sy: s,
816
- no_op: false,
817
- uniform: true
818
- };
819
- }
820
- case "unsupported": return {
821
- sx: 1,
822
- sy: 1,
823
- no_op: true,
824
- uniform: false
825
- };
826
- }
827
- }
828
- /** Position of a bbox corner / edge midpoint by direction. */
829
- function corner_of_rect(r, dir) {
830
- switch (dir) {
831
- case "nw": return {
832
- x: r.x,
833
- y: r.y
834
- };
835
- case "n": return {
836
- x: r.x + r.width / 2,
837
- y: r.y
838
- };
839
- case "ne": return {
840
- x: r.x + r.width,
841
- y: r.y
842
- };
843
- case "e": return {
844
- x: r.x + r.width,
845
- y: r.y + r.height / 2
846
- };
847
- case "se": return {
848
- x: r.x + r.width,
849
- y: r.y + r.height
850
- };
851
- case "s": return {
852
- x: r.x + r.width / 2,
853
- y: r.y + r.height
854
- };
855
- case "sw": return {
856
- x: r.x,
857
- y: r.y + r.height
858
- };
859
- case "w": return {
860
- x: r.x,
861
- y: r.y + r.height / 2
862
- };
863
- }
864
- }
865
- /** The fixed-origin corner for a drag (opposite the moving corner). */
866
- function origin_of_direction(r, dir) {
867
- switch (dir) {
868
- case "nw": return {
869
- x: r.x + r.width,
870
- y: r.y + r.height
871
- };
872
- case "n": return {
873
- x: r.x + r.width / 2,
874
- y: r.y + r.height
875
- };
876
- case "ne": return {
877
- x: r.x,
878
- y: r.y + r.height
879
- };
880
- case "e": return {
881
- x: r.x,
882
- y: r.y + r.height / 2
883
- };
884
- case "se": return {
885
- x: r.x,
886
- y: r.y
887
- };
888
- case "s": return {
889
- x: r.x + r.width / 2,
890
- y: r.y
891
- };
892
- case "sw": return {
893
- x: r.x + r.width,
894
- y: r.y
895
- };
896
- case "w": return {
897
- x: r.x + r.width,
898
- y: r.y + r.height / 2
899
- };
900
- }
901
- }
732
+ //#region src/core/text-edit.ts
902
733
  /**
903
- * Compute the effective rect that `apply_resize` would write for the
904
- * given gesture. This is what snap operates on.
734
+ * Decide what happens when inline text content-editing exits.
905
735
  *
906
- * The rect is computed by scaling `baseline.bbox` around `origin` by the
907
- * constrained `(sx, sy)`. For free elements this matches the gesture
908
- * exactly; for circle / text-on-corner it collapses to a uniform-scale
909
- * rect; for text-on-edge it returns the baseline rect unchanged.
910
- */
911
- function effective_resize(baseline, dir, sx_gesture, sy_gesture) {
912
- const bbox = baseline.bbox;
913
- const origin = origin_of_direction(bbox, dir);
914
- const c = resize_constraint(baseline, dir, sx_gesture, sy_gesture);
915
- const mask = direction_mask(dir);
916
- const rect = {
917
- x: origin.x + (bbox.x - origin.x) * c.sx,
918
- y: origin.y + (bbox.y - origin.y) * c.sy,
919
- width: bbox.width * c.sx,
920
- height: bbox.height * c.sy
921
- };
922
- const moving_corner = corner_of_rect(rect, dir);
923
- return {
924
- rect,
925
- sx: c.sx,
926
- sy: c.sy,
927
- moving_corner,
928
- origin,
929
- no_op: c.no_op,
930
- uniform: c.uniform,
931
- mask
932
- };
933
- }
934
- //#endregion
935
- //#region src/core/resize-pipeline/stages.ts
936
- function pipeline_baseline(plan) {
937
- return plan.baseline;
938
- }
939
- /** Collapse `(dx, dy)` to a uniform scale when Shift-drag is active.
940
- * Element-driven uniform (circle / text-on-corner) is enforced inside
941
- * `resize_constraint`, not here — this stage is *only* the user-visible
942
- * modifier. No-op on edge handles (uniform across one axis isn't a
943
- * meaningful constraint). */
944
- const stage_aspect_lock = {
945
- name: "aspect_lock",
946
- run(plan, ctx) {
947
- if (ctx.modifiers.aspect_lock !== "uniform") return { plan };
948
- if (!is_corner_direction(plan.direction)) return { plan };
949
- const pbase = pipeline_baseline(plan);
950
- const locked = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, true);
951
- const bbox = pbase.bbox;
952
- const Hx_base = (() => {
953
- switch (plan.direction) {
954
- case "nw":
955
- case "w":
956
- case "sw": return bbox.x;
957
- case "ne":
958
- case "e":
959
- case "se": return bbox.x + bbox.width;
960
- case "n":
961
- case "s": return bbox.x + bbox.width / 2;
962
- }
963
- })();
964
- const Hy_base = (() => {
965
- switch (plan.direction) {
966
- case "nw":
967
- case "n":
968
- case "ne": return bbox.y;
969
- case "sw":
970
- case "s":
971
- case "se": return bbox.y + bbox.height;
972
- case "e":
973
- case "w": return bbox.y + bbox.height / 2;
974
- }
975
- })();
976
- const new_Hx = locked.origin.x + (Hx_base - locked.origin.x) * locked.sx;
977
- const new_Hy = locked.origin.y + (Hy_base - locked.origin.y) * locked.sy;
978
- return { plan: {
979
- ...plan,
980
- dx: new_Hx - Hx_base,
981
- dy: new_Hy - Hy_base
982
- } };
983
- }
984
- };
985
- /** Consults `ctx.snap_session` for moving-edge alignment correction.
986
- * Identity on `force_disable_snap`, missing session, or
987
- * `snap_enabled === false`. */
988
- const stage_snap = {
989
- name: "snap",
990
- run(plan, ctx) {
991
- if (ctx.modifiers.force_disable_snap) return { plan };
992
- if (!ctx.snap_session) return { plan };
993
- if (!ctx.options.snap_enabled) return { plan };
994
- const pbase = pipeline_baseline(plan);
995
- const f = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
996
- const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
997
- if (eff.no_op) return { plan };
998
- const r = ctx.snap_session.snap_resize(eff.rect, {
999
- x: eff.mask.x_edge,
1000
- y: eff.mask.y_edge
1001
- }, {
1002
- enabled: true,
1003
- threshold_px: ctx.options.snap_threshold_px
1004
- });
1005
- if (r.dx === 0 && r.dy === 0) return {
1006
- plan,
1007
- emit: r.guide ? { guide: r.guide } : void 0
1008
- };
1009
- if (eff.uniform) {
1010
- const bbox = pbase.bbox;
1011
- const new_Hx = eff.moving_corner.x + r.dx;
1012
- const new_Hy = eff.moving_corner.y + r.dy;
1013
- const sx_from_x = eff.mask.x_edge !== null && r.dx !== 0 && bbox.width !== 0 ? (new_Hx - eff.origin.x) / (eff.moving_corner.x - eff.origin.x) * eff.sx : null;
1014
- const sy_from_y = eff.mask.y_edge !== null && r.dy !== 0 && bbox.height !== 0 ? (new_Hy - eff.origin.y) / (eff.moving_corner.y - eff.origin.y) * eff.sy : null;
1015
- let s = eff.sx;
1016
- if (sx_from_x !== null && sy_from_y !== null) s = Math.min(sx_from_x, sy_from_y);
1017
- else if (sx_from_x !== null) s = sx_from_x;
1018
- else if (sy_from_y !== null) s = sy_from_y;
1019
- const Hx_base = corner_x_of(bbox, plan.direction);
1020
- const Hy_base = corner_y_of(bbox, plan.direction);
1021
- const target_Hx = eff.origin.x + (Hx_base - eff.origin.x) * s;
1022
- const target_Hy = eff.origin.y + (Hy_base - eff.origin.y) * s;
1023
- return {
1024
- plan: {
1025
- ...plan,
1026
- dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
1027
- dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
1028
- },
1029
- emit: r.guide ? { guide: r.guide } : void 0
1030
- };
1031
- }
1032
- return {
1033
- plan: {
1034
- ...plan,
1035
- dx: eff.mask.affects_x ? plan.dx + r.dx : plan.dx,
1036
- dy: eff.mask.affects_y ? plan.dy + r.dy : plan.dy
1037
- },
1038
- emit: r.guide ? { guide: r.guide } : void 0
1039
- };
1040
- }
1041
- };
1042
- /** Quantize the moving corner's *post-resize* position to integer
1043
- * multiples of `pixel_grid_quantum` in own-frame space. Identity when
1044
- * quantum is `null` / `<= 0` or the gesture is a no-op. */
1045
- const stage_pixel_grid = {
1046
- name: "pixel_grid",
1047
- run(plan, ctx) {
1048
- const q = ctx.options.pixel_grid_quantum;
1049
- if (q === null || q <= 0) return { plan };
1050
- const pbase = pipeline_baseline(plan);
1051
- const f = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
1052
- const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
1053
- if (eff.no_op) return { plan };
1054
- const target_Hx = eff.mask.affects_x ? Math.round(eff.moving_corner.x / q) * q : eff.moving_corner.x;
1055
- const target_Hy = eff.mask.affects_y ? Math.round(eff.moving_corner.y / q) * q : eff.moving_corner.y;
1056
- const bbox = pbase.bbox;
1057
- const Hx_base = corner_x_of(bbox, plan.direction);
1058
- const Hy_base = corner_y_of(bbox, plan.direction);
1059
- return { plan: {
1060
- ...plan,
1061
- dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
1062
- dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
1063
- } };
1064
- }
1065
- };
1066
- function corner_x_of(r, dir) {
1067
- switch (dir) {
1068
- case "nw":
1069
- case "w":
1070
- case "sw": return r.x;
1071
- case "ne":
1072
- case "e":
1073
- case "se": return r.x + r.width;
1074
- case "n":
1075
- case "s": return r.x + r.width / 2;
1076
- }
1077
- }
1078
- function corner_y_of(r, dir) {
1079
- switch (dir) {
1080
- case "nw":
1081
- case "n":
1082
- case "ne": return r.y;
1083
- case "sw":
1084
- case "s":
1085
- case "se": return r.y + r.height;
1086
- case "e":
1087
- case "w": return r.y + r.height / 2;
1088
- }
1089
- }
1090
- /** Default stage list for HUD-driven resize gestures (drag).
1091
- * No NUDGE / RPC analogs at v1 (no `commands.resize` per README). */
1092
- const STAGES_DEFAULT = Object.freeze([
1093
- stage_aspect_lock,
1094
- stage_snap,
1095
- stage_pixel_grid
1096
- ]);
1097
- //#endregion
1098
- //#region src/core/resize-pipeline/apply.ts
1099
- function applyResizePlan(doc, plan, phase = "commit") {
1100
- const f = require_insertions.compute_resize_factors(plan.baseline, plan.direction, plan.dx, plan.dy, false);
1101
- const members = plan.members ?? [{
1102
- id: plan.id,
1103
- baseline: plan.baseline
1104
- }];
1105
- for (const m of members) require_insertions.apply_resize(doc, m.id, m.baseline, f.sx, f.sy, f.origin, phase);
1106
- }
1107
- function revertResizePlan(doc, plan) {
1108
- const f = require_insertions.compute_resize_factors(plan.baseline, plan.direction, 0, 0, false);
1109
- const members = plan.members ?? [{
1110
- id: plan.id,
1111
- baseline: plan.baseline
1112
- }];
1113
- for (const m of members) require_insertions.apply_resize(doc, m.id, m.baseline, 1, 1, f.origin, "preview");
1114
- }
1115
- /**
1116
- * Synthesize a "group" baseline over an arbitrary union rect. The attrs
1117
- * carrier is `rect`-kind so the pipeline math (snap / pixel-grid)
1118
- * treats the group as free per-axis — per-member constraints (circle
1119
- * uniform, text edge no-op) kick in at apply time against each
1120
- * member's own captured baseline.
736
+ * `result` is the text that should remain the typed text on commit, the
737
+ * original on cancel. "Empty" means zero-length: a space is authored
738
+ * content and is kept. The rule is unconditional an empty result deletes
739
+ * the node however it got there (freshly placed and never typed, cleared by
740
+ * the author, or already empty on entry).
1121
741
  *
1122
- * For single-member groups callers should pass the member's own
1123
- * baseline directly so the per-element snap correction (`eff.uniform`
1124
- * branch) fires correctly.
742
+ * See docs/wg/feat-svg-editor/text-tool.md.
1125
743
  */
1126
- function synthesize_group_baseline(union) {
1127
- return {
1128
- bbox: {
1129
- x: union.x,
1130
- y: union.y,
1131
- width: union.width,
1132
- height: union.height
1133
- },
1134
- attrs: {
1135
- kind: "rect",
1136
- x: union.x,
1137
- y: union.y,
1138
- w: union.width,
1139
- h: union.height
1140
- }
1141
- };
1142
- }
1143
- //#endregion
1144
- //#region src/core/resize-pipeline/orchestrator.ts
1145
- const PROVIDER_ID = "svg-editor";
1146
- /** West/north-anchor flips invert the corresponding world delta so a
1147
- * positive value always grows the moving edge outward — the convention
1148
- * `compute_resize_factors` consumes. */
1149
- function sign_adjust(dir, dx_world, dy_world) {
1150
- return {
1151
- dx: dir === "w" || dir === "nw" || dir === "sw" ? -dx_world : dx_world,
1152
- dy: dir === "n" || dir === "ne" || dir === "nw" ? -dy_world : dy_world
744
+ function resolve_text_exit(input) {
745
+ const is_empty = input.result.length === 0;
746
+ if (input.origin === "fresh") return is_empty ? { kind: "discard_insert" } : { kind: "commit_insert" };
747
+ if (is_empty) return { kind: "remove" };
748
+ if (input.result !== input.original) return {
749
+ kind: "set_text",
750
+ value: input.result
1153
751
  };
752
+ return { kind: "noop" };
1154
753
  }
1155
- /** Stable, order-independent key for an id set — used by `is_active_for`
1156
- * to decide whether the current session targets the same group. */
1157
- function ids_key(ids) {
1158
- return [...ids].sort().join("\0");
1159
- }
1160
- var ResizeOrchestrator = class {
1161
- constructor(deps) {
1162
- this.deps = deps;
1163
- this.active = null;
1164
- this._last_guides = [];
1165
- }
1166
- /** Guides emitted by the most recent pipeline run. Cleared on
1167
- * cancel/dispose. */
1168
- get last_guides() {
1169
- return this._last_guides;
1170
- }
1171
- has_active_session() {
1172
- return this.active !== null;
1173
- }
1174
- /** Is the gesture currently targeting `ids` with `direction`? Used by
1175
- * the HUD dispatch to decide whether to reset the session on a new
1176
- * handle / target. Order-independent. */
1177
- is_active_for(ids, direction) {
1178
- return this.active !== null && this.active.direction === direction && this.active.ids_key === ids_key(ids);
1179
- }
1180
- /** Per-frame drive. Opens a session lazily on the first call. The
1181
- * HUD passes its gesture-target rect dimensions in **world space**;
1182
- * the orchestrator derives the signed world-frame delta against its
1183
- * captured `baseline.bbox`. The DOM adapter is responsible for the
1184
- * CSS-px → world conversion at the intent boundary. */
1185
- drive(input, modifiers, opts) {
1186
- if (input.ids.length === 0) return null;
1187
- const doc = this.deps.get_doc();
1188
- for (const id of input.ids) if (!require_insertions.is_resizable_node(doc, id)) return null;
1189
- const key = ids_key(input.ids);
1190
- if (this.active && (this.active.ids_key !== key || this.active.direction !== input.direction)) {
1191
- this.active.preview.discard();
1192
- this.dispose_session();
1193
- }
1194
- if (this.active === null) this.active = this.open(input.ids, input.direction, opts.snap, opts.label ?? "resize");
1195
- const session = this.active;
1196
- const bbox = session.baseline.bbox;
1197
- const dx_world = input.target_width - bbox.width;
1198
- const dy_world = input.target_height - bbox.height;
1199
- const d = sign_adjust(input.direction, dx_world, dy_world);
1200
- const stages = opts.stages ?? STAGES_DEFAULT;
1201
- const result = this.run_pass(session, d.dx, d.dy, modifiers, stages);
1202
- session.last_dx = d.dx;
1203
- session.last_dy = d.dy;
1204
- session.last_stages = stages;
1205
- this.write_preview(session, result.plan, opts.phase);
1206
- if (opts.phase === "commit") {
1207
- session.preview.commit();
1208
- this.dispose_session();
1209
- }
1210
- return result;
1211
- }
1212
- /** Re-run the current preview frame with new modifiers. */
1213
- redrive_modifiers(modifiers) {
1214
- if (!this.active) return null;
1215
- const session = this.active;
1216
- const result = this.run_pass(session, session.last_dx, session.last_dy, modifiers, session.last_stages);
1217
- this.write_preview(session, result.plan, "preview");
1218
- return result;
1219
- }
1220
- cancel() {
1221
- if (!this.active) return;
1222
- this.active.preview.discard();
1223
- this.dispose_session();
1224
- }
1225
- run_pass(session, dx, dy, modifiers, stages) {
1226
- const result = run_resize_pipeline({
1227
- id: session.primary_id,
1228
- baseline: session.baseline,
1229
- members: session.members,
1230
- direction: session.direction,
1231
- dx,
1232
- dy
1233
- }, stages, {
1234
- input: {
1235
- id: session.primary_id,
1236
- direction: session.direction,
1237
- dx,
1238
- dy
1239
- },
1240
- modifiers,
1241
- options: this.deps.options(),
1242
- snap_session: session.snap
1243
- });
1244
- this._last_guides = result.guides;
1245
- return result;
1246
- }
1247
- open(ids, direction, snap, label) {
1248
- const doc = this.deps.get_doc();
1249
- const members = ids.map((id) => ({
1250
- id,
1251
- baseline: require_insertions.capture_resize_baseline(doc, id, this.deps.bbox_world(id))
1252
- }));
1253
- const baseline = members.length === 1 ? members[0].baseline : synthesize_group_baseline(_grida_cmath.default.rect.union(members.map((m) => m.baseline.bbox)));
1254
- return {
1255
- ids_key: ids_key(ids),
1256
- primary_id: members[0].id,
1257
- direction,
1258
- members,
1259
- baseline,
1260
- snap: snap ? this.deps.open_snap(ids) : null,
1261
- preview: this.deps.open_preview(label),
1262
- last_dx: 0,
1263
- last_dy: 0,
1264
- last_stages: STAGES_DEFAULT
1265
- };
1266
- }
1267
- write_preview(session, plan, phase) {
1268
- const doc = this.deps.get_doc();
1269
- const emit = this.deps.emit;
1270
- session.preview.set({
1271
- providerId: PROVIDER_ID,
1272
- apply: () => {
1273
- applyResizePlan(doc, plan, phase);
1274
- emit();
1275
- },
1276
- revert: () => {
1277
- revertResizePlan(doc, plan);
1278
- emit();
1279
- }
1280
- });
1281
- }
1282
- dispose_session() {
1283
- if (!this.active) return;
1284
- this.active.snap?.dispose();
1285
- this.active = null;
1286
- this._last_guides = [];
1287
- }
1288
- };
1289
754
  //#endregion
1290
755
  //#region src/gestures/gestures.ts
1291
756
  /**
@@ -1421,7 +886,7 @@ const DEFAULT_GESTURE_BINDINGS = [
1421
886
  WHEEL_PAN_ZOOM,
1422
887
  {
1423
888
  id: "space-drag-pan",
1424
- install({ container, camera }) {
889
+ install({ container, camera, is_attended }) {
1425
890
  let space_held = false;
1426
891
  let prev_cursor = null;
1427
892
  const set_cursor = (next) => {
@@ -1431,7 +896,8 @@ const DEFAULT_GESTURE_BINDINGS = [
1431
896
  };
1432
897
  const on_keydown = (e) => {
1433
898
  if (e.code !== "Space" || e.repeat) return;
1434
- if (require_insertions.is_text_input_focused()) return;
899
+ if (require_model.is_text_input_focused()) return;
900
+ if (!is_attended()) return;
1435
901
  space_held = true;
1436
902
  set_cursor("grab");
1437
903
  e.preventDefault();
@@ -1484,12 +950,11 @@ const DEFAULT_GESTURE_BINDINGS = [
1484
950
  },
1485
951
  {
1486
952
  id: "keyboard-zoom",
1487
- install({ container, camera }) {
953
+ install({ container, camera, is_attended }) {
1488
954
  const owner_doc = container.ownerDocument;
1489
955
  const on_keydown = (e) => {
1490
- const active = owner_doc.activeElement;
1491
- if (active && active !== owner_doc.body && !container.contains(active)) return;
1492
- if (require_insertions.is_text_input_focused()) return;
956
+ if (!is_attended()) return;
957
+ if (require_model.is_text_input_focused()) return;
1493
958
  const mod = e.metaKey || e.ctrlKey;
1494
959
  if (e.shiftKey && !mod && (e.code === "Digit0" || e.code === "Numpad0")) {
1495
960
  camera.reset();
@@ -1518,6 +983,39 @@ function applyDefaultGestures(gestures) {
1518
983
  for (const b of DEFAULT_GESTURE_BINDINGS) gestures.bind(b);
1519
984
  }
1520
985
  //#endregion
986
+ //#region src/util/attention.ts
987
+ /**
988
+ * Install pointer-tracking listeners on `container` and return the
989
+ * read-side handle. The tracker is owned by the surface and disposed
990
+ * alongside it; gesture bindings that need to consult it receive the
991
+ * read-only `is_attended` predicate through `GestureContext`.
992
+ */
993
+ function create_attention_tracker(container) {
994
+ let pointer_over = false;
995
+ const on_enter = () => {
996
+ pointer_over = true;
997
+ };
998
+ const on_leave = () => {
999
+ pointer_over = false;
1000
+ };
1001
+ container.addEventListener("pointerenter", on_enter);
1002
+ container.addEventListener("pointerleave", on_leave);
1003
+ const is_attended = () => {
1004
+ const owner = container.ownerDocument;
1005
+ if (!owner) return pointer_over;
1006
+ const active = owner.activeElement;
1007
+ if (active && active !== owner.body && container.contains(active)) return true;
1008
+ return pointer_over;
1009
+ };
1010
+ return {
1011
+ is_attended,
1012
+ dispose: () => {
1013
+ container.removeEventListener("pointerenter", on_enter);
1014
+ container.removeEventListener("pointerleave", on_leave);
1015
+ }
1016
+ };
1017
+ }
1018
+ //#endregion
1521
1019
  //#region src/text-surface.ts
1522
1020
  const SVG_NS = "http://www.w3.org/2000/svg";
1523
1021
  const XML_NS = "http://www.w3.org/XML/1998/namespace";
@@ -1677,6 +1175,312 @@ var SvgTextSurface = class {
1677
1175
  }
1678
1176
  };
1679
1177
  //#endregion
1178
+ //#region src/core/vector-edit/session.ts
1179
+ function tangents_equal(a, b) {
1180
+ return a[0] === b[0] && a[1] === b[1];
1181
+ }
1182
+ /**
1183
+ * Shallow equality over the three sub-selection arrays. Order-sensitive
1184
+ * (mirrors how the host stores them). Used by the orchestrator to skip
1185
+ * pushing a no-op undo entry when a selection handler resolves to the
1186
+ * same state — e.g. clicking an already-selected vertex in `replace`
1187
+ * mode.
1188
+ */
1189
+ function sub_selection_equal(a, b) {
1190
+ if (a === b) return true;
1191
+ if (a.vertices.length !== b.vertices.length) return false;
1192
+ if (a.segments.length !== b.segments.length) return false;
1193
+ if (a.tangents.length !== b.tangents.length) return false;
1194
+ for (let i = 0; i < a.vertices.length; i++) if (a.vertices[i] !== b.vertices[i]) return false;
1195
+ for (let i = 0; i < a.segments.length; i++) if (a.segments[i] !== b.segments[i]) return false;
1196
+ for (let i = 0; i < a.tangents.length; i++) if (!tangents_equal(a.tangents[i], b.tangents[i])) return false;
1197
+ return true;
1198
+ }
1199
+ /**
1200
+ * Host-side state for vector content-edit (vertex / segment / tangent
1201
+ * gestures) on the supported source tags. "Vector" here names the
1202
+ * editing mode — NOT a `vn.VectorNetwork` wrapper. The session holds
1203
+ * the source tag's authored attrs plus a path-form session-d, and
1204
+ * delegates geometry to {@link PathModel} via the apply.ts shim.
1205
+ */
1206
+ var VectorEditSession = class {
1207
+ constructor(node_id, source, session_d) {
1208
+ this.node_id = node_id;
1209
+ this.source = source;
1210
+ this._session_d = session_d;
1211
+ this._last_seen_d = session_d;
1212
+ this._selected_vertices = [];
1213
+ this._selected_segments = [];
1214
+ this._selected_tangents = [];
1215
+ this._hovered_control = null;
1216
+ }
1217
+ /** The session's current PathModel-form `d`. Gesture handlers read
1218
+ * this instead of `doc.get_attr(node_id, "d")` so they stay tag-
1219
+ * oblivious (non-path sources have no `d` on the document). */
1220
+ get current_d() {
1221
+ return this._session_d;
1222
+ }
1223
+ /** Update the session's view after a write produced `next_d`. Caller
1224
+ * is `apply_session_d` (or the gesture handler that called it). */
1225
+ update_session_d(next_d) {
1226
+ this._session_d = next_d;
1227
+ }
1228
+ get last_seen_d() {
1229
+ return this._last_seen_d;
1230
+ }
1231
+ get selected_vertices() {
1232
+ return this._selected_vertices;
1233
+ }
1234
+ get selected_segments() {
1235
+ return this._selected_segments;
1236
+ }
1237
+ get selected_tangents() {
1238
+ return this._selected_tangents;
1239
+ }
1240
+ get hovered_control() {
1241
+ return this._hovered_control;
1242
+ }
1243
+ /**
1244
+ * Record that the host's most recent gesture write produced `d`.
1245
+ * Updates both the session-d (the in-session canonical form) and the
1246
+ * last-seen mark. The next state-change tick uses last_seen to
1247
+ * distinguish "we wrote this" from "the document changed under us".
1248
+ */
1249
+ mark_seen(d) {
1250
+ this._session_d = d;
1251
+ this._last_seen_d = d;
1252
+ }
1253
+ /**
1254
+ * The session's response to a detected external mutation of `d`
1255
+ * (undo / redo / programmatic / collab). Atomically (a) advances
1256
+ * `last_seen_d` to the now-current value and (b) drops sub-selection
1257
+ * — selection indices reference vertices and segments by ordinal
1258
+ * position, and an external mutation may have shifted or removed
1259
+ * them.
1260
+ *
1261
+ * Exposed as a single method so callers cannot get the two halves
1262
+ * out of order. Doing `clear_selection` without `mark_seen` would
1263
+ * leave us "stuck dirty" — the next tick would reconcile again.
1264
+ * Doing `mark_seen` without `clear_selection` would leave stale
1265
+ * indices pointing into a geometry that no longer matches.
1266
+ */
1267
+ reconcile_after_external_mutation(d) {
1268
+ this.mark_seen(d);
1269
+ this.clear_selection();
1270
+ }
1271
+ select_vertex(index, mode) {
1272
+ switch (mode) {
1273
+ case "replace":
1274
+ this._selected_vertices = [index];
1275
+ break;
1276
+ case "add":
1277
+ if (!this._selected_vertices.includes(index)) this._selected_vertices = [...this._selected_vertices, index];
1278
+ break;
1279
+ case "toggle":
1280
+ this._selected_vertices = this._selected_vertices.includes(index) ? this._selected_vertices.filter((v) => v !== index) : [...this._selected_vertices, index];
1281
+ break;
1282
+ }
1283
+ if (mode === "replace") {
1284
+ this._selected_segments = [];
1285
+ this._selected_tangents = [];
1286
+ }
1287
+ }
1288
+ select_segment(index, mode) {
1289
+ switch (mode) {
1290
+ case "replace":
1291
+ this._selected_segments = [index];
1292
+ break;
1293
+ case "add":
1294
+ if (!this._selected_segments.includes(index)) this._selected_segments = [...this._selected_segments, index];
1295
+ break;
1296
+ case "toggle":
1297
+ this._selected_segments = this._selected_segments.includes(index) ? this._selected_segments.filter((s) => s !== index) : [...this._selected_segments, index];
1298
+ break;
1299
+ }
1300
+ if (mode === "replace") {
1301
+ this._selected_vertices = [];
1302
+ this._selected_tangents = [];
1303
+ }
1304
+ }
1305
+ select_tangent(ref, mode) {
1306
+ const has = this._selected_tangents.some((t) => tangents_equal(t, ref));
1307
+ switch (mode) {
1308
+ case "replace":
1309
+ this._selected_tangents = [ref];
1310
+ break;
1311
+ case "add":
1312
+ if (!has) this._selected_tangents = [...this._selected_tangents, ref];
1313
+ break;
1314
+ case "toggle":
1315
+ this._selected_tangents = has ? this._selected_tangents.filter((t) => !tangents_equal(t, ref)) : [...this._selected_tangents, ref];
1316
+ break;
1317
+ }
1318
+ if (mode === "replace") {
1319
+ this._selected_vertices = [];
1320
+ this._selected_segments = [];
1321
+ }
1322
+ }
1323
+ /**
1324
+ * Replace the entire sub-selection at once. Useful for marquee /
1325
+ * lasso results, which compute the full set up-front.
1326
+ */
1327
+ set_selection(next) {
1328
+ this._selected_vertices = [...next.vertices];
1329
+ this._selected_segments = [...next.segments];
1330
+ this._selected_tangents = next.tangents.map((t) => [t[0], t[1]]);
1331
+ }
1332
+ /**
1333
+ * Capture the current sub-selection as a frozen triple. The
1334
+ * orchestrator closes over snapshots in gesture deltas (so undo
1335
+ * restores selection alongside geometry) and in standalone selection
1336
+ * deltas (so a click on a vertex is itself undoable).
1337
+ *
1338
+ * Returned arrays are fresh copies — safe to retain across
1339
+ * subsequent mutations of the session.
1340
+ */
1341
+ snapshot_selection() {
1342
+ return Object.freeze({
1343
+ vertices: Object.freeze([...this._selected_vertices]),
1344
+ segments: Object.freeze([...this._selected_segments]),
1345
+ tangents: Object.freeze(this._selected_tangents.map((t) => [t[0], t[1]]))
1346
+ });
1347
+ }
1348
+ /**
1349
+ * Restore a previously-captured sub-selection. Counterpart to
1350
+ * {@link snapshot_selection}. Equivalent to calling
1351
+ * {@link set_selection} with the snapshot's contents.
1352
+ */
1353
+ restore_selection(snap) {
1354
+ this.set_selection(snap);
1355
+ }
1356
+ clear_selection() {
1357
+ if (this._selected_vertices.length === 0 && this._selected_segments.length === 0 && this._selected_tangents.length === 0) return;
1358
+ this._selected_vertices = [];
1359
+ this._selected_segments = [];
1360
+ this._selected_tangents = [];
1361
+ }
1362
+ };
1363
+ //#endregion
1364
+ //#region src/core/vector-edit/apply.ts
1365
+ /**
1366
+ * Build the in-session path-form `d` ("session-d") for a freshly-
1367
+ * entered vector-edit. For `<path>` this is the verbatim authored
1368
+ * string; for the two vertex-chain tags we route through
1369
+ * `vn.fromPolyline` / `vn.fromPolygon` and emit via `vn.toSVGPathData`
1370
+ * — same zero-tangent `M`/`L` sequence the gesture handlers'
1371
+ * `PathModel.toSvgPathD()` will produce on subsequent commits.
1372
+ *
1373
+ * The returned string is internal to the session — it is never written
1374
+ * to the document on its own. Native-attr writeback happens through
1375
+ * {@link apply_session_d} (which calls {@link PathModel.toNativeAttrs}
1376
+ * to project the path-form geometry back to source-tag attrs).
1377
+ */
1378
+ function source_to_session_d(source) {
1379
+ switch (source.kind) {
1380
+ case "path": return source.d;
1381
+ case "polyline": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromPolyline(source.points.map((p) => [p[0], p[1]])));
1382
+ case "polygon": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromPolygon(source.points.map((p) => [p[0], p[1]])));
1383
+ }
1384
+ }
1385
+ /**
1386
+ * Tag-aware document write. Given a new path-data `d` from a gesture,
1387
+ * project it back into the source tag's native attrs and write those —
1388
+ * unless the source is `<path>`, in which case `d` is written
1389
+ * directly.
1390
+ *
1391
+ * Returns `true` on success. Returns `false` for non-path sources when
1392
+ * the model can no longer be expressed in the source tag's native attrs
1393
+ * (tangent introduced, topology change). v1 treats `false` as gesture
1394
+ * refusal — the caller should NOT fall through and write `d` on a non-
1395
+ * path element. Promotion to `<path>` lives in v1.1+.
1396
+ *
1397
+ * Symmetric across apply / revert: gesture handlers call this for both
1398
+ * the in-flight write and the undo-revert (since both are just "set the
1399
+ * geometry to this d"), so apply/revert stay consistent even when one
1400
+ * writes native attrs and the other would.
1401
+ */
1402
+ function apply_session_d(doc, node_id, source, d) {
1403
+ if (source.kind === "path") {
1404
+ doc.set_attr(node_id, "d", d);
1405
+ return true;
1406
+ }
1407
+ const native = require_model.PathModel.fromSvgPathD(d).toNativeAttrs(source.kind);
1408
+ if (native === null) return false;
1409
+ const points = native.points.map((p) => `${p[0]},${p[1]}`).join(" ");
1410
+ doc.set_attr(node_id, "points", points);
1411
+ return true;
1412
+ }
1413
+ //#endregion
1414
+ //#region src/core/vector-edit/marquee.ts
1415
+ let marquee;
1416
+ (function(_marquee) {
1417
+ function points_in_rect(candidates, rect) {
1418
+ const hits = [];
1419
+ for (const c of candidates) if (_grida_cmath.default.rect.containsPoint(rect, c.pos)) hits.push(c.key);
1420
+ return hits;
1421
+ }
1422
+ _marquee.points_in_rect = points_in_rect;
1423
+ function points_in_polygon(candidates, polygon) {
1424
+ const hits = [];
1425
+ const poly = polygon;
1426
+ for (const c of candidates) if (_grida_cmath.default.polygon.pointInPolygon(c.pos, poly)) hits.push(c.key);
1427
+ return hits;
1428
+ }
1429
+ _marquee.points_in_polygon = points_in_polygon;
1430
+ function subpath_select_candidates(model, selection, to_doc = identity_proj) {
1431
+ const snap = model.snapshot();
1432
+ const vertices = snap.vertices.map((v, i) => ({
1433
+ key: i,
1434
+ pos: to_doc(v)
1435
+ }));
1436
+ const neigh_set = new Set(model.neighbouringVertices(selection));
1437
+ const tangents = [];
1438
+ for (let si = 0; si < snap.segments.length; si++) {
1439
+ const s = snap.segments[si];
1440
+ if (neigh_set.has(s.a)) {
1441
+ const va = snap.vertices[s.a];
1442
+ tangents.push({
1443
+ key: [s.a, 0],
1444
+ pos: to_doc([va[0] + s.ta[0], va[1] + s.ta[1]])
1445
+ });
1446
+ }
1447
+ if (neigh_set.has(s.b)) {
1448
+ const vb = snap.vertices[s.b];
1449
+ tangents.push({
1450
+ key: [s.b, 1],
1451
+ pos: to_doc([vb[0] + s.tb[0], vb[1] + s.tb[1]])
1452
+ });
1453
+ }
1454
+ }
1455
+ return {
1456
+ vertices,
1457
+ tangents,
1458
+ segments: Array.from({ length: snap.segments.length }, (_, i) => i)
1459
+ };
1460
+ }
1461
+ _marquee.subpath_select_candidates = subpath_select_candidates;
1462
+ function identity_proj(p) {
1463
+ return p;
1464
+ }
1465
+ function merge_subpath_hits(prev, hits, additive) {
1466
+ if (!additive) return {
1467
+ vertices: [...hits.vertices],
1468
+ segments: [...hits.segments],
1469
+ tangents: hits.tangents.map((t) => [t[0], t[1]])
1470
+ };
1471
+ const vertices = Array.from(new Set([...prev.vertices, ...hits.vertices]));
1472
+ const segments = Array.from(new Set([...prev.segments, ...hits.segments]));
1473
+ const tangents = prev.tangents.map((t) => [t[0], t[1]]);
1474
+ for (const t of hits.tangents) if (!tangents.some((x) => x[0] === t[0] && x[1] === t[1])) tangents.push(t);
1475
+ return {
1476
+ vertices,
1477
+ segments,
1478
+ tangents
1479
+ };
1480
+ }
1481
+ _marquee.merge_subpath_hits = merge_subpath_hits;
1482
+ })(marquee || (marquee = {}));
1483
+ //#endregion
1680
1484
  //#region src/dom.ts
1681
1485
  /** Stamped on every rendered SVG element by `render()` so external
1682
1486
  * tooling (host inspectors, the layers panel, snapshot tests) can map
@@ -1717,6 +1521,11 @@ const IS_MODIFIER_KEY = {
1717
1521
  * surface skips render() during the in-flight mount and doesn't yank the
1718
1522
  * live `<text>` element out from under the about-to-mount text surface. */
1719
1523
  const TEXT_EDIT_PENDING = { __pending: true };
1524
+ /** Per-frame `neighbours: []` for the `vector_of` HUD projection. Polyline
1525
+ * and polygon sources never render tangent handles in v1 (curve edits would
1526
+ * promote to `<path>`); using a frozen module-level array avoids allocating
1527
+ * a fresh empty array per redraw frame. */
1528
+ const EMPTY_NEIGHBOURS = Object.freeze([]);
1720
1529
  /**
1721
1530
  * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
1722
1531
  * whose `detach()` is the inverse — DOM cleared, listeners removed,
@@ -1758,12 +1567,17 @@ var DomSurface = class DomSurface {
1758
1567
  this.text_edit = null;
1759
1568
  this.text_edit_target = null;
1760
1569
  this.text_edit_original = "";
1761
- this.current_tool = require_insertions.TOOL_CURSOR;
1570
+ this.pending_text_insert = null;
1571
+ this.vector_edit = null;
1572
+ this.vector_edit_region_baseline = null;
1573
+ this.current_tool = require_model.TOOL_CURSOR;
1762
1574
  this.pending_insert = null;
1763
1575
  this.editor_hover_internal = null;
1764
1576
  this.container = options.container;
1765
1577
  const container = this.container;
1766
1578
  this.fit_on_attach = options.fit === true;
1579
+ this.attention = create_attention_tracker(container);
1580
+ this.teardown.push(() => this.attention.dispose());
1767
1581
  if (process.env.NODE_ENV !== "production" && container.children.length > 0) console.warn("@grida/svg-editor: surface container is not empty at attach time. Render chrome (toolbars, layer lists, inspectors) as siblings of the container, not children — otherwise clicks on those children will silently break. See README §Surface.");
1768
1582
  if (getComputedStyle(container).position === "static") container.style.position = "relative";
1769
1583
  container.style.userSelect = "none";
@@ -1777,7 +1591,7 @@ var DomSurface = class DomSurface {
1777
1591
  snap_threshold_px: style.snap_threshold_px / zoom
1778
1592
  };
1779
1593
  };
1780
- this.translate_orchestrator = new require_insertions.TranslateOrchestrator({
1594
+ this.translate_orchestrator = new require_model.TranslateOrchestrator({
1781
1595
  get_doc: () => this.editor_internal().doc,
1782
1596
  emit: () => this.editor_internal().emit(),
1783
1597
  open_preview: (label) => this.editor_internal().history.preview(label),
@@ -1793,7 +1607,7 @@ var DomSurface = class DomSurface {
1793
1607
  snap_threshold_px: style.snap_threshold_px / zoom
1794
1608
  };
1795
1609
  };
1796
- this.resize_orchestrator = new ResizeOrchestrator({
1610
+ this.resize_orchestrator = new require_model.ResizeOrchestrator({
1797
1611
  get_doc: () => this.editor_internal().doc,
1798
1612
  emit: () => this.editor_internal().emit(),
1799
1613
  open_preview: (label) => this.editor_internal().history.preview(label),
@@ -1807,7 +1621,7 @@ var DomSurface = class DomSurface {
1807
1621
  }
1808
1622
  });
1809
1623
  const rotate_options = () => ({ angle_snap_step_radians: this.editor.style.angle_snap_step_radians });
1810
- this.rotate_orchestrator = new require_insertions.RotateOrchestrator({
1624
+ this.rotate_orchestrator = new require_model.RotateOrchestrator({
1811
1625
  get_doc: () => this.editor_internal().doc,
1812
1626
  emit: () => this.editor_internal().emit(),
1813
1627
  open_preview: (label) => this.editor_internal().history.preview(label),
@@ -1829,7 +1643,7 @@ var DomSurface = class DomSurface {
1829
1643
  },
1830
1644
  subscribe_translate_commit: (cb) => this.editor_internal().subscribe_translate_commit(cb)
1831
1645
  };
1832
- this.nudge_dwell_watcher = new require_insertions.NudgeDwellWatcher({
1646
+ this.nudge_dwell_watcher = new require_model.NudgeDwellWatcher({
1833
1647
  editor: editor_for_watcher,
1834
1648
  open_snap: (ids) => this.open_snap_session_for(ids),
1835
1649
  options: translate_options,
@@ -1848,6 +1662,7 @@ var DomSurface = class DomSurface {
1848
1662
  this.hud = new _grida_hud.Surface(this.hud_canvas, {
1849
1663
  pick: (p) => this.hit_test(p[0], p[1]),
1850
1664
  shapeOf: (id) => this.shape_of(id),
1665
+ vectorOf: (id) => this.vector_of(id),
1851
1666
  onIntent: (i) => this.commit_intent(i),
1852
1667
  style: {
1853
1668
  chromeColor: editor.style.chrome_color,
@@ -1900,11 +1715,14 @@ var DomSurface = class DomSurface {
1900
1715
  hud_canvas: this.hud_canvas,
1901
1716
  camera: this.camera,
1902
1717
  editor,
1903
- handle: { detach: () => {} }
1718
+ handle: { detach: () => {} },
1719
+ is_attended: () => this.attention.is_attended()
1904
1720
  });
1905
1721
  if (options.gestures !== false) applyDefaultGestures(this.gestures);
1906
1722
  const unsub = editor.subscribe(() => {
1907
1723
  this.current_tool = editor.state.tool;
1724
+ this.hud.setVectorSelectionMode(this.current_tool.type === "lasso" ? "lasso" : "marquee");
1725
+ this.hud.setVectorBendMode(this.current_tool.type === "bend" ? "always" : "auto");
1908
1726
  this.render();
1909
1727
  this.sync_surface_selection();
1910
1728
  this.hud.setPixelGrid({
@@ -1921,6 +1739,23 @@ var DomSurface = class DomSurface {
1921
1739
  this.pending_insert = null;
1922
1740
  }
1923
1741
  }
1742
+ if (this.vector_edit && !this.active_preview) {
1743
+ const ses = this.vector_edit;
1744
+ const live_source = this.editor_internal().doc.is_vector_edit_target(ses.node_id);
1745
+ const had_selection = ses.selected_vertices.length > 0 || ses.selected_segments.length > 0 || ses.selected_tangents.length > 0;
1746
+ if (live_source === null || live_source.kind !== ses.source.kind) {
1747
+ if (had_selection) {
1748
+ ses.clear_selection();
1749
+ this.sync_selection_mirror();
1750
+ }
1751
+ } else {
1752
+ const live_d = source_to_session_d(live_source);
1753
+ if (live_d !== ses.last_seen_d) {
1754
+ ses.reconcile_after_external_mutation(live_d);
1755
+ if (had_selection) this.sync_selection_mirror();
1756
+ }
1757
+ }
1758
+ }
1924
1759
  this.request_redraw();
1925
1760
  });
1926
1761
  this.teardown.push(unsub);
@@ -1954,7 +1789,7 @@ var DomSurface = class DomSurface {
1954
1789
  if (computed === "") return null;
1955
1790
  return {
1956
1791
  computed,
1957
- resolved_paint: require_insertions.parse_paint(computed)
1792
+ resolved_paint: require_model.paint.parse(computed)
1958
1793
  };
1959
1794
  }
1960
1795
  });
@@ -2006,8 +1841,8 @@ var DomSurface = class DomSurface {
2006
1841
  });
2007
1842
  this.teardown.push(() => internal.set_surface_hover_override_driver(null));
2008
1843
  }
2009
- paint(_snapshot) {}
2010
1844
  hit_test(x, y) {
1845
+ if (this.vector_edit) return null;
2011
1846
  return this.pick_at(x, y, false);
2012
1847
  }
2013
1848
  /** Element-walk under (x, y) → first ancestor with `ID_ATTR`. When
@@ -2035,7 +1870,7 @@ var DomSurface = class DomSurface {
2035
1870
  * `<text>` / `<use>` / transformed nodes). Tolerance is screen-
2036
1871
  * CSS-px, converted to world units via `camera.zoom` so the band
2037
1872
  * stays the same width on screen regardless of zoom. Has known
2038
- * issues — see `docs/wg/feat-svg-editor/hit-test.md`.
1873
+ * issues — see https://grida.co/docs/wg/feat-svg-editor/hit-test.
2039
1874
  *
2040
1875
  * - **`<= 0` (legacy elementFromPoint)** — opt-out of the cmath
2041
1876
  * picker. Uses the browser's painted-pixel hit-test plus a
@@ -2113,15 +1948,17 @@ var DomSurface = class DomSurface {
2113
1948
  if (this._z_order_cache.length > 0 && this._z_order_cache[0] === root_id) return this._z_order_cache.slice(1);
2114
1949
  return this._z_order_cache.filter((id) => id !== root_id);
2115
1950
  }
2116
- on_input(_listener) {
2117
- return () => {};
2118
- }
2119
1951
  dispose() {
2120
1952
  if (this.text_edit) {
2121
1953
  this.text_edit.cancel();
2122
1954
  this.text_edit = null;
2123
1955
  this.text_edit_target = null;
2124
1956
  }
1957
+ if (this.vector_edit) {
1958
+ this.vector_edit = null;
1959
+ this.vector_edit_region_baseline = null;
1960
+ this.hud.setVectorSelection(null);
1961
+ }
2125
1962
  this.gestures._dispose();
2126
1963
  this.translate_orchestrator.cancel();
2127
1964
  this.resize_orchestrator.cancel();
@@ -2189,6 +2026,17 @@ var DomSurface = class DomSurface {
2189
2026
  style.left = "0";
2190
2027
  style.top = "0";
2191
2028
  style.transformOrigin = "0 0";
2029
+ const vb = this.svg_root.getAttribute("viewBox");
2030
+ if (vb) {
2031
+ const parts = vb.split(/[\s,]+/).map(Number);
2032
+ if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
2033
+ const [, , w, h] = parts;
2034
+ if (w > 0 && h > 0) {
2035
+ style.width = `${w}px`;
2036
+ style.height = `${h}px`;
2037
+ }
2038
+ }
2039
+ }
2192
2040
  }
2193
2041
  /**
2194
2042
  * Push the current camera transform to the SVG as a CSS `matrix(...)`.
@@ -2836,6 +2684,17 @@ var DomSurface = class DomSurface {
2836
2684
  }
2837
2685
  }
2838
2686
  }
2687
+ if (tool.type === "insert-text") {
2688
+ if (kind === "pointer_down" && e.button === 0) {
2689
+ const world = this.camera.screen_to_world({
2690
+ x,
2691
+ y
2692
+ });
2693
+ this.editor.set_tool({ type: "cursor" });
2694
+ this.begin_text_insert(world);
2695
+ return;
2696
+ }
2697
+ }
2839
2698
  const button = e.button === 0 ? "primary" : e.button === 2 ? "secondary" : "middle";
2840
2699
  let event;
2841
2700
  if (kind === "pointer_move") event = {
@@ -2894,7 +2753,7 @@ var DomSurface = class DomSurface {
2894
2753
  }
2895
2754
  /** Transition `armed` → `drawing`: open `insert_preview` + snap session. */
2896
2755
  arm_to_draw(armed) {
2897
- const session = this.editor.commands.insert_preview(armed.tag, require_insertions.initial_attrs(armed.tag, armed.anchor));
2756
+ const session = this.editor.commands.insert_preview(armed.tag, require_model.insertions.initial_attrs(armed.tag, armed.anchor));
2898
2757
  const snap_session = this.editor.style.snap_enabled && (armed.tag === "rect" || armed.tag === "ellipse") ? this.open_snap_session_for([session.id]) : null;
2899
2758
  this.pending_insert = {
2900
2759
  phase: "drawing",
@@ -2916,7 +2775,7 @@ var DomSurface = class DomSurface {
2916
2775
  shift: mods.shift,
2917
2776
  alt: mods.alt
2918
2777
  };
2919
- drawing.session.update(require_insertions.compute_drag_attrs(drawing.tag, drawing.anchor, world, dm));
2778
+ drawing.session.update(require_model.insertions.compute_drag_attrs(drawing.tag, drawing.anchor, world, dm));
2920
2779
  }
2921
2780
  /** Commit on pointer-up. `armed` → one-shot `commands.insert` with
2922
2781
  * `default_attrs` (click-no-drag, never touches the IR mid-gesture).
@@ -2924,7 +2783,7 @@ var DomSurface = class DomSurface {
2924
2783
  commit_insert_gesture(screen_pt, mods) {
2925
2784
  const cur = this.pending_insert;
2926
2785
  if (!cur) return;
2927
- if (cur.phase === "armed") this.editor.commands.insert(cur.tag, require_insertions.default_attrs(cur.tag, cur.anchor));
2786
+ if (cur.phase === "armed") this.editor.commands.insert(cur.tag, require_model.insertions.default_attrs(cur.tag, cur.anchor));
2928
2787
  else {
2929
2788
  this.push_drawing_frame(cur, screen_pt, mods);
2930
2789
  cur.session.commit();
@@ -2975,11 +2834,20 @@ var DomSurface = class DomSurface {
2975
2834
  this.container.style.cursor = "crosshair";
2976
2835
  return;
2977
2836
  }
2837
+ if (this.current_tool.type === "insert-text") {
2838
+ this.container.style.cursor = "text";
2839
+ return;
2840
+ }
2978
2841
  this.container.style.cursor = this.hud.cursorCss();
2979
2842
  }
2980
2843
  on_keydown(e) {
2981
2844
  if (this.text_edit) return;
2982
- if (e.code === "Escape") this.cancel_in_flight();
2845
+ if (e.code !== "Escape" && !this.attention.is_attended()) return;
2846
+ if (e.code === "Escape") {
2847
+ const canceled = this.cancel_in_flight();
2848
+ if (!this.attention.is_attended()) return;
2849
+ if (!canceled && this.vector_edit) this.exit_vector_edit();
2850
+ }
2983
2851
  if ((this.active_preview || this.translate_orchestrator.has_active_session() || this.resize_orchestrator.has_active_session() || this.rotate_orchestrator.has_active_session() || this.pending_insert) && e.code !== "Escape") return;
2984
2852
  if (this.editor.keymap.claims(e)) e.preventDefault();
2985
2853
  this.editor.keymap.dispatch(e);
@@ -2992,6 +2860,15 @@ var DomSurface = class DomSurface {
2992
2860
  case "deselect_all":
2993
2861
  this.editor.commands.deselect();
2994
2862
  return;
2863
+ case "clear_vector_selection": {
2864
+ if (!this.vector_edit) return;
2865
+ const before = this.vector_edit.snapshot_selection();
2866
+ this.vector_edit.clear_selection();
2867
+ this.sync_selection_mirror();
2868
+ this.redraw();
2869
+ this.record_vector_selection_change(before, "clear vector selection");
2870
+ return;
2871
+ }
2995
2872
  case "translate":
2996
2873
  this.handle_translate(intent);
2997
2874
  return;
@@ -3004,6 +2881,9 @@ var DomSurface = class DomSurface {
3004
2881
  case "marquee_select":
3005
2882
  this.handle_marquee(intent);
3006
2883
  return;
2884
+ case "lasso_select":
2885
+ this.handle_lasso_select(intent);
2886
+ return;
3007
2887
  case "set_endpoint":
3008
2888
  this.handle_set_endpoint(intent);
3009
2889
  return;
@@ -3011,6 +2891,33 @@ var DomSurface = class DomSurface {
3011
2891
  this.editor.commands.select(intent.id);
3012
2892
  this.editor.enter_content_edit(intent.id);
3013
2893
  return;
2894
+ case "exit_content_edit":
2895
+ this.exit_vector_edit();
2896
+ return;
2897
+ case "select_vertex":
2898
+ this.handle_select_vertex(intent);
2899
+ return;
2900
+ case "translate_vertices":
2901
+ this.handle_translate_vertices(intent);
2902
+ return;
2903
+ case "translate_vector_selection":
2904
+ this.handle_translate_vector_selection(intent);
2905
+ return;
2906
+ case "select_segment":
2907
+ this.handle_select_segment(intent);
2908
+ return;
2909
+ case "select_tangent":
2910
+ this.handle_select_tangent(intent);
2911
+ return;
2912
+ case "set_tangent":
2913
+ this.handle_set_tangent_intent(intent);
2914
+ return;
2915
+ case "split_segment":
2916
+ this.handle_split_segment(intent);
2917
+ return;
2918
+ case "bend_segment":
2919
+ this.handle_bend_segment(intent);
2920
+ return;
3014
2921
  case "cancel_gesture":
3015
2922
  this.cancel_in_flight();
3016
2923
  return;
@@ -3050,7 +2957,7 @@ var DomSurface = class DomSurface {
3050
2957
  }
3051
2958
  handle_resize(intent) {
3052
2959
  if (intent.ids.length === 0) return;
3053
- for (const id of intent.ids) if (!require_insertions.is_resizable_node(this.editor.document, id)) return;
2960
+ for (const id of intent.ids) if (!require_model.resize_pipeline.intent.is_resizable_node(this.editor.document, id)) return;
3054
2961
  const dir = intent.anchor;
3055
2962
  let target_width;
3056
2963
  let target_height;
@@ -3098,9 +3005,7 @@ var DomSurface = class DomSurface {
3098
3005
  for (const v of verdicts.values()) {
3099
3006
  if (v.kind !== "refuse") continue;
3100
3007
  const message = v.reason === "non-trivial-transform" ? "Cannot rotate cleanly — element has a composite transform. Use Flatten Transform first." : v.reason === "text-with-glyph-rotate" ? "Cannot rotate — text has per-glyph rotation. Edit `rotate=` or remove it first." : v.reason === "css-property-transform" ? "Cannot rotate — transform is set via CSS. Move the declaration to the `transform` attribute first." : "Cannot rotate — element has an animated transform. Remove `<animateTransform>` first.";
3101
- const hud = this.hud;
3102
- if (typeof hud.setTransientToast === "function") hud.setTransientToast(message);
3103
- else console.warn(`[svg-editor] ${message}`);
3008
+ console.warn(`[svg-editor] ${message}`);
3104
3009
  return;
3105
3010
  }
3106
3011
  }
@@ -3185,7 +3090,28 @@ var DomSurface = class DomSurface {
3185
3090
  y: t.y
3186
3091
  };
3187
3092
  }
3093
+ /**
3094
+ * Capture the vector-edit sub-selection on the first region-gesture
3095
+ * preview, and reuse it for every subsequent preview + the commit.
3096
+ * Anchors additive merging and tangent-candidate eligibility to the
3097
+ * gesture-start state so they don't shift mid-drag. Shared by marquee
3098
+ * and lasso — both gestures emit a preview-per-move, both consume the
3099
+ * same baseline. See `vector_edit_region_baseline` doc-comment for the
3100
+ * full rationale. Caller must have a non-null `this.vector_edit`.
3101
+ */
3102
+ ensure_region_baseline() {
3103
+ if (!this.vector_edit_region_baseline) this.vector_edit_region_baseline = {
3104
+ vertices: this.vector_edit.selected_vertices.slice(),
3105
+ segments: this.vector_edit.selected_segments.slice(),
3106
+ tangents: this.vector_edit.selected_tangents.map((t) => [t[0], t[1]])
3107
+ };
3108
+ return this.vector_edit_region_baseline;
3109
+ }
3188
3110
  handle_marquee(intent) {
3111
+ if (this.vector_edit) {
3112
+ this.handle_marquee_vectors(intent);
3113
+ return;
3114
+ }
3189
3115
  if (intent.phase !== "commit") return;
3190
3116
  const ids = [];
3191
3117
  for (const id of this.element_index.keys()) {
@@ -3200,36 +3126,190 @@ var DomSurface = class DomSurface {
3200
3126
  }
3201
3127
  this.editor.commands.select(ids, { mode: intent.additive ? "add" : "replace" });
3202
3128
  }
3203
- enter_content_edit(id) {
3204
- if (this.text_edit) return false;
3205
- const el = this.element_index.get(id);
3206
- if (!(el instanceof SVGElement)) return false;
3207
- const doc = this.editor._internal;
3208
- if (!(el instanceof SVGTextContentElement)) return false;
3209
- this.text_edit_target = id;
3210
- this.text_edit_original = doc.doc.text_of(id);
3211
- this.text_edit = TEXT_EDIT_PENDING;
3212
- this.editor.commands.set_mode("edit-content");
3213
- this.sync_surface_selection();
3214
- this.sync_cursor();
3215
- this.redraw();
3216
- const text_surface = new SvgTextSurface(this.element_index.get(id) ?? el);
3217
- const is_mac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
3218
- let settled = false;
3219
- const cleanup_after_commit_or_cancel = () => {
3220
- this.editor.commands.set_mode("select");
3221
- this.render();
3222
- this.sync_surface_selection();
3223
- this.sync_cursor();
3224
- this.redraw();
3225
- this.text_edit = null;
3226
- this.text_edit_target = null;
3129
+ /**
3130
+ * Vector marquee predicate — applies the **vertex-priority precedence
3131
+ * rule** ported from the main editor:
3132
+ *
3133
+ * 1. Vertices: keep those whose container-space position falls inside
3134
+ * the marquee rect.
3135
+ * 2. Tangents: keep only those at the (new) selection's neighbouring
3136
+ * vertices whose control point falls inside the rect.
3137
+ * 3. Segments: keep only segments **fully contained** in the rect AND
3138
+ * whose endpoints are NOT among the selected vertices. (If a vertex
3139
+ * is selected, the segments meeting it would double-credit the
3140
+ * drag, so they're dropped — "vertex priority.")
3141
+ *
3142
+ * The rect comes in container CSS-px (HUD's frame). We project it back
3143
+ * to path-local by applying the inverse-CTM linear part to its size and
3144
+ * shifting its position via `project_delta_inverse_ctm` against the
3145
+ * top-left in doc-space but a cleaner approach is to project each
3146
+ * vertex/tangent/segment-sample into doc-space and test against the rect
3147
+ * directly. For consistency with how `vector_of` projects, we do the
3148
+ * "project geometry to doc-space, test against doc-space rect" approach.
3149
+ */
3150
+ handle_marquee_vectors(intent) {
3151
+ if (!this.vector_edit) return;
3152
+ const node_id = this.vector_edit.node_id;
3153
+ const el = this.element_index.get(node_id);
3154
+ if (!(el instanceof SVGGraphicsElement)) return;
3155
+ if (typeof el.getScreenCTM !== "function") return;
3156
+ const ctm = el.getScreenCTM();
3157
+ if (!ctm) return;
3158
+ const cr = this.container.getBoundingClientRect();
3159
+ const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3160
+ const model = this.session_model();
3161
+ if (!model) return;
3162
+ const rect = intent.rect;
3163
+ const baseline = this.ensure_region_baseline();
3164
+ const pre_selection = {
3165
+ vertices: baseline.vertices,
3166
+ segments: baseline.segments,
3167
+ tangents: baseline.tangents
3227
3168
  };
3228
- this.text_edit = (0, _grida_text_editor_dom.createTextEditor)({
3229
- container: this.container,
3230
- initialText: this.text_edit_original,
3231
- layout: text_surface,
3232
- surface: text_surface,
3169
+ const candidates = marquee.subpath_select_candidates(model, pre_selection, (p) => project_point_through_ctm(p[0], p[1], ctm, offset));
3170
+ const vertex_hits = marquee.points_in_rect(candidates.vertices, rect);
3171
+ const tangent_hits = marquee.points_in_rect(candidates.tangents, rect);
3172
+ const rect_local = inverse_project_rect(rect, ctm, offset);
3173
+ const vertex_hit_set = new Set(vertex_hits);
3174
+ const segment_hits = [];
3175
+ if (rect_local) {
3176
+ const segs = model.snapshot().segments;
3177
+ for (const sid of candidates.segments) {
3178
+ const s = segs[sid];
3179
+ if (vertex_hit_set.has(s.a) || vertex_hit_set.has(s.b)) continue;
3180
+ if (model.segmentContainedByRect(sid, rect_local)) segment_hits.push(sid);
3181
+ }
3182
+ }
3183
+ const merged = marquee.merge_subpath_hits(pre_selection, {
3184
+ vertices: vertex_hits,
3185
+ segments: segment_hits,
3186
+ tangents: tangent_hits
3187
+ }, intent.additive);
3188
+ this.vector_edit.set_selection(merged);
3189
+ this.sync_selection_mirror();
3190
+ if (intent.phase === "commit") {
3191
+ const baseline_snapshot = Object.freeze({
3192
+ vertices: baseline.vertices,
3193
+ segments: baseline.segments,
3194
+ tangents: baseline.tangents
3195
+ });
3196
+ this.vector_edit_region_baseline = null;
3197
+ this.record_vector_selection_change(baseline_snapshot, "marquee select");
3198
+ }
3199
+ this.redraw();
3200
+ }
3201
+ /**
3202
+ * Lasso (freeform polygon) sub-selection — vector analogue of
3203
+ * `handle_marquee`. Per the main editor's decision
3204
+ * (editor/grida-canvas/reducers/methods/vector.ts:163–291 +
3205
+ * event-target.reducer.ts:629–641) lasso targets **vertices and
3206
+ * tangents only** — segments are NOT tested against the polygon. The
3207
+ * segment-vs-region test is rect-only and lives in the marquee path.
3208
+ *
3209
+ * Lifecycle / baseline behaviour matches marquee: snapshot on first
3210
+ * preview, reuse for additive merge and tangent-eligibility, clear on
3211
+ * commit and on vector-edit exit. Scene (non-vector-edit) lasso is a
3212
+ * follow-up.
3213
+ */
3214
+ handle_lasso_select(intent) {
3215
+ if (!this.vector_edit) return;
3216
+ const node_id = this.vector_edit.node_id;
3217
+ const el = this.element_index.get(node_id);
3218
+ if (!(el instanceof SVGGraphicsElement)) return;
3219
+ if (typeof el.getScreenCTM !== "function") return;
3220
+ const ctm = el.getScreenCTM();
3221
+ if (!ctm) return;
3222
+ const cr = this.container.getBoundingClientRect();
3223
+ const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3224
+ const model = this.session_model();
3225
+ if (!model) return;
3226
+ const polygon = intent.polygon;
3227
+ if (polygon.length < 3) return;
3228
+ const baseline = this.ensure_region_baseline();
3229
+ const pre_selection = {
3230
+ vertices: baseline.vertices,
3231
+ segments: baseline.segments,
3232
+ tangents: baseline.tangents
3233
+ };
3234
+ const candidates = marquee.subpath_select_candidates(model, pre_selection, (p) => project_point_through_ctm(p[0], p[1], ctm, offset));
3235
+ const vertex_hits = marquee.points_in_polygon(candidates.vertices, polygon);
3236
+ const tangent_hits = marquee.points_in_polygon(candidates.tangents, polygon);
3237
+ const merged = marquee.merge_subpath_hits(pre_selection, {
3238
+ vertices: vertex_hits,
3239
+ segments: [],
3240
+ tangents: tangent_hits
3241
+ }, intent.additive);
3242
+ this.vector_edit.set_selection(merged);
3243
+ this.sync_selection_mirror();
3244
+ if (intent.phase === "commit") {
3245
+ const baseline_snapshot = Object.freeze({
3246
+ vertices: baseline.vertices,
3247
+ segments: baseline.segments,
3248
+ tangents: baseline.tangents
3249
+ });
3250
+ this.vector_edit_region_baseline = null;
3251
+ this.record_vector_selection_change(baseline_snapshot, "lasso select");
3252
+ }
3253
+ this.redraw();
3254
+ }
3255
+ /**
3256
+ * Dispatched by the editor when `enter_content_edit(id)` is called. The
3257
+ * editor has already gated on text-OR-path eligibility; this method
3258
+ * routes on the actual tag.
3259
+ */
3260
+ enter_content_edit(id) {
3261
+ if (this.text_edit || this.vector_edit) return false;
3262
+ const tag = this.tag_of(id);
3263
+ if (tag === "text" || tag === "tspan") return this.enter_text_edit(id);
3264
+ if (tag === "path" || tag === "polyline" || tag === "polygon") return this.enter_vector_edit(id);
3265
+ return false;
3266
+ }
3267
+ /**
3268
+ * Place a new single-line `<text>` at `world` and immediately enter
3269
+ * content-edit on it. Creation + first edit are bracketed in one history
3270
+ * preview (via `insert_text_preview`): committing with content is one
3271
+ * undo step, exiting empty discards the node entirely (empty-equals-
3272
+ * delete). The bracket is finalized in {@link enter_text_edit}'s
3273
+ * commit/cancel callbacks via `this.pending_text_insert`.
3274
+ *
3275
+ * Default font appearance lives in `core/insertions.ts`
3276
+ * (`default_text_attrs`), alongside the shape insertion defaults — not
3277
+ * hard-coded here — so the per-element insert semantics stay in core (P3).
3278
+ */
3279
+ begin_text_insert(world) {
3280
+ if (this.text_edit || this.vector_edit) return;
3281
+ const session = this.editor_internal().insert_text_preview(require_model.insertions.default_text_attrs(world));
3282
+ this.pending_text_insert = {
3283
+ id: session.id,
3284
+ session
3285
+ };
3286
+ this.editor.enter_content_edit(session.id);
3287
+ if (this.text_edit_target !== session.id) {
3288
+ this.pending_text_insert = null;
3289
+ session.discard();
3290
+ }
3291
+ }
3292
+ enter_text_edit(id) {
3293
+ if (this.text_edit) return false;
3294
+ const el = this.element_index.get(id);
3295
+ if (!(el instanceof SVGElement)) return false;
3296
+ const doc = this.editor._internal;
3297
+ if (!(el instanceof SVGTextContentElement)) return false;
3298
+ this.text_edit_target = id;
3299
+ this.text_edit_original = doc.doc.text_of(id);
3300
+ this.text_edit = TEXT_EDIT_PENDING;
3301
+ this.editor.commands.set_mode("edit-content");
3302
+ this.sync_surface_selection();
3303
+ this.sync_cursor();
3304
+ this.redraw();
3305
+ const text_surface = new SvgTextSurface(this.element_index.get(id) ?? el);
3306
+ const is_mac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
3307
+ let settled = false;
3308
+ this.text_edit = (0, _grida_text_editor_dom.createTextEditor)({
3309
+ container: this.container,
3310
+ initialText: this.text_edit_original,
3311
+ layout: text_surface,
3312
+ surface: text_surface,
3233
3313
  isMac: is_mac,
3234
3314
  ariaLabel: "edit svg text",
3235
3315
  requiresMutationsForCommit: (text) => /\s{2,}|^\s|\s$/.test(text),
@@ -3240,16 +3320,12 @@ var DomSurface = class DomSurface {
3240
3320
  onCommit: (final_text) => {
3241
3321
  if (settled) return;
3242
3322
  settled = true;
3243
- doc.doc.set_text(id, this.text_edit_original);
3244
- cleanup_after_commit_or_cancel();
3245
- if (final_text !== this.text_edit_original) this.editor.commands.set_text(final_text);
3323
+ this.finalize_text_exit(id, final_text);
3246
3324
  },
3247
3325
  onCancel: () => {
3248
3326
  if (settled) return;
3249
3327
  settled = true;
3250
- doc.doc.set_text(id, this.text_edit_original);
3251
- cleanup_after_commit_or_cancel();
3252
- doc.emit();
3328
+ this.finalize_text_exit(id, this.text_edit_original);
3253
3329
  },
3254
3330
  onUndoFallthrough: () => {
3255
3331
  this.text_edit?.commit();
@@ -3263,6 +3339,800 @@ var DomSurface = class DomSurface {
3263
3339
  });
3264
3340
  return true;
3265
3341
  }
3342
+ /**
3343
+ * Exit inline text-edit back to select mode. P4 — observers should see a
3344
+ * consistent post-edit state, so do all observable mutations + surface
3345
+ * syncs first, then clear the text-edit handles last (so anything polling
3346
+ * "is text-edit active?" still says yes until the world is settled).
3347
+ */
3348
+ cleanup_text_edit() {
3349
+ this.editor.commands.set_mode("select");
3350
+ this.render();
3351
+ this.sync_surface_selection();
3352
+ this.sync_cursor();
3353
+ this.redraw();
3354
+ this.text_edit = null;
3355
+ this.text_edit_target = null;
3356
+ }
3357
+ /**
3358
+ * Realize the result of a text content-edit session and exit. `result`
3359
+ * is the text that should remain — the typed text on commit, the original
3360
+ * on cancel. Implements the empty-equals-delete rule (design:
3361
+ * docs/wg/feat-svg-editor/text-tool.md): an empty result removes the node.
3362
+ * (see test/svg-editor-text-empty-delete.md)
3363
+ *
3364
+ * The empty-equals-delete decision is pure and lives in
3365
+ * {@link resolve_text_exit} (`core/text-edit.ts`) — tested headlessly.
3366
+ * This method is the thin dispatcher that realizes each action against
3367
+ * the surface + editor.
3368
+ */
3369
+ finalize_text_exit(id, result) {
3370
+ const internal = this.editor_internal();
3371
+ const insert = this.pending_text_insert;
3372
+ this.pending_text_insert = null;
3373
+ const action = resolve_text_exit({
3374
+ origin: insert && insert.id === id ? "fresh" : "existing",
3375
+ result,
3376
+ original: this.text_edit_original
3377
+ });
3378
+ if (action.kind === "commit_insert" || action.kind === "discard_insert") {
3379
+ if (!insert) return;
3380
+ if (action.kind === "discard_insert") {
3381
+ this.cleanup_text_edit();
3382
+ insert.session.discard();
3383
+ } else {
3384
+ this.cleanup_text_edit();
3385
+ insert.session.commit();
3386
+ }
3387
+ return;
3388
+ }
3389
+ internal.doc.set_text(id, this.text_edit_original);
3390
+ this.cleanup_text_edit();
3391
+ switch (action.kind) {
3392
+ case "remove":
3393
+ this.editor.commands.select(id);
3394
+ this.editor.commands.remove();
3395
+ break;
3396
+ case "set_text":
3397
+ this.editor.commands.set_text(action.value);
3398
+ break;
3399
+ case "noop":
3400
+ internal.emit();
3401
+ break;
3402
+ default:
3403
+ }
3404
+ }
3405
+ /**
3406
+ * Enter path-vertex-edit mode. Mirrors `enter_text_edit` shape: capture
3407
+ * the original `d`, flip the editor mode, push a vector-selection mirror
3408
+ * to the HUD, and start serving `vectorOf` from the live session.
3409
+ *
3410
+ * Exit happens via `exit_vector_edit` (Esc / `set_mode("select")` /
3411
+ * dblclick away). On exit, any in-flight preview is discarded; committed
3412
+ * intermediate states stay (each gesture's commit was its own history
3413
+ * entry, per the gesture-bracketed history doctrine).
3414
+ *
3415
+ * The enter is itself a history step (tagged `vector-mode-enter`) so
3416
+ * undo from inside vector-edit reverts the entry — symmetric to the
3417
+ * `vector-mode-exit` push in {@link exit_vector_edit}. Both deltas
3418
+ * delegate to the unchecked {@link _do_enter_vector_edit} /
3419
+ * {@link _do_exit_vector_edit} helpers; the public wrappers below own
3420
+ * the history side, the unchecked helpers own the state mutation.
3421
+ */
3422
+ enter_vector_edit(id) {
3423
+ if (this.vector_edit) return false;
3424
+ if (!this._do_enter_vector_edit(id)) return false;
3425
+ const internal = this.editor_internal();
3426
+ const node_id = id;
3427
+ const preview = internal.history.preview("enter vector edit");
3428
+ preview.set({
3429
+ providerId: "svg-editor",
3430
+ descriptor: { kind: "vector-mode-enter" },
3431
+ apply: () => {
3432
+ this._do_enter_vector_edit(node_id);
3433
+ },
3434
+ revert: () => {
3435
+ this._do_exit_vector_edit();
3436
+ }
3437
+ });
3438
+ preview.commit();
3439
+ return true;
3440
+ }
3441
+ /**
3442
+ * Discard any in-flight preview, clear the vector-edit session, and return
3443
+ * the editor to select mode. Safe to call when no vector-edit is active
3444
+ * (no-op). Idempotent.
3445
+ *
3446
+ * Pushes a `vector-mode-exit` history step that closes over the
3447
+ * session's `node_id` and its final sub-selection, so undo re-enters
3448
+ * the same path and restores the selection the user was about to
3449
+ * leave. Pairs with {@link enter_vector_edit}.
3450
+ */
3451
+ exit_vector_edit() {
3452
+ if (!this.vector_edit) return;
3453
+ const node_id = this.vector_edit.node_id;
3454
+ const final_selection = this.vector_edit.snapshot_selection();
3455
+ this._do_exit_vector_edit();
3456
+ const preview = this.editor_internal().history.preview("exit vector edit");
3457
+ preview.set({
3458
+ providerId: "svg-editor",
3459
+ descriptor: { kind: "vector-mode-exit" },
3460
+ apply: () => {
3461
+ this._do_exit_vector_edit();
3462
+ },
3463
+ revert: () => {
3464
+ if (this._do_enter_vector_edit(node_id)) {
3465
+ this.vector_edit?.restore_selection(final_selection);
3466
+ this.sync_selection_mirror();
3467
+ this.redraw();
3468
+ }
3469
+ }
3470
+ });
3471
+ preview.commit();
3472
+ }
3473
+ /**
3474
+ * Unchecked enter — performs the mode flip + HUD wiring without
3475
+ * pushing to history. Called by {@link enter_vector_edit} (user-facing,
3476
+ * which pushes the delta) and by the exit-delta's revert (history-
3477
+ * driven re-entry on undo). Returns `false` if the node has no usable
3478
+ * `d` attribute, leaving editor state untouched.
3479
+ */
3480
+ _do_enter_vector_edit(id) {
3481
+ if (this.vector_edit) return false;
3482
+ const source = this.editor_internal().doc.is_vector_edit_target(id);
3483
+ if (source === null) return false;
3484
+ const session_d = source_to_session_d(source);
3485
+ this.vector_edit = new VectorEditSession(id, source, session_d);
3486
+ this.editor.commands.set_mode("edit-content");
3487
+ this.sync_selection_mirror();
3488
+ this.sync_surface_selection();
3489
+ this.sync_cursor();
3490
+ this.redraw();
3491
+ return true;
3492
+ }
3493
+ /**
3494
+ * Unchecked exit — counterpart to {@link _do_enter_vector_edit}. No
3495
+ * history push. Safe to call when no session is active.
3496
+ */
3497
+ _do_exit_vector_edit() {
3498
+ if (!this.vector_edit) return;
3499
+ 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")) {
3500
+ this.active_preview.session.discard();
3501
+ this.active_preview = null;
3502
+ }
3503
+ this.vector_edit = null;
3504
+ this.vector_edit_region_baseline = null;
3505
+ this.hud.setVectorSelection(null);
3506
+ if (this.current_tool.type === "lasso" || this.current_tool.type === "bend") this.editor.set_tool({ type: "cursor" });
3507
+ this.editor.commands.set_mode("select");
3508
+ this.sync_surface_selection();
3509
+ this.sync_cursor();
3510
+ this.redraw();
3511
+ }
3512
+ /**
3513
+ * `vectorOf` provider for the HUD. Returns the live PathSnapshot for the
3514
+ * named node when a vector-edit session is active for it; otherwise null.
3515
+ * HUD calls this each frame; cheap enough to recompute (snapshot just
3516
+ * copies the underlying network's arrays via the model's getter).
3517
+ */
3518
+ vector_of(id) {
3519
+ if (!this.vector_edit || this.vector_edit.node_id !== id) return null;
3520
+ const model = this.active_preview_model_for(id) ?? this.session_model();
3521
+ if (!model) return null;
3522
+ const snap = model.snapshot();
3523
+ const el = this.element_index.get(id);
3524
+ if (!(el instanceof SVGGraphicsElement)) return null;
3525
+ if (typeof el.getScreenCTM !== "function") return null;
3526
+ const ctm = el.getScreenCTM();
3527
+ if (!ctm) return null;
3528
+ const cr = this.container.getBoundingClientRect();
3529
+ const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
3530
+ return {
3531
+ vertices: Array.from({ length: snap.vertices.length }, (_, i) => {
3532
+ const v = snap.vertices[i];
3533
+ return project_point_through_ctm(v[0], v[1], ctm, offset);
3534
+ }),
3535
+ segments: snap.segments.map((s) => {
3536
+ const va = snap.vertices[s.a];
3537
+ const vb = snap.vertices[s.b];
3538
+ const a_ctrl_local = [va[0] + s.ta[0], va[1] + s.ta[1]];
3539
+ const b_ctrl_local = [vb[0] + s.tb[0], vb[1] + s.tb[1]];
3540
+ return {
3541
+ a: s.a,
3542
+ b: s.b,
3543
+ a_control: project_point_through_ctm(a_ctrl_local[0], a_ctrl_local[1], ctm, offset),
3544
+ b_control: project_point_through_ctm(b_ctrl_local[0], b_ctrl_local[1], ctm, offset)
3545
+ };
3546
+ }),
3547
+ neighbours: this.vector_edit.source.kind === "path" ? model.neighbouringVertices({
3548
+ vertices: this.vector_edit.selected_vertices,
3549
+ segments: this.vector_edit.selected_segments,
3550
+ tangents: this.vector_edit.selected_tangents
3551
+ }) : EMPTY_NEIGHBOURS,
3552
+ origin: [0, 0]
3553
+ };
3554
+ }
3555
+ /**
3556
+ * The session's in-memory PathModel-form `d`. For `<path>` sources
3557
+ * this stays in lock-step with `doc.get_attr(id, "d")`; for
3558
+ * `<line>` / `<polyline>` / `<polygon>` sources the document holds
3559
+ * native attrs and the session-d is the lingua-franca view that
3560
+ * gesture handlers parse from and write back to via
3561
+ * {@link apply_session_d}. Returns `null` if no session is active.
3562
+ */
3563
+ read_session_d() {
3564
+ if (!this.vector_edit) return null;
3565
+ return this.vector_edit.current_d;
3566
+ }
3567
+ /**
3568
+ * Derive a fresh `PathModel` from the current `d` for the path under
3569
+ * edit. Computed on read — there is no cached copy held on the session.
3570
+ * See `vector-edit/session.ts` for the doctrine ("d is the live store").
3571
+ */
3572
+ session_model() {
3573
+ const d = this.read_session_d();
3574
+ if (d === null) return null;
3575
+ return require_model.PathModel.fromSvgPathD(d);
3576
+ }
3577
+ /**
3578
+ * Republish the vector-edit sub-selection to the HUD. No-op when no
3579
+ * session is open. Every selection-changing handler ends with this so
3580
+ * the surface mirror stays in lock-step with `this.vector_edit` — the
3581
+ * inline `setVectorSelection({ node_id, vertices, segments, tangents })`
3582
+ * block was repeated at 6+ sites before this collapse.
3583
+ */
3584
+ sync_selection_mirror() {
3585
+ if (!this.vector_edit) return;
3586
+ this.hud.setVectorSelection({
3587
+ node_id: this.vector_edit.node_id,
3588
+ vertices: this.vector_edit.selected_vertices,
3589
+ segments: this.vector_edit.selected_segments,
3590
+ tangents: this.vector_edit.selected_tangents
3591
+ });
3592
+ }
3593
+ /**
3594
+ * Replay the session-side effects of a committed vector-edit delta onto
3595
+ * the LIVE `this.vector_edit` session — but only if it is still aimed at
3596
+ * the same node. Geometry restoration is the closure's `commit(d)` job
3597
+ * (document-level, always safe); this method handles the bits the
3598
+ * geometry write cannot reach on its own: advancing `last_seen_d` so
3599
+ * the external-mutation watcher doesn't pounce, and re-installing the
3600
+ * captured sub-selection.
3601
+ *
3602
+ * Closures used to call `session.mark_seen(...)` / `session.restore_selection(...)`
3603
+ * on a `const session = this.vector_edit` captured at gesture start.
3604
+ * After exit + undo-exit + undo-geometry, that capture pointed at the
3605
+ * disposed session while the live session was a fresh one — geometry
3606
+ * still restored correctly (via `commit`), but sub-selection didn't,
3607
+ * and the live session's stale `last_seen_d` would then cause the
3608
+ * watcher to clear the new session's selection on its next tick.
3609
+ *
3610
+ * Pass `d = null` for selection-only deltas (no geometry change → no
3611
+ * watermark advance).
3612
+ */
3613
+ replay_vector_session_state(target_node_id, d, selection) {
3614
+ const cur = this.vector_edit;
3615
+ if (!cur || cur.node_id !== target_node_id) return;
3616
+ if (d !== null) cur.mark_seen(d);
3617
+ cur.restore_selection(selection);
3618
+ this.sync_selection_mirror();
3619
+ }
3620
+ /**
3621
+ * Push a standalone vector sub-selection change as one history entry.
3622
+ *
3623
+ * Called by selection-only handlers (vertex / segment / tangent click,
3624
+ * marquee / lasso commit, clear-vector-selection) AFTER the
3625
+ * `VectorEditSession` has been mutated to the new state. `before` is the
3626
+ * snapshot captured before the mutation; the current session state is
3627
+ * captured here as `after`.
3628
+ *
3629
+ * Tagged with `descriptor: { kind: "vector-selection" }` so hosts that
3630
+ * want Figma-style "skip selection on undo" can filter on the
3631
+ * descriptor without inspecting closure internals. Default behavior:
3632
+ * standalone vector-selection IS undoable.
3633
+ *
3634
+ * No-op when the snapshot is unchanged — avoids spamming the stack
3635
+ * with entries from clicks that resolve to the same state (e.g.
3636
+ * clicking an already-selected vertex in `replace` mode).
3637
+ */
3638
+ record_vector_selection_change(before, label) {
3639
+ if (!this.vector_edit) return;
3640
+ const after = this.vector_edit.snapshot_selection();
3641
+ if (sub_selection_equal(before, after)) return;
3642
+ const target_node_id = this.vector_edit.node_id;
3643
+ const preview = this.editor_internal().history.preview(label);
3644
+ preview.set({
3645
+ providerId: "svg-editor",
3646
+ descriptor: { kind: "vector-selection" },
3647
+ apply: () => {
3648
+ this.replay_vector_session_state(target_node_id, null, after);
3649
+ this.redraw();
3650
+ },
3651
+ revert: () => {
3652
+ this.replay_vector_session_state(target_node_id, null, before);
3653
+ this.redraw();
3654
+ }
3655
+ });
3656
+ preview.commit();
3657
+ }
3658
+ /** Resolve the in-flight `PathModel` for the named node id when a
3659
+ * vector-preview is active; null otherwise. */
3660
+ active_preview_model_for(id) {
3661
+ const ap = this.active_preview;
3662
+ if (!ap) return null;
3663
+ switch (ap.kind) {
3664
+ case "vector_vertex_translate": return ap.node_id === id ? ap.preview_model : null;
3665
+ case "vector_set_tangent": return ap.node_id === id ? ap.preview_model : null;
3666
+ case "vector_bend_segment": return ap.node_id === id ? ap.preview_model : null;
3667
+ case "vector_translate_selection": return ap.node_id === id ? ap.preview_model : null;
3668
+ default: return null;
3669
+ }
3670
+ }
3671
+ /**
3672
+ * Apply a `select_vertex` intent. Updates the vector-edit session's sub-
3673
+ * selection and pushes a fresh mirror to the HUD.
3674
+ */
3675
+ handle_select_vertex(intent) {
3676
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3677
+ const before = this.vector_edit.snapshot_selection();
3678
+ this.vector_edit.select_vertex(intent.index, intent.mode);
3679
+ this.sync_selection_mirror();
3680
+ this.redraw();
3681
+ this.record_vector_selection_change(before, "select vertex");
3682
+ }
3683
+ /**
3684
+ * Apply a `select_segment` intent. Mirrors {@link handle_select_vertex}.
3685
+ * Fired when the user clicks a segment OFF the ghost insertion knob —
3686
+ * clicking the ghost itself fires `split_segment` instead.
3687
+ */
3688
+ handle_select_segment(intent) {
3689
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3690
+ const before = this.vector_edit.snapshot_selection();
3691
+ this.vector_edit.select_segment(intent.segment, intent.mode);
3692
+ this.sync_selection_mirror();
3693
+ this.redraw();
3694
+ this.record_vector_selection_change(before, "select segment");
3695
+ }
3696
+ /**
3697
+ * Apply a `translate_vertices` intent. Mirrors the `set_endpoint` flow:
3698
+ * - First frame opens a `history.preview` session capturing the original `d`.
3699
+ * - Each subsequent preview frame applies a fresh translation FROM the
3700
+ * original baseline (so the cumulative delta on the intent stays correct
3701
+ * and we don't accumulate drift across frames).
3702
+ * - The commit frame finalizes the preview; the session keeps its updated
3703
+ * model for the next gesture.
3704
+ *
3705
+ * The vector-edit session's `model` is updated to reflect the committed
3706
+ * state (preview model is computed on the fly each frame from the
3707
+ * baseline; only commit writes back into session.model).
3708
+ */
3709
+ handle_translate_vertices(intent) {
3710
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3711
+ const internal = this.editor_internal();
3712
+ const doc = internal.doc;
3713
+ const emit = internal.emit;
3714
+ const node_id = intent.node_id;
3715
+ const source = this.vector_edit.source;
3716
+ const commit = (d) => apply_session_d(doc, node_id, source, d);
3717
+ if (!this.active_preview || this.active_preview.kind !== "vector_vertex_translate" || this.active_preview.node_id !== node_id || !require_model.array_shallow_equal(this.active_preview.indices, intent.indices)) {
3718
+ if (this.active_preview) {
3719
+ if ("session" in this.active_preview) this.active_preview.session.discard();
3720
+ }
3721
+ const initial_d = this.read_session_d();
3722
+ if (initial_d === null) return;
3723
+ const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
3724
+ this.active_preview = {
3725
+ kind: "vector_vertex_translate",
3726
+ node_id,
3727
+ indices: [...intent.indices],
3728
+ initial_d,
3729
+ baseline_model,
3730
+ before_selection: this.vector_edit.snapshot_selection(),
3731
+ preview_model: baseline_model,
3732
+ session: internal.history.preview("vector/translate-vertex")
3733
+ };
3734
+ }
3735
+ const baseline_d = this.active_preview.initial_d;
3736
+ const indices = this.active_preview.indices;
3737
+ let local_dx = intent.dx;
3738
+ let local_dy = intent.dy;
3739
+ const el = this.element_index.get(node_id);
3740
+ if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
3741
+ const ctm = el.getScreenCTM();
3742
+ if (ctm) {
3743
+ if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
3744
+ }
3745
+ }
3746
+ const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
3747
+ const target_d = preview_model.toSvgPathD();
3748
+ this.active_preview.preview_model = preview_model;
3749
+ if (intent.phase === "commit") {
3750
+ const before_selection = this.active_preview.before_selection;
3751
+ const after_selection = this.vector_edit.snapshot_selection();
3752
+ this.active_preview.session.set({
3753
+ providerId: "svg-editor",
3754
+ apply: () => {
3755
+ commit(target_d);
3756
+ this.replay_vector_session_state(node_id, target_d, after_selection);
3757
+ emit();
3758
+ },
3759
+ revert: () => {
3760
+ commit(baseline_d);
3761
+ this.replay_vector_session_state(node_id, baseline_d, before_selection);
3762
+ emit();
3763
+ }
3764
+ });
3765
+ this.active_preview.session.commit();
3766
+ this.active_preview = null;
3767
+ } else this.active_preview.session.set({
3768
+ providerId: "svg-editor",
3769
+ apply: () => {
3770
+ commit(target_d);
3771
+ emit();
3772
+ },
3773
+ revert: () => {
3774
+ commit(baseline_d);
3775
+ emit();
3776
+ }
3777
+ });
3778
+ }
3779
+ /**
3780
+ * `translate_vector_selection` — the sub-selection-aware delta-translate.
3781
+ *
3782
+ * Mirrors main editor's `translate-vector-controls`
3783
+ * (`editor/grida-canvas/reducers/tools/event-target.cem-vector.reducer.ts:667-675`).
3784
+ * Translates the union of:
3785
+ *
3786
+ * - selected vertices (authoritative sub-selection)
3787
+ * - endpoints of selected segments (segment selection implies
3788
+ * its two endpoints translate)
3789
+ * - intent.additional_vertex_indices (carried by segment drag so
3790
+ * endpoints translate even
3791
+ * when the segment isn't yet
3792
+ * in sub-selection — the
3793
+ * deferred select_segment was
3794
+ * canceled by drag promotion)
3795
+ *
3796
+ * AND delta-translates selected tangents, EXCLUDING tangents whose parent
3797
+ * vertex is already in the translated set (mirrors `vector.ts:39-42`'s
3798
+ * `getUXNeighbouringVertices`-style exclusion: the vertex move already
3799
+ * carries its tangent controls, so double-applying would shift them
3800
+ * twice). Mirror policy pinned to `"none"` during multi-translate; mirror
3801
+ * behavior is reserved for the singleton-tangent curve gesture.
3802
+ *
3803
+ * Opens a dedicated `vector_translate_selection` preview so the
3804
+ * vertex translate AND tangent delta apply atomically per frame.
3805
+ */
3806
+ handle_translate_vector_selection(intent) {
3807
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3808
+ const internal = this.editor_internal();
3809
+ const doc = internal.doc;
3810
+ const emit = internal.emit;
3811
+ const node_id = intent.node_id;
3812
+ const source = this.vector_edit.source;
3813
+ const commit = (d) => apply_session_d(doc, node_id, source, d);
3814
+ const ses = this.vector_edit;
3815
+ const current_d = this.read_session_d();
3816
+ if (current_d === null) return;
3817
+ const resolved_model = require_model.PathModel.fromSvgPathD(current_d);
3818
+ const vertex_count = resolved_model.vertexCount();
3819
+ const segment_snapshot = resolved_model.snapshot().segments;
3820
+ const indices_set = /* @__PURE__ */ new Set();
3821
+ const add_if_valid = (i) => {
3822
+ if (i >= 0 && i < vertex_count) indices_set.add(i);
3823
+ };
3824
+ for (const v of ses.selected_vertices) add_if_valid(v);
3825
+ for (const s of ses.selected_segments) {
3826
+ const seg = segment_snapshot[s];
3827
+ if (!seg) continue;
3828
+ add_if_valid(seg.a);
3829
+ add_if_valid(seg.b);
3830
+ }
3831
+ for (const v of intent.additional_vertex_indices) add_if_valid(v);
3832
+ const tangent_refs = [];
3833
+ for (const ref of ses.selected_tangents) {
3834
+ if (indices_set.has(ref[0])) continue;
3835
+ tangent_refs.push(ref);
3836
+ }
3837
+ if (indices_set.size === 0 && tangent_refs.length === 0) return;
3838
+ const indices = Array.from(indices_set).sort((a, b) => a - b);
3839
+ if (!this.active_preview || this.active_preview.kind !== "vector_translate_selection" || this.active_preview.node_id !== node_id || !require_model.array_shallow_equal(this.active_preview.indices, indices) || !sameTangentRefs(this.active_preview.tangent_refs, tangent_refs)) {
3840
+ if (this.active_preview) {
3841
+ if ("session" in this.active_preview) this.active_preview.session.discard();
3842
+ }
3843
+ const initial_d = this.read_session_d();
3844
+ if (initial_d === null) return;
3845
+ const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
3846
+ const baseline_tangent_abs = tangent_refs.map((ref) => baseline_model.tangentAbsolute(ref, [0, 0]));
3847
+ this.active_preview = {
3848
+ kind: "vector_translate_selection",
3849
+ node_id,
3850
+ indices: [...indices],
3851
+ tangent_refs: [...tangent_refs],
3852
+ initial_d,
3853
+ before_selection: this.vector_edit.snapshot_selection(),
3854
+ preview_model: baseline_model,
3855
+ baseline_model,
3856
+ baseline_tangent_abs,
3857
+ session: internal.history.preview("vector/translate-selection")
3858
+ };
3859
+ }
3860
+ const baseline_d = this.active_preview.initial_d;
3861
+ let local_dx = intent.dx;
3862
+ let local_dy = intent.dy;
3863
+ const el = this.element_index.get(node_id);
3864
+ if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
3865
+ const ctm = el.getScreenCTM();
3866
+ if (ctm) {
3867
+ if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
3868
+ }
3869
+ }
3870
+ const baseline_model = this.active_preview.baseline_model;
3871
+ const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
3872
+ let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;
3873
+ for (let i = 0; i < tangent_refs.length; i++) {
3874
+ const baseline_abs = baseline_tangent_abs[i];
3875
+ if (baseline_abs === null) continue;
3876
+ preview_model = preview_model.setTangent(tangent_refs[i], [baseline_abs[0] + local_dx, baseline_abs[1] + local_dy], "none");
3877
+ }
3878
+ const target_d = preview_model.toSvgPathD();
3879
+ this.active_preview.preview_model = preview_model;
3880
+ if (intent.phase === "commit") {
3881
+ const before_selection = this.active_preview.before_selection;
3882
+ const after_selection = this.vector_edit.snapshot_selection();
3883
+ this.active_preview.session.set({
3884
+ providerId: "svg-editor",
3885
+ apply: () => {
3886
+ commit(target_d);
3887
+ this.replay_vector_session_state(node_id, target_d, after_selection);
3888
+ emit();
3889
+ },
3890
+ revert: () => {
3891
+ commit(baseline_d);
3892
+ this.replay_vector_session_state(node_id, baseline_d, before_selection);
3893
+ emit();
3894
+ }
3895
+ });
3896
+ this.active_preview.session.commit();
3897
+ this.active_preview = null;
3898
+ } else this.active_preview.session.set({
3899
+ providerId: "svg-editor",
3900
+ apply: () => {
3901
+ commit(target_d);
3902
+ emit();
3903
+ },
3904
+ revert: () => {
3905
+ commit(baseline_d);
3906
+ emit();
3907
+ }
3908
+ });
3909
+ }
3910
+ /** Mirror handler for `select_tangent` (analogous to `select_vertex`). */
3911
+ handle_select_tangent(intent) {
3912
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3913
+ const before = this.vector_edit.snapshot_selection();
3914
+ this.vector_edit.select_tangent(intent.tangent, intent.mode);
3915
+ this.sync_selection_mirror();
3916
+ this.redraw();
3917
+ this.record_vector_selection_change(before, "select tangent");
3918
+ }
3919
+ /**
3920
+ * Tangent drag handler. Mirrors `handle_translate_vertices`:
3921
+ *
3922
+ * - First frame opens a `history.preview` session, captures `original_d`.
3923
+ * - Each preview frame replays setTangent from the baseline model so
3924
+ * cumulative drift never accumulates.
3925
+ * - Commit finalizes the preview and reseeds the session.
3926
+ *
3927
+ * `intent.pos` arrives in container CSS-px (HUD's doc-space). We project
3928
+ * it back through the inverse-CTM to path-local before calling
3929
+ * `PathModel.setTangent`.
3930
+ */
3931
+ handle_set_tangent_intent(intent) {
3932
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
3933
+ if (this.vector_edit.source.kind !== "path") return;
3934
+ const internal = this.editor_internal();
3935
+ const doc = internal.doc;
3936
+ const emit = internal.emit;
3937
+ const node_id = intent.node_id;
3938
+ const source = this.vector_edit.source;
3939
+ const commit = (d) => apply_session_d(doc, node_id, source, d);
3940
+ 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]) {
3941
+ if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
3942
+ const initial_d = this.read_session_d();
3943
+ if (initial_d === null) return;
3944
+ const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
3945
+ this.active_preview = {
3946
+ kind: "vector_set_tangent",
3947
+ node_id,
3948
+ tangent: [intent.tangent[0], intent.tangent[1]],
3949
+ initial_d,
3950
+ baseline_model,
3951
+ before_selection: this.vector_edit.snapshot_selection(),
3952
+ preview_model: baseline_model,
3953
+ session: internal.history.preview("vector/set-tangent")
3954
+ };
3955
+ }
3956
+ const baseline_d = this.active_preview.initial_d;
3957
+ const local_pos = this.project_doc_point_to_local(node_id, intent.pos);
3958
+ if (!local_pos) return;
3959
+ const preview_model = this.active_preview.baseline_model.setTangent(intent.tangent, local_pos, intent.mirror);
3960
+ const target_d = preview_model.toSvgPathD();
3961
+ this.active_preview.preview_model = preview_model;
3962
+ if (intent.phase === "commit") {
3963
+ const before_selection = this.active_preview.before_selection;
3964
+ const after_selection = this.vector_edit.snapshot_selection();
3965
+ this.active_preview.session.set({
3966
+ providerId: "svg-editor",
3967
+ apply: () => {
3968
+ commit(target_d);
3969
+ this.replay_vector_session_state(node_id, target_d, after_selection);
3970
+ emit();
3971
+ },
3972
+ revert: () => {
3973
+ commit(baseline_d);
3974
+ this.replay_vector_session_state(node_id, baseline_d, before_selection);
3975
+ emit();
3976
+ }
3977
+ });
3978
+ this.active_preview.session.commit();
3979
+ this.active_preview = null;
3980
+ } else this.active_preview.session.set({
3981
+ providerId: "svg-editor",
3982
+ apply: () => {
3983
+ commit(target_d);
3984
+ emit();
3985
+ },
3986
+ revert: () => {
3987
+ commit(baseline_d);
3988
+ emit();
3989
+ }
3990
+ });
3991
+ }
3992
+ /**
3993
+ * Split a segment at parametric position `t`. One-shot atomic edit; no
3994
+ * preview phase. After the split, the new vertex is auto-selected — this
3995
+ * matches Figma's add-anchor behavior and prepares the user to immediately
3996
+ * drag the newly inserted anchor.
3997
+ */
3998
+ handle_split_segment(intent) {
3999
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
4000
+ const node_id = intent.node_id;
4001
+ const source = this.vector_edit.source;
4002
+ const commit = (d) => apply_session_d(doc, node_id, source, d);
4003
+ const baseline_d = this.read_session_d();
4004
+ if (baseline_d === null) return;
4005
+ const { model: next_model, new_vertex } = require_model.PathModel.fromSvgPathD(baseline_d).splitSegment(intent.segment, intent.t);
4006
+ const target_d = next_model.toSvgPathD();
4007
+ const internal = this.editor_internal();
4008
+ const doc = internal.doc;
4009
+ const emit = internal.emit;
4010
+ const before_selection = this.vector_edit.snapshot_selection();
4011
+ const after_selection = Object.freeze({
4012
+ vertices: Object.freeze([new_vertex]),
4013
+ segments: Object.freeze([]),
4014
+ tangents: Object.freeze([])
4015
+ });
4016
+ const split_session = internal.history.preview("vector/split-segment");
4017
+ split_session.set({
4018
+ providerId: "svg-editor",
4019
+ apply: () => {
4020
+ commit(target_d);
4021
+ this.replay_vector_session_state(node_id, target_d, after_selection);
4022
+ emit();
4023
+ },
4024
+ revert: () => {
4025
+ commit(baseline_d);
4026
+ this.replay_vector_session_state(node_id, baseline_d, before_selection);
4027
+ emit();
4028
+ }
4029
+ });
4030
+ split_session.commit();
4031
+ this.redraw();
4032
+ }
4033
+ /**
4034
+ * Bend a segment by dragging an interior point. Mirrors set_tangent —
4035
+ * preview session opened on first frame, replays from baseline each
4036
+ * frame, commits / reseeds on phase===commit.
4037
+ *
4038
+ * `frozen` is captured ONCE at session open from the baseline model;
4039
+ * `vne.bendSegment` solves for new tangents against this snapshot, so
4040
+ * the cumulative drag delta is correct without per-frame drift.
4041
+ */
4042
+ handle_bend_segment(intent) {
4043
+ if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
4044
+ if (this.vector_edit.source.kind !== "path") return;
4045
+ const internal = this.editor_internal();
4046
+ const doc = internal.doc;
4047
+ const emit = internal.emit;
4048
+ const node_id = intent.node_id;
4049
+ const source = this.vector_edit.source;
4050
+ const commit = (d) => apply_session_d(doc, node_id, source, d);
4051
+ 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) {
4052
+ if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
4053
+ const initial_d = this.read_session_d();
4054
+ if (initial_d === null) return;
4055
+ const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
4056
+ const snap = baseline_model.snapshot();
4057
+ const s = snap.segments[intent.segment];
4058
+ if (!s) return;
4059
+ const va = snap.vertices[s.a];
4060
+ const vb = snap.vertices[s.b];
4061
+ this.active_preview = {
4062
+ kind: "vector_bend_segment",
4063
+ node_id,
4064
+ segment: intent.segment,
4065
+ ca: intent.ca,
4066
+ frozen: {
4067
+ a: [va[0], va[1]],
4068
+ b: [vb[0], vb[1]],
4069
+ ta: [s.ta[0], s.ta[1]],
4070
+ tb: [s.tb[0], s.tb[1]]
4071
+ },
4072
+ initial_d,
4073
+ baseline_model,
4074
+ before_selection: this.vector_edit.snapshot_selection(),
4075
+ preview_model: baseline_model,
4076
+ session: internal.history.preview("vector/bend-segment")
4077
+ };
4078
+ }
4079
+ const baseline_d = this.active_preview.initial_d;
4080
+ const frozen = this.active_preview.frozen;
4081
+ const local_cb = this.project_doc_point_to_local(node_id, intent.cb);
4082
+ if (!local_cb) return;
4083
+ const preview_model = this.active_preview.baseline_model.bendSegment(intent.segment, intent.ca, local_cb, frozen);
4084
+ const target_d = preview_model.toSvgPathD();
4085
+ this.active_preview.preview_model = preview_model;
4086
+ if (intent.phase === "commit") {
4087
+ const before_selection = this.active_preview.before_selection;
4088
+ const after_selection = this.vector_edit.snapshot_selection();
4089
+ this.active_preview.session.set({
4090
+ providerId: "svg-editor",
4091
+ apply: () => {
4092
+ commit(target_d);
4093
+ this.replay_vector_session_state(node_id, target_d, after_selection);
4094
+ emit();
4095
+ },
4096
+ revert: () => {
4097
+ commit(baseline_d);
4098
+ this.replay_vector_session_state(node_id, baseline_d, before_selection);
4099
+ emit();
4100
+ }
4101
+ });
4102
+ this.active_preview.session.commit();
4103
+ this.active_preview = null;
4104
+ } else this.active_preview.session.set({
4105
+ providerId: "svg-editor",
4106
+ apply: () => {
4107
+ commit(target_d);
4108
+ emit();
4109
+ },
4110
+ revert: () => {
4111
+ commit(baseline_d);
4112
+ emit();
4113
+ }
4114
+ });
4115
+ }
4116
+ /**
4117
+ * Project a doc-space point (HUD's container CSS-px frame) back to the
4118
+ * named element's local frame via inverse-CTM. Returns null if no CTM
4119
+ * is available or the CTM is singular.
4120
+ */
4121
+ project_doc_point_to_local(id, p) {
4122
+ const el = this.element_index.get(id);
4123
+ if (!(el instanceof SVGGraphicsElement)) return null;
4124
+ if (typeof el.getScreenCTM !== "function") return null;
4125
+ const ctm = el.getScreenCTM();
4126
+ if (!ctm) return null;
4127
+ const cr = this.container.getBoundingClientRect();
4128
+ const offset_x = -cr.left + this.container.scrollLeft;
4129
+ const offset_y = -cr.top + this.container.scrollTop;
4130
+ const det = ctm.a * ctm.d - ctm.c * ctm.b;
4131
+ if (det === 0) return null;
4132
+ const px = p[0] - offset_x;
4133
+ const py = p[1] - offset_y;
4134
+ return [(ctm.d * (px - ctm.e) - ctm.c * (py - ctm.f)) / det, (-ctm.b * (px - ctm.e) + ctm.a * (py - ctm.f)) / det];
4135
+ }
3266
4136
  tag_of(id) {
3267
4137
  return this.editor.tree().nodes.get(id)?.tag ?? "";
3268
4138
  }
@@ -3295,7 +4165,8 @@ var DomSurface = class DomSurface {
3295
4165
  bbox_world(id) {
3296
4166
  const local = this.bbox_local(id);
3297
4167
  if (!local) return null;
3298
- return require_insertions.project_local_bbox(local, this.editor.document.get_attr(id, "transform"));
4168
+ const transform_str = this.editor.document.get_attr(id, "transform");
4169
+ return require_model.transform.project(local, transform_str);
3299
4170
  }
3300
4171
  /** World-space rect for snap purposes. Differs from `bbox_world` for
3301
4172
  * `<svg>` viewport-establishing elements: `getBBox()` on an `<svg>`
@@ -3322,13 +4193,92 @@ var DomSurface = class DomSurface {
3322
4193
  function numAttr(doc, id, name) {
3323
4194
  return _grida_svg_parse.svg_parse.parse_number(doc.get_attr(id, name));
3324
4195
  }
4196
+ /** Order-sensitive shallow equality for tangent-ref arrays. */
4197
+ function sameTangentRefs(a, b) {
4198
+ if (a.length !== b.length) return false;
4199
+ for (let i = 0; i < a.length; i++) if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
4200
+ return true;
4201
+ }
4202
+ /**
4203
+ * Affine projection of a point through a 2×3 CTM, then offset by a
4204
+ * container origin (in page CSS-px). Mirrors how `line_endpoints_in_container`
4205
+ * and `shape_of` (transformed branch) bridge from local SVG coords to the
4206
+ * HUD's container-CSS-px space (HUD keeps its own transform at identity;
4207
+ * the SVG carries the camera as a CSS transform, which getScreenCTM
4208
+ * folds in).
4209
+ *
4210
+ * Exported for headless test coverage — pure function, no DOM types.
4211
+ */
4212
+ function project_point_through_ctm(px, py, ctm, container_offset) {
4213
+ const [sx, sy] = _grida_cmath.default.vector2.transform([px, py], [[
4214
+ ctm.a,
4215
+ ctm.c,
4216
+ ctm.e
4217
+ ], [
4218
+ ctm.b,
4219
+ ctm.d,
4220
+ ctm.f
4221
+ ]]);
4222
+ return [sx + container_offset[0], sy + container_offset[1]];
4223
+ }
4224
+ /**
4225
+ * Inverse of the CTM's linear part applied to a delta vector. Drops
4226
+ * translation. Used to convert a HUD-reported container-space drag delta
4227
+ * back to the path's local coord space for `PathModel.translateVertices`.
4228
+ *
4229
+ * Throws on a degenerate (det = 0) matrix — the caller is expected to
4230
+ * have a non-singular CTM for any visible element.
4231
+ */
4232
+ function project_delta_inverse_ctm(dx, dy, ctm) {
4233
+ if (ctm.a * ctm.d - ctm.c * ctm.b === 0) throw new Error("project_delta_inverse_ctm: singular CTM linear part");
4234
+ const inv = _grida_cmath.default.transform.invert([[
4235
+ ctm.a,
4236
+ ctm.c,
4237
+ 0
4238
+ ], [
4239
+ ctm.b,
4240
+ ctm.d,
4241
+ 0
4242
+ ]]);
4243
+ return _grida_cmath.default.vector2.transform([dx, dy], inv);
4244
+ }
4245
+ /**
4246
+ * Inverse-project a doc-space rect through a CTM + container offset back
4247
+ * into the element's local frame. The output is the AABB of the four
4248
+ * inverse-projected corners — when the CTM has a rotation component the
4249
+ * AABB is an approximation, but it matches what the user visually
4250
+ * expects from a screen-aligned marquee drag.
4251
+ *
4252
+ * Returns `null` when the CTM's linear part is singular (degenerate
4253
+ * camera) — the caller should skip any test that needs the local rect.
4254
+ */
4255
+ function inverse_project_rect(rect, ctm, offset) {
4256
+ if (ctm.a * ctm.d - ctm.c * ctm.b === 0) return null;
4257
+ const inv = _grida_cmath.default.transform.invert([[
4258
+ ctm.a,
4259
+ ctm.c,
4260
+ ctm.e
4261
+ ], [
4262
+ ctm.b,
4263
+ ctm.d,
4264
+ ctm.f
4265
+ ]]);
4266
+ const to_local = (px, py) => _grida_cmath.default.vector2.transform([px - offset[0], py - offset[1]], inv);
4267
+ const corners = [
4268
+ to_local(rect.x, rect.y),
4269
+ to_local(rect.x + rect.width, rect.y),
4270
+ to_local(rect.x, rect.y + rect.height),
4271
+ to_local(rect.x + rect.width, rect.y + rect.height)
4272
+ ];
4273
+ return _grida_cmath.default.rect.fromPoints(corners);
4274
+ }
3325
4275
  /** World-space viewport rect of an `<svg>` element. Prefers `viewBox`
3326
4276
  * (the declared user-space rect — what the user perceives as canvas),
3327
4277
  * falls back to `width`/`height` at (0,0). For nested `<svg>` with a
3328
4278
  * positional `x`/`y`, the declared viewBox/(0,0) is in the nested
3329
4279
  * element's OWN user space; callers are responsible for CTM
3330
4280
  * projection if a different frame is desired. v1 nested-svg story is
3331
- * documented in docs/wg/feat-svg-editor/geometry.md as out of scope. */
4281
+ * documented in ../docs/geometry.md as out of scope. */
3332
4282
  function svg_viewport_bounds(el) {
3333
4283
  const vb = el.getAttribute("viewBox");
3334
4284
  if (vb) {
@@ -3498,9 +4448,9 @@ var SvgHitShapeDriver = class {
3498
4448
  hit_shape_of(id) {
3499
4449
  const doc = this.accessors.doc();
3500
4450
  if (!doc) return null;
3501
- const intrinsic = require_insertions.hit_shape_of_doc(doc, id);
4451
+ const intrinsic = require_model.hit_shape_svg.of_doc(doc, id);
3502
4452
  if (intrinsic) return intrinsic;
3503
- if (require_insertions.is_transparent_tag(doc.tag_of(id))) return null;
4453
+ if (require_model.hit_shape_svg.is_transparent_tag(doc.tag_of(id))) return null;
3504
4454
  const bounds = this.accessors.bounds_of(id);
3505
4455
  if (!bounds) return null;
3506
4456
  return {
@@ -3543,3 +4493,21 @@ Object.defineProperty(exports, "attach_dom_surface", {
3543
4493
  return attach_dom_surface;
3544
4494
  }
3545
4495
  });
4496
+ Object.defineProperty(exports, "inverse_project_rect", {
4497
+ enumerable: true,
4498
+ get: function() {
4499
+ return inverse_project_rect;
4500
+ }
4501
+ });
4502
+ Object.defineProperty(exports, "project_delta_inverse_ctm", {
4503
+ enumerable: true,
4504
+ get: function() {
4505
+ return project_delta_inverse_ctm;
4506
+ }
4507
+ });
4508
+ Object.defineProperty(exports, "project_point_through_ctm", {
4509
+ enumerable: true,
4510
+ get: function() {
4511
+ return project_point_through_ctm;
4512
+ }
4513
+ });