@grida/svg-editor 1.0.0-alpha.15 → 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,9 +1,7 @@
1
- import { _ as is_text_input_focused, a as paint, g as array_shallow_equal, h as group, i as TOOL_CURSOR, m as transform, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline } from "./model-B2UWgViT.mjs";
1
+ import { _ as XLINK_NS, a as paint, b as is_text_input_focused, g as SvgDocument, h as transform, i as TOOL_CURSOR, m as group, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline, v as XMLNS_NS, y as array_shallow_equal } from "./model-L3t9ixT_.mjs";
2
2
  import { HistoryImpl } from "@grida/history";
3
3
  import { KeyCode, M, chunkKey, eventToChunk, getKeyboardOS, kb, keybindingsToKeyCodes } from "@grida/keybinding";
4
4
  import cmath from "@grida/cmath";
5
- import { XLINK_NS, encode_attr_value, encode_text, parse_svg } from "@grida/svg/parser";
6
- import { svg_parse } from "@grida/svg/parse";
7
5
  //#region src/commands/registry.ts
8
6
  var CommandRegistry = class {
9
7
  constructor() {
@@ -84,12 +82,26 @@ function registerDefaultCommands(reg, editor) {
84
82
  if (editor.state.selection.length === 0) return false;
85
83
  return editor.commands.group();
86
84
  });
85
+ reg.register("selection.ungroup", () => {
86
+ if (editor.state.mode !== "select") return false;
87
+ if (editor.state.selection.length !== 1) return false;
88
+ return editor.commands.ungroup();
89
+ });
87
90
  reg.register("selection.resize_to", (args) => {
88
91
  if (editor.state.mode !== "select") return false;
89
92
  if (editor.state.selection.length === 0) return false;
90
93
  const target = args;
91
94
  return editor.commands.resize_to(target);
92
95
  });
96
+ reg.register("selection.nudge_resize", (args) => {
97
+ if (editor.state.mode !== "select") return false;
98
+ if (editor.state.selection.length === 0) return false;
99
+ const { dw, dh } = args;
100
+ return editor.commands.resize_by({
101
+ dw,
102
+ dh
103
+ });
104
+ });
93
105
  reg.register("selection.rotate", (args) => {
94
106
  if (editor.state.mode !== "select") return false;
95
107
  if (editor.state.selection.length === 0) return false;
@@ -309,7 +321,8 @@ function compareEntries(a, b) {
309
321
  * Same key, multiple meanings? Add multiple rows. The chain semantics
310
322
  * (handler returns `false` when not applicable) handle the rest.
311
323
  */
312
- const NUDGE_MEANINGFUL = M.Shift;
324
+ const NUDGE_MEANINGFUL = M.Shift | M.Ctrl;
325
+ const RESIZE_MEANINGFUL = M.Shift | M.Ctrl | M.Alt;
313
326
  const DEFAULT_BINDINGS = [
314
327
  {
315
328
  keybinding: kb(KeyCode.KeyZ, M.CtrlCmd),
@@ -339,6 +352,10 @@ const DEFAULT_BINDINGS = [
339
352
  keybinding: kb(KeyCode.KeyG, M.CtrlCmd),
340
353
  command: "selection.group"
341
354
  },
355
+ {
356
+ keybinding: kb(KeyCode.KeyG, M.CtrlCmd | M.Shift),
357
+ command: "selection.ungroup"
358
+ },
342
359
  {
343
360
  keybinding: kb(KeyCode.KeyA, M.CtrlCmd),
344
361
  command: "selection.all"
@@ -459,6 +476,70 @@ const DEFAULT_BINDINGS = [
459
476
  dy: 10
460
477
  }
461
478
  },
479
+ {
480
+ keybinding: kb(KeyCode.RightArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
481
+ command: "selection.nudge_resize",
482
+ args: {
483
+ dw: 1,
484
+ dh: 0
485
+ }
486
+ },
487
+ {
488
+ keybinding: kb(KeyCode.LeftArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
489
+ command: "selection.nudge_resize",
490
+ args: {
491
+ dw: -1,
492
+ dh: 0
493
+ }
494
+ },
495
+ {
496
+ keybinding: kb(KeyCode.DownArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
497
+ command: "selection.nudge_resize",
498
+ args: {
499
+ dw: 0,
500
+ dh: 1
501
+ }
502
+ },
503
+ {
504
+ keybinding: kb(KeyCode.UpArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
505
+ command: "selection.nudge_resize",
506
+ args: {
507
+ dw: 0,
508
+ dh: -1
509
+ }
510
+ },
511
+ {
512
+ keybinding: kb(KeyCode.RightArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
513
+ command: "selection.nudge_resize",
514
+ args: {
515
+ dw: 10,
516
+ dh: 0
517
+ }
518
+ },
519
+ {
520
+ keybinding: kb(KeyCode.LeftArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
521
+ command: "selection.nudge_resize",
522
+ args: {
523
+ dw: -10,
524
+ dh: 0
525
+ }
526
+ },
527
+ {
528
+ keybinding: kb(KeyCode.DownArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
529
+ command: "selection.nudge_resize",
530
+ args: {
531
+ dw: 0,
532
+ dh: 10
533
+ }
534
+ },
535
+ {
536
+ keybinding: kb(KeyCode.UpArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
537
+ command: "selection.nudge_resize",
538
+ args: {
539
+ dw: 0,
540
+ dh: -10
541
+ }
542
+ },
462
543
  {
463
544
  keybinding: kb(KeyCode.KeyV),
464
545
  command: TOOL_SET,
@@ -755,825 +836,6 @@ function create_defs(doc) {
755
836
  return { gradients: new GradientsRegistry(doc) };
756
837
  }
757
838
  //#endregion
758
- //#region src/core/document.ts
759
- /** The native vector tags `retype_to_path` can re-type, keyed by tag → the
760
- * native geometry attributes it consumes (so no orphaned geometry attr
761
- * survives on the resulting `<path>`). Covers the geometry primitives
762
- * (rect / circle / ellipse — always re-typed) and the vertex tags (line /
763
- * polyline / polygon — re-typed only when an edit escapes their native
764
- * form). */
765
- const RETYPABLE_GEOMETRY_ATTRS = {
766
- line: new Set([
767
- "x1",
768
- "y1",
769
- "x2",
770
- "y2"
771
- ]),
772
- polyline: new Set(["points"]),
773
- polygon: new Set(["points"]),
774
- rect: new Set([
775
- "x",
776
- "y",
777
- "width",
778
- "height",
779
- "rx",
780
- "ry"
781
- ]),
782
- circle: new Set([
783
- "cx",
784
- "cy",
785
- "r"
786
- ]),
787
- ellipse: new Set([
788
- "cx",
789
- "cy",
790
- "rx",
791
- "ry"
792
- ])
793
- };
794
- /**
795
- * Parse a single SVG length attribute as a plain user-unit number. Returns
796
- * `null` for absent, non-finite, or unit/percentage values (`50%`, `5px`,
797
- * `5em`) — those are an out-of-scope geometry gap, and refusing them here
798
- * means the editor never offers a promotion it cannot perform faithfully.
799
- */
800
- function parse_user_unit(raw) {
801
- if (raw === null) return null;
802
- const s = raw.trim();
803
- if (s === "") return null;
804
- if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
805
- const n = Number(s);
806
- return Number.isFinite(n) ? n : null;
807
- }
808
- /**
809
- * Attribute names whose writes can shift a node's rendered bounds.
810
- * Membership drives `_geometry_version` bumps in `set_attr`. Only
811
- * non-namespaced attribute names — namespaced writes (xlink:href, etc.)
812
- * never bump because they're references, not geometry.
813
- *
814
- * Includes text-shaping attributes (font-*) because they re-shape glyph
815
- * runs and change `<text>` bbox.
816
- */
817
- const GEOMETRY_ATTRS = new Set([
818
- "x",
819
- "y",
820
- "x1",
821
- "y1",
822
- "x2",
823
- "y2",
824
- "cx",
825
- "cy",
826
- "width",
827
- "height",
828
- "r",
829
- "rx",
830
- "ry",
831
- "points",
832
- "d",
833
- "transform",
834
- "viewBox",
835
- "font-size",
836
- "font-family",
837
- "font-weight",
838
- "font-style",
839
- "text-anchor",
840
- "dx",
841
- "dy",
842
- "rotate",
843
- "textLength",
844
- "lengthAdjust",
845
- "pathLength",
846
- "marker-start",
847
- "marker-mid",
848
- "marker-end"
849
- ]);
850
- /** `transform:` CSS property at the start of a declaration list or after `;`. */
851
- const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
852
- var SvgDocument = class SvgDocument {
853
- constructor(svg) {
854
- this.listeners = /* @__PURE__ */ new Set();
855
- this._structure_version = 0;
856
- this._geometry_version = 0;
857
- if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
858
- this.source = svg;
859
- const parsed = parse_svg(svg);
860
- this.original = parsed;
861
- this.nodes = parsed.nodes;
862
- this.prolog = parsed.prolog;
863
- this.epilog = parsed.epilog;
864
- this.root = parsed.root;
865
- }
866
- static parse(svg) {
867
- return new SvgDocument(svg);
868
- }
869
- /** Reload from the original parse, discarding all edits. */
870
- reset_to_original() {
871
- const parsed = parse_svg(this.source);
872
- this.original = parsed;
873
- this.nodes = parsed.nodes;
874
- this.prolog = parsed.prolog;
875
- this.epilog = parsed.epilog;
876
- this.root = parsed.root;
877
- this._structure_version++;
878
- this._geometry_version++;
879
- this.emit();
880
- }
881
- /** Replace document with new svg source (clears edits + history-owned state). */
882
- load(svg) {
883
- if (typeof svg !== "string") throw new TypeError(`SvgDocument.load(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
884
- this.source = svg;
885
- const parsed = parse_svg(svg);
886
- this.original = parsed;
887
- this.nodes = parsed.nodes;
888
- this.prolog = parsed.prolog;
889
- this.epilog = parsed.epilog;
890
- this.root = parsed.root;
891
- this._structure_version++;
892
- this._geometry_version++;
893
- this.emit();
894
- }
895
- on_change(fn) {
896
- this.listeners.add(fn);
897
- return () => this.listeners.delete(fn);
898
- }
899
- /** See `_structure_version` for what this counter signals. */
900
- get structure_version() {
901
- return this._structure_version;
902
- }
903
- /** See `_geometry_version` for what this counter signals. */
904
- get geometry_version() {
905
- return this._geometry_version;
906
- }
907
- emit() {
908
- for (const fn of this.listeners) fn();
909
- }
910
- /** Notify subscribers — for callers that mutate directly via setAttr/etc. */
911
- notify() {
912
- this.emit();
913
- }
914
- get(id) {
915
- return this.nodes.get(id) ?? null;
916
- }
917
- is_element(id) {
918
- return this.nodes.get(id)?.kind === "element";
919
- }
920
- parent_of(id) {
921
- return this.nodes.get(id)?.parent ?? null;
922
- }
923
- children_of(id) {
924
- const n = this.nodes.get(id);
925
- if (!n || n.kind !== "element") return [];
926
- return n.children;
927
- }
928
- /** Element children only — text/comment/cdata filtered out. */
929
- element_children_of(id) {
930
- return this.children_of(id).filter((c) => this.is_element(c));
931
- }
932
- next_sibling_of(id) {
933
- const parent = this.parent_of(id);
934
- if (parent === null) return null;
935
- const siblings = this.children_of(parent);
936
- const i = siblings.indexOf(id);
937
- return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
938
- }
939
- next_element_sibling_of(id) {
940
- const parent = this.parent_of(id);
941
- if (parent === null) return null;
942
- const siblings = this.element_children_of(parent);
943
- const i = siblings.indexOf(id);
944
- return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
945
- }
946
- tag_of(id) {
947
- const n = this.nodes.get(id);
948
- return n && n.kind === "element" ? n.local : "";
949
- }
950
- contains(ancestor, descendant) {
951
- if (ancestor === descendant) return true;
952
- let cur = this.parent_of(descendant);
953
- while (cur !== null) {
954
- if (cur === ancestor) return true;
955
- cur = this.parent_of(cur);
956
- }
957
- return false;
958
- }
959
- /**
960
- * Filter a selection down to its **subtree roots** — drop any id whose
961
- * ancestor is also in the input set.
962
- *
963
- * Mirrors `pruneNestedNodes` in the main canvas editor's query module
964
- * ([editor/grida-canvas/query/index.ts:138](../../../../editor/grida-canvas/query/index.ts)) and shares its UX motivation:
965
- * when a parent and a descendant are both selected, only the parent
966
- * should drive multi-node mutations — otherwise the descendant
967
- * accumulates the transform twice (once via the parent's `transform`,
968
- * once via its own attribute write). Required for `commands.remove`
969
- * (avoids re-attaching detached descendants on undo) and any multi-
970
- * member translate path (avoids 2× drift for the Bar-chart marquee
971
- * case).
972
- *
973
- * Order: preserves the input order for retained ids. Duplicates in
974
- * the input are not deduplicated — callers are responsible (the
975
- * editor's `commands.select` already dedupes).
976
- *
977
- * Performance: `O(n × depth)`. Builds a `Set` over the input once,
978
- * then walks each id's ancestor chain at most once. The main editor's
979
- * version is `O(n² × depth)` (per-pair `isAncestor`) — fine at typical
980
- * selection sizes (a few dozen), worth winning here for free since
981
- * `parent_of` is `O(1)` on our parent-map.
982
- */
983
- prune_nested_nodes(ids) {
984
- if (ids.length <= 1) return [...ids];
985
- const set = new Set(ids);
986
- const out = [];
987
- for (const id of ids) {
988
- let nested = false;
989
- let cur = this.parent_of(id);
990
- while (cur !== null) {
991
- if (set.has(cur)) {
992
- nested = true;
993
- break;
994
- }
995
- cur = this.parent_of(cur);
996
- }
997
- if (!nested) out.push(id);
998
- }
999
- return out;
1000
- }
1001
- all_nodes() {
1002
- const out = [];
1003
- const walk = (id) => {
1004
- out.push(id);
1005
- const c = this.children_of(id);
1006
- for (const ch of c) walk(ch);
1007
- };
1008
- walk(this.root);
1009
- return out;
1010
- }
1011
- all_elements() {
1012
- return this.all_nodes().filter((id) => this.is_element(id));
1013
- }
1014
- find_by_tag(ancestor, tag) {
1015
- const out = [];
1016
- const walk = (id) => {
1017
- if (id !== ancestor && this.is_element(id) && this.tag_of(id) === tag) out.push(id);
1018
- for (const c of this.children_of(id)) walk(c);
1019
- };
1020
- walk(ancestor);
1021
- return out;
1022
- }
1023
- /** Read attribute by local name, optionally namespace-filtered. */
1024
- get_attr(id, name, ns = null) {
1025
- const n = this.nodes.get(id);
1026
- if (!n || n.kind !== "element") return null;
1027
- for (const a of n.attrs) if (a.local === name && (ns === null || a.ns === ns)) return a.value;
1028
- return null;
1029
- }
1030
- /**
1031
- * Set / remove an attribute. If the attribute exists, it is mutated in place
1032
- * (preserving source position). If it doesn't, it's appended.
1033
- */
1034
- set_attr(id, name, value, ns = null) {
1035
- const n = this.nodes.get(id);
1036
- if (!n || n.kind !== "element") return;
1037
- const structural = name === "id";
1038
- const geometry = ns === null && GEOMETRY_ATTRS.has(name);
1039
- for (let i = 0; i < n.attrs.length; i++) {
1040
- const a = n.attrs[i];
1041
- if (a.local === name && (ns === null || a.ns === ns)) {
1042
- if (value === null) n.attrs.splice(i, 1);
1043
- else a.value = value;
1044
- if (structural) this._structure_version++;
1045
- if (geometry) this._geometry_version++;
1046
- this.emit();
1047
- return;
1048
- }
1049
- }
1050
- if (value !== null) {
1051
- const prefix = ns === XLINK_NS ? "xlink" : null;
1052
- n.attrs.push({
1053
- raw_name: prefix ? `${prefix}:${name}` : name,
1054
- prefix,
1055
- local: name,
1056
- ns,
1057
- value,
1058
- pre: " ",
1059
- eq_trivia: "",
1060
- quote: "\""
1061
- });
1062
- if (structural) this._structure_version++;
1063
- if (geometry) this._geometry_version++;
1064
- this.emit();
1065
- }
1066
- }
1067
- attributes_of(id) {
1068
- const n = this.nodes.get(id);
1069
- if (!n || n.kind !== "element") return [];
1070
- return n.attrs.map((a) => ({
1071
- name: a.local,
1072
- ns: a.ns,
1073
- value: a.value
1074
- }));
1075
- }
1076
- get_style(id, property) {
1077
- const style = this.get_attr(id, "style");
1078
- if (!style) return null;
1079
- const decls = parse_inline_style(style);
1080
- for (const d of decls) if (d.property === property) return d.value;
1081
- return null;
1082
- }
1083
- set_style(id, property, value) {
1084
- const decls = parse_inline_style(this.get_attr(id, "style") ?? "");
1085
- const idx = decls.findIndex((d) => d.property === property);
1086
- if (value === null) {
1087
- if (idx === -1) return;
1088
- decls.splice(idx, 1);
1089
- } else if (idx === -1) decls.push({
1090
- property,
1091
- value
1092
- });
1093
- else decls[idx].value = value;
1094
- const next = decls.map((d) => `${d.property}: ${d.value}`).join("; ");
1095
- this.set_attr(id, "style", next === "" ? null : next);
1096
- }
1097
- get_all_styles(id) {
1098
- const style = this.get_attr(id, "style");
1099
- if (!style) return [];
1100
- return parse_inline_style(style);
1101
- }
1102
- /**
1103
- * Whether `id` can be opened in the flat-string text editor.
1104
- *
1105
- * v1 contract: the editor only operates on a *single flat text run*. That
1106
- * means the target must be a `<text>` or `<tspan>` whose direct children
1107
- * are all text nodes (or it has no children). A `<text>` containing a
1108
- * `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
1109
- * content from the editor's view, and a flat-text write would leave the
1110
- * tspan dangling. Tspan-as-target is fine and well-defined when it's a
1111
- * leaf; only the host decides whether to route double-click to a tspan
1112
- * or its parent text.
1113
- */
1114
- is_text_edit_target(id) {
1115
- const n = this.nodes.get(id);
1116
- if (!n || n.kind !== "element") return false;
1117
- if (n.local !== "text" && n.local !== "tspan") return false;
1118
- for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
1119
- return true;
1120
- }
1121
- /**
1122
- * Returns a tag-discriminated snapshot of the authored geometry attrs
1123
- * if this node is eligible for vector (vertex) editing — else `null`.
1124
- *
1125
- * Eligibility:
1126
- * - `<path>` — requires non-empty `d`.
1127
- * - `<line>` — requires two distinct finite user-unit endpoints.
1128
- * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
1129
- * - `<polygon>` — same as polyline.
1130
- * - `<rect>` — requires finite user-unit `width`/`height` > 0.
1131
- * - `<circle>` — requires finite user-unit `r` > 0.
1132
- * - `<ellipse>` — requires finite user-unit `rx`/`ry` > 0.
1133
- *
1134
- * The vertex tags (`line` / `polyline` / `polygon`) write edits back to
1135
- * their native attributes while the geometry stays expressible there; an
1136
- * edit that escapes the native form (a curve, or a topology change that
1137
- * leaves the canonical chain) re-types the element to `<path>`. The
1138
- * geometry primitives (`rect` / `circle` / `ellipse`) have no native
1139
- * vector form, so any vector edit re-types them. In all cases the native
1140
- * tag is preserved byte-for-byte until the first re-typing edit commits
1141
- * (see `retype_to_path`). Design:
1142
- * `docs/wg/feat-svg-editor/promote-to-path.md`.
1143
- *
1144
- * Geometry that is not a plain user-unit number (`%`, `px`, `em`, …) is
1145
- * an out-of-scope gap, so such an element returns `null` rather than
1146
- * advertising an edit the editor cannot perform faithfully.
1147
- *
1148
- * Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
1149
- * editable outline).
1150
- */
1151
- /**
1152
- * Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
1153
- * endpoints). An **absent** attribute takes the SVG default (`0`); a
1154
- * **present** attribute that is not a plain user-unit number (`%`, `px`,
1155
- * `em`, …) is out of scope and yields `null` so the caller refuses the
1156
- * element — the same gate required attrs (width / radius) already apply.
1157
- *
1158
- * The absent-vs-present distinction is the point: a bare `?? 0` would
1159
- * silently coerce an authored `x1="5px"` to `0`, then the first native
1160
- * writeback would overwrite that authored value. Refusing keeps the
1161
- * editor from misrepresenting geometry it cannot read faithfully.
1162
- */
1163
- optional_user_unit_coord(id, name) {
1164
- const raw = this.get_attr(id, name);
1165
- if (raw === null) return 0;
1166
- return parse_user_unit(raw);
1167
- }
1168
- is_vector_edit_target(id) {
1169
- const n = this.nodes.get(id);
1170
- if (!n || n.kind !== "element") return null;
1171
- if (RETYPABLE_GEOMETRY_ATTRS[n.local] && n.attrs.some((a) => a.prefix === null && a.ns === null && a.local === "d")) return null;
1172
- switch (n.local) {
1173
- case "path": {
1174
- const d = this.get_attr(id, "d");
1175
- if (d === null || d.trim().length === 0) return null;
1176
- return {
1177
- kind: "path",
1178
- d
1179
- };
1180
- }
1181
- case "line": {
1182
- const x1 = this.optional_user_unit_coord(id, "x1");
1183
- const y1 = this.optional_user_unit_coord(id, "y1");
1184
- const x2 = this.optional_user_unit_coord(id, "x2");
1185
- const y2 = this.optional_user_unit_coord(id, "y2");
1186
- if (x1 === null || y1 === null || x2 === null || y2 === null) return null;
1187
- if (x1 === x2 && y1 === y2) return null;
1188
- return {
1189
- kind: "line",
1190
- x1,
1191
- y1,
1192
- x2,
1193
- y2
1194
- };
1195
- }
1196
- case "polyline":
1197
- case "polygon": {
1198
- const raw = this.get_attr(id, "points") ?? "";
1199
- const parsed = svg_parse.parse_points(raw);
1200
- if (parsed.length < 2) return null;
1201
- const points = parsed.map((p) => [p.x, p.y]);
1202
- return n.local === "polyline" ? {
1203
- kind: "polyline",
1204
- points
1205
- } : {
1206
- kind: "polygon",
1207
- points
1208
- };
1209
- }
1210
- case "rect": {
1211
- const x = this.optional_user_unit_coord(id, "x");
1212
- const y = this.optional_user_unit_coord(id, "y");
1213
- if (x === null || y === null) return null;
1214
- const width = parse_user_unit(this.get_attr(id, "width"));
1215
- const height = parse_user_unit(this.get_attr(id, "height"));
1216
- if (width === null || height === null) return null;
1217
- if (width <= 0 || height <= 0) return null;
1218
- const rx_attr = this.get_attr(id, "rx");
1219
- const ry_attr = this.get_attr(id, "ry");
1220
- const rx_parsed = rx_attr === null ? null : parse_user_unit(rx_attr);
1221
- const ry_parsed = ry_attr === null ? null : parse_user_unit(ry_attr);
1222
- if (rx_attr !== null && rx_parsed === null) return null;
1223
- if (ry_attr !== null && ry_parsed === null) return null;
1224
- let rx = rx_parsed ?? ry_parsed ?? 0;
1225
- let ry = ry_parsed ?? rx_parsed ?? 0;
1226
- rx = Math.max(0, Math.min(rx, width / 2));
1227
- ry = Math.max(0, Math.min(ry, height / 2));
1228
- return {
1229
- kind: "rect",
1230
- x,
1231
- y,
1232
- width,
1233
- height,
1234
- rx,
1235
- ry
1236
- };
1237
- }
1238
- case "circle": {
1239
- const cx = this.optional_user_unit_coord(id, "cx");
1240
- const cy = this.optional_user_unit_coord(id, "cy");
1241
- if (cx === null || cy === null) return null;
1242
- const r = parse_user_unit(this.get_attr(id, "r"));
1243
- if (r === null || r <= 0) return null;
1244
- return {
1245
- kind: "circle",
1246
- cx,
1247
- cy,
1248
- r
1249
- };
1250
- }
1251
- case "ellipse": {
1252
- const cx = this.optional_user_unit_coord(id, "cx");
1253
- const cy = this.optional_user_unit_coord(id, "cy");
1254
- if (cx === null || cy === null) return null;
1255
- const rx = parse_user_unit(this.get_attr(id, "rx"));
1256
- const ry = parse_user_unit(this.get_attr(id, "ry"));
1257
- if (rx === null || ry === null) return null;
1258
- if (rx <= 0 || ry <= 0) return null;
1259
- return {
1260
- kind: "ellipse",
1261
- cx,
1262
- cy,
1263
- rx,
1264
- ry
1265
- };
1266
- }
1267
- default: return null;
1268
- }
1269
- }
1270
- /**
1271
- * Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
1272
- * `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
1273
- * its native geometry attributes and setting `d`. A structural mutation:
1274
- * this layer executes the re-type; it does not decide when one is
1275
- * warranted.
1276
- *
1277
- * Idempotent: returns `null` if `id` is not currently one of those tags
1278
- * (so it is safe to call repeatedly — once re-typed, e.g. already a
1279
- * `<path>`, further calls are no-ops). Otherwise mutates the node and
1280
- * returns an opaque {@link RetypeRecord} reversal token.
1281
- *
1282
- * Identity, children, `self_closing`, non-geometry attributes, and all
1283
- * source trivia are preserved unchanged — only the tag and the geometry
1284
- * attributes move. Pass the token to {@link revert_retype} to restore
1285
- * the original primitive byte-for-byte.
1286
- *
1287
- * (see test/svg-editor-vector-promote-to-path.md)
1288
- */
1289
- retype_to_path(id, d) {
1290
- const n = this.nodes.get(id);
1291
- if (!n || n.kind !== "element") return null;
1292
- const geom = RETYPABLE_GEOMETRY_ATTRS[n.local];
1293
- if (!geom) return null;
1294
- const prev_local = n.local;
1295
- const prev_raw_tag = n.raw_tag;
1296
- const removed = [];
1297
- for (let i = n.attrs.length - 1; i >= 0; i--) {
1298
- const a = n.attrs[i];
1299
- if (a.prefix === null && a.ns === null && geom.has(a.local)) {
1300
- removed.push({
1301
- index: i,
1302
- token: a
1303
- });
1304
- n.attrs.splice(i, 1);
1305
- }
1306
- }
1307
- removed.reverse();
1308
- n.local = "path";
1309
- n.raw_tag = n.prefix ? `${n.prefix}:path` : "path";
1310
- n.attrs.push({
1311
- raw_name: "d",
1312
- prefix: null,
1313
- local: "d",
1314
- ns: null,
1315
- value: d,
1316
- pre: " ",
1317
- eq_trivia: "",
1318
- quote: "\""
1319
- });
1320
- let added_fill_none = false;
1321
- if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
1322
- n.attrs.push({
1323
- raw_name: "fill",
1324
- prefix: null,
1325
- local: "fill",
1326
- ns: null,
1327
- value: "none",
1328
- pre: " ",
1329
- eq_trivia: "",
1330
- quote: "\""
1331
- });
1332
- added_fill_none = true;
1333
- }
1334
- this._structure_version++;
1335
- this._geometry_version++;
1336
- this.emit();
1337
- return {
1338
- prev_local,
1339
- prev_raw_tag,
1340
- removed,
1341
- added_fill_none
1342
- };
1343
- }
1344
- /**
1345
- * Reverse a {@link retype_to_path}: restore the original tag, remove the
1346
- * `d` attribute the promotion added, and splice the captured geometry
1347
- * attribute tokens back at their original positions (preserving their
1348
- * trivia, so a later `serialize()` is byte-equal to the pre-promotion
1349
- * source).
1350
- */
1351
- revert_retype(id, token) {
1352
- const n = this.nodes.get(id);
1353
- if (!n || n.kind !== "element") return;
1354
- for (let i = n.attrs.length - 1; i >= 0; i--) {
1355
- const a = n.attrs[i];
1356
- if (a.prefix === null && a.ns === null && a.local === "d") {
1357
- n.attrs.splice(i, 1);
1358
- break;
1359
- }
1360
- }
1361
- if (token.added_fill_none) for (let i = n.attrs.length - 1; i >= 0; i--) {
1362
- const a = n.attrs[i];
1363
- if (a.prefix === null && a.ns === null && a.local === "fill") {
1364
- n.attrs.splice(i, 1);
1365
- break;
1366
- }
1367
- }
1368
- n.local = token.prev_local;
1369
- n.raw_tag = token.prev_raw_tag;
1370
- for (const { index, token: t } of token.removed) n.attrs.splice(index, 0, t);
1371
- this._structure_version++;
1372
- this._geometry_version++;
1373
- this.emit();
1374
- }
1375
- /**
1376
- * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
1377
- * per-glyph attribute (which conflicts with element-level rotation).
1378
- */
1379
- has_glyph_rotate(id) {
1380
- const tag = this.tag_of(id);
1381
- if (tag !== "text" && tag !== "tspan") return false;
1382
- const value = this.get_attr(id, "rotate");
1383
- if (value === null) return false;
1384
- return value.trim() !== "";
1385
- }
1386
- /**
1387
- * True iff this element's inline `style=""` declares a `transform:`
1388
- * CSS property (which would shadow the editor's `transform=` writes).
1389
- */
1390
- has_inline_css_transform(id) {
1391
- const style = this.get_attr(id, "style");
1392
- if (!style) return false;
1393
- return CSS_TRANSFORM_PROPERTY.test(style);
1394
- }
1395
- /**
1396
- * True iff this element has a direct `<animateTransform>` child
1397
- * (which produces a time-varying transform invisible to attribute writes).
1398
- * Only direct children are checked — nested cases attach to the nearer ancestor.
1399
- */
1400
- has_animate_transform_child(id) {
1401
- for (const c of this.children_of(id)) {
1402
- const n = this.nodes.get(c);
1403
- if (n?.kind === "element" && n.local === "animateTransform") return true;
1404
- }
1405
- return false;
1406
- }
1407
- text_of(id) {
1408
- const n = this.nodes.get(id);
1409
- if (!n || n.kind !== "element") return "";
1410
- let out = "";
1411
- for (const c of n.children) {
1412
- const cn = this.nodes.get(c);
1413
- if (cn?.kind === "text") out += cn.value;
1414
- }
1415
- return out;
1416
- }
1417
- /** Replace all direct text children with a single text node carrying `value`. */
1418
- set_text(id, value) {
1419
- const n = this.nodes.get(id);
1420
- if (!n || n.kind !== "element") return;
1421
- n.children = n.children.filter((c) => this.nodes.get(c)?.kind !== "text");
1422
- if (value !== "") {
1423
- const text_id = `t${Math.random().toString(36).slice(2, 10)}`;
1424
- const text_node = {
1425
- kind: "text",
1426
- id: text_id,
1427
- parent: id,
1428
- value
1429
- };
1430
- this.nodes.set(text_id, text_node);
1431
- n.children.push(text_id);
1432
- }
1433
- this._structure_version++;
1434
- this._geometry_version++;
1435
- this.emit();
1436
- }
1437
- insert(id, parent, before) {
1438
- const node = this.nodes.get(id);
1439
- const parent_node = this.nodes.get(parent);
1440
- if (!node || !parent_node || parent_node.kind !== "element") return;
1441
- if (node.parent !== null) {
1442
- const old_parent = this.nodes.get(node.parent);
1443
- if (old_parent && old_parent.kind === "element") {
1444
- const i = old_parent.children.indexOf(id);
1445
- if (i >= 0) old_parent.children.splice(i, 1);
1446
- }
1447
- }
1448
- const ix = before === null ? -1 : parent_node.children.indexOf(before);
1449
- if (ix < 0) parent_node.children.push(id);
1450
- else parent_node.children.splice(ix, 0, id);
1451
- node.parent = parent;
1452
- this._structure_version++;
1453
- this._geometry_version++;
1454
- this.emit();
1455
- }
1456
- remove(id) {
1457
- const n = this.nodes.get(id);
1458
- if (!n || n.parent === null) return;
1459
- const parent = this.nodes.get(n.parent);
1460
- if (!parent || parent.kind !== "element") return;
1461
- const i = parent.children.indexOf(id);
1462
- if (i >= 0) parent.children.splice(i, 1);
1463
- n.parent = null;
1464
- this._structure_version++;
1465
- this._geometry_version++;
1466
- this.emit();
1467
- }
1468
- /** Create a new element node and register it (not yet inserted). */
1469
- create_element(local, opts) {
1470
- const id = `e${Math.random().toString(36).slice(2, 10)}`;
1471
- const prefix = opts?.prefix ?? null;
1472
- const ns = opts?.ns ?? null;
1473
- const node = {
1474
- kind: "element",
1475
- id,
1476
- parent: null,
1477
- raw_tag: prefix ? `${prefix}:${local}` : local,
1478
- prefix,
1479
- local,
1480
- ns,
1481
- attrs: [],
1482
- children: [],
1483
- self_closing: false,
1484
- open_tag_trailing: "",
1485
- close_tag_leading: "",
1486
- close_tag_trailing: ""
1487
- };
1488
- this.nodes.set(id, node);
1489
- return id;
1490
- }
1491
- serialize() {
1492
- let out = "";
1493
- for (const p of this.prolog) out += this.emit_node(p);
1494
- out += this.emit_node(this.nodes.get(this.root));
1495
- for (const e of this.epilog) out += this.emit_node(e);
1496
- return out;
1497
- }
1498
- /**
1499
- * Serialize a single element's subtree as an SVG **fragment**, using the
1500
- * same trivia-preserving rules as {@link serialize} (attribute order,
1501
- * quote style, whitespace, comments — emitted exactly as authored).
1502
- *
1503
- * This is NOT {@link serialize} scoped to a node — it is a deliberately
1504
- * weaker output (sdk-design D3, asymmetric outputs stay separate):
1505
- *
1506
- * - `serialize()` emits the whole document and carries the P1
1507
- * whole-document round-trip guarantee.
1508
- * - `serialize_node()` emits a fragment and does NOT. Namespace
1509
- * declarations that live on an ancestor (`xmlns:xlink` and friends,
1510
- * normally on the root `<svg>`) are NOT inlined — a node using
1511
- * `xlink:href` serializes without `xmlns:xlink`. The fragment is the
1512
- * element's markup as authored, not a standalone parseable document.
1513
- *
1514
- * Throws on an unknown id, a non-element node, or a node detached from
1515
- * the live tree: the contract is "the markup for a selected element,"
1516
- * selections are always live elements, and a string return of `""` for a
1517
- * bad id would hide consumer bugs. The detached case matters because
1518
- * `remove()` keeps the node in the id map for undo — a stale id from a
1519
- * removed node would otherwise serialize content no longer in the
1520
- * document, silently feeding a consumer deleted markup.
1521
- */
1522
- serialize_node(id) {
1523
- const n = this.nodes.get(id);
1524
- if (!n) throw new Error(`serialize_node: unknown node id ${JSON.stringify(id)}`);
1525
- if (n.kind !== "element") throw new Error(`serialize_node: node ${JSON.stringify(id)} is a ${n.kind} node, not an element`);
1526
- if (!this.contains(this.root, id)) throw new Error(`serialize_node: node ${JSON.stringify(id)} is detached from the current document`);
1527
- return this.emit_node(n);
1528
- }
1529
- emit_node(n) {
1530
- switch (n.kind) {
1531
- case "text": return encode_text(n.value);
1532
- case "comment": return `<!--${n.value}-->`;
1533
- case "cdata": return `<![CDATA[${n.value}]]>`;
1534
- case "pi": {
1535
- const pi = n;
1536
- return `<?${pi.target}${pi.value ? " " + pi.value : ""}?>`;
1537
- }
1538
- case "doctype": return `<!DOCTYPE${n.value}>`;
1539
- case "element": {
1540
- const e = n;
1541
- let s = `<${e.raw_tag}`;
1542
- for (const a of e.attrs) s += this.emit_attr(a);
1543
- if (e.children.length === 0 && e.self_closing) {
1544
- s += `${e.open_tag_trailing}/>`;
1545
- return s;
1546
- }
1547
- s += `${e.open_tag_trailing}>`;
1548
- for (const cid of e.children) {
1549
- const cn = this.nodes.get(cid);
1550
- if (cn) s += this.emit_node(cn);
1551
- }
1552
- s += `</${e.close_tag_leading}${e.raw_tag}${e.close_tag_trailing}>`;
1553
- return s;
1554
- }
1555
- }
1556
- }
1557
- emit_attr(a) {
1558
- return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${encode_attr_value(a.value, a.quote)}${a.quote}`;
1559
- }
1560
- };
1561
- function parse_inline_style(s) {
1562
- const out = [];
1563
- const decls = s.split(";");
1564
- for (const decl of decls) {
1565
- const colon = decl.indexOf(":");
1566
- if (colon === -1) continue;
1567
- const property = decl.slice(0, colon).trim();
1568
- const value = decl.slice(colon + 1).trim();
1569
- if (property) out.push({
1570
- property,
1571
- value
1572
- });
1573
- }
1574
- return out;
1575
- }
1576
- //#endregion
1577
839
  //#region src/core/align.ts
1578
840
  /**
1579
841
  * Compute per-member translation deltas to align `members` against `target`.
@@ -1844,12 +1106,22 @@ function _create_svg_editor_internal(opts) {
1844
1106
  const notify_translate_commit = () => {
1845
1107
  for (const cb of translate_commit_listeners) cb();
1846
1108
  };
1847
- doc.on_change(() => {
1848
- doc_version++;
1109
+ /**
1110
+ * Fan out the geometry channel iff the doc's `geometry_version` has
1111
+ * moved since we last fired. Shared by the `doc.on_change` handler
1112
+ * (mutation-driven bumps) and the surface-driven `bump_geometry` seam
1113
+ * (font-load reflow). Idempotent against a stale version — never
1114
+ * double-fires for the same value.
1115
+ */
1116
+ function fire_geometry_listeners_if_advanced() {
1849
1117
  if (doc.geometry_version !== last_emitted_geometry_version) {
1850
1118
  last_emitted_geometry_version = doc.geometry_version;
1851
1119
  for (const cb of geometry_listeners) cb();
1852
1120
  }
1121
+ }
1122
+ doc.on_change(() => {
1123
+ doc_version++;
1124
+ fire_geometry_listeners_if_advanced();
1853
1125
  });
1854
1126
  function subscribe(fn) {
1855
1127
  listeners.add(fn);
@@ -2149,66 +1421,79 @@ function _create_svg_editor_internal(opts) {
2149
1421
  if (do_translate_oneshot(delta, translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
2150
1422
  }
2151
1423
  /**
2152
- * One-shot multi-member resize to an explicit target rect. Mirrors a
2153
- * drag-resize gesture in mechanics capture per-member baselines,
2154
- * scale around the union's NW corner, translate the result so the
2155
- * union NW lands at the requested position — but as a single
2156
- * atomic step rather than a preview session.
2157
- *
2158
- * The function does its own geometry lookup via the
2159
- * `geometry_provider` registered by the DOM surface. When no surface
2160
- * is attached, the call is a no-op (returns `false`). Members whose
2161
- * tag is not resizable are silently filtered.
1424
+ * Gate + capture for a resize gesture. Returns the resizable members (with
1425
+ * captured baseline / pre-transform / bbox), or `null` if the gesture can't
1426
+ * run: no geometry provider, empty selection, or in `all_or_nothing` mode
1427
+ * any member fails the gate.
2162
1428
  *
2163
- * Revert restores the captured `transform` attribute and all
2164
- * geometry attrs the apply step wrote — so a `<rect>` with an
2165
- * existing `transform` round-trips cleanly. See `apply_translate`'s
2166
- * `viaTransform` arm for why this matters.
1429
+ * `mode`:
1430
+ * - `"skip"` — drop members failing the `is_resizable_node` gate
1431
+ * (tag + transform class) or lacking a bbox; resize the rest. Used by the
1432
+ * inspector `resize_to` (set-bbox) path.
1433
+ * - `"all_or_nothing"` — refuse the WHOLE gesture (return `null`) if ANY
1434
+ * member fails. Used by keyboard `resize_by` (nudge), matching the resize
1435
+ * HUD, whose handle-drag is rejected when any member is unsafe.
2167
1436
  */
2168
- function resize_to(target, opts) {
2169
- const ids = opts?.ids ?? selection;
2170
- if (ids.length === 0) return false;
2171
- if (!geometry_provider) return false;
1437
+ function collect_resize_members(ids, mode) {
1438
+ if (ids.length === 0) return null;
1439
+ if (!geometry_provider) return null;
2172
1440
  const members = [];
2173
1441
  for (const id of ids) {
2174
- if (!resize_pipeline.intent.is_resizable(doc.tag_of(id))) continue;
1442
+ if (!resize_pipeline.intent.is_resizable_node(doc, id)) {
1443
+ if (mode === "all_or_nothing") return null;
1444
+ continue;
1445
+ }
2175
1446
  const bbox = geometry_provider.bounds_of(id);
2176
- if (!bbox) continue;
1447
+ if (!bbox) {
1448
+ if (mode === "all_or_nothing") return null;
1449
+ continue;
1450
+ }
2177
1451
  members.push({
2178
1452
  id,
2179
1453
  rz: resize_pipeline.intent.capture_baseline(doc, id, bbox),
2180
- tx_pre: translate_pipeline.intent.capture_baseline(doc, id),
2181
1454
  transform_pre: doc.get_attr(id, "transform"),
2182
1455
  bbox
2183
1456
  });
2184
1457
  }
2185
- if (members.length === 0) return false;
2186
- const union = cmath.rect.union(members.map((m) => m.bbox));
2187
- const sx = union.width === 0 ? 1 : target.width / union.width;
2188
- const sy = union.height === 0 ? 1 : target.height / union.height;
2189
- const origin = {
2190
- x: union.x,
2191
- y: union.y
2192
- };
2193
- const dx = target.x - union.x;
2194
- const dy = target.y - union.y;
1458
+ return members.length === 0 ? null : members;
1459
+ }
1460
+ /**
1461
+ * Apply a resize to each member, optionally followed by a uniform group
1462
+ * translate, as ONE atomic history step. `op` resolves each member's scale
1463
+ * factors + scale origin; `group_translate` is the post-scale envelope shift
1464
+ * (group resize only — `null` for per-element). Callers guarantee `members`
1465
+ * is non-empty. Returns `true` when a history step was pushed; `false` when
1466
+ * the gesture is geometrically identity (no member scales and no group
1467
+ * translate) so undo isn't polluted with an empty step. NOTE: a per-tag
1468
+ * constraint that collapses a non-1 factor to identity *inside* the handler
1469
+ * (e.g. `<circle>` uniform `min` on a single-axis nudge) is not detected
1470
+ * here — the op-level factor is still ≠ 1, so that case still pushes a step.
1471
+ */
1472
+ function commit_resize(members, op, group_translate, label) {
1473
+ const ops = members.map((m) => ({
1474
+ m,
1475
+ ...op(m)
1476
+ }));
1477
+ const scales = ops.some(({ sx, sy }) => sx !== 1 || sy !== 1);
1478
+ const translates = !!group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0);
1479
+ if (!scales && !translates) return false;
2195
1480
  const apply = () => {
2196
- for (const m of members) resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
2197
- if (dx !== 0 || dy !== 0) for (const m of members) {
1481
+ for (const { m, sx, sy, origin } of ops) resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
1482
+ if (group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0)) for (const m of members) {
2198
1483
  const tx_after = translate_pipeline.intent.capture_baseline(doc, m.id);
2199
- translate_pipeline.intent.apply(doc, m.id, tx_after, dx, dy);
1484
+ translate_pipeline.intent.apply(doc, m.id, tx_after, group_translate.dx, group_translate.dy);
2200
1485
  }
2201
1486
  emit();
2202
1487
  };
2203
1488
  const revert = () => {
2204
- for (const m of members) {
1489
+ for (const { m, origin } of ops) {
2205
1490
  resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
2206
1491
  doc.set_attr(m.id, "transform", m.transform_pre);
2207
1492
  }
2208
1493
  emit();
2209
1494
  };
2210
1495
  apply();
2211
- history.atomic("resize-to", (tx) => {
1496
+ history.atomic(label, (tx) => {
2212
1497
  tx.push({
2213
1498
  providerId: PROVIDER_ID,
2214
1499
  apply,
@@ -2217,6 +1502,73 @@ function _create_svg_editor_internal(opts) {
2217
1502
  });
2218
1503
  return true;
2219
1504
  }
1505
+ /**
1506
+ * One-shot multi-member resize to an explicit target rect. Mirrors a
1507
+ * drag-resize gesture in mechanics — capture per-member baselines,
1508
+ * scale around the union's NW corner, translate the result so the
1509
+ * union NW lands at the requested position — but as a single
1510
+ * atomic step rather than a preview session. This is the GROUP path:
1511
+ * the whole selection is treated as one envelope.
1512
+ *
1513
+ * The function does its own geometry lookup via the
1514
+ * `geometry_provider` registered by the DOM surface. When no surface
1515
+ * is attached, the call is a no-op (returns `false`). Members that fail
1516
+ * the `is_resizable_node` gate — an unresizable tag (e.g. `<g>`) OR a
1517
+ * non-trivially-transformed element — are silently skipped (see
1518
+ * `collect_resize_members`).
1519
+ *
1520
+ * Revert restores the captured `transform` attribute and all
1521
+ * geometry attrs the apply step wrote — so a `<rect>` with an
1522
+ * existing `transform` round-trips cleanly. See `apply_translate`'s
1523
+ * `viaTransform` arm for why this matters.
1524
+ */
1525
+ function resize_to(target, opts) {
1526
+ const members = collect_resize_members(opts?.ids ?? selection, "skip");
1527
+ if (!members) return false;
1528
+ const union = cmath.rect.union(members.map((m) => m.bbox));
1529
+ const sx = union.width === 0 ? 1 : target.width / union.width;
1530
+ const sy = union.height === 0 ? 1 : target.height / union.height;
1531
+ const origin = {
1532
+ x: union.x,
1533
+ y: union.y
1534
+ };
1535
+ return commit_resize(members, () => ({
1536
+ sx,
1537
+ sy,
1538
+ origin
1539
+ }), {
1540
+ dx: target.x - union.x,
1541
+ dy: target.y - union.y
1542
+ }, opts?.label ?? "resize-to");
1543
+ }
1544
+ /**
1545
+ * Resize by a `{dw, dh}` delta — the core verb behind keyboard nudge-resize
1546
+ * (`Ctrl+Alt+Arrow`). This is the PER-ELEMENT path: each selected member
1547
+ * grows/shrinks by the delta around ITS OWN NW corner, so members keep their
1548
+ * positions relative to one another. This deliberately differs from
1549
+ * {@link resize_to} (the group/envelope path): a HUD group-resize scales the
1550
+ * whole selection around the shared union origin, translating off-origin
1551
+ * members — correct for a drag handle, wrong for a keyboard nudge, whose UX
1552
+ * is "apply the delta to each".
1553
+ *
1554
+ * ALL-OR-NOTHING gate (`collect_resize_members("all_or_nothing")`): refuses
1555
+ * (returns `false`, no history step) on empty selection, no geometry
1556
+ * provider, or any member failing the `is_resizable_node` gate — matching
1557
+ * the resize HUD rather than `resize_to`'s per-member skip.
1558
+ */
1559
+ function resize_by(delta, opts) {
1560
+ const members = collect_resize_members(opts?.ids ?? selection, "all_or_nothing");
1561
+ if (!members) return false;
1562
+ const axis = (size, d) => size === 0 ? 1 : Math.max(0, size + d) / size;
1563
+ return commit_resize(members, (m) => ({
1564
+ sx: axis(m.bbox.width, delta.dw),
1565
+ sy: axis(m.bbox.height, delta.dh),
1566
+ origin: {
1567
+ x: m.bbox.x,
1568
+ y: m.bbox.y
1569
+ }
1570
+ }), null, "nudge-resize");
1571
+ }
2220
1572
  /** Shared helper: compute a default rotation pivot from the live
2221
1573
  * geometry_provider when the caller omitted one. Falls back to (0,0)
2222
1574
  * if no surface is attached. */
@@ -2298,6 +1650,70 @@ function _create_svg_editor_internal(opts) {
2298
1650
  });
2299
1651
  return true;
2300
1652
  }
1653
+ /**
1654
+ * Relative affine compose about a pivot. See the `Commands.transform`
1655
+ * doc for the full contract. This function owns ONLY the pivot/effective-
1656
+ * matrix computation (which needs `geometry_provider`); the parse→fold→
1657
+ * emit round-trip is delegated per-member to the pure
1658
+ * `transform.apply_affine` helper.
1659
+ */
1660
+ function apply_transform(matrix, opts) {
1661
+ const ids = opts?.ids ?? selection;
1662
+ if (ids.length === 0) return false;
1663
+ if (!geometry_provider) return false;
1664
+ for (const id of ids) if (rotate_pipeline.intent.is_transformable(doc, id).kind === "refuse") return false;
1665
+ const pivot = opts?.pivot ?? default_rotate_pivot(ids);
1666
+ const [a, b, c, d, e, f] = matrix;
1667
+ const requested = [[
1668
+ a,
1669
+ c,
1670
+ e
1671
+ ], [
1672
+ b,
1673
+ d,
1674
+ f
1675
+ ]];
1676
+ const t_pivot = [[
1677
+ 1,
1678
+ 0,
1679
+ pivot.x
1680
+ ], [
1681
+ 0,
1682
+ 1,
1683
+ pivot.y
1684
+ ]];
1685
+ const t_neg_pivot = [[
1686
+ 1,
1687
+ 0,
1688
+ -pivot.x
1689
+ ], [
1690
+ 0,
1691
+ 1,
1692
+ -pivot.y
1693
+ ]];
1694
+ const effective = cmath.transform.multiply(cmath.transform.multiply(t_pivot, requested), t_neg_pivot);
1695
+ const members = ids.map((id) => ({
1696
+ id,
1697
+ transform_pre: doc.get_attr(id, "transform")
1698
+ }));
1699
+ const apply = () => {
1700
+ for (const m of members) doc.set_attr(m.id, "transform", transform.apply_affine(m.transform_pre, effective));
1701
+ emit();
1702
+ };
1703
+ const revert = () => {
1704
+ for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
1705
+ emit();
1706
+ };
1707
+ apply();
1708
+ history.atomic("transform", (tx) => {
1709
+ tx.push({
1710
+ providerId: PROVIDER_ID,
1711
+ apply,
1712
+ revert
1713
+ });
1714
+ });
1715
+ return true;
1716
+ }
2301
1717
  function flatten_transform(opts) {
2302
1718
  const ids = opts?.ids ?? selection;
2303
1719
  if (ids.length === 0) return false;
@@ -2549,6 +1965,47 @@ function _create_svg_editor_internal(opts) {
2549
1965
  });
2550
1966
  return true;
2551
1967
  }
1968
+ function ungroup(opts) {
1969
+ let target;
1970
+ if (opts?.id !== void 0) target = opts.id;
1971
+ else {
1972
+ if (selection.length !== 1) return false;
1973
+ target = selection[0];
1974
+ }
1975
+ const plan = group.plan_ungroup(doc, target);
1976
+ if (!plan) return false;
1977
+ const group_id = plan.group_id;
1978
+ const group_next_sibling = doc.next_element_sibling_of(group_id);
1979
+ const original_child_transforms = /* @__PURE__ */ new Map();
1980
+ for (const child of plan.children) original_child_transforms.set(child, doc.get_attr(child, "transform"));
1981
+ const group_ops = plan.group_transform === null ? [] : transform.parse(plan.group_transform) ?? [];
1982
+ const original_selection = selection;
1983
+ const apply = () => {
1984
+ if (group_ops.length > 0) for (const child of plan.children) {
1985
+ const child_ops = transform.parse(doc.get_attr(child, "transform")) ?? [];
1986
+ const next = transform.emit([...group_ops, ...child_ops]);
1987
+ doc.set_attr(child, "transform", next === "" ? null : next);
1988
+ }
1989
+ for (const child of plan.children) doc.insert(child, plan.parent, group_id);
1990
+ doc.remove(group_id);
1991
+ set_selection(plan.children);
1992
+ };
1993
+ const revert = () => {
1994
+ doc.insert(group_id, plan.parent, group_next_sibling);
1995
+ for (const child of plan.children) doc.insert(child, group_id, null);
1996
+ if (group_ops.length > 0) for (const child of plan.children) doc.set_attr(child, "transform", original_child_transforms.get(child) ?? null);
1997
+ set_selection(original_selection);
1998
+ };
1999
+ apply();
2000
+ history.atomic("ungroup", (tx) => {
2001
+ tx.push({
2002
+ providerId: PROVIDER_ID,
2003
+ apply,
2004
+ revert
2005
+ });
2006
+ });
2007
+ return true;
2008
+ }
2552
2009
  /**
2553
2010
  * Atomic one-shot insertion. Used by paste, programmatic RPC, and the
2554
2011
  * click-no-drag commit path inside the insertion gesture driver. One
@@ -2558,11 +2015,20 @@ function _create_svg_editor_internal(opts) {
2558
2015
  * win. `opts.parent` defaults to root; `opts.index` (insert-before
2559
2016
  * sibling index) defaults to append; `opts.select` defaults to `true`.
2560
2017
  */
2018
+ /**
2019
+ * Resolve an optional `index` (position in `parent`'s element-children
2020
+ * list to insert AT — anything at or after it shifts; out-of-range or
2021
+ * `undefined` appends) to an insert-before anchor. Shared by `insert`,
2022
+ * `insert_fragment`, and `insert_preview`.
2023
+ */
2024
+ function resolve_insert_before(parent, index) {
2025
+ if (index === void 0) return null;
2026
+ return doc.element_children_of(parent)[index] ?? null;
2027
+ }
2561
2028
  function insert(tag, attrs, opts) {
2562
2029
  const parent = opts?.parent ?? doc.root;
2563
2030
  const select_after = opts?.select !== false;
2564
- let insert_before = null;
2565
- if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
2031
+ const insert_before = resolve_insert_before(parent, opts?.index);
2566
2032
  const id = doc.create_element(tag);
2567
2033
  const merged_attrs = {
2568
2034
  ...default_paint_attrs_for(tag),
@@ -2590,6 +2056,56 @@ function _create_svg_editor_internal(opts) {
2590
2056
  return id;
2591
2057
  }
2592
2058
  /**
2059
+ * Atomic fragment insertion — contract in {@link Commands.insert_fragment}.
2060
+ * Parses + adopts via `doc.create_fragment` (subtrees registered but
2061
+ * detached, like `create_element` — history.redo finds them via
2062
+ * closure), computes the namespace hoist plan, then brackets inserts +
2063
+ * hoisted declarations + selection in ONE history step.
2064
+ */
2065
+ function insert_fragment(svg, opts) {
2066
+ const parent = opts?.parent ?? doc.root;
2067
+ 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`);
2068
+ const select_after = opts?.select !== false;
2069
+ const { roots, xmlns } = doc.create_fragment(svg);
2070
+ if (roots.length === 0) return [];
2071
+ const known_uri = new Map([["xlink", XLINK_NS]]);
2072
+ for (const d of xmlns) known_uri.set(d.prefix, d.uri);
2073
+ const hoist = [];
2074
+ const considered = /* @__PURE__ */ new Set();
2075
+ for (const id of roots) for (const prefix of doc.undeclared_ns_prefixes(id)) {
2076
+ if (considered.has(prefix)) continue;
2077
+ considered.add(prefix);
2078
+ if (doc.get_attr(doc.root, prefix, XMLNS_NS) !== null) continue;
2079
+ const uri = known_uri.get(prefix);
2080
+ if (uri === void 0) continue;
2081
+ hoist.push({
2082
+ prefix,
2083
+ uri
2084
+ });
2085
+ }
2086
+ const insert_before = resolve_insert_before(parent, opts?.index);
2087
+ const previous_selection = selection;
2088
+ const apply = () => {
2089
+ for (const { prefix, uri } of hoist) doc.declare_xmlns(prefix, uri);
2090
+ for (const id of roots) doc.insert(id, parent, insert_before);
2091
+ if (select_after) set_selection(roots);
2092
+ };
2093
+ const revert = () => {
2094
+ for (let i = roots.length - 1; i >= 0; i--) doc.remove(roots[i]);
2095
+ for (const { prefix } of hoist) doc.set_attr(doc.root, prefix, null, XMLNS_NS);
2096
+ if (select_after) set_selection(previous_selection);
2097
+ };
2098
+ apply();
2099
+ history.atomic("insert fragment", (tx) => {
2100
+ tx.push({
2101
+ providerId: PROVIDER_ID,
2102
+ apply,
2103
+ revert
2104
+ });
2105
+ });
2106
+ return roots;
2107
+ }
2108
+ /**
2593
2109
  * Preview-bracketed insertion. Used by the pointer-driven drag gesture
2594
2110
  * in the DOM surface. Per-frame attr writes call `update(attrs)`; one
2595
2111
  * undo step on `commit()`; clean rollback on `discard()`.
@@ -2600,8 +2116,7 @@ function _create_svg_editor_internal(opts) {
2600
2116
  */
2601
2117
  function insert_preview(tag, initial, opts) {
2602
2118
  const parent = opts?.parent ?? doc.root;
2603
- let insert_before = null;
2604
- if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
2119
+ const insert_before = resolve_insert_before(parent, opts?.index);
2605
2120
  const id = doc.create_element(tag);
2606
2121
  const previous_selection = selection;
2607
2122
  const live_attrs = {
@@ -2759,6 +2274,10 @@ function _create_svg_editor_internal(opts) {
2759
2274
  current_surface_hover = id;
2760
2275
  notify_surface_hover();
2761
2276
  }
2277
+ const pick_listeners = /* @__PURE__ */ new Set();
2278
+ function notify_pick(e) {
2279
+ for (const cb of pick_listeners) cb(e);
2280
+ }
2762
2281
  function enter_content_edit(target) {
2763
2282
  const id = target ?? (selection.length === 1 ? selection[0] : null);
2764
2283
  if (!id) return false;
@@ -2804,14 +2323,18 @@ function _create_svg_editor_internal(opts) {
2804
2323
  translate,
2805
2324
  nudge,
2806
2325
  resize_to,
2326
+ resize_by,
2807
2327
  rotate,
2808
2328
  rotate_to,
2329
+ transform: apply_transform,
2809
2330
  flatten_transform,
2810
2331
  align,
2811
2332
  reorder,
2812
2333
  remove,
2813
2334
  group: group$1,
2335
+ ungroup,
2814
2336
  insert,
2337
+ insert_fragment,
2815
2338
  insert_preview,
2816
2339
  set_text,
2817
2340
  load_svg,
@@ -2857,6 +2380,10 @@ function _create_svg_editor_internal(opts) {
2857
2380
  function dispose() {
2858
2381
  detach();
2859
2382
  listeners.clear();
2383
+ surface_hover_listeners.clear();
2384
+ geometry_listeners.clear();
2385
+ translate_commit_listeners.clear();
2386
+ pick_listeners.clear();
2860
2387
  }
2861
2388
  function set_style(partial) {
2862
2389
  style = {
@@ -2948,6 +2475,22 @@ function _create_svg_editor_internal(opts) {
2948
2475
  };
2949
2476
  },
2950
2477
  /**
2478
+ * Subscribe to pick (tap) outcomes — a discrete click on the canvas,
2479
+ * reporting the document-space point and the node under it (`null` for
2480
+ * empty canvas), plus the button and modifier snapshot. Fires once per
2481
+ * tap, after the editor's own selection handling. Observe-only: a pick
2482
+ * cannot alter selection, and the channel does NOT bump `state.version`.
2483
+ * See {@link PickEvent}.
2484
+ *
2485
+ * @unstable
2486
+ */
2487
+ subscribe_pick(cb) {
2488
+ pick_listeners.add(cb);
2489
+ return () => {
2490
+ pick_listeners.delete(cb);
2491
+ };
2492
+ },
2493
+ /**
2951
2494
  * Subscribe to bounds-affecting changes. Fires when any document
2952
2495
  * mutation advances `state.geometry_version` — drag, resize, text
2953
2496
  * edit, structural insert/remove. Skips presentation-only writes
@@ -3018,11 +2561,18 @@ function _create_svg_editor_internal(opts) {
3018
2561
  push_surface_hover(id) {
3019
2562
  _set_current_surface_hover(id);
3020
2563
  },
2564
+ push_pick(e) {
2565
+ notify_pick(e);
2566
+ },
3021
2567
  set_computed_resolver(fn) {
3022
2568
  computed_resolver = fn;
3023
2569
  },
3024
2570
  set_geometry(p) {
3025
2571
  geometry_provider = p;
2572
+ },
2573
+ bump_geometry() {
2574
+ doc.bump_geometry();
2575
+ fire_geometry_listeners_if_advanced();
3026
2576
  }
3027
2577
  },
3028
2578
  keymap