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

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-CJ1Ctq14.js");
1
+ const require_model = require("./model-GpysNbOv.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,31 @@ 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.duplicate", () => {
88
+ if (editor.state.mode !== "select") return false;
89
+ if (editor.state.selection.length === 0) return false;
90
+ return editor.commands.duplicate().length > 0;
91
+ });
92
+ reg.register("selection.ungroup", () => {
93
+ if (editor.state.mode !== "select") return false;
94
+ if (editor.state.selection.length !== 1) return false;
95
+ return editor.commands.ungroup();
96
+ });
88
97
  reg.register("selection.resize_to", (args) => {
89
98
  if (editor.state.mode !== "select") return false;
90
99
  if (editor.state.selection.length === 0) return false;
91
100
  const target = args;
92
101
  return editor.commands.resize_to(target);
93
102
  });
103
+ reg.register("selection.nudge_resize", (args) => {
104
+ if (editor.state.mode !== "select") return false;
105
+ if (editor.state.selection.length === 0) return false;
106
+ const { dw, dh } = args;
107
+ return editor.commands.resize_by({
108
+ dw,
109
+ dh
110
+ });
111
+ });
94
112
  reg.register("selection.rotate", (args) => {
95
113
  if (editor.state.mode !== "select") return false;
96
114
  if (editor.state.selection.length === 0) return false;
@@ -120,6 +138,31 @@ function registerDefaultCommands(reg, editor) {
120
138
  if (editor.state.mode !== "select") return false;
121
139
  return editor.commands.align(args);
122
140
  });
141
+ reg.register("clipboard.copy", () => {
142
+ if (editor.state.mode !== "select") return false;
143
+ if (editor.state.selection.length === 0) return false;
144
+ return editor.commands.copy() !== null;
145
+ });
146
+ reg.register("clipboard.cut", () => {
147
+ if (editor.state.mode !== "select") return false;
148
+ if (editor.state.selection.length === 0) return false;
149
+ return editor.commands.cut() !== null;
150
+ });
151
+ reg.register("clipboard.paste", (args) => {
152
+ if (editor.state.mode !== "select") return false;
153
+ const text = args?.text;
154
+ if (typeof text === "string") return editor.commands.paste(text).length > 0;
155
+ const provider = editor.providers.clipboard;
156
+ if (provider) {
157
+ provider.read().then((text) => {
158
+ if (text) editor.commands.paste(text);
159
+ }).catch((err) => {
160
+ console.warn("[svg-editor] clipboard provider read failed:", err);
161
+ });
162
+ return true;
163
+ }
164
+ return editor.commands.paste().length > 0;
165
+ });
123
166
  reg.register("content.enter", () => editor.enter_content_edit());
124
167
  reg.register("hierarchy.enter", () => {
125
168
  if (editor.state.selection.length !== 1) return false;
@@ -310,7 +353,8 @@ function compareEntries(a, b) {
310
353
  * Same key, multiple meanings? Add multiple rows. The chain semantics
311
354
  * (handler returns `false` when not applicable) handle the rest.
312
355
  */
313
- const NUDGE_MEANINGFUL = _grida_keybinding.M.Shift;
356
+ const NUDGE_MEANINGFUL = _grida_keybinding.M.Shift | _grida_keybinding.M.Ctrl;
357
+ const RESIZE_MEANINGFUL = _grida_keybinding.M.Shift | _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt;
314
358
  const DEFAULT_BINDINGS = [
315
359
  {
316
360
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyZ, _grida_keybinding.M.CtrlCmd),
@@ -340,6 +384,14 @@ const DEFAULT_BINDINGS = [
340
384
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd),
341
385
  command: "selection.group"
342
386
  },
387
+ {
388
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd | _grida_keybinding.M.Shift),
389
+ command: "selection.ungroup"
390
+ },
391
+ {
392
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyD, _grida_keybinding.M.CtrlCmd),
393
+ command: "selection.duplicate"
394
+ },
343
395
  {
344
396
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyA, _grida_keybinding.M.CtrlCmd),
345
397
  command: "selection.all"
@@ -460,6 +512,70 @@ const DEFAULT_BINDINGS = [
460
512
  dy: 10
461
513
  }
462
514
  },
515
+ {
516
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
517
+ command: "selection.nudge_resize",
518
+ args: {
519
+ dw: 1,
520
+ dh: 0
521
+ }
522
+ },
523
+ {
524
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
525
+ command: "selection.nudge_resize",
526
+ args: {
527
+ dw: -1,
528
+ dh: 0
529
+ }
530
+ },
531
+ {
532
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
533
+ command: "selection.nudge_resize",
534
+ args: {
535
+ dw: 0,
536
+ dh: 1
537
+ }
538
+ },
539
+ {
540
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
541
+ command: "selection.nudge_resize",
542
+ args: {
543
+ dw: 0,
544
+ dh: -1
545
+ }
546
+ },
547
+ {
548
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
549
+ command: "selection.nudge_resize",
550
+ args: {
551
+ dw: 10,
552
+ dh: 0
553
+ }
554
+ },
555
+ {
556
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
557
+ command: "selection.nudge_resize",
558
+ args: {
559
+ dw: -10,
560
+ dh: 0
561
+ }
562
+ },
563
+ {
564
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
565
+ command: "selection.nudge_resize",
566
+ args: {
567
+ dw: 0,
568
+ dh: 10
569
+ }
570
+ },
571
+ {
572
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
573
+ command: "selection.nudge_resize",
574
+ args: {
575
+ dw: 0,
576
+ dh: -10
577
+ }
578
+ },
463
579
  {
464
580
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyV),
465
581
  command: TOOL_SET,
@@ -756,824 +872,175 @@ function create_defs(doc) {
756
872
  return { gradients: new GradientsRegistry(doc) };
757
873
  }
758
874
  //#endregion
759
- //#region src/core/document.ts
760
- /** The native vector tags `retype_to_path` can re-type, keyed by tag → the
761
- * native geometry attributes it consumes (so no orphaned geometry attr
762
- * survives on the resulting `<path>`). Covers the geometry primitives
763
- * (rect / circle / ellipse — always re-typed) and the vertex tags (line /
764
- * polyline / polygon — re-typed only when an edit escapes their native
765
- * form). */
766
- const RETYPABLE_GEOMETRY_ATTRS = {
767
- line: new Set([
768
- "x1",
769
- "y1",
770
- "x2",
771
- "y2"
772
- ]),
773
- polyline: new Set(["points"]),
774
- polygon: new Set(["points"]),
775
- rect: new Set([
776
- "x",
777
- "y",
778
- "width",
779
- "height",
780
- "rx",
781
- "ry"
782
- ]),
783
- circle: new Set([
784
- "cx",
785
- "cy",
786
- "r"
787
- ]),
788
- ellipse: new Set([
789
- "cx",
790
- "cy",
791
- "rx",
792
- "ry"
793
- ])
794
- };
875
+ //#region src/core/clipboard.ts
795
876
  /**
796
- * Parse a single SVG length attribute as a plain user-unit number. Returns
797
- * `null` for absent, non-finite, or unit/percentage values (`50%`, `5px`,
798
- * `5em`) those are an out-of-scope geometry gap, and refusing them here
799
- * means the editor never offers a promotion it cannot perform faithfully.
800
- */
801
- function parse_user_unit(raw) {
802
- if (raw === null) return null;
803
- const s = raw.trim();
804
- if (s === "") return null;
805
- if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
806
- const n = Number(s);
807
- return Number.isFinite(n) ? n : null;
808
- }
809
- /**
810
- * Attribute names whose writes can shift a node's rendered bounds.
811
- * Membership drives `_geometry_version` bumps in `set_attr`. Only
812
- * non-namespaced attribute names — namespaced writes (xlink:href, etc.)
813
- * never bump because they're references, not geometry.
877
+ * Clipboard payload extraction selection standalone SVG document.
878
+ *
879
+ * Implements the copy side of the clipboard FRD
880
+ * ([docs/wg/feat-svg-editor/clipboard.md](../../../../docs/wg/feat-svg-editor/clipboard.md)):
881
+ * the payload is a standalone, namespace-well-formed SVG document — not a
882
+ * private envelope. Assembly is a pure function of (document, selection):
883
+ * no geometry, no environment, no randomness (FRD R6 the same selection
884
+ * yields the same bytes headless or surface-attached).
814
885
  *
815
- * Includes text-shaping attributes (font-*) because they re-shape glyph
816
- * runs and change `<text>` bbox.
886
+ * What the payload carries, and what it deliberately does not
887
+ * (FRD §Extraction five context kinds):
888
+ *
889
+ * 1. Referenced resources — CARRIED. The outbound `url(#…)` / `href`
890
+ * closure is walked from the closed carrier list below and emitted
891
+ * verbatim in one `<defs>` block.
892
+ * 2. Namespace declarations — CARRIED. Prefixes a subtree borrows from
893
+ * ancestor scope are declared on the payload shell (an undeclared
894
+ * prefix is a well-formedness error, so this includes the deliberate
895
+ * well-known-table repair for e.g. `xlink`).
896
+ * 3. Ancestor transforms — NOT carried (verbatim policy).
897
+ * 4. Inherited presentation / cascade — NOT carried (verbatim policy).
898
+ * 5. Viewport — NOT carried (no `viewBox`, no sizing on the shell).
899
+ *
900
+ * This module is the **payload extraction** operation only. The sibling
901
+ * operation the FRD names — in-document subtree CLONE (duplicate /
902
+ * clone-drag), which must NOT carry the closure — lives in `./subtree`.
903
+ * The two share exactly selection normalization (`subtree.normalize_roots`)
904
+ * and verbatim subtree serialization (`doc.serialize_node`).
817
905
  */
818
- const GEOMETRY_ATTRS = new Set([
819
- "x",
820
- "y",
821
- "x1",
822
- "y1",
823
- "x2",
824
- "y2",
825
- "cx",
826
- "cy",
827
- "width",
828
- "height",
829
- "r",
830
- "rx",
831
- "ry",
832
- "points",
833
- "d",
834
- "transform",
835
- "viewBox",
836
- "font-size",
837
- "font-family",
838
- "font-weight",
839
- "font-style",
840
- "text-anchor",
841
- "dx",
842
- "dy",
843
- "rotate",
844
- "textLength",
845
- "lengthAdjust",
846
- "pathLength",
847
- "marker-start",
848
- "marker-mid",
849
- "marker-end"
850
- ]);
851
- /** `transform:` CSS property at the start of a declaration list or after `;`. */
852
- const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
853
- var SvgDocument = class SvgDocument {
854
- constructor(svg) {
855
- this.listeners = /* @__PURE__ */ new Set();
856
- this._structure_version = 0;
857
- this._geometry_version = 0;
858
- if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
859
- this.source = svg;
860
- const parsed = (0, _grida_svg_parser.parse_svg)(svg);
861
- this.original = parsed;
862
- this.nodes = parsed.nodes;
863
- this.prolog = parsed.prolog;
864
- this.epilog = parsed.epilog;
865
- this.root = parsed.root;
866
- }
867
- static parse(svg) {
868
- return new SvgDocument(svg);
869
- }
870
- /** Reload from the original parse, discarding all edits. */
871
- reset_to_original() {
872
- const parsed = (0, _grida_svg_parser.parse_svg)(this.source);
873
- this.original = parsed;
874
- this.nodes = parsed.nodes;
875
- this.prolog = parsed.prolog;
876
- this.epilog = parsed.epilog;
877
- this.root = parsed.root;
878
- this._structure_version++;
879
- this._geometry_version++;
880
- this.emit();
881
- }
882
- /** Replace document with new svg source (clears edits + history-owned state). */
883
- load(svg) {
884
- if (typeof svg !== "string") throw new TypeError(`SvgDocument.load(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
885
- this.source = svg;
886
- const parsed = (0, _grida_svg_parser.parse_svg)(svg);
887
- this.original = parsed;
888
- this.nodes = parsed.nodes;
889
- this.prolog = parsed.prolog;
890
- this.epilog = parsed.epilog;
891
- this.root = parsed.root;
892
- this._structure_version++;
893
- this._geometry_version++;
894
- this.emit();
895
- }
896
- on_change(fn) {
897
- this.listeners.add(fn);
898
- return () => this.listeners.delete(fn);
899
- }
900
- /** See `_structure_version` for what this counter signals. */
901
- get structure_version() {
902
- return this._structure_version;
903
- }
904
- /** See `_geometry_version` for what this counter signals. */
905
- get geometry_version() {
906
- return this._geometry_version;
907
- }
908
- emit() {
909
- for (const fn of this.listeners) fn();
910
- }
911
- /** Notify subscribers — for callers that mutate directly via setAttr/etc. */
912
- notify() {
913
- this.emit();
914
- }
915
- get(id) {
916
- return this.nodes.get(id) ?? null;
917
- }
918
- is_element(id) {
919
- return this.nodes.get(id)?.kind === "element";
920
- }
921
- parent_of(id) {
922
- return this.nodes.get(id)?.parent ?? null;
923
- }
924
- children_of(id) {
925
- const n = this.nodes.get(id);
926
- if (!n || n.kind !== "element") return [];
927
- return n.children;
928
- }
929
- /** Element children only — text/comment/cdata filtered out. */
930
- element_children_of(id) {
931
- return this.children_of(id).filter((c) => this.is_element(c));
932
- }
933
- next_sibling_of(id) {
934
- const parent = this.parent_of(id);
935
- if (parent === null) return null;
936
- const siblings = this.children_of(parent);
937
- const i = siblings.indexOf(id);
938
- return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
939
- }
940
- next_element_sibling_of(id) {
941
- const parent = this.parent_of(id);
942
- if (parent === null) return null;
943
- const siblings = this.element_children_of(parent);
944
- const i = siblings.indexOf(id);
945
- return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
946
- }
947
- tag_of(id) {
948
- const n = this.nodes.get(id);
949
- return n && n.kind === "element" ? n.local : "";
950
- }
951
- contains(ancestor, descendant) {
952
- if (ancestor === descendant) return true;
953
- let cur = this.parent_of(descendant);
954
- while (cur !== null) {
955
- if (cur === ancestor) return true;
956
- cur = this.parent_of(cur);
957
- }
958
- return false;
959
- }
960
- /**
961
- * Filter a selection down to its **subtree roots** — drop any id whose
962
- * ancestor is also in the input set.
963
- *
964
- * Mirrors `pruneNestedNodes` in the main canvas editor's query module
965
- * ([editor/grida-canvas/query/index.ts:138](../../../../editor/grida-canvas/query/index.ts)) and shares its UX motivation:
966
- * when a parent and a descendant are both selected, only the parent
967
- * should drive multi-node mutations — otherwise the descendant
968
- * accumulates the transform twice (once via the parent's `transform`,
969
- * once via its own attribute write). Required for `commands.remove`
970
- * (avoids re-attaching detached descendants on undo) and any multi-
971
- * member translate path (avoids 2× drift for the Bar-chart marquee
972
- * case).
973
- *
974
- * Order: preserves the input order for retained ids. Duplicates in
975
- * the input are not deduplicated — callers are responsible (the
976
- * editor's `commands.select` already dedupes).
977
- *
978
- * Performance: `O(n × depth)`. Builds a `Set` over the input once,
979
- * then walks each id's ancestor chain at most once. The main editor's
980
- * version is `O(n² × depth)` (per-pair `isAncestor`) — fine at typical
981
- * selection sizes (a few dozen), worth winning here for free since
982
- * `parent_of` is `O(1)` on our parent-map.
983
- */
984
- prune_nested_nodes(ids) {
985
- if (ids.length <= 1) return [...ids];
986
- const set = new Set(ids);
987
- const out = [];
988
- for (const id of ids) {
989
- let nested = false;
990
- let cur = this.parent_of(id);
991
- while (cur !== null) {
992
- if (set.has(cur)) {
993
- nested = true;
994
- break;
995
- }
996
- cur = this.parent_of(cur);
997
- }
998
- if (!nested) out.push(id);
999
- }
1000
- return out;
1001
- }
1002
- all_nodes() {
1003
- const out = [];
1004
- const walk = (id) => {
1005
- out.push(id);
1006
- const c = this.children_of(id);
1007
- for (const ch of c) walk(ch);
1008
- };
1009
- walk(this.root);
1010
- return out;
1011
- }
1012
- all_elements() {
1013
- return this.all_nodes().filter((id) => this.is_element(id));
1014
- }
1015
- find_by_tag(ancestor, tag) {
1016
- const out = [];
1017
- const walk = (id) => {
1018
- if (id !== ancestor && this.is_element(id) && this.tag_of(id) === tag) out.push(id);
1019
- for (const c of this.children_of(id)) walk(c);
1020
- };
1021
- walk(ancestor);
1022
- return out;
1023
- }
1024
- /** Read attribute by local name, optionally namespace-filtered. */
1025
- get_attr(id, name, ns = null) {
1026
- const n = this.nodes.get(id);
1027
- if (!n || n.kind !== "element") return null;
1028
- for (const a of n.attrs) if (a.local === name && (ns === null || a.ns === ns)) return a.value;
1029
- return null;
1030
- }
1031
- /**
1032
- * Set / remove an attribute. If the attribute exists, it is mutated in place
1033
- * (preserving source position). If it doesn't, it's appended.
1034
- */
1035
- set_attr(id, name, value, ns = null) {
1036
- const n = this.nodes.get(id);
1037
- if (!n || n.kind !== "element") return;
1038
- const structural = name === "id";
1039
- const geometry = ns === null && GEOMETRY_ATTRS.has(name);
1040
- for (let i = 0; i < n.attrs.length; i++) {
1041
- const a = n.attrs[i];
1042
- if (a.local === name && (ns === null || a.ns === ns)) {
1043
- if (value === null) n.attrs.splice(i, 1);
1044
- else a.value = value;
1045
- if (structural) this._structure_version++;
1046
- if (geometry) this._geometry_version++;
1047
- this.emit();
1048
- return;
1049
- }
1050
- }
1051
- if (value !== null) {
1052
- const prefix = ns === _grida_svg_parser.XLINK_NS ? "xlink" : null;
1053
- n.attrs.push({
1054
- raw_name: prefix ? `${prefix}:${name}` : name,
1055
- prefix,
1056
- local: name,
1057
- ns,
1058
- value,
1059
- pre: " ",
1060
- eq_trivia: "",
1061
- quote: "\""
1062
- });
1063
- if (structural) this._structure_version++;
1064
- if (geometry) this._geometry_version++;
1065
- this.emit();
1066
- }
1067
- }
1068
- attributes_of(id) {
1069
- const n = this.nodes.get(id);
1070
- if (!n || n.kind !== "element") return [];
1071
- return n.attrs.map((a) => ({
1072
- name: a.local,
1073
- ns: a.ns,
1074
- value: a.value
1075
- }));
1076
- }
1077
- get_style(id, property) {
1078
- const style = this.get_attr(id, "style");
1079
- if (!style) return null;
1080
- const decls = parse_inline_style(style);
1081
- for (const d of decls) if (d.property === property) return d.value;
1082
- return null;
1083
- }
1084
- set_style(id, property, value) {
1085
- const decls = parse_inline_style(this.get_attr(id, "style") ?? "");
1086
- const idx = decls.findIndex((d) => d.property === property);
1087
- if (value === null) {
1088
- if (idx === -1) return;
1089
- decls.splice(idx, 1);
1090
- } else if (idx === -1) decls.push({
1091
- property,
1092
- value
1093
- });
1094
- else decls[idx].value = value;
1095
- const next = decls.map((d) => `${d.property}: ${d.value}`).join("; ");
1096
- this.set_attr(id, "style", next === "" ? null : next);
1097
- }
1098
- get_all_styles(id) {
1099
- const style = this.get_attr(id, "style");
1100
- if (!style) return [];
1101
- return parse_inline_style(style);
1102
- }
906
+ let clipboard;
907
+ (function(_clipboard) {
1103
908
  /**
1104
- * Whether `id` can be opened in the flat-string text editor.
909
+ * Presentation carriers that may hold `url(#…)` references, read both as
910
+ * a presentation attribute and as an inline `style=""` declaration.
1105
911
  *
1106
- * v1 contract: the editor only operates on a *single flat text run*. That
1107
- * means the target must be a `<text>` or `<tspan>` whose direct children
1108
- * are all text nodes (or it has no children). A `<text>` containing a
1109
- * `<tspan>` is *not* honestly editable `text_of` would drop the tspan
1110
- * content from the editor's view, and a flat-text write would leave the
1111
- * tspan dangling. Tspan-as-target is fine and well-defined when it's a
1112
- * leaf; only the host decides whether to route double-click to a tspan
1113
- * or its parent text.
1114
- */
1115
- is_text_edit_target(id) {
1116
- const n = this.nodes.get(id);
1117
- if (!n || n.kind !== "element") return false;
1118
- if (n.local !== "text" && n.local !== "tspan") return false;
1119
- for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
1120
- return true;
1121
- }
1122
- /**
1123
- * Returns a tag-discriminated snapshot of the authored geometry attrs
1124
- * if this node is eligible for vector (vertex) editing — else `null`.
1125
- *
1126
- * Eligibility:
1127
- * - `<path>` — requires non-empty `d`.
1128
- * - `<line>` — requires two distinct finite user-unit endpoints.
1129
- * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
1130
- * - `<polygon>` — same as polyline.
1131
- * - `<rect>` — requires finite user-unit `width`/`height` > 0.
1132
- * - `<circle>` — requires finite user-unit `r` > 0.
1133
- * - `<ellipse>` — requires finite user-unit `rx`/`ry` > 0.
1134
- *
1135
- * The vertex tags (`line` / `polyline` / `polygon`) write edits back to
1136
- * their native attributes while the geometry stays expressible there; an
1137
- * edit that escapes the native form (a curve, or a topology change that
1138
- * leaves the canonical chain) re-types the element to `<path>`. The
1139
- * geometry primitives (`rect` / `circle` / `ellipse`) have no native
1140
- * vector form, so any vector edit re-types them. In all cases the native
1141
- * tag is preserved byte-for-byte until the first re-typing edit commits
1142
- * (see `retype_to_path`). Design:
1143
- * `docs/wg/feat-svg-editor/promote-to-path.md`.
1144
- *
1145
- * Geometry that is not a plain user-unit number (`%`, `px`, `em`, …) is
1146
- * an out-of-scope gap, so such an element returns `null` rather than
1147
- * advertising an edit the editor cannot perform faithfully.
1148
- *
1149
- * Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
1150
- * editable outline).
912
+ * CLOSED LIST extending it is a spec change to the FRD's §Extraction 1
913
+ * carrier list, not a bug fix. Deliberately NOT walked in v1 (documented
914
+ * degradations): `<style>` element rules (CSS parsing is the deferred
915
+ * cascade capability), SMIL timing/value references, `cursor`, SVG 2
916
+ * text-layout properties.
1151
917
  */
918
+ const URL_REF_PROPS = [
919
+ "fill",
920
+ "stroke",
921
+ "filter",
922
+ "clip-path",
923
+ "mask",
924
+ "marker-start",
925
+ "marker-mid",
926
+ "marker-end",
927
+ "marker"
928
+ ];
929
+ /** Set view of {@link URL_REF_PROPS} for membership tests over parsed
930
+ * style declarations. */
931
+ const URL_REF_PROP_SET = new Set(URL_REF_PROPS);
1152
932
  /**
1153
- * Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
1154
- * endpoints). An **absent** attribute takes the SVG default (`0`); a
1155
- * **present** attribute that is not a plain user-unit number (`%`, `px`,
1156
- * `em`, …) is out of scope and yields `null` so the caller refuses the
1157
- * element — the same gate required attrs (width / radius) already apply.
1158
- *
1159
- * The absent-vs-present distinction is the point: a bare `?? 0` would
1160
- * silently coerce an authored `x1="5px"` to `0`, then the first native
1161
- * writeback would overwrite that authored value. Refusing keeps the
1162
- * editor from misrepresenting geometry it cannot read faithfully.
933
+ * Elements whose `href` / `xlink:href` is a same-document resource
934
+ * reference the closure follows. CLOSED LIST `<a href>` is navigation,
935
+ * `<image href>` is content, SMIL `href` is an animation target; none of
936
+ * them are walked.
1163
937
  */
1164
- optional_user_unit_coord(id, name) {
1165
- const raw = this.get_attr(id, name);
1166
- if (raw === null) return 0;
1167
- return parse_user_unit(raw);
1168
- }
1169
- is_vector_edit_target(id) {
1170
- const n = this.nodes.get(id);
1171
- if (!n || n.kind !== "element") return null;
1172
- if (RETYPABLE_GEOMETRY_ATTRS[n.local] && n.attrs.some((a) => a.prefix === null && a.ns === null && a.local === "d")) return null;
1173
- switch (n.local) {
1174
- case "path": {
1175
- const d = this.get_attr(id, "d");
1176
- if (d === null || d.trim().length === 0) return null;
1177
- return {
1178
- kind: "path",
1179
- d
1180
- };
1181
- }
1182
- case "line": {
1183
- const x1 = this.optional_user_unit_coord(id, "x1");
1184
- const y1 = this.optional_user_unit_coord(id, "y1");
1185
- const x2 = this.optional_user_unit_coord(id, "x2");
1186
- const y2 = this.optional_user_unit_coord(id, "y2");
1187
- if (x1 === null || y1 === null || x2 === null || y2 === null) return null;
1188
- if (x1 === x2 && y1 === y2) return null;
1189
- return {
1190
- kind: "line",
1191
- x1,
1192
- y1,
1193
- x2,
1194
- y2
1195
- };
1196
- }
1197
- case "polyline":
1198
- case "polygon": {
1199
- const raw = this.get_attr(id, "points") ?? "";
1200
- const parsed = _grida_svg_parse.svg_parse.parse_points(raw);
1201
- if (parsed.length < 2) return null;
1202
- const points = parsed.map((p) => [p.x, p.y]);
1203
- return n.local === "polyline" ? {
1204
- kind: "polyline",
1205
- points
1206
- } : {
1207
- kind: "polygon",
1208
- points
1209
- };
1210
- }
1211
- case "rect": {
1212
- const x = this.optional_user_unit_coord(id, "x");
1213
- const y = this.optional_user_unit_coord(id, "y");
1214
- if (x === null || y === null) return null;
1215
- const width = parse_user_unit(this.get_attr(id, "width"));
1216
- const height = parse_user_unit(this.get_attr(id, "height"));
1217
- if (width === null || height === null) return null;
1218
- if (width <= 0 || height <= 0) return null;
1219
- const rx_attr = this.get_attr(id, "rx");
1220
- const ry_attr = this.get_attr(id, "ry");
1221
- const rx_parsed = rx_attr === null ? null : parse_user_unit(rx_attr);
1222
- const ry_parsed = ry_attr === null ? null : parse_user_unit(ry_attr);
1223
- if (rx_attr !== null && rx_parsed === null) return null;
1224
- if (ry_attr !== null && ry_parsed === null) return null;
1225
- let rx = rx_parsed ?? ry_parsed ?? 0;
1226
- let ry = ry_parsed ?? rx_parsed ?? 0;
1227
- rx = Math.max(0, Math.min(rx, width / 2));
1228
- ry = Math.max(0, Math.min(ry, height / 2));
1229
- return {
1230
- kind: "rect",
1231
- x,
1232
- y,
1233
- width,
1234
- height,
1235
- rx,
1236
- ry
1237
- };
1238
- }
1239
- case "circle": {
1240
- const cx = this.optional_user_unit_coord(id, "cx");
1241
- const cy = this.optional_user_unit_coord(id, "cy");
1242
- if (cx === null || cy === null) return null;
1243
- const r = parse_user_unit(this.get_attr(id, "r"));
1244
- if (r === null || r <= 0) return null;
1245
- return {
1246
- kind: "circle",
1247
- cx,
1248
- cy,
1249
- r
1250
- };
1251
- }
1252
- case "ellipse": {
1253
- const cx = this.optional_user_unit_coord(id, "cx");
1254
- const cy = this.optional_user_unit_coord(id, "cy");
1255
- if (cx === null || cy === null) return null;
1256
- const rx = parse_user_unit(this.get_attr(id, "rx"));
1257
- const ry = parse_user_unit(this.get_attr(id, "ry"));
1258
- if (rx === null || ry === null) return null;
1259
- if (rx <= 0 || ry <= 0) return null;
1260
- return {
1261
- kind: "ellipse",
1262
- cx,
1263
- cy,
1264
- rx,
1265
- ry
1266
- };
1267
- }
1268
- default: return null;
1269
- }
1270
- }
938
+ const HREF_TAGS = new Set([
939
+ "use",
940
+ "textPath",
941
+ "mpath",
942
+ "feImage",
943
+ "pattern",
944
+ "linearGradient",
945
+ "radialGradient",
946
+ "filter"
947
+ ]);
1271
948
  /**
1272
- * Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
1273
- * `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
1274
- * its native geometry attributes and setting `d`. A structural mutation:
1275
- * this layer executes the re-type; it does not decide when one is
1276
- * warranted.
1277
- *
1278
- * Idempotent: returns `null` if `id` is not currently one of those tags
1279
- * (so it is safe to call repeatedly — once re-typed, e.g. already a
1280
- * `<path>`, further calls are no-ops). Otherwise mutates the node and
1281
- * returns an opaque {@link RetypeRecord} reversal token.
1282
- *
1283
- * Identity, children, `self_closing`, non-geometry attributes, and all
1284
- * source trivia are preserved unchanged — only the tag and the geometry
1285
- * attributes move. Pass the token to {@link revert_retype} to restore
1286
- * the original primitive byte-for-byte.
1287
- *
1288
- * (see test/svg-editor-vector-promote-to-path.md)
949
+ * `url(#id)` extractor. Global a single value can carry several
950
+ * references (`filter: url(#a) blur(2px) url(#b)` is a legal filter
951
+ * function list). Same quoting tolerance as the defs registry's
952
+ * ref-counting pattern.
1289
953
  */
1290
- retype_to_path(id, d) {
1291
- const n = this.nodes.get(id);
1292
- if (!n || n.kind !== "element") return null;
1293
- const geom = RETYPABLE_GEOMETRY_ATTRS[n.local];
1294
- if (!geom) return null;
1295
- const prev_local = n.local;
1296
- const prev_raw_tag = n.raw_tag;
1297
- const removed = [];
1298
- for (let i = n.attrs.length - 1; i >= 0; i--) {
1299
- const a = n.attrs[i];
1300
- if (a.prefix === null && a.ns === null && geom.has(a.local)) {
1301
- removed.push({
1302
- index: i,
1303
- token: a
1304
- });
1305
- n.attrs.splice(i, 1);
1306
- }
954
+ const URL_REF_RE = /url\(\s*["']?#([^"')\s]+)["']?\s*\)/g;
955
+ function extract_payload(doc, selection) {
956
+ const order = require_model.subtree.by_document_order(doc);
957
+ const roots = require_model.subtree.normalize_roots(doc, selection, order);
958
+ if (roots.length === 0) return null;
959
+ const closure = collect_reference_closure(doc, roots);
960
+ const shell_ns = /* @__PURE__ */ new Map();
961
+ for (const member of [...closure, ...roots].sort(order)) for (const prefix of doc.undeclared_ns_prefixes(member)) {
962
+ if (shell_ns.has(prefix)) continue;
963
+ const uri = resolve_prefix(doc, member, prefix);
964
+ if (uri !== null) shell_ns.set(prefix, uri);
1307
965
  }
1308
- removed.reverse();
1309
- n.local = "path";
1310
- n.raw_tag = n.prefix ? `${n.prefix}:path` : "path";
1311
- n.attrs.push({
1312
- raw_name: "d",
1313
- prefix: null,
1314
- local: "d",
1315
- ns: null,
1316
- value: d,
1317
- pre: " ",
1318
- eq_trivia: "",
1319
- quote: "\""
1320
- });
1321
- let added_fill_none = false;
1322
- if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
1323
- n.attrs.push({
1324
- raw_name: "fill",
1325
- prefix: null,
1326
- local: "fill",
1327
- ns: null,
1328
- value: "none",
1329
- pre: " ",
1330
- eq_trivia: "",
1331
- quote: "\""
1332
- });
1333
- added_fill_none = true;
966
+ let shell = `<svg xmlns="${_grida_svg_parser.SVG_NS}"`;
967
+ for (const [prefix, uri] of shell_ns) shell += ` xmlns:${prefix}="${(0, _grida_svg_parser.encode_attr_value)(uri, "\"")}"`;
968
+ shell += ">";
969
+ const defs_block = closure.length > 0 ? `<defs>${closure.map((id) => doc.serialize_node(id)).join("")}</defs>` : "";
970
+ const content = roots.map((id) => doc.serialize_node(id)).join("");
971
+ return `${shell}${defs_block}${content}</svg>`;
972
+ }
973
+ _clipboard.extract_payload = extract_payload;
974
+ function collect_reference_closure(doc, roots) {
975
+ const id_map = /* @__PURE__ */ new Map();
976
+ for (const el of doc.all_elements()) {
977
+ const id_attr = doc.get_attr(el, "id");
978
+ if (id_attr !== null && !id_map.has(id_attr)) id_map.set(id_attr, el);
1334
979
  }
1335
- this._structure_version++;
1336
- this._geometry_version++;
1337
- this.emit();
1338
- return {
1339
- prev_local,
1340
- prev_raw_tag,
1341
- removed,
1342
- added_fill_none
1343
- };
1344
- }
1345
- /**
1346
- * Reverse a {@link retype_to_path}: restore the original tag, remove the
1347
- * `d` attribute the promotion added, and splice the captured geometry
1348
- * attribute tokens back at their original positions (preserving their
1349
- * trivia, so a later `serialize()` is byte-equal to the pre-promotion
1350
- * source).
1351
- */
1352
- revert_retype(id, token) {
1353
- const n = this.nodes.get(id);
1354
- if (!n || n.kind !== "element") return;
1355
- for (let i = n.attrs.length - 1; i >= 0; i--) {
1356
- const a = n.attrs[i];
1357
- if (a.prefix === null && a.ns === null && a.local === "d") {
1358
- n.attrs.splice(i, 1);
1359
- break;
1360
- }
1361
- }
1362
- if (token.added_fill_none) for (let i = n.attrs.length - 1; i >= 0; i--) {
1363
- const a = n.attrs[i];
1364
- if (a.prefix === null && a.ns === null && a.local === "fill") {
1365
- n.attrs.splice(i, 1);
1366
- break;
980
+ const in_forest = (target) => roots.some((r) => doc.contains(r, target));
981
+ const collected = /* @__PURE__ */ new Set();
982
+ const pending = [...roots];
983
+ while (pending.length > 0) {
984
+ const subtree = pending.pop();
985
+ for (const el of elements_of_subtree(doc, subtree)) for (const ref of refs_of(doc, el)) {
986
+ const target = id_map.get(ref);
987
+ if (target === void 0) continue;
988
+ if (in_forest(target)) continue;
989
+ if (collected.has(target)) continue;
990
+ collected.add(target);
991
+ pending.push(target);
1367
992
  }
1368
993
  }
1369
- n.local = token.prev_local;
1370
- n.raw_tag = token.prev_raw_tag;
1371
- for (const { index, token: t } of token.removed) n.attrs.splice(index, 0, t);
1372
- this._structure_version++;
1373
- this._geometry_version++;
1374
- this.emit();
1375
- }
1376
- /**
1377
- * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
1378
- * per-glyph attribute (which conflicts with element-level rotation).
1379
- */
1380
- has_glyph_rotate(id) {
1381
- const tag = this.tag_of(id);
1382
- if (tag !== "text" && tag !== "tspan") return false;
1383
- const value = this.get_attr(id, "rotate");
1384
- if (value === null) return false;
1385
- return value.trim() !== "";
1386
- }
1387
- /**
1388
- * True iff this element's inline `style=""` declares a `transform:`
1389
- * CSS property (which would shadow the editor's `transform=` writes).
1390
- */
1391
- has_inline_css_transform(id) {
1392
- const style = this.get_attr(id, "style");
1393
- if (!style) return false;
1394
- return CSS_TRANSFORM_PROPERTY.test(style);
1395
- }
1396
- /**
1397
- * True iff this element has a direct `<animateTransform>` child
1398
- * (which produces a time-varying transform invisible to attribute writes).
1399
- * Only direct children are checked — nested cases attach to the nearer ancestor.
1400
- */
1401
- has_animate_transform_child(id) {
1402
- for (const c of this.children_of(id)) {
1403
- const n = this.nodes.get(c);
1404
- if (n?.kind === "element" && n.local === "animateTransform") return true;
1405
- }
1406
- return false;
994
+ return doc.prune_nested_nodes([...collected]).sort(require_model.subtree.by_document_order(doc));
1407
995
  }
1408
- text_of(id) {
1409
- const n = this.nodes.get(id);
1410
- if (!n || n.kind !== "element") return "";
1411
- let out = "";
1412
- for (const c of n.children) {
1413
- const cn = this.nodes.get(c);
1414
- if (cn?.kind === "text") out += cn.value;
1415
- }
996
+ _clipboard.collect_reference_closure = collect_reference_closure;
997
+ /** Preorder element walk of `root`'s subtree, root included. */
998
+ function elements_of_subtree(doc, root) {
999
+ const out = [];
1000
+ const walk = (id) => {
1001
+ if (!doc.is_element(id)) return;
1002
+ out.push(id);
1003
+ for (const c of doc.children_of(id)) walk(c);
1004
+ };
1005
+ walk(root);
1416
1006
  return out;
1417
1007
  }
1418
- /** Replace all direct text children with a single text node carrying `value`. */
1419
- set_text(id, value) {
1420
- const n = this.nodes.get(id);
1421
- if (!n || n.kind !== "element") return;
1422
- n.children = n.children.filter((c) => this.nodes.get(c)?.kind !== "text");
1423
- if (value !== "") {
1424
- const text_id = `t${Math.random().toString(36).slice(2, 10)}`;
1425
- const text_node = {
1426
- kind: "text",
1427
- id: text_id,
1428
- parent: id,
1429
- value
1430
- };
1431
- this.nodes.set(text_id, text_node);
1432
- n.children.push(text_id);
1008
+ /** Same-document reference ids carried by one element, per the closed
1009
+ * carrier list. */
1010
+ function refs_of(doc, id) {
1011
+ const out = [];
1012
+ for (const prop of URL_REF_PROPS) {
1013
+ const value = doc.get_attr(id, prop);
1014
+ if (!value) continue;
1015
+ for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
1433
1016
  }
1434
- this._structure_version++;
1435
- this._geometry_version++;
1436
- this.emit();
1437
- }
1438
- insert(id, parent, before) {
1439
- const node = this.nodes.get(id);
1440
- const parent_node = this.nodes.get(parent);
1441
- if (!node || !parent_node || parent_node.kind !== "element") return;
1442
- if (node.parent !== null) {
1443
- const old_parent = this.nodes.get(node.parent);
1444
- if (old_parent && old_parent.kind === "element") {
1445
- const i = old_parent.children.indexOf(id);
1446
- if (i >= 0) old_parent.children.splice(i, 1);
1447
- }
1017
+ for (const { property, value } of doc.get_all_styles(id)) {
1018
+ if (!URL_REF_PROP_SET.has(property)) continue;
1019
+ for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
1020
+ }
1021
+ if (HREF_TAGS.has(doc.tag_of(id))) {
1022
+ const href = doc.get_attr(id, "href");
1023
+ if (href !== null && href.startsWith("#") && href.length > 1) out.push(href.slice(1));
1448
1024
  }
1449
- const ix = before === null ? -1 : parent_node.children.indexOf(before);
1450
- if (ix < 0) parent_node.children.push(id);
1451
- else parent_node.children.splice(ix, 0, id);
1452
- node.parent = parent;
1453
- this._structure_version++;
1454
- this._geometry_version++;
1455
- this.emit();
1456
- }
1457
- remove(id) {
1458
- const n = this.nodes.get(id);
1459
- if (!n || n.parent === null) return;
1460
- const parent = this.nodes.get(n.parent);
1461
- if (!parent || parent.kind !== "element") return;
1462
- const i = parent.children.indexOf(id);
1463
- if (i >= 0) parent.children.splice(i, 1);
1464
- n.parent = null;
1465
- this._structure_version++;
1466
- this._geometry_version++;
1467
- this.emit();
1468
- }
1469
- /** Create a new element node and register it (not yet inserted). */
1470
- create_element(local, opts) {
1471
- const id = `e${Math.random().toString(36).slice(2, 10)}`;
1472
- const prefix = opts?.prefix ?? null;
1473
- const ns = opts?.ns ?? null;
1474
- const node = {
1475
- kind: "element",
1476
- id,
1477
- parent: null,
1478
- raw_tag: prefix ? `${prefix}:${local}` : local,
1479
- prefix,
1480
- local,
1481
- ns,
1482
- attrs: [],
1483
- children: [],
1484
- self_closing: false,
1485
- open_tag_trailing: "",
1486
- close_tag_leading: "",
1487
- close_tag_trailing: ""
1488
- };
1489
- this.nodes.set(id, node);
1490
- return id;
1491
- }
1492
- serialize() {
1493
- let out = "";
1494
- for (const p of this.prolog) out += this.emit_node(p);
1495
- out += this.emit_node(this.nodes.get(this.root));
1496
- for (const e of this.epilog) out += this.emit_node(e);
1497
1025
  return out;
1498
1026
  }
1499
1027
  /**
1500
- * Serialize a single element's subtree as an SVG **fragment**, using the
1501
- * same trivia-preserving rules as {@link serialize} (attribute order,
1502
- * quote style, whitespace, comments emitted exactly as authored).
1503
- *
1504
- * This is NOT {@link serialize} scoped to a node — it is a deliberately
1505
- * weaker output (sdk-design D3, asymmetric outputs stay separate):
1506
- *
1507
- * - `serialize()` emits the whole document and carries the P1
1508
- * whole-document round-trip guarantee.
1509
- * - `serialize_node()` emits a fragment and does NOT. Namespace
1510
- * declarations that live on an ancestor (`xmlns:xlink` and friends,
1511
- * normally on the root `<svg>`) are NOT inlined — a node using
1512
- * `xlink:href` serializes without `xmlns:xlink`. The fragment is the
1513
- * element's markup as authored, not a standalone parseable document.
1514
- *
1515
- * Throws on an unknown id, a non-element node, or a node detached from
1516
- * the live tree: the contract is "the markup for a selected element,"
1517
- * selections are always live elements, and a string return of `""` for a
1518
- * bad id would hide consumer bugs. The detached case matters because
1519
- * `remove()` keeps the node in the id map for undo — a stale id from a
1520
- * removed node would otherwise serialize content no longer in the
1521
- * document, silently feeding a consumer deleted markup.
1028
+ * Resolve a prefix a member's subtree borrows from ancestor scope:
1029
+ * nearest ancestor `xmlns:<prefix>` declaration wins (correct XML
1030
+ * scoping), falling back to the well-known table — the deliberate
1031
+ * repair that keeps the payload namespace-well-formed even when the
1032
+ * source never declared the prefix (FRD §Extraction 2).
1522
1033
  */
1523
- serialize_node(id) {
1524
- const n = this.nodes.get(id);
1525
- if (!n) throw new Error(`serialize_node: unknown node id ${JSON.stringify(id)}`);
1526
- if (n.kind !== "element") throw new Error(`serialize_node: node ${JSON.stringify(id)} is a ${n.kind} node, not an element`);
1527
- if (!this.contains(this.root, id)) throw new Error(`serialize_node: node ${JSON.stringify(id)} is detached from the current document`);
1528
- return this.emit_node(n);
1529
- }
1530
- emit_node(n) {
1531
- switch (n.kind) {
1532
- case "text": return (0, _grida_svg_parser.encode_text)(n.value);
1533
- case "comment": return `<!--${n.value}-->`;
1534
- case "cdata": return `<![CDATA[${n.value}]]>`;
1535
- case "pi": {
1536
- const pi = n;
1537
- return `<?${pi.target}${pi.value ? " " + pi.value : ""}?>`;
1538
- }
1539
- case "doctype": return `<!DOCTYPE${n.value}>`;
1540
- case "element": {
1541
- const e = n;
1542
- let s = `<${e.raw_tag}`;
1543
- for (const a of e.attrs) s += this.emit_attr(a);
1544
- if (e.children.length === 0 && e.self_closing) {
1545
- s += `${e.open_tag_trailing}/>`;
1546
- return s;
1547
- }
1548
- s += `${e.open_tag_trailing}>`;
1549
- for (const cid of e.children) {
1550
- const cn = this.nodes.get(cid);
1551
- if (cn) s += this.emit_node(cn);
1552
- }
1553
- s += `</${e.close_tag_leading}${e.raw_tag}${e.close_tag_trailing}>`;
1554
- return s;
1555
- }
1034
+ function resolve_prefix(doc, member, prefix) {
1035
+ let cur = doc.parent_of(member);
1036
+ while (cur !== null) {
1037
+ const uri = doc.get_attr(cur, prefix, _grida_svg_parser.XMLNS_NS);
1038
+ if (uri !== null) return uri;
1039
+ cur = doc.parent_of(cur);
1556
1040
  }
1041
+ return require_model.WELL_KNOWN_NS_PREFIXES.get(prefix) ?? null;
1557
1042
  }
1558
- emit_attr(a) {
1559
- return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${(0, _grida_svg_parser.encode_attr_value)(a.value, a.quote)}${a.quote}`;
1560
- }
1561
- };
1562
- function parse_inline_style(s) {
1563
- const out = [];
1564
- const decls = s.split(";");
1565
- for (const decl of decls) {
1566
- const colon = decl.indexOf(":");
1567
- if (colon === -1) continue;
1568
- const property = decl.slice(0, colon).trim();
1569
- const value = decl.slice(colon + 1).trim();
1570
- if (property) out.push({
1571
- property,
1572
- value
1573
- });
1574
- }
1575
- return out;
1576
- }
1043
+ })(clipboard || (clipboard = {}));
1577
1044
  //#endregion
1578
1045
  //#region src/core/align.ts
1579
1046
  /**
@@ -1782,7 +1249,7 @@ const DISPLAY_LABEL_MAX_LEN = 40;
1782
1249
  * to `SvgEditor` so the published `.d.ts` doesn't advertise internals.
1783
1250
  */
1784
1251
  function _create_svg_editor_internal(opts) {
1785
- const doc = new SvgDocument(opts.svg);
1252
+ const doc = new require_model.SvgDocument(opts.svg);
1786
1253
  const history = new _grida_history.HistoryImpl();
1787
1254
  const defs = create_defs(doc);
1788
1255
  let selection = [];
@@ -1790,10 +1257,11 @@ function _create_svg_editor_internal(opts) {
1790
1257
  let mode = "select";
1791
1258
  let tool = require_model.TOOL_CURSOR;
1792
1259
  let version = 0;
1793
- /** Document-edit counter only bumps on actual mutations, not selection. */
1794
- let doc_version = 0;
1795
- /** doc_version at the last load()/serialize(); compared to derive `dirty`. */
1796
- let baseline_doc_version = 0;
1260
+ /** `doc.revision` at the last load()/reset(); compared to derive `dirty`.
1261
+ * The doc's own total mutation counter is the single edit-version
1262
+ * source `content_version`, `dirty`, and the typed-read memo caches
1263
+ * all derive from it (no editor-side shadow counter to drift). */
1264
+ let baseline_revision = doc.revision;
1797
1265
  /**
1798
1266
  * Bumps once per `editor.load(svg)` call. The constructor's initial parse
1799
1267
  * does NOT count — it's the "factory" state. Hosts subscribe via
@@ -1806,6 +1274,23 @@ function _create_svg_editor_internal(opts) {
1806
1274
  ...opts.style
1807
1275
  };
1808
1276
  const providers = opts.providers ?? {};
1277
+ /**
1278
+ * In-memory clipboard buffer — the transport floor (FRD R1: the buffer
1279
+ * write cannot fail; external channels are best-effort on top). NOT part
1280
+ * of `EditorState` and NOT history-managed: it survives `load()` /
1281
+ * `reset()` / undo, like the OS clipboard it mirrors.
1282
+ */
1283
+ let clipboard_buffer = null;
1284
+ /**
1285
+ * The last committed duplication — read by the NEXT `duplicate()` to
1286
+ * repeat the user's translate delta (gridaco/grida#825; spec
1287
+ * §Repeating offset). Session state like `clipboard_buffer`: not in
1288
+ * `EditorState`, not history-managed (undo/redo replay never re-arms
1289
+ * it — only a user-initiated ⌘D or cloned-drag commit does). Staleness
1290
+ * is caught at use by `subtree.repeat_delta`; the only eager clears are
1291
+ * `load()` / `reset()`, where every NodeId dies wholesale.
1292
+ */
1293
+ let active_duplication = null;
1809
1294
  const listeners = /* @__PURE__ */ new Set();
1810
1295
  let attached_surface = null;
1811
1296
  /**
@@ -1821,11 +1306,11 @@ function _create_svg_editor_internal(opts) {
1821
1306
  scope,
1822
1307
  mode,
1823
1308
  tool,
1824
- dirty: doc_version !== baseline_doc_version,
1309
+ dirty: doc.revision !== baseline_revision,
1825
1310
  can_undo: history.stack.canUndo,
1826
1311
  can_redo: history.stack.canRedo,
1827
1312
  version,
1828
- content_version: doc_version,
1313
+ content_version: doc.revision,
1829
1314
  structure_version: doc.structure_version,
1830
1315
  geometry_version: doc.geometry_version,
1831
1316
  load_version
@@ -1845,12 +1330,21 @@ function _create_svg_editor_internal(opts) {
1845
1330
  const notify_translate_commit = () => {
1846
1331
  for (const cb of translate_commit_listeners) cb();
1847
1332
  };
1848
- doc.on_change(() => {
1849
- doc_version++;
1333
+ /**
1334
+ * Fan out the geometry channel iff the doc's `geometry_version` has
1335
+ * moved since we last fired. Shared by the `doc.on_change` handler
1336
+ * (mutation-driven bumps) and the surface-driven `bump_geometry` seam
1337
+ * (font-load reflow). Idempotent against a stale version — never
1338
+ * double-fires for the same value.
1339
+ */
1340
+ function fire_geometry_listeners_if_advanced() {
1850
1341
  if (doc.geometry_version !== last_emitted_geometry_version) {
1851
1342
  last_emitted_geometry_version = doc.geometry_version;
1852
1343
  for (const cb of geometry_listeners) cb();
1853
1344
  }
1345
+ }
1346
+ doc.on_change(() => {
1347
+ fire_geometry_listeners_if_advanced();
1854
1348
  });
1855
1349
  function subscribe(fn) {
1856
1350
  listeners.add(fn);
@@ -1974,14 +1468,14 @@ function _create_svg_editor_internal(opts) {
1974
1468
  function node_property_cached(id, name) {
1975
1469
  const key = `${id}${name}`;
1976
1470
  const cached = property_cache.get(key);
1977
- if (cached && cached.doc_version === doc_version) return cached.value;
1471
+ if (cached && cached.revision === doc.revision) return cached.value;
1978
1472
  const next = properties.read(doc, id, name);
1979
1473
  if (cached && properties.value_equals(cached.value, next)) {
1980
- cached.doc_version = doc_version;
1474
+ cached.revision = doc.revision;
1981
1475
  return cached.value;
1982
1476
  }
1983
1477
  property_cache.set(key, {
1984
- doc_version,
1478
+ revision: doc.revision,
1985
1479
  value: next
1986
1480
  });
1987
1481
  return next;
@@ -1989,7 +1483,7 @@ function _create_svg_editor_internal(opts) {
1989
1483
  function node_properties(id, names) {
1990
1484
  const key = `${id}${names.join("")}`;
1991
1485
  const cached = properties_cache.get(key);
1992
- if (cached && cached.doc_version === doc_version) return cached.value;
1486
+ if (cached && cached.revision === doc.revision) return cached.value;
1993
1487
  const next = {};
1994
1488
  let changed = !cached;
1995
1489
  for (const name of names) {
@@ -1998,12 +1492,12 @@ function _create_svg_editor_internal(opts) {
1998
1492
  if (cached && cached.value[name] !== v) changed = true;
1999
1493
  }
2000
1494
  if (cached && !changed) {
2001
- cached.doc_version = doc_version;
1495
+ cached.revision = doc.revision;
2002
1496
  return cached.value;
2003
1497
  }
2004
1498
  const frozen = Object.freeze(next);
2005
1499
  properties_cache.set(key, {
2006
- doc_version,
1500
+ revision: doc.revision,
2007
1501
  value: frozen
2008
1502
  });
2009
1503
  return frozen;
@@ -2011,7 +1505,7 @@ function _create_svg_editor_internal(opts) {
2011
1505
  function node_paint(id, channel) {
2012
1506
  const key = `${id}${channel}`;
2013
1507
  const cached = paint_cache.get(key);
2014
- if (cached && cached.doc_version === doc_version) return cached.value;
1508
+ if (cached && cached.revision === doc.revision) return cached.value;
2015
1509
  const { declared, provenance } = properties.resolve_declared(doc, id, channel);
2016
1510
  const next = {
2017
1511
  declared,
@@ -2019,11 +1513,11 @@ function _create_svg_editor_internal(opts) {
2019
1513
  provenance
2020
1514
  };
2021
1515
  if (cached && require_model.paint.value_equals(cached.value, next)) {
2022
- cached.doc_version = doc_version;
1516
+ cached.revision = doc.revision;
2023
1517
  return cached.value;
2024
1518
  }
2025
1519
  paint_cache.set(key, {
2026
- doc_version,
1520
+ revision: doc.revision,
2027
1521
  value: next
2028
1522
  });
2029
1523
  return next;
@@ -2112,6 +1606,13 @@ function _create_svg_editor_internal(opts) {
2112
1606
  });
2113
1607
  return { gradient_id };
2114
1608
  }
1609
+ /** World→local delta projection shared by every one-shot translate
1610
+ * writer (translate / nudge via `prepare_rpc`, align). Re-expresses a
1611
+ * world-space delta in the frame the target's position attributes are
1612
+ * written in — nested-viewport / transformed-ancestor correctness.
1613
+ * Identity for flat docs and DOM-less hosts (no provider, or a
1614
+ * provider without a layout engine). */
1615
+ const project_world_delta = (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d;
2115
1616
  /** Shared one-shot translate runner. `stages` selects semantics — see
2116
1617
  * `core/translate-pipeline/README.md`'s "Stage lists per entry point". */
2117
1618
  function do_translate_oneshot(delta, stages, label) {
@@ -2131,7 +1632,7 @@ function _create_svg_editor_internal(opts) {
2131
1632
  },
2132
1633
  emit,
2133
1634
  stages,
2134
- project: (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d
1635
+ project: project_world_delta
2135
1636
  });
2136
1637
  apply();
2137
1638
  history.atomic(label, (tx) => {
@@ -2150,66 +1651,79 @@ function _create_svg_editor_internal(opts) {
2150
1651
  if (do_translate_oneshot(delta, require_model.translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
2151
1652
  }
2152
1653
  /**
2153
- * One-shot multi-member resize to an explicit target rect. Mirrors a
2154
- * drag-resize gesture in mechanics capture per-member baselines,
2155
- * scale around the union's NW corner, translate the result so the
2156
- * union NW lands at the requested position — but as a single
2157
- * atomic step rather than a preview session.
1654
+ * Gate + capture for a resize gesture. Returns the resizable members (with
1655
+ * captured baseline / pre-transform / bbox), or `null` if the gesture can't
1656
+ * run: no geometry provider, empty selection, or in `all_or_nothing` mode
1657
+ * any member fails the gate.
2158
1658
  *
2159
- * The function does its own geometry lookup via the
2160
- * `geometry_provider` registered by the DOM surface. When no surface
2161
- * is attached, the call is a no-op (returns `false`). Members whose
2162
- * tag is not resizable are silently filtered.
2163
- *
2164
- * Revert restores the captured `transform` attribute and all
2165
- * geometry attrs the apply step wrote so a `<rect>` with an
2166
- * existing `transform` round-trips cleanly. See `apply_translate`'s
2167
- * `viaTransform` arm for why this matters.
1659
+ * `mode`:
1660
+ * - `"skip"` drop members failing the `is_resizable_node` gate
1661
+ * (tag + transform class) or lacking a bbox; resize the rest. Used by the
1662
+ * inspector `resize_to` (set-bbox) path.
1663
+ * - `"all_or_nothing"` — refuse the WHOLE gesture (return `null`) if ANY
1664
+ * member fails. Used by keyboard `resize_by` (nudge), matching the resize
1665
+ * HUD, whose handle-drag is rejected when any member is unsafe.
2168
1666
  */
2169
- function resize_to(target, opts) {
2170
- const ids = opts?.ids ?? selection;
2171
- if (ids.length === 0) return false;
2172
- if (!geometry_provider) return false;
1667
+ function collect_resize_members(ids, mode) {
1668
+ if (ids.length === 0) return null;
1669
+ if (!geometry_provider) return null;
2173
1670
  const members = [];
2174
1671
  for (const id of ids) {
2175
- if (!require_model.resize_pipeline.intent.is_resizable(doc.tag_of(id))) continue;
1672
+ if (!require_model.resize_pipeline.intent.is_resizable_node(doc, id)) {
1673
+ if (mode === "all_or_nothing") return null;
1674
+ continue;
1675
+ }
2176
1676
  const bbox = geometry_provider.bounds_of(id);
2177
- if (!bbox) continue;
1677
+ if (!bbox) {
1678
+ if (mode === "all_or_nothing") return null;
1679
+ continue;
1680
+ }
2178
1681
  members.push({
2179
1682
  id,
2180
1683
  rz: require_model.resize_pipeline.intent.capture_baseline(doc, id, bbox),
2181
- tx_pre: require_model.translate_pipeline.intent.capture_baseline(doc, id),
2182
1684
  transform_pre: doc.get_attr(id, "transform"),
2183
1685
  bbox
2184
1686
  });
2185
1687
  }
2186
- if (members.length === 0) return false;
2187
- const union = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
2188
- const sx = union.width === 0 ? 1 : target.width / union.width;
2189
- const sy = union.height === 0 ? 1 : target.height / union.height;
2190
- const origin = {
2191
- x: union.x,
2192
- y: union.y
2193
- };
2194
- const dx = target.x - union.x;
2195
- const dy = target.y - union.y;
1688
+ return members.length === 0 ? null : members;
1689
+ }
1690
+ /**
1691
+ * Apply a resize to each member, optionally followed by a uniform group
1692
+ * translate, as ONE atomic history step. `op` resolves each member's scale
1693
+ * factors + scale origin; `group_translate` is the post-scale envelope shift
1694
+ * (group resize only — `null` for per-element). Callers guarantee `members`
1695
+ * is non-empty. Returns `true` when a history step was pushed; `false` when
1696
+ * the gesture is geometrically identity (no member scales and no group
1697
+ * translate) so undo isn't polluted with an empty step. NOTE: a per-tag
1698
+ * constraint that collapses a non-1 factor to identity *inside* the handler
1699
+ * (e.g. `<circle>` uniform `min` on a single-axis nudge) is not detected
1700
+ * here — the op-level factor is still ≠ 1, so that case still pushes a step.
1701
+ */
1702
+ function commit_resize(members, op, group_translate, label) {
1703
+ const ops = members.map((m) => ({
1704
+ m,
1705
+ ...op(m)
1706
+ }));
1707
+ const scales = ops.some(({ sx, sy }) => sx !== 1 || sy !== 1);
1708
+ const translates = !!group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0);
1709
+ if (!scales && !translates) return false;
2196
1710
  const apply = () => {
2197
- for (const m of members) require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
2198
- if (dx !== 0 || dy !== 0) for (const m of members) {
1711
+ for (const { m, sx, sy, origin } of ops) require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
1712
+ if (group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0)) for (const m of members) {
2199
1713
  const tx_after = require_model.translate_pipeline.intent.capture_baseline(doc, m.id);
2200
- require_model.translate_pipeline.intent.apply(doc, m.id, tx_after, dx, dy);
1714
+ require_model.translate_pipeline.intent.apply(doc, m.id, tx_after, group_translate.dx, group_translate.dy);
2201
1715
  }
2202
1716
  emit();
2203
1717
  };
2204
1718
  const revert = () => {
2205
- for (const m of members) {
1719
+ for (const { m, origin } of ops) {
2206
1720
  require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
2207
1721
  doc.set_attr(m.id, "transform", m.transform_pre);
2208
1722
  }
2209
1723
  emit();
2210
1724
  };
2211
1725
  apply();
2212
- history.atomic("resize-to", (tx) => {
1726
+ history.atomic(label, (tx) => {
2213
1727
  tx.push({
2214
1728
  providerId: PROVIDER_ID,
2215
1729
  apply,
@@ -2218,6 +1732,73 @@ function _create_svg_editor_internal(opts) {
2218
1732
  });
2219
1733
  return true;
2220
1734
  }
1735
+ /**
1736
+ * One-shot multi-member resize to an explicit target rect. Mirrors a
1737
+ * drag-resize gesture in mechanics — capture per-member baselines,
1738
+ * scale around the union's NW corner, translate the result so the
1739
+ * union NW lands at the requested position — but as a single
1740
+ * atomic step rather than a preview session. This is the GROUP path:
1741
+ * the whole selection is treated as one envelope.
1742
+ *
1743
+ * The function does its own geometry lookup via the
1744
+ * `geometry_provider` registered by the DOM surface. When no surface
1745
+ * is attached, the call is a no-op (returns `false`). Members that fail
1746
+ * the `is_resizable_node` gate — an unresizable tag (e.g. `<g>`) OR a
1747
+ * non-trivially-transformed element — are silently skipped (see
1748
+ * `collect_resize_members`).
1749
+ *
1750
+ * Revert restores the captured `transform` attribute and all
1751
+ * geometry attrs the apply step wrote — so a `<rect>` with an
1752
+ * existing `transform` round-trips cleanly. See `apply_translate`'s
1753
+ * `viaTransform` arm for why this matters.
1754
+ */
1755
+ function resize_to(target, opts) {
1756
+ const members = collect_resize_members(opts?.ids ?? selection, "skip");
1757
+ if (!members) return false;
1758
+ const union = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
1759
+ const sx = union.width === 0 ? 1 : target.width / union.width;
1760
+ const sy = union.height === 0 ? 1 : target.height / union.height;
1761
+ const origin = {
1762
+ x: union.x,
1763
+ y: union.y
1764
+ };
1765
+ return commit_resize(members, () => ({
1766
+ sx,
1767
+ sy,
1768
+ origin
1769
+ }), {
1770
+ dx: target.x - union.x,
1771
+ dy: target.y - union.y
1772
+ }, opts?.label ?? "resize-to");
1773
+ }
1774
+ /**
1775
+ * Resize by a `{dw, dh}` delta — the core verb behind keyboard nudge-resize
1776
+ * (`Ctrl+Alt+Arrow`). This is the PER-ELEMENT path: each selected member
1777
+ * grows/shrinks by the delta around ITS OWN NW corner, so members keep their
1778
+ * positions relative to one another. This deliberately differs from
1779
+ * {@link resize_to} (the group/envelope path): a HUD group-resize scales the
1780
+ * whole selection around the shared union origin, translating off-origin
1781
+ * members — correct for a drag handle, wrong for a keyboard nudge, whose UX
1782
+ * is "apply the delta to each".
1783
+ *
1784
+ * ALL-OR-NOTHING gate (`collect_resize_members("all_or_nothing")`): refuses
1785
+ * (returns `false`, no history step) on empty selection, no geometry
1786
+ * provider, or any member failing the `is_resizable_node` gate — matching
1787
+ * the resize HUD rather than `resize_to`'s per-member skip.
1788
+ */
1789
+ function resize_by(delta, opts) {
1790
+ const members = collect_resize_members(opts?.ids ?? selection, "all_or_nothing");
1791
+ if (!members) return false;
1792
+ const axis = (size, d) => size === 0 ? 1 : Math.max(0, size + d) / size;
1793
+ return commit_resize(members, (m) => ({
1794
+ sx: axis(m.bbox.width, delta.dw),
1795
+ sy: axis(m.bbox.height, delta.dh),
1796
+ origin: {
1797
+ x: m.bbox.x,
1798
+ y: m.bbox.y
1799
+ }
1800
+ }), null, "nudge-resize");
1801
+ }
2221
1802
  /** Shared helper: compute a default rotation pivot from the live
2222
1803
  * geometry_provider when the caller omitted one. Falls back to (0,0)
2223
1804
  * if no surface is attached. */
@@ -2299,6 +1880,70 @@ function _create_svg_editor_internal(opts) {
2299
1880
  });
2300
1881
  return true;
2301
1882
  }
1883
+ /**
1884
+ * Relative affine compose about a pivot. See the `Commands.transform`
1885
+ * doc for the full contract. This function owns ONLY the pivot/effective-
1886
+ * matrix computation (which needs `geometry_provider`); the parse→fold→
1887
+ * emit round-trip is delegated per-member to the pure
1888
+ * `transform.apply_affine` helper.
1889
+ */
1890
+ function apply_transform(matrix, opts) {
1891
+ const ids = opts?.ids ?? selection;
1892
+ if (ids.length === 0) return false;
1893
+ if (!geometry_provider) return false;
1894
+ for (const id of ids) if (require_model.rotate_pipeline.intent.is_transformable(doc, id).kind === "refuse") return false;
1895
+ const pivot = opts?.pivot ?? default_rotate_pivot(ids);
1896
+ const [a, b, c, d, e, f] = matrix;
1897
+ const requested = [[
1898
+ a,
1899
+ c,
1900
+ e
1901
+ ], [
1902
+ b,
1903
+ d,
1904
+ f
1905
+ ]];
1906
+ const t_pivot = [[
1907
+ 1,
1908
+ 0,
1909
+ pivot.x
1910
+ ], [
1911
+ 0,
1912
+ 1,
1913
+ pivot.y
1914
+ ]];
1915
+ const t_neg_pivot = [[
1916
+ 1,
1917
+ 0,
1918
+ -pivot.x
1919
+ ], [
1920
+ 0,
1921
+ 1,
1922
+ -pivot.y
1923
+ ]];
1924
+ const effective = _grida_cmath.default.transform.multiply(_grida_cmath.default.transform.multiply(t_pivot, requested), t_neg_pivot);
1925
+ const members = ids.map((id) => ({
1926
+ id,
1927
+ transform_pre: doc.get_attr(id, "transform")
1928
+ }));
1929
+ const apply = () => {
1930
+ for (const m of members) doc.set_attr(m.id, "transform", require_model.transform.apply_affine(m.transform_pre, effective));
1931
+ emit();
1932
+ };
1933
+ const revert = () => {
1934
+ for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
1935
+ emit();
1936
+ };
1937
+ apply();
1938
+ history.atomic("transform", (tx) => {
1939
+ tx.push({
1940
+ providerId: PROVIDER_ID,
1941
+ apply,
1942
+ revert
1943
+ });
1944
+ });
1945
+ return true;
1946
+ }
2302
1947
  function flatten_transform(opts) {
2303
1948
  const ids = opts?.ids ?? selection;
2304
1949
  if (ids.length === 0) return false;
@@ -2351,7 +1996,10 @@ function _create_svg_editor_internal(opts) {
2351
1996
  * center of a reference rect. Same mechanics as `resize_to`: per-member
2352
1997
  * translate baselines (so `<g>`, transformed, and natively-attributed
2353
1998
  * nodes all write the cleanest in-place representation), one atomic
2354
- * history step.
1999
+ * history step. Deltas are computed in world space and re-expressed in
2000
+ * each member's local frame before writing (`world_delta_to_local`),
2001
+ * so members under scaled/rotated ancestors land exactly and a repeat
2002
+ * invocation is a no-op.
2355
2003
  *
2356
2004
  * Reference rect is selection-size dependent:
2357
2005
  * - multi-selection: union of member bboxes
@@ -2387,8 +2035,10 @@ function _create_svg_editor_internal(opts) {
2387
2035
  if (!parent_bbox) return false;
2388
2036
  target = parent_bbox;
2389
2037
  } else target = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
2390
- const deltas = compute_align_deltas(members, target, direction);
2391
- if (deltas.size === 0) return false;
2038
+ const world_deltas = compute_align_deltas(members, target, direction);
2039
+ if (world_deltas.size === 0) return false;
2040
+ const deltas = /* @__PURE__ */ new Map();
2041
+ for (const [id, d] of world_deltas) deltas.set(id, project_world_delta(id, d));
2392
2042
  const apply = () => {
2393
2043
  for (const m of members) {
2394
2044
  const d = deltas.get(m.id);
@@ -2490,13 +2140,19 @@ function _create_svg_editor_internal(opts) {
2490
2140
  });
2491
2141
  }
2492
2142
  function remove() {
2143
+ remove_selection("remove");
2144
+ }
2145
+ /**
2146
+ * Shared deletion body for `remove` and `cut` — identical
2147
+ * capture/revert semantics, differing only in the history label
2148
+ * (`verb`), so undo attribution names the gesture that caused the
2149
+ * deletion.
2150
+ */
2151
+ function remove_selection(verb) {
2493
2152
  if (selection.length === 0) return;
2494
2153
  const filtered = doc.prune_nested_nodes(selection).filter((id) => doc.parent_of(id) !== null);
2495
2154
  if (filtered.length === 0) return;
2496
- const doc_order = doc.all_elements();
2497
- const index_of = /* @__PURE__ */ new Map();
2498
- for (let i = 0; i < doc_order.length; i++) index_of.set(doc_order[i], i);
2499
- const captures = [...filtered].sort((a, b) => (index_of.get(a) ?? 0) - (index_of.get(b) ?? 0)).map((id) => ({
2155
+ const captures = [...filtered].sort(require_model.subtree.by_document_order(doc)).map((id) => ({
2500
2156
  id,
2501
2157
  parent: doc.parent_of(id),
2502
2158
  next_sibling: doc.next_element_sibling_of(id)
@@ -2514,7 +2170,7 @@ function _create_svg_editor_internal(opts) {
2514
2170
  set_selection(old_selection);
2515
2171
  };
2516
2172
  apply();
2517
- history.atomic(captures.length === 1 ? "remove" : `remove ${captures.length}`, (tx) => {
2173
+ history.atomic(captures.length === 1 ? verb : `${verb} ${captures.length}`, (tx) => {
2518
2174
  tx.push({
2519
2175
  providerId: PROVIDER_ID,
2520
2176
  apply,
@@ -2550,6 +2206,47 @@ function _create_svg_editor_internal(opts) {
2550
2206
  });
2551
2207
  return true;
2552
2208
  }
2209
+ function ungroup(opts) {
2210
+ let target;
2211
+ if (opts?.id !== void 0) target = opts.id;
2212
+ else {
2213
+ if (selection.length !== 1) return false;
2214
+ target = selection[0];
2215
+ }
2216
+ const plan = require_model.group.plan_ungroup(doc, target);
2217
+ if (!plan) return false;
2218
+ const group_id = plan.group_id;
2219
+ const group_next_sibling = doc.next_element_sibling_of(group_id);
2220
+ const original_child_transforms = /* @__PURE__ */ new Map();
2221
+ for (const child of plan.children) original_child_transforms.set(child, doc.get_attr(child, "transform"));
2222
+ const group_ops = plan.group_transform === null ? [] : require_model.transform.parse(plan.group_transform) ?? [];
2223
+ const original_selection = selection;
2224
+ const apply = () => {
2225
+ if (group_ops.length > 0) for (const child of plan.children) {
2226
+ const child_ops = require_model.transform.parse(doc.get_attr(child, "transform")) ?? [];
2227
+ const next = require_model.transform.emit([...group_ops, ...child_ops]);
2228
+ doc.set_attr(child, "transform", next === "" ? null : next);
2229
+ }
2230
+ for (const child of plan.children) doc.insert(child, plan.parent, group_id);
2231
+ doc.remove(group_id);
2232
+ set_selection(plan.children);
2233
+ };
2234
+ const revert = () => {
2235
+ doc.insert(group_id, plan.parent, group_next_sibling);
2236
+ for (const child of plan.children) doc.insert(child, group_id, null);
2237
+ if (group_ops.length > 0) for (const child of plan.children) doc.set_attr(child, "transform", original_child_transforms.get(child) ?? null);
2238
+ set_selection(original_selection);
2239
+ };
2240
+ apply();
2241
+ history.atomic("ungroup", (tx) => {
2242
+ tx.push({
2243
+ providerId: PROVIDER_ID,
2244
+ apply,
2245
+ revert
2246
+ });
2247
+ });
2248
+ return true;
2249
+ }
2553
2250
  /**
2554
2251
  * Atomic one-shot insertion. Used by paste, programmatic RPC, and the
2555
2252
  * click-no-drag commit path inside the insertion gesture driver. One
@@ -2559,11 +2256,20 @@ function _create_svg_editor_internal(opts) {
2559
2256
  * win. `opts.parent` defaults to root; `opts.index` (insert-before
2560
2257
  * sibling index) defaults to append; `opts.select` defaults to `true`.
2561
2258
  */
2259
+ /**
2260
+ * Resolve an optional `index` (position in `parent`'s element-children
2261
+ * list to insert AT — anything at or after it shifts; out-of-range or
2262
+ * `undefined` appends) to an insert-before anchor. Shared by `insert`,
2263
+ * `insert_fragment`, and `insert_preview`.
2264
+ */
2265
+ function resolve_insert_before(parent, index) {
2266
+ if (index === void 0) return null;
2267
+ return doc.element_children_of(parent)[index] ?? null;
2268
+ }
2562
2269
  function insert(tag, attrs, opts) {
2563
2270
  const parent = opts?.parent ?? doc.root;
2564
2271
  const select_after = opts?.select !== false;
2565
- let insert_before = null;
2566
- if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
2272
+ const insert_before = resolve_insert_before(parent, opts?.index);
2567
2273
  const id = doc.create_element(tag);
2568
2274
  const merged_attrs = {
2569
2275
  ...default_paint_attrs_for(tag),
@@ -2591,6 +2297,153 @@ function _create_svg_editor_internal(opts) {
2591
2297
  return id;
2592
2298
  }
2593
2299
  /**
2300
+ * Atomic fragment insertion — contract in {@link Commands.insert_fragment}.
2301
+ * Parses + adopts via `doc.create_fragment` (subtrees registered but
2302
+ * detached, like `create_element` — history.redo finds them via
2303
+ * closure), computes the namespace hoist plan, then brackets inserts +
2304
+ * hoisted declarations + selection in ONE history step.
2305
+ */
2306
+ function insert_fragment(svg, opts) {
2307
+ return insert_fragment_impl(svg, opts, "insert fragment");
2308
+ }
2309
+ /**
2310
+ * Label-bearing body shared by `insert_fragment` and `paste` — same
2311
+ * atomic insertion, differing only in history attribution (undo for a
2312
+ * paste gesture should read "paste", not "insert fragment").
2313
+ */
2314
+ function insert_fragment_impl(svg, opts, label) {
2315
+ const parent = opts?.parent ?? doc.root;
2316
+ 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`);
2317
+ const select_after = opts?.select !== false;
2318
+ const { roots, xmlns } = doc.create_fragment(svg);
2319
+ if (roots.length === 0) return [];
2320
+ const known_uri = new Map(require_model.WELL_KNOWN_NS_PREFIXES);
2321
+ for (const d of xmlns) known_uri.set(d.prefix, d.uri);
2322
+ const hoist = [];
2323
+ const considered = /* @__PURE__ */ new Set();
2324
+ for (const id of roots) for (const prefix of doc.undeclared_ns_prefixes(id)) {
2325
+ if (considered.has(prefix)) continue;
2326
+ considered.add(prefix);
2327
+ if (doc.get_attr(doc.root, prefix, _grida_svg_parser.XMLNS_NS) !== null) continue;
2328
+ const uri = known_uri.get(prefix);
2329
+ if (uri === void 0) continue;
2330
+ hoist.push({
2331
+ prefix,
2332
+ uri
2333
+ });
2334
+ }
2335
+ const insert_before = resolve_insert_before(parent, opts?.index);
2336
+ const previous_selection = selection;
2337
+ const apply = () => {
2338
+ for (const { prefix, uri } of hoist) doc.declare_xmlns(prefix, uri);
2339
+ for (const id of roots) doc.insert(id, parent, insert_before);
2340
+ if (select_after) set_selection(roots);
2341
+ };
2342
+ const revert = () => {
2343
+ for (let i = roots.length - 1; i >= 0; i--) doc.remove(roots[i]);
2344
+ for (const { prefix } of hoist) doc.set_attr(doc.root, prefix, null, _grida_svg_parser.XMLNS_NS);
2345
+ if (select_after) set_selection(previous_selection);
2346
+ };
2347
+ apply();
2348
+ history.atomic(label, (tx) => {
2349
+ tx.push({
2350
+ providerId: PROVIDER_ID,
2351
+ apply,
2352
+ revert
2353
+ });
2354
+ });
2355
+ return roots;
2356
+ }
2357
+ function copy_impl(deliver_external) {
2358
+ const payload = clipboard.extract_payload(doc, selection);
2359
+ if (payload === null) return null;
2360
+ clipboard_buffer = payload;
2361
+ if (deliver_external && providers.clipboard) providers.clipboard.write(payload).catch((err) => {
2362
+ console.warn("[svg-editor] clipboard provider write failed:", err);
2363
+ });
2364
+ return payload;
2365
+ }
2366
+ function copy() {
2367
+ return copy_impl(true);
2368
+ }
2369
+ function cut_impl(deliver_external) {
2370
+ const payload = copy_impl(deliver_external);
2371
+ if (payload === null) return null;
2372
+ remove_selection("cut");
2373
+ return payload;
2374
+ }
2375
+ function cut() {
2376
+ return cut_impl(true);
2377
+ }
2378
+ function paste(text) {
2379
+ if (text !== void 0 && typeof text !== "string") throw new TypeError(`paste(text) requires a string when provided, got ${text === null ? "null" : typeof text}`);
2380
+ const source = text ?? clipboard_buffer;
2381
+ if (source === null) return [];
2382
+ try {
2383
+ return insert_fragment_impl(source, void 0, "paste");
2384
+ } catch (err) {
2385
+ if (err instanceof TypeError) throw err;
2386
+ return [];
2387
+ }
2388
+ }
2389
+ /**
2390
+ * Duplicate over `subtree.clone_plan` — see the `Commands` doc. Same
2391
+ * atomic shape as `insert_fragment_impl`: closures own the
2392
+ * insert/remove pair so redo re-inserts the same NodeIds.
2393
+ *
2394
+ * Repeating offset (gridaco/grida#825, spec §Repeating offset): when
2395
+ * the targets are exactly the previous duplication's clones and
2396
+ * geometry witnesses a rigid translate between that record's origins
2397
+ * and clones, the fresh clones land displaced by the same delta. The
2398
+ * offset rides the translate pipeline INSIDE the same atomic step —
2399
+ * one undo removes copy + offset together. Clone baselines are
2400
+ * key-swapped from the origins (a clone is a verbatim copy at rest —
2401
+ * the orchestrator's `enter_clone` trick), so nothing reads the
2402
+ * detached clones. Any failed precondition degrades to plain
2403
+ * duplicate-in-place; never an error.
2404
+ */
2405
+ function duplicate() {
2406
+ const plan = require_model.subtree.clone_plan(doc, selection);
2407
+ if (plan.length === 0) return [];
2408
+ const clones = plan.map((p) => p.clone);
2409
+ const origins = plan.map((p) => p.origin);
2410
+ const previous_selection = selection;
2411
+ const delta = require_model.subtree.repeat_delta(active_duplication, origins, (id) => geometry_provider ? geometry_provider.bounds_of(id) : null);
2412
+ let offset_plan = null;
2413
+ if (delta) {
2414
+ const baselines = /* @__PURE__ */ new Map();
2415
+ for (const p of plan) baselines.set(p.clone, require_model.translate_pipeline.intent.capture_baseline(doc, p.origin));
2416
+ offset_plan = {
2417
+ ids: clones,
2418
+ baselines,
2419
+ delta
2420
+ };
2421
+ }
2422
+ const apply = () => {
2423
+ require_model.subtree.insert_plan(doc, plan);
2424
+ if (offset_plan) require_model.translate_pipeline.apply(doc, offset_plan, project_world_delta);
2425
+ set_selection(clones);
2426
+ };
2427
+ const revert = () => {
2428
+ if (offset_plan) require_model.translate_pipeline.revert(doc, offset_plan);
2429
+ require_model.subtree.remove_plan(doc, plan);
2430
+ set_selection(previous_selection);
2431
+ };
2432
+ apply();
2433
+ history.atomic("duplicate", (tx) => {
2434
+ tx.push({
2435
+ providerId: PROVIDER_ID,
2436
+ apply,
2437
+ revert
2438
+ });
2439
+ });
2440
+ active_duplication = {
2441
+ origins,
2442
+ clones
2443
+ };
2444
+ return clones;
2445
+ }
2446
+ /**
2594
2447
  * Preview-bracketed insertion. Used by the pointer-driven drag gesture
2595
2448
  * in the DOM surface. Per-frame attr writes call `update(attrs)`; one
2596
2449
  * undo step on `commit()`; clean rollback on `discard()`.
@@ -2601,8 +2454,7 @@ function _create_svg_editor_internal(opts) {
2601
2454
  */
2602
2455
  function insert_preview(tag, initial, opts) {
2603
2456
  const parent = opts?.parent ?? doc.root;
2604
- let insert_before = null;
2605
- if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
2457
+ const insert_before = resolve_insert_before(parent, opts?.index);
2606
2458
  const id = doc.create_element(tag);
2607
2459
  const previous_selection = selection;
2608
2460
  const live_attrs = {
@@ -2760,6 +2612,10 @@ function _create_svg_editor_internal(opts) {
2760
2612
  current_surface_hover = id;
2761
2613
  notify_surface_hover();
2762
2614
  }
2615
+ const pick_listeners = /* @__PURE__ */ new Set();
2616
+ function notify_pick(e) {
2617
+ for (const cb of pick_listeners) cb(e);
2618
+ }
2763
2619
  function enter_content_edit(target) {
2764
2620
  const id = target ?? (selection.length === 1 ? selection[0] : null);
2765
2621
  if (!id) return false;
@@ -2774,7 +2630,8 @@ function _create_svg_editor_internal(opts) {
2774
2630
  mode = "select";
2775
2631
  tool = require_model.TOOL_CURSOR;
2776
2632
  history.clear();
2777
- baseline_doc_version = doc_version;
2633
+ active_duplication = null;
2634
+ baseline_revision = doc.revision;
2778
2635
  load_version++;
2779
2636
  emit();
2780
2637
  }
@@ -2805,14 +2662,22 @@ function _create_svg_editor_internal(opts) {
2805
2662
  translate,
2806
2663
  nudge,
2807
2664
  resize_to,
2665
+ resize_by,
2808
2666
  rotate,
2809
2667
  rotate_to,
2668
+ transform: apply_transform,
2810
2669
  flatten_transform,
2811
2670
  align,
2812
2671
  reorder,
2813
2672
  remove,
2673
+ copy,
2674
+ cut,
2675
+ paste,
2676
+ duplicate,
2814
2677
  group: group$1,
2678
+ ungroup,
2815
2679
  insert,
2680
+ insert_fragment,
2816
2681
  insert_preview,
2817
2682
  set_text,
2818
2683
  load_svg,
@@ -2836,7 +2701,8 @@ function _create_svg_editor_internal(opts) {
2836
2701
  scope = null;
2837
2702
  mode = "select";
2838
2703
  tool = require_model.TOOL_CURSOR;
2839
- baseline_doc_version = doc_version;
2704
+ active_duplication = null;
2705
+ baseline_revision = doc.revision;
2840
2706
  emit();
2841
2707
  }
2842
2708
  function attach(surface) {
@@ -2858,6 +2724,10 @@ function _create_svg_editor_internal(opts) {
2858
2724
  function dispose() {
2859
2725
  detach();
2860
2726
  listeners.clear();
2727
+ surface_hover_listeners.clear();
2728
+ geometry_listeners.clear();
2729
+ translate_commit_listeners.clear();
2730
+ pick_listeners.clear();
2861
2731
  }
2862
2732
  function set_style(partial) {
2863
2733
  style = {
@@ -2949,6 +2819,22 @@ function _create_svg_editor_internal(opts) {
2949
2819
  };
2950
2820
  },
2951
2821
  /**
2822
+ * Subscribe to pick (tap) outcomes — a discrete click on the canvas,
2823
+ * reporting the document-space point and the node under it (`null` for
2824
+ * empty canvas), plus the button and modifier snapshot. Fires once per
2825
+ * tap, after the editor's own selection handling. Observe-only: a pick
2826
+ * cannot alter selection, and the channel does NOT bump `state.version`.
2827
+ * See {@link PickEvent}.
2828
+ *
2829
+ * @unstable
2830
+ */
2831
+ subscribe_pick(cb) {
2832
+ pick_listeners.add(cb);
2833
+ return () => {
2834
+ pick_listeners.delete(cb);
2835
+ };
2836
+ },
2837
+ /**
2952
2838
  * Subscribe to bounds-affecting changes. Fires when any document
2953
2839
  * mutation advances `state.geometry_version` — drag, resize, text
2954
2840
  * edit, structural insert/remove. Skips presentation-only writes
@@ -2999,7 +2885,14 @@ function _create_svg_editor_internal(opts) {
2999
2885
  providers,
3000
2886
  _internal: {
3001
2887
  doc,
3002
- history: { preview: (label) => history.preview(label) },
2888
+ history: {
2889
+ preview: (label) => history.preview(label),
2890
+ undo_label: () => history.stack.undoLabel
2891
+ },
2892
+ clipboard: {
2893
+ copy: () => copy_impl(false),
2894
+ cut: () => cut_impl(false)
2895
+ },
3003
2896
  insert_text_preview,
3004
2897
  emit,
3005
2898
  subscribe_translate_commit(cb) {
@@ -3009,6 +2902,9 @@ function _create_svg_editor_internal(opts) {
3009
2902
  };
3010
2903
  },
3011
2904
  notify_translate_commit,
2905
+ seed_duplication(record) {
2906
+ active_duplication = record;
2907
+ },
3012
2908
  set_content_edit_driver(fn) {
3013
2909
  content_edit_driver = fn;
3014
2910
  },
@@ -3019,11 +2915,18 @@ function _create_svg_editor_internal(opts) {
3019
2915
  push_surface_hover(id) {
3020
2916
  _set_current_surface_hover(id);
3021
2917
  },
2918
+ push_pick(e) {
2919
+ notify_pick(e);
2920
+ },
3022
2921
  set_computed_resolver(fn) {
3023
2922
  computed_resolver = fn;
3024
2923
  },
3025
2924
  set_geometry(p) {
3026
2925
  geometry_provider = p;
2926
+ },
2927
+ bump_geometry() {
2928
+ doc.bump_geometry();
2929
+ fire_geometry_listeners_if_advanced();
3027
2930
  }
3028
2931
  },
3029
2932
  keymap