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

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,10 +1,9 @@
1
- const require_model = require("./model-DqGqV1H4.js");
1
+ const require_model = require("./model-D0nU_EkL.js");
2
2
  let _grida_history = require("@grida/history");
3
3
  let _grida_keybinding = require("@grida/keybinding");
4
4
  let _grida_cmath = require("@grida/cmath");
5
5
  _grida_cmath = require_model.__toESM(_grida_cmath);
6
6
  let _grida_svg_parser = require("@grida/svg/parser");
7
- let _grida_svg_parse = require("@grida/svg/parse");
8
7
  //#region src/commands/registry.ts
9
8
  var CommandRegistry = class {
10
9
  constructor() {
@@ -85,12 +84,26 @@ function registerDefaultCommands(reg, editor) {
85
84
  if (editor.state.selection.length === 0) return false;
86
85
  return editor.commands.group();
87
86
  });
87
+ reg.register("selection.ungroup", () => {
88
+ if (editor.state.mode !== "select") return false;
89
+ if (editor.state.selection.length !== 1) return false;
90
+ return editor.commands.ungroup();
91
+ });
88
92
  reg.register("selection.resize_to", (args) => {
89
93
  if (editor.state.mode !== "select") return false;
90
94
  if (editor.state.selection.length === 0) return false;
91
95
  const target = args;
92
96
  return editor.commands.resize_to(target);
93
97
  });
98
+ reg.register("selection.nudge_resize", (args) => {
99
+ if (editor.state.mode !== "select") return false;
100
+ if (editor.state.selection.length === 0) return false;
101
+ const { dw, dh } = args;
102
+ return editor.commands.resize_by({
103
+ dw,
104
+ dh
105
+ });
106
+ });
94
107
  reg.register("selection.rotate", (args) => {
95
108
  if (editor.state.mode !== "select") return false;
96
109
  if (editor.state.selection.length === 0) return false;
@@ -310,7 +323,8 @@ function compareEntries(a, b) {
310
323
  * Same key, multiple meanings? Add multiple rows. The chain semantics
311
324
  * (handler returns `false` when not applicable) handle the rest.
312
325
  */
313
- const NUDGE_MEANINGFUL = _grida_keybinding.M.Shift;
326
+ const NUDGE_MEANINGFUL = _grida_keybinding.M.Shift | _grida_keybinding.M.Ctrl;
327
+ const RESIZE_MEANINGFUL = _grida_keybinding.M.Shift | _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt;
314
328
  const DEFAULT_BINDINGS = [
315
329
  {
316
330
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyZ, _grida_keybinding.M.CtrlCmd),
@@ -340,6 +354,10 @@ const DEFAULT_BINDINGS = [
340
354
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd),
341
355
  command: "selection.group"
342
356
  },
357
+ {
358
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd | _grida_keybinding.M.Shift),
359
+ command: "selection.ungroup"
360
+ },
343
361
  {
344
362
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyA, _grida_keybinding.M.CtrlCmd),
345
363
  command: "selection.all"
@@ -460,6 +478,70 @@ const DEFAULT_BINDINGS = [
460
478
  dy: 10
461
479
  }
462
480
  },
481
+ {
482
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
483
+ command: "selection.nudge_resize",
484
+ args: {
485
+ dw: 1,
486
+ dh: 0
487
+ }
488
+ },
489
+ {
490
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
491
+ command: "selection.nudge_resize",
492
+ args: {
493
+ dw: -1,
494
+ dh: 0
495
+ }
496
+ },
497
+ {
498
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
499
+ command: "selection.nudge_resize",
500
+ args: {
501
+ dw: 0,
502
+ dh: 1
503
+ }
504
+ },
505
+ {
506
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
507
+ command: "selection.nudge_resize",
508
+ args: {
509
+ dw: 0,
510
+ dh: -1
511
+ }
512
+ },
513
+ {
514
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
515
+ command: "selection.nudge_resize",
516
+ args: {
517
+ dw: 10,
518
+ dh: 0
519
+ }
520
+ },
521
+ {
522
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
523
+ command: "selection.nudge_resize",
524
+ args: {
525
+ dw: -10,
526
+ dh: 0
527
+ }
528
+ },
529
+ {
530
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
531
+ command: "selection.nudge_resize",
532
+ args: {
533
+ dw: 0,
534
+ dh: 10
535
+ }
536
+ },
537
+ {
538
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
539
+ command: "selection.nudge_resize",
540
+ args: {
541
+ dw: 0,
542
+ dh: -10
543
+ }
544
+ },
463
545
  {
464
546
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyV),
465
547
  command: TOOL_SET,
@@ -756,541 +838,6 @@ function create_defs(doc) {
756
838
  return { gradients: new GradientsRegistry(doc) };
757
839
  }
758
840
  //#endregion
759
- //#region src/core/document.ts
760
- /**
761
- * Attribute names whose writes can shift a node's rendered bounds.
762
- * Membership drives `_geometry_version` bumps in `set_attr`. Only
763
- * non-namespaced attribute names — namespaced writes (xlink:href, etc.)
764
- * never bump because they're references, not geometry.
765
- *
766
- * Includes text-shaping attributes (font-*) because they re-shape glyph
767
- * runs and change `<text>` bbox.
768
- */
769
- const GEOMETRY_ATTRS = new Set([
770
- "x",
771
- "y",
772
- "x1",
773
- "y1",
774
- "x2",
775
- "y2",
776
- "cx",
777
- "cy",
778
- "width",
779
- "height",
780
- "r",
781
- "rx",
782
- "ry",
783
- "points",
784
- "d",
785
- "transform",
786
- "viewBox",
787
- "font-size",
788
- "font-family",
789
- "font-weight",
790
- "font-style",
791
- "text-anchor",
792
- "dx",
793
- "dy",
794
- "rotate",
795
- "textLength",
796
- "lengthAdjust",
797
- "pathLength",
798
- "marker-start",
799
- "marker-mid",
800
- "marker-end"
801
- ]);
802
- /** `transform:` CSS property at the start of a declaration list or after `;`. */
803
- const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
804
- var SvgDocument = class SvgDocument {
805
- constructor(svg) {
806
- this.listeners = /* @__PURE__ */ new Set();
807
- this._structure_version = 0;
808
- this._geometry_version = 0;
809
- if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
810
- this.source = svg;
811
- const parsed = (0, _grida_svg_parser.parse_svg)(svg);
812
- this.original = parsed;
813
- this.nodes = parsed.nodes;
814
- this.prolog = parsed.prolog;
815
- this.epilog = parsed.epilog;
816
- this.root = parsed.root;
817
- }
818
- static parse(svg) {
819
- return new SvgDocument(svg);
820
- }
821
- /** Reload from the original parse, discarding all edits. */
822
- reset_to_original() {
823
- const parsed = (0, _grida_svg_parser.parse_svg)(this.source);
824
- this.original = parsed;
825
- this.nodes = parsed.nodes;
826
- this.prolog = parsed.prolog;
827
- this.epilog = parsed.epilog;
828
- this.root = parsed.root;
829
- this._structure_version++;
830
- this._geometry_version++;
831
- this.emit();
832
- }
833
- /** Replace document with new svg source (clears edits + history-owned state). */
834
- load(svg) {
835
- if (typeof svg !== "string") throw new TypeError(`SvgDocument.load(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
836
- this.source = svg;
837
- const parsed = (0, _grida_svg_parser.parse_svg)(svg);
838
- this.original = parsed;
839
- this.nodes = parsed.nodes;
840
- this.prolog = parsed.prolog;
841
- this.epilog = parsed.epilog;
842
- this.root = parsed.root;
843
- this._structure_version++;
844
- this._geometry_version++;
845
- this.emit();
846
- }
847
- on_change(fn) {
848
- this.listeners.add(fn);
849
- return () => this.listeners.delete(fn);
850
- }
851
- /** See `_structure_version` for what this counter signals. */
852
- get structure_version() {
853
- return this._structure_version;
854
- }
855
- /** See `_geometry_version` for what this counter signals. */
856
- get geometry_version() {
857
- return this._geometry_version;
858
- }
859
- emit() {
860
- for (const fn of this.listeners) fn();
861
- }
862
- /** Notify subscribers — for callers that mutate directly via setAttr/etc. */
863
- notify() {
864
- this.emit();
865
- }
866
- get(id) {
867
- return this.nodes.get(id) ?? null;
868
- }
869
- is_element(id) {
870
- return this.nodes.get(id)?.kind === "element";
871
- }
872
- parent_of(id) {
873
- return this.nodes.get(id)?.parent ?? null;
874
- }
875
- children_of(id) {
876
- const n = this.nodes.get(id);
877
- if (!n || n.kind !== "element") return [];
878
- return n.children;
879
- }
880
- /** Element children only — text/comment/cdata filtered out. */
881
- element_children_of(id) {
882
- return this.children_of(id).filter((c) => this.is_element(c));
883
- }
884
- next_sibling_of(id) {
885
- const parent = this.parent_of(id);
886
- if (parent === null) return null;
887
- const siblings = this.children_of(parent);
888
- const i = siblings.indexOf(id);
889
- return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
890
- }
891
- next_element_sibling_of(id) {
892
- const parent = this.parent_of(id);
893
- if (parent === null) return null;
894
- const siblings = this.element_children_of(parent);
895
- const i = siblings.indexOf(id);
896
- return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
897
- }
898
- tag_of(id) {
899
- const n = this.nodes.get(id);
900
- return n && n.kind === "element" ? n.local : "";
901
- }
902
- contains(ancestor, descendant) {
903
- if (ancestor === descendant) return true;
904
- let cur = this.parent_of(descendant);
905
- while (cur !== null) {
906
- if (cur === ancestor) return true;
907
- cur = this.parent_of(cur);
908
- }
909
- return false;
910
- }
911
- /**
912
- * Filter a selection down to its **subtree roots** — drop any id whose
913
- * ancestor is also in the input set.
914
- *
915
- * Mirrors `pruneNestedNodes` in the main canvas editor's query module
916
- * ([editor/grida-canvas/query/index.ts:138](../../../../editor/grida-canvas/query/index.ts)) and shares its UX motivation:
917
- * when a parent and a descendant are both selected, only the parent
918
- * should drive multi-node mutations — otherwise the descendant
919
- * accumulates the transform twice (once via the parent's `transform`,
920
- * once via its own attribute write). Required for `commands.remove`
921
- * (avoids re-attaching detached descendants on undo) and any multi-
922
- * member translate path (avoids 2× drift for the Bar-chart marquee
923
- * case).
924
- *
925
- * Order: preserves the input order for retained ids. Duplicates in
926
- * the input are not deduplicated — callers are responsible (the
927
- * editor's `commands.select` already dedupes).
928
- *
929
- * Performance: `O(n × depth)`. Builds a `Set` over the input once,
930
- * then walks each id's ancestor chain at most once. The main editor's
931
- * version is `O(n² × depth)` (per-pair `isAncestor`) — fine at typical
932
- * selection sizes (a few dozen), worth winning here for free since
933
- * `parent_of` is `O(1)` on our parent-map.
934
- */
935
- prune_nested_nodes(ids) {
936
- if (ids.length <= 1) return [...ids];
937
- const set = new Set(ids);
938
- const out = [];
939
- for (const id of ids) {
940
- let nested = false;
941
- let cur = this.parent_of(id);
942
- while (cur !== null) {
943
- if (set.has(cur)) {
944
- nested = true;
945
- break;
946
- }
947
- cur = this.parent_of(cur);
948
- }
949
- if (!nested) out.push(id);
950
- }
951
- return out;
952
- }
953
- all_nodes() {
954
- const out = [];
955
- const walk = (id) => {
956
- out.push(id);
957
- const c = this.children_of(id);
958
- for (const ch of c) walk(ch);
959
- };
960
- walk(this.root);
961
- return out;
962
- }
963
- all_elements() {
964
- return this.all_nodes().filter((id) => this.is_element(id));
965
- }
966
- find_by_tag(ancestor, tag) {
967
- const out = [];
968
- const walk = (id) => {
969
- if (id !== ancestor && this.is_element(id) && this.tag_of(id) === tag) out.push(id);
970
- for (const c of this.children_of(id)) walk(c);
971
- };
972
- walk(ancestor);
973
- return out;
974
- }
975
- /** Read attribute by local name, optionally namespace-filtered. */
976
- get_attr(id, name, ns = null) {
977
- const n = this.nodes.get(id);
978
- if (!n || n.kind !== "element") return null;
979
- for (const a of n.attrs) if (a.local === name && (ns === null || a.ns === ns)) return a.value;
980
- return null;
981
- }
982
- /**
983
- * Set / remove an attribute. If the attribute exists, it is mutated in place
984
- * (preserving source position). If it doesn't, it's appended.
985
- */
986
- set_attr(id, name, value, ns = null) {
987
- const n = this.nodes.get(id);
988
- if (!n || n.kind !== "element") return;
989
- const structural = name === "id";
990
- const geometry = ns === null && GEOMETRY_ATTRS.has(name);
991
- for (let i = 0; i < n.attrs.length; i++) {
992
- const a = n.attrs[i];
993
- if (a.local === name && (ns === null || a.ns === ns)) {
994
- if (value === null) n.attrs.splice(i, 1);
995
- else a.value = value;
996
- if (structural) this._structure_version++;
997
- if (geometry) this._geometry_version++;
998
- this.emit();
999
- return;
1000
- }
1001
- }
1002
- if (value !== null) {
1003
- const prefix = ns === _grida_svg_parser.XLINK_NS ? "xlink" : null;
1004
- n.attrs.push({
1005
- raw_name: prefix ? `${prefix}:${name}` : name,
1006
- prefix,
1007
- local: name,
1008
- ns,
1009
- value,
1010
- pre: " ",
1011
- eq_trivia: "",
1012
- quote: "\""
1013
- });
1014
- if (structural) this._structure_version++;
1015
- if (geometry) this._geometry_version++;
1016
- this.emit();
1017
- }
1018
- }
1019
- attributes_of(id) {
1020
- const n = this.nodes.get(id);
1021
- if (!n || n.kind !== "element") return [];
1022
- return n.attrs.map((a) => ({
1023
- name: a.local,
1024
- ns: a.ns,
1025
- value: a.value
1026
- }));
1027
- }
1028
- get_style(id, property) {
1029
- const style = this.get_attr(id, "style");
1030
- if (!style) return null;
1031
- const decls = parse_inline_style(style);
1032
- for (const d of decls) if (d.property === property) return d.value;
1033
- return null;
1034
- }
1035
- set_style(id, property, value) {
1036
- const decls = parse_inline_style(this.get_attr(id, "style") ?? "");
1037
- const idx = decls.findIndex((d) => d.property === property);
1038
- if (value === null) {
1039
- if (idx === -1) return;
1040
- decls.splice(idx, 1);
1041
- } else if (idx === -1) decls.push({
1042
- property,
1043
- value
1044
- });
1045
- else decls[idx].value = value;
1046
- const next = decls.map((d) => `${d.property}: ${d.value}`).join("; ");
1047
- this.set_attr(id, "style", next === "" ? null : next);
1048
- }
1049
- get_all_styles(id) {
1050
- const style = this.get_attr(id, "style");
1051
- if (!style) return [];
1052
- return parse_inline_style(style);
1053
- }
1054
- /**
1055
- * Whether `id` can be opened in the flat-string text editor.
1056
- *
1057
- * v1 contract: the editor only operates on a *single flat text run*. That
1058
- * means the target must be a `<text>` or `<tspan>` whose direct children
1059
- * are all text nodes (or it has no children). A `<text>` containing a
1060
- * `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
1061
- * content from the editor's view, and a flat-text write would leave the
1062
- * tspan dangling. Tspan-as-target is fine and well-defined when it's a
1063
- * leaf; only the host decides whether to route double-click to a tspan
1064
- * or its parent text.
1065
- */
1066
- is_text_edit_target(id) {
1067
- const n = this.nodes.get(id);
1068
- if (!n || n.kind !== "element") return false;
1069
- if (n.local !== "text" && n.local !== "tspan") return false;
1070
- for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
1071
- return true;
1072
- }
1073
- /**
1074
- * Returns a tag-discriminated snapshot of the authored geometry attrs
1075
- * if this node is eligible for vector (vertex) editing — else `null`.
1076
- *
1077
- * v1 eligibility:
1078
- * - `<path>` — requires non-empty `d`.
1079
- * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
1080
- * - `<polygon>` — same as polyline.
1081
- *
1082
- * Deliberately rejects `<line>` in v1: the only useful vertex-edit
1083
- * gestures on a `<line>` are (a) introducing a new vertex (which would
1084
- * have to promote it to `<polyline>`) and (b) bending it with a tangent
1085
- * (which would have to promote it to `<path>`). Both promotions are
1086
- * out of scope for v1, so opening a `<line>` in vector-edit mode would
1087
- * advertise capabilities that don't work.
1088
- *
1089
- * Also rejects `<rect>`, `<circle>`, `<ellipse>`, `<image>`, `<use>` —
1090
- * those would force the same promotion-to-`<path>` machinery (trivia
1091
- * transfer, cross-cutting attr carry, DOM-element swap, history-bracket
1092
- * changes) that v1 keeps out of scope.
1093
- */
1094
- is_vector_edit_target(id) {
1095
- const n = this.nodes.get(id);
1096
- if (!n || n.kind !== "element") return null;
1097
- switch (n.local) {
1098
- case "path": {
1099
- const d = this.get_attr(id, "d");
1100
- if (d === null || d.trim().length === 0) return null;
1101
- return {
1102
- kind: "path",
1103
- d
1104
- };
1105
- }
1106
- case "polyline":
1107
- case "polygon": {
1108
- const raw = this.get_attr(id, "points") ?? "";
1109
- const parsed = _grida_svg_parse.svg_parse.parse_points(raw);
1110
- if (parsed.length < 2) return null;
1111
- const points = parsed.map((p) => [p.x, p.y]);
1112
- return n.local === "polyline" ? {
1113
- kind: "polyline",
1114
- points
1115
- } : {
1116
- kind: "polygon",
1117
- points
1118
- };
1119
- }
1120
- default: return null;
1121
- }
1122
- }
1123
- /**
1124
- * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
1125
- * per-glyph attribute (which conflicts with element-level rotation).
1126
- */
1127
- has_glyph_rotate(id) {
1128
- const tag = this.tag_of(id);
1129
- if (tag !== "text" && tag !== "tspan") return false;
1130
- const value = this.get_attr(id, "rotate");
1131
- if (value === null) return false;
1132
- return value.trim() !== "";
1133
- }
1134
- /**
1135
- * True iff this element's inline `style=""` declares a `transform:`
1136
- * CSS property (which would shadow the editor's `transform=` writes).
1137
- */
1138
- has_inline_css_transform(id) {
1139
- const style = this.get_attr(id, "style");
1140
- if (!style) return false;
1141
- return CSS_TRANSFORM_PROPERTY.test(style);
1142
- }
1143
- /**
1144
- * True iff this element has a direct `<animateTransform>` child
1145
- * (which produces a time-varying transform invisible to attribute writes).
1146
- * Only direct children are checked — nested cases attach to the nearer ancestor.
1147
- */
1148
- has_animate_transform_child(id) {
1149
- for (const c of this.children_of(id)) {
1150
- const n = this.nodes.get(c);
1151
- if (n?.kind === "element" && n.local === "animateTransform") return true;
1152
- }
1153
- return false;
1154
- }
1155
- text_of(id) {
1156
- const n = this.nodes.get(id);
1157
- if (!n || n.kind !== "element") return "";
1158
- let out = "";
1159
- for (const c of n.children) {
1160
- const cn = this.nodes.get(c);
1161
- if (cn?.kind === "text") out += cn.value;
1162
- }
1163
- return out;
1164
- }
1165
- /** Replace all direct text children with a single text node carrying `value`. */
1166
- set_text(id, value) {
1167
- const n = this.nodes.get(id);
1168
- if (!n || n.kind !== "element") return;
1169
- n.children = n.children.filter((c) => this.nodes.get(c)?.kind !== "text");
1170
- if (value !== "") {
1171
- const text_id = `t${Math.random().toString(36).slice(2, 10)}`;
1172
- const text_node = {
1173
- kind: "text",
1174
- id: text_id,
1175
- parent: id,
1176
- value
1177
- };
1178
- this.nodes.set(text_id, text_node);
1179
- n.children.push(text_id);
1180
- }
1181
- this._structure_version++;
1182
- this._geometry_version++;
1183
- this.emit();
1184
- }
1185
- insert(id, parent, before) {
1186
- const node = this.nodes.get(id);
1187
- const parent_node = this.nodes.get(parent);
1188
- if (!node || !parent_node || parent_node.kind !== "element") return;
1189
- if (node.parent !== null) {
1190
- const old_parent = this.nodes.get(node.parent);
1191
- if (old_parent && old_parent.kind === "element") {
1192
- const i = old_parent.children.indexOf(id);
1193
- if (i >= 0) old_parent.children.splice(i, 1);
1194
- }
1195
- }
1196
- const ix = before === null ? -1 : parent_node.children.indexOf(before);
1197
- if (ix < 0) parent_node.children.push(id);
1198
- else parent_node.children.splice(ix, 0, id);
1199
- node.parent = parent;
1200
- this._structure_version++;
1201
- this._geometry_version++;
1202
- this.emit();
1203
- }
1204
- remove(id) {
1205
- const n = this.nodes.get(id);
1206
- if (!n || n.parent === null) return;
1207
- const parent = this.nodes.get(n.parent);
1208
- if (!parent || parent.kind !== "element") return;
1209
- const i = parent.children.indexOf(id);
1210
- if (i >= 0) parent.children.splice(i, 1);
1211
- n.parent = null;
1212
- this._structure_version++;
1213
- this._geometry_version++;
1214
- this.emit();
1215
- }
1216
- /** Create a new element node and register it (not yet inserted). */
1217
- create_element(local, opts) {
1218
- const id = `e${Math.random().toString(36).slice(2, 10)}`;
1219
- const prefix = opts?.prefix ?? null;
1220
- const ns = opts?.ns ?? null;
1221
- const node = {
1222
- kind: "element",
1223
- id,
1224
- parent: null,
1225
- raw_tag: prefix ? `${prefix}:${local}` : local,
1226
- prefix,
1227
- local,
1228
- ns,
1229
- attrs: [],
1230
- children: [],
1231
- self_closing: false,
1232
- open_tag_trailing: "",
1233
- close_tag_leading: "",
1234
- close_tag_trailing: ""
1235
- };
1236
- this.nodes.set(id, node);
1237
- return id;
1238
- }
1239
- serialize() {
1240
- let out = "";
1241
- for (const p of this.prolog) out += this.emit_node(p);
1242
- out += this.emit_node(this.nodes.get(this.root));
1243
- for (const e of this.epilog) out += this.emit_node(e);
1244
- return out;
1245
- }
1246
- emit_node(n) {
1247
- switch (n.kind) {
1248
- case "text": return (0, _grida_svg_parser.encode_text)(n.value);
1249
- case "comment": return `<!--${n.value}-->`;
1250
- case "cdata": return `<![CDATA[${n.value}]]>`;
1251
- case "pi": {
1252
- const pi = n;
1253
- return `<?${pi.target}${pi.value ? " " + pi.value : ""}?>`;
1254
- }
1255
- case "doctype": return `<!DOCTYPE${n.value}>`;
1256
- case "element": {
1257
- const e = n;
1258
- let s = `<${e.raw_tag}`;
1259
- for (const a of e.attrs) s += this.emit_attr(a);
1260
- if (e.children.length === 0 && e.self_closing) {
1261
- s += `${e.open_tag_trailing}/>`;
1262
- return s;
1263
- }
1264
- s += `${e.open_tag_trailing}>`;
1265
- for (const cid of e.children) {
1266
- const cn = this.nodes.get(cid);
1267
- if (cn) s += this.emit_node(cn);
1268
- }
1269
- s += `</${e.close_tag_leading}${e.raw_tag}${e.close_tag_trailing}>`;
1270
- return s;
1271
- }
1272
- }
1273
- }
1274
- emit_attr(a) {
1275
- return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${(0, _grida_svg_parser.encode_attr_value)(a.value, a.quote)}${a.quote}`;
1276
- }
1277
- };
1278
- function parse_inline_style(s) {
1279
- const out = [];
1280
- const decls = s.split(";");
1281
- for (const decl of decls) {
1282
- const colon = decl.indexOf(":");
1283
- if (colon === -1) continue;
1284
- const property = decl.slice(0, colon).trim();
1285
- const value = decl.slice(colon + 1).trim();
1286
- if (property) out.push({
1287
- property,
1288
- value
1289
- });
1290
- }
1291
- return out;
1292
- }
1293
- //#endregion
1294
841
  //#region src/core/align.ts
1295
842
  /**
1296
843
  * Compute per-member translation deltas to align `members` against `target`.
@@ -1498,7 +1045,7 @@ const DISPLAY_LABEL_MAX_LEN = 40;
1498
1045
  * to `SvgEditor` so the published `.d.ts` doesn't advertise internals.
1499
1046
  */
1500
1047
  function _create_svg_editor_internal(opts) {
1501
- const doc = new SvgDocument(opts.svg);
1048
+ const doc = new require_model.SvgDocument(opts.svg);
1502
1049
  const history = new _grida_history.HistoryImpl();
1503
1050
  const defs = create_defs(doc);
1504
1051
  let selection = [];
@@ -1561,12 +1108,22 @@ function _create_svg_editor_internal(opts) {
1561
1108
  const notify_translate_commit = () => {
1562
1109
  for (const cb of translate_commit_listeners) cb();
1563
1110
  };
1564
- doc.on_change(() => {
1565
- doc_version++;
1111
+ /**
1112
+ * Fan out the geometry channel iff the doc's `geometry_version` has
1113
+ * moved since we last fired. Shared by the `doc.on_change` handler
1114
+ * (mutation-driven bumps) and the surface-driven `bump_geometry` seam
1115
+ * (font-load reflow). Idempotent against a stale version — never
1116
+ * double-fires for the same value.
1117
+ */
1118
+ function fire_geometry_listeners_if_advanced() {
1566
1119
  if (doc.geometry_version !== last_emitted_geometry_version) {
1567
1120
  last_emitted_geometry_version = doc.geometry_version;
1568
1121
  for (const cb of geometry_listeners) cb();
1569
1122
  }
1123
+ }
1124
+ doc.on_change(() => {
1125
+ doc_version++;
1126
+ fire_geometry_listeners_if_advanced();
1570
1127
  });
1571
1128
  function subscribe(fn) {
1572
1129
  listeners.add(fn);
@@ -1846,7 +1403,8 @@ function _create_svg_editor_internal(opts) {
1846
1403
  snap_threshold_px: style.snap_threshold_px
1847
1404
  },
1848
1405
  emit,
1849
- stages
1406
+ stages,
1407
+ project: (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d
1850
1408
  });
1851
1409
  apply();
1852
1410
  history.atomic(label, (tx) => {
@@ -1865,66 +1423,79 @@ function _create_svg_editor_internal(opts) {
1865
1423
  if (do_translate_oneshot(delta, require_model.translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
1866
1424
  }
1867
1425
  /**
1868
- * One-shot multi-member resize to an explicit target rect. Mirrors a
1869
- * drag-resize gesture in mechanics capture per-member baselines,
1870
- * scale around the union's NW corner, translate the result so the
1871
- * union NW lands at the requested position — but as a single
1872
- * atomic step rather than a preview session.
1426
+ * Gate + capture for a resize gesture. Returns the resizable members (with
1427
+ * captured baseline / pre-transform / bbox), or `null` if the gesture can't
1428
+ * run: no geometry provider, empty selection, or in `all_or_nothing` mode
1429
+ * any member fails the gate.
1873
1430
  *
1874
- * The function does its own geometry lookup via the
1875
- * `geometry_provider` registered by the DOM surface. When no surface
1876
- * is attached, the call is a no-op (returns `false`). Members whose
1877
- * tag is not resizable are silently filtered.
1878
- *
1879
- * Revert restores the captured `transform` attribute and all
1880
- * geometry attrs the apply step wrote so a `<rect>` with an
1881
- * existing `transform` round-trips cleanly. See `apply_translate`'s
1882
- * `viaTransform` arm for why this matters.
1431
+ * `mode`:
1432
+ * - `"skip"` drop members failing the `is_resizable_node` gate
1433
+ * (tag + transform class) or lacking a bbox; resize the rest. Used by the
1434
+ * inspector `resize_to` (set-bbox) path.
1435
+ * - `"all_or_nothing"` — refuse the WHOLE gesture (return `null`) if ANY
1436
+ * member fails. Used by keyboard `resize_by` (nudge), matching the resize
1437
+ * HUD, whose handle-drag is rejected when any member is unsafe.
1883
1438
  */
1884
- function resize_to(target, opts) {
1885
- const ids = opts?.ids ?? selection;
1886
- if (ids.length === 0) return false;
1887
- if (!geometry_provider) return false;
1439
+ function collect_resize_members(ids, mode) {
1440
+ if (ids.length === 0) return null;
1441
+ if (!geometry_provider) return null;
1888
1442
  const members = [];
1889
1443
  for (const id of ids) {
1890
- if (!require_model.resize_pipeline.intent.is_resizable(doc.tag_of(id))) continue;
1444
+ if (!require_model.resize_pipeline.intent.is_resizable_node(doc, id)) {
1445
+ if (mode === "all_or_nothing") return null;
1446
+ continue;
1447
+ }
1891
1448
  const bbox = geometry_provider.bounds_of(id);
1892
- if (!bbox) continue;
1449
+ if (!bbox) {
1450
+ if (mode === "all_or_nothing") return null;
1451
+ continue;
1452
+ }
1893
1453
  members.push({
1894
1454
  id,
1895
1455
  rz: require_model.resize_pipeline.intent.capture_baseline(doc, id, bbox),
1896
- tx_pre: require_model.translate_pipeline.intent.capture_baseline(doc, id),
1897
1456
  transform_pre: doc.get_attr(id, "transform"),
1898
1457
  bbox
1899
1458
  });
1900
1459
  }
1901
- if (members.length === 0) return false;
1902
- const union = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
1903
- const sx = union.width === 0 ? 1 : target.width / union.width;
1904
- const sy = union.height === 0 ? 1 : target.height / union.height;
1905
- const origin = {
1906
- x: union.x,
1907
- y: union.y
1908
- };
1909
- const dx = target.x - union.x;
1910
- const dy = target.y - union.y;
1460
+ return members.length === 0 ? null : members;
1461
+ }
1462
+ /**
1463
+ * Apply a resize to each member, optionally followed by a uniform group
1464
+ * translate, as ONE atomic history step. `op` resolves each member's scale
1465
+ * factors + scale origin; `group_translate` is the post-scale envelope shift
1466
+ * (group resize only — `null` for per-element). Callers guarantee `members`
1467
+ * is non-empty. Returns `true` when a history step was pushed; `false` when
1468
+ * the gesture is geometrically identity (no member scales and no group
1469
+ * translate) so undo isn't polluted with an empty step. NOTE: a per-tag
1470
+ * constraint that collapses a non-1 factor to identity *inside* the handler
1471
+ * (e.g. `<circle>` uniform `min` on a single-axis nudge) is not detected
1472
+ * here — the op-level factor is still ≠ 1, so that case still pushes a step.
1473
+ */
1474
+ function commit_resize(members, op, group_translate, label) {
1475
+ const ops = members.map((m) => ({
1476
+ m,
1477
+ ...op(m)
1478
+ }));
1479
+ const scales = ops.some(({ sx, sy }) => sx !== 1 || sy !== 1);
1480
+ const translates = !!group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0);
1481
+ if (!scales && !translates) return false;
1911
1482
  const apply = () => {
1912
- for (const m of members) require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
1913
- if (dx !== 0 || dy !== 0) for (const m of members) {
1483
+ for (const { m, sx, sy, origin } of ops) require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
1484
+ if (group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0)) for (const m of members) {
1914
1485
  const tx_after = require_model.translate_pipeline.intent.capture_baseline(doc, m.id);
1915
- require_model.translate_pipeline.intent.apply(doc, m.id, tx_after, dx, dy);
1486
+ require_model.translate_pipeline.intent.apply(doc, m.id, tx_after, group_translate.dx, group_translate.dy);
1916
1487
  }
1917
1488
  emit();
1918
1489
  };
1919
1490
  const revert = () => {
1920
- for (const m of members) {
1491
+ for (const { m, origin } of ops) {
1921
1492
  require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
1922
1493
  doc.set_attr(m.id, "transform", m.transform_pre);
1923
1494
  }
1924
1495
  emit();
1925
1496
  };
1926
1497
  apply();
1927
- history.atomic("resize-to", (tx) => {
1498
+ history.atomic(label, (tx) => {
1928
1499
  tx.push({
1929
1500
  providerId: PROVIDER_ID,
1930
1501
  apply,
@@ -1933,6 +1504,73 @@ function _create_svg_editor_internal(opts) {
1933
1504
  });
1934
1505
  return true;
1935
1506
  }
1507
+ /**
1508
+ * One-shot multi-member resize to an explicit target rect. Mirrors a
1509
+ * drag-resize gesture in mechanics — capture per-member baselines,
1510
+ * scale around the union's NW corner, translate the result so the
1511
+ * union NW lands at the requested position — but as a single
1512
+ * atomic step rather than a preview session. This is the GROUP path:
1513
+ * the whole selection is treated as one envelope.
1514
+ *
1515
+ * The function does its own geometry lookup via the
1516
+ * `geometry_provider` registered by the DOM surface. When no surface
1517
+ * is attached, the call is a no-op (returns `false`). Members that fail
1518
+ * the `is_resizable_node` gate — an unresizable tag (e.g. `<g>`) OR a
1519
+ * non-trivially-transformed element — are silently skipped (see
1520
+ * `collect_resize_members`).
1521
+ *
1522
+ * Revert restores the captured `transform` attribute and all
1523
+ * geometry attrs the apply step wrote — so a `<rect>` with an
1524
+ * existing `transform` round-trips cleanly. See `apply_translate`'s
1525
+ * `viaTransform` arm for why this matters.
1526
+ */
1527
+ function resize_to(target, opts) {
1528
+ const members = collect_resize_members(opts?.ids ?? selection, "skip");
1529
+ if (!members) return false;
1530
+ const union = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
1531
+ const sx = union.width === 0 ? 1 : target.width / union.width;
1532
+ const sy = union.height === 0 ? 1 : target.height / union.height;
1533
+ const origin = {
1534
+ x: union.x,
1535
+ y: union.y
1536
+ };
1537
+ return commit_resize(members, () => ({
1538
+ sx,
1539
+ sy,
1540
+ origin
1541
+ }), {
1542
+ dx: target.x - union.x,
1543
+ dy: target.y - union.y
1544
+ }, opts?.label ?? "resize-to");
1545
+ }
1546
+ /**
1547
+ * Resize by a `{dw, dh}` delta — the core verb behind keyboard nudge-resize
1548
+ * (`Ctrl+Alt+Arrow`). This is the PER-ELEMENT path: each selected member
1549
+ * grows/shrinks by the delta around ITS OWN NW corner, so members keep their
1550
+ * positions relative to one another. This deliberately differs from
1551
+ * {@link resize_to} (the group/envelope path): a HUD group-resize scales the
1552
+ * whole selection around the shared union origin, translating off-origin
1553
+ * members — correct for a drag handle, wrong for a keyboard nudge, whose UX
1554
+ * is "apply the delta to each".
1555
+ *
1556
+ * ALL-OR-NOTHING gate (`collect_resize_members("all_or_nothing")`): refuses
1557
+ * (returns `false`, no history step) on empty selection, no geometry
1558
+ * provider, or any member failing the `is_resizable_node` gate — matching
1559
+ * the resize HUD rather than `resize_to`'s per-member skip.
1560
+ */
1561
+ function resize_by(delta, opts) {
1562
+ const members = collect_resize_members(opts?.ids ?? selection, "all_or_nothing");
1563
+ if (!members) return false;
1564
+ const axis = (size, d) => size === 0 ? 1 : Math.max(0, size + d) / size;
1565
+ return commit_resize(members, (m) => ({
1566
+ sx: axis(m.bbox.width, delta.dw),
1567
+ sy: axis(m.bbox.height, delta.dh),
1568
+ origin: {
1569
+ x: m.bbox.x,
1570
+ y: m.bbox.y
1571
+ }
1572
+ }), null, "nudge-resize");
1573
+ }
1936
1574
  /** Shared helper: compute a default rotation pivot from the live
1937
1575
  * geometry_provider when the caller omitted one. Falls back to (0,0)
1938
1576
  * if no surface is attached. */
@@ -2014,6 +1652,70 @@ function _create_svg_editor_internal(opts) {
2014
1652
  });
2015
1653
  return true;
2016
1654
  }
1655
+ /**
1656
+ * Relative affine compose about a pivot. See the `Commands.transform`
1657
+ * doc for the full contract. This function owns ONLY the pivot/effective-
1658
+ * matrix computation (which needs `geometry_provider`); the parse→fold→
1659
+ * emit round-trip is delegated per-member to the pure
1660
+ * `transform.apply_affine` helper.
1661
+ */
1662
+ function apply_transform(matrix, opts) {
1663
+ const ids = opts?.ids ?? selection;
1664
+ if (ids.length === 0) return false;
1665
+ if (!geometry_provider) return false;
1666
+ for (const id of ids) if (require_model.rotate_pipeline.intent.is_transformable(doc, id).kind === "refuse") return false;
1667
+ const pivot = opts?.pivot ?? default_rotate_pivot(ids);
1668
+ const [a, b, c, d, e, f] = matrix;
1669
+ const requested = [[
1670
+ a,
1671
+ c,
1672
+ e
1673
+ ], [
1674
+ b,
1675
+ d,
1676
+ f
1677
+ ]];
1678
+ const t_pivot = [[
1679
+ 1,
1680
+ 0,
1681
+ pivot.x
1682
+ ], [
1683
+ 0,
1684
+ 1,
1685
+ pivot.y
1686
+ ]];
1687
+ const t_neg_pivot = [[
1688
+ 1,
1689
+ 0,
1690
+ -pivot.x
1691
+ ], [
1692
+ 0,
1693
+ 1,
1694
+ -pivot.y
1695
+ ]];
1696
+ const effective = _grida_cmath.default.transform.multiply(_grida_cmath.default.transform.multiply(t_pivot, requested), t_neg_pivot);
1697
+ const members = ids.map((id) => ({
1698
+ id,
1699
+ transform_pre: doc.get_attr(id, "transform")
1700
+ }));
1701
+ const apply = () => {
1702
+ for (const m of members) doc.set_attr(m.id, "transform", require_model.transform.apply_affine(m.transform_pre, effective));
1703
+ emit();
1704
+ };
1705
+ const revert = () => {
1706
+ for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
1707
+ emit();
1708
+ };
1709
+ apply();
1710
+ history.atomic("transform", (tx) => {
1711
+ tx.push({
1712
+ providerId: PROVIDER_ID,
1713
+ apply,
1714
+ revert
1715
+ });
1716
+ });
1717
+ return true;
1718
+ }
2017
1719
  function flatten_transform(opts) {
2018
1720
  const ids = opts?.ids ?? selection;
2019
1721
  if (ids.length === 0) return false;
@@ -2265,6 +1967,47 @@ function _create_svg_editor_internal(opts) {
2265
1967
  });
2266
1968
  return true;
2267
1969
  }
1970
+ function ungroup(opts) {
1971
+ let target;
1972
+ if (opts?.id !== void 0) target = opts.id;
1973
+ else {
1974
+ if (selection.length !== 1) return false;
1975
+ target = selection[0];
1976
+ }
1977
+ const plan = require_model.group.plan_ungroup(doc, target);
1978
+ if (!plan) return false;
1979
+ const group_id = plan.group_id;
1980
+ const group_next_sibling = doc.next_element_sibling_of(group_id);
1981
+ const original_child_transforms = /* @__PURE__ */ new Map();
1982
+ for (const child of plan.children) original_child_transforms.set(child, doc.get_attr(child, "transform"));
1983
+ const group_ops = plan.group_transform === null ? [] : require_model.transform.parse(plan.group_transform) ?? [];
1984
+ const original_selection = selection;
1985
+ const apply = () => {
1986
+ if (group_ops.length > 0) for (const child of plan.children) {
1987
+ const child_ops = require_model.transform.parse(doc.get_attr(child, "transform")) ?? [];
1988
+ const next = require_model.transform.emit([...group_ops, ...child_ops]);
1989
+ doc.set_attr(child, "transform", next === "" ? null : next);
1990
+ }
1991
+ for (const child of plan.children) doc.insert(child, plan.parent, group_id);
1992
+ doc.remove(group_id);
1993
+ set_selection(plan.children);
1994
+ };
1995
+ const revert = () => {
1996
+ doc.insert(group_id, plan.parent, group_next_sibling);
1997
+ for (const child of plan.children) doc.insert(child, group_id, null);
1998
+ if (group_ops.length > 0) for (const child of plan.children) doc.set_attr(child, "transform", original_child_transforms.get(child) ?? null);
1999
+ set_selection(original_selection);
2000
+ };
2001
+ apply();
2002
+ history.atomic("ungroup", (tx) => {
2003
+ tx.push({
2004
+ providerId: PROVIDER_ID,
2005
+ apply,
2006
+ revert
2007
+ });
2008
+ });
2009
+ return true;
2010
+ }
2268
2011
  /**
2269
2012
  * Atomic one-shot insertion. Used by paste, programmatic RPC, and the
2270
2013
  * click-no-drag commit path inside the insertion gesture driver. One
@@ -2274,11 +2017,20 @@ function _create_svg_editor_internal(opts) {
2274
2017
  * win. `opts.parent` defaults to root; `opts.index` (insert-before
2275
2018
  * sibling index) defaults to append; `opts.select` defaults to `true`.
2276
2019
  */
2020
+ /**
2021
+ * Resolve an optional `index` (position in `parent`'s element-children
2022
+ * list to insert AT — anything at or after it shifts; out-of-range or
2023
+ * `undefined` appends) to an insert-before anchor. Shared by `insert`,
2024
+ * `insert_fragment`, and `insert_preview`.
2025
+ */
2026
+ function resolve_insert_before(parent, index) {
2027
+ if (index === void 0) return null;
2028
+ return doc.element_children_of(parent)[index] ?? null;
2029
+ }
2277
2030
  function insert(tag, attrs, opts) {
2278
2031
  const parent = opts?.parent ?? doc.root;
2279
2032
  const select_after = opts?.select !== false;
2280
- let insert_before = null;
2281
- if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
2033
+ const insert_before = resolve_insert_before(parent, opts?.index);
2282
2034
  const id = doc.create_element(tag);
2283
2035
  const merged_attrs = {
2284
2036
  ...default_paint_attrs_for(tag),
@@ -2306,6 +2058,56 @@ function _create_svg_editor_internal(opts) {
2306
2058
  return id;
2307
2059
  }
2308
2060
  /**
2061
+ * Atomic fragment insertion — contract in {@link Commands.insert_fragment}.
2062
+ * Parses + adopts via `doc.create_fragment` (subtrees registered but
2063
+ * detached, like `create_element` — history.redo finds them via
2064
+ * closure), computes the namespace hoist plan, then brackets inserts +
2065
+ * hoisted declarations + selection in ONE history step.
2066
+ */
2067
+ function insert_fragment(svg, opts) {
2068
+ const parent = opts?.parent ?? doc.root;
2069
+ if (!doc.is_element(parent) || !doc.contains(doc.root, parent)) throw new Error(`insert_fragment: parent ${JSON.stringify(parent)} is not an element in the current document`);
2070
+ const select_after = opts?.select !== false;
2071
+ const { roots, xmlns } = doc.create_fragment(svg);
2072
+ if (roots.length === 0) return [];
2073
+ const known_uri = new Map([["xlink", _grida_svg_parser.XLINK_NS]]);
2074
+ for (const d of xmlns) known_uri.set(d.prefix, d.uri);
2075
+ const hoist = [];
2076
+ const considered = /* @__PURE__ */ new Set();
2077
+ for (const id of roots) for (const prefix of doc.undeclared_ns_prefixes(id)) {
2078
+ if (considered.has(prefix)) continue;
2079
+ considered.add(prefix);
2080
+ if (doc.get_attr(doc.root, prefix, _grida_svg_parser.XMLNS_NS) !== null) continue;
2081
+ const uri = known_uri.get(prefix);
2082
+ if (uri === void 0) continue;
2083
+ hoist.push({
2084
+ prefix,
2085
+ uri
2086
+ });
2087
+ }
2088
+ const insert_before = resolve_insert_before(parent, opts?.index);
2089
+ const previous_selection = selection;
2090
+ const apply = () => {
2091
+ for (const { prefix, uri } of hoist) doc.declare_xmlns(prefix, uri);
2092
+ for (const id of roots) doc.insert(id, parent, insert_before);
2093
+ if (select_after) set_selection(roots);
2094
+ };
2095
+ const revert = () => {
2096
+ for (let i = roots.length - 1; i >= 0; i--) doc.remove(roots[i]);
2097
+ for (const { prefix } of hoist) doc.set_attr(doc.root, prefix, null, _grida_svg_parser.XMLNS_NS);
2098
+ if (select_after) set_selection(previous_selection);
2099
+ };
2100
+ apply();
2101
+ history.atomic("insert fragment", (tx) => {
2102
+ tx.push({
2103
+ providerId: PROVIDER_ID,
2104
+ apply,
2105
+ revert
2106
+ });
2107
+ });
2108
+ return roots;
2109
+ }
2110
+ /**
2309
2111
  * Preview-bracketed insertion. Used by the pointer-driven drag gesture
2310
2112
  * in the DOM surface. Per-frame attr writes call `update(attrs)`; one
2311
2113
  * undo step on `commit()`; clean rollback on `discard()`.
@@ -2316,8 +2118,7 @@ function _create_svg_editor_internal(opts) {
2316
2118
  */
2317
2119
  function insert_preview(tag, initial, opts) {
2318
2120
  const parent = opts?.parent ?? doc.root;
2319
- let insert_before = null;
2320
- if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
2121
+ const insert_before = resolve_insert_before(parent, opts?.index);
2321
2122
  const id = doc.create_element(tag);
2322
2123
  const previous_selection = selection;
2323
2124
  const live_attrs = {
@@ -2475,6 +2276,10 @@ function _create_svg_editor_internal(opts) {
2475
2276
  current_surface_hover = id;
2476
2277
  notify_surface_hover();
2477
2278
  }
2279
+ const pick_listeners = /* @__PURE__ */ new Set();
2280
+ function notify_pick(e) {
2281
+ for (const cb of pick_listeners) cb(e);
2282
+ }
2478
2283
  function enter_content_edit(target) {
2479
2284
  const id = target ?? (selection.length === 1 ? selection[0] : null);
2480
2285
  if (!id) return false;
@@ -2520,14 +2325,18 @@ function _create_svg_editor_internal(opts) {
2520
2325
  translate,
2521
2326
  nudge,
2522
2327
  resize_to,
2328
+ resize_by,
2523
2329
  rotate,
2524
2330
  rotate_to,
2331
+ transform: apply_transform,
2525
2332
  flatten_transform,
2526
2333
  align,
2527
2334
  reorder,
2528
2335
  remove,
2529
2336
  group: group$1,
2337
+ ungroup,
2530
2338
  insert,
2339
+ insert_fragment,
2531
2340
  insert_preview,
2532
2341
  set_text,
2533
2342
  load_svg,
@@ -2573,6 +2382,10 @@ function _create_svg_editor_internal(opts) {
2573
2382
  function dispose() {
2574
2383
  detach();
2575
2384
  listeners.clear();
2385
+ surface_hover_listeners.clear();
2386
+ geometry_listeners.clear();
2387
+ translate_commit_listeners.clear();
2388
+ pick_listeners.clear();
2576
2389
  }
2577
2390
  function set_style(partial) {
2578
2391
  style = {
@@ -2664,6 +2477,22 @@ function _create_svg_editor_internal(opts) {
2664
2477
  };
2665
2478
  },
2666
2479
  /**
2480
+ * Subscribe to pick (tap) outcomes — a discrete click on the canvas,
2481
+ * reporting the document-space point and the node under it (`null` for
2482
+ * empty canvas), plus the button and modifier snapshot. Fires once per
2483
+ * tap, after the editor's own selection handling. Observe-only: a pick
2484
+ * cannot alter selection, and the channel does NOT bump `state.version`.
2485
+ * See {@link PickEvent}.
2486
+ *
2487
+ * @unstable
2488
+ */
2489
+ subscribe_pick(cb) {
2490
+ pick_listeners.add(cb);
2491
+ return () => {
2492
+ pick_listeners.delete(cb);
2493
+ };
2494
+ },
2495
+ /**
2667
2496
  * Subscribe to bounds-affecting changes. Fires when any document
2668
2497
  * mutation advances `state.geometry_version` — drag, resize, text
2669
2498
  * edit, structural insert/remove. Skips presentation-only writes
@@ -2692,6 +2521,21 @@ function _create_svg_editor_internal(opts) {
2692
2521
  set_style,
2693
2522
  load,
2694
2523
  serialize,
2524
+ /**
2525
+ * Serialize a single element's subtree as an SVG **fragment**, using the
2526
+ * same trivia-preserving rules as {@link serialize} — for handing "the
2527
+ * markup of the element the user selected" to a downstream consumer
2528
+ * (e.g. an AI agent) without re-serializing the whole document.
2529
+ *
2530
+ * Fragment, not document (see `SvgDocument.serialize_node`): it does NOT
2531
+ * carry `serialize()`'s whole-document round-trip guarantee. Namespace
2532
+ * declarations on an ancestor (`xmlns:xlink`, normally on the root
2533
+ * `<svg>`) are NOT inlined — a node using `xlink:href` serializes without
2534
+ * `xmlns:xlink`. Throws on an unknown id or a non-element node.
2535
+ */
2536
+ serialize_node(id) {
2537
+ return doc.serialize_node(id);
2538
+ },
2695
2539
  reset,
2696
2540
  attach,
2697
2541
  detach,
@@ -2719,11 +2563,18 @@ function _create_svg_editor_internal(opts) {
2719
2563
  push_surface_hover(id) {
2720
2564
  _set_current_surface_hover(id);
2721
2565
  },
2566
+ push_pick(e) {
2567
+ notify_pick(e);
2568
+ },
2722
2569
  set_computed_resolver(fn) {
2723
2570
  computed_resolver = fn;
2724
2571
  },
2725
2572
  set_geometry(p) {
2726
2573
  geometry_provider = p;
2574
+ },
2575
+ bump_geometry() {
2576
+ doc.bump_geometry();
2577
+ fire_geometry_listeners_if_advanced();
2727
2578
  }
2728
2579
  },
2729
2580
  keymap