@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,9 +1,8 @@
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 { S as is_text_input_focused, _ as SVG_NS, a as paint, b as XMLNS_NS, g as subtree, 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 SvgDocument, x as array_shallow_equal, y as WELL_KNOWN_NS_PREFIXES } from "./model-DMaN5GnH.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";
5
+ import { encode_attr_value } from "@grida/svg/parser";
7
6
  //#region src/commands/registry.ts
8
7
  var CommandRegistry = class {
9
8
  constructor() {
@@ -84,12 +83,31 @@ function registerDefaultCommands(reg, editor) {
84
83
  if (editor.state.selection.length === 0) return false;
85
84
  return editor.commands.group();
86
85
  });
86
+ reg.register("selection.duplicate", () => {
87
+ if (editor.state.mode !== "select") return false;
88
+ if (editor.state.selection.length === 0) return false;
89
+ return editor.commands.duplicate().length > 0;
90
+ });
91
+ reg.register("selection.ungroup", () => {
92
+ if (editor.state.mode !== "select") return false;
93
+ if (editor.state.selection.length !== 1) return false;
94
+ return editor.commands.ungroup();
95
+ });
87
96
  reg.register("selection.resize_to", (args) => {
88
97
  if (editor.state.mode !== "select") return false;
89
98
  if (editor.state.selection.length === 0) return false;
90
99
  const target = args;
91
100
  return editor.commands.resize_to(target);
92
101
  });
102
+ reg.register("selection.nudge_resize", (args) => {
103
+ if (editor.state.mode !== "select") return false;
104
+ if (editor.state.selection.length === 0) return false;
105
+ const { dw, dh } = args;
106
+ return editor.commands.resize_by({
107
+ dw,
108
+ dh
109
+ });
110
+ });
93
111
  reg.register("selection.rotate", (args) => {
94
112
  if (editor.state.mode !== "select") return false;
95
113
  if (editor.state.selection.length === 0) return false;
@@ -119,6 +137,31 @@ function registerDefaultCommands(reg, editor) {
119
137
  if (editor.state.mode !== "select") return false;
120
138
  return editor.commands.align(args);
121
139
  });
140
+ reg.register("clipboard.copy", () => {
141
+ if (editor.state.mode !== "select") return false;
142
+ if (editor.state.selection.length === 0) return false;
143
+ return editor.commands.copy() !== null;
144
+ });
145
+ reg.register("clipboard.cut", () => {
146
+ if (editor.state.mode !== "select") return false;
147
+ if (editor.state.selection.length === 0) return false;
148
+ return editor.commands.cut() !== null;
149
+ });
150
+ reg.register("clipboard.paste", (args) => {
151
+ if (editor.state.mode !== "select") return false;
152
+ const text = args?.text;
153
+ if (typeof text === "string") return editor.commands.paste(text).length > 0;
154
+ const provider = editor.providers.clipboard;
155
+ if (provider) {
156
+ provider.read().then((text) => {
157
+ if (text) editor.commands.paste(text);
158
+ }).catch((err) => {
159
+ console.warn("[svg-editor] clipboard provider read failed:", err);
160
+ });
161
+ return true;
162
+ }
163
+ return editor.commands.paste().length > 0;
164
+ });
122
165
  reg.register("content.enter", () => editor.enter_content_edit());
123
166
  reg.register("hierarchy.enter", () => {
124
167
  if (editor.state.selection.length !== 1) return false;
@@ -309,7 +352,8 @@ function compareEntries(a, b) {
309
352
  * Same key, multiple meanings? Add multiple rows. The chain semantics
310
353
  * (handler returns `false` when not applicable) handle the rest.
311
354
  */
312
- const NUDGE_MEANINGFUL = M.Shift;
355
+ const NUDGE_MEANINGFUL = M.Shift | M.Ctrl;
356
+ const RESIZE_MEANINGFUL = M.Shift | M.Ctrl | M.Alt;
313
357
  const DEFAULT_BINDINGS = [
314
358
  {
315
359
  keybinding: kb(KeyCode.KeyZ, M.CtrlCmd),
@@ -339,6 +383,14 @@ const DEFAULT_BINDINGS = [
339
383
  keybinding: kb(KeyCode.KeyG, M.CtrlCmd),
340
384
  command: "selection.group"
341
385
  },
386
+ {
387
+ keybinding: kb(KeyCode.KeyG, M.CtrlCmd | M.Shift),
388
+ command: "selection.ungroup"
389
+ },
390
+ {
391
+ keybinding: kb(KeyCode.KeyD, M.CtrlCmd),
392
+ command: "selection.duplicate"
393
+ },
342
394
  {
343
395
  keybinding: kb(KeyCode.KeyA, M.CtrlCmd),
344
396
  command: "selection.all"
@@ -459,6 +511,70 @@ const DEFAULT_BINDINGS = [
459
511
  dy: 10
460
512
  }
461
513
  },
514
+ {
515
+ keybinding: kb(KeyCode.RightArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
516
+ command: "selection.nudge_resize",
517
+ args: {
518
+ dw: 1,
519
+ dh: 0
520
+ }
521
+ },
522
+ {
523
+ keybinding: kb(KeyCode.LeftArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
524
+ command: "selection.nudge_resize",
525
+ args: {
526
+ dw: -1,
527
+ dh: 0
528
+ }
529
+ },
530
+ {
531
+ keybinding: kb(KeyCode.DownArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
532
+ command: "selection.nudge_resize",
533
+ args: {
534
+ dw: 0,
535
+ dh: 1
536
+ }
537
+ },
538
+ {
539
+ keybinding: kb(KeyCode.UpArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
540
+ command: "selection.nudge_resize",
541
+ args: {
542
+ dw: 0,
543
+ dh: -1
544
+ }
545
+ },
546
+ {
547
+ keybinding: kb(KeyCode.RightArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
548
+ command: "selection.nudge_resize",
549
+ args: {
550
+ dw: 10,
551
+ dh: 0
552
+ }
553
+ },
554
+ {
555
+ keybinding: kb(KeyCode.LeftArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
556
+ command: "selection.nudge_resize",
557
+ args: {
558
+ dw: -10,
559
+ dh: 0
560
+ }
561
+ },
562
+ {
563
+ keybinding: kb(KeyCode.DownArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
564
+ command: "selection.nudge_resize",
565
+ args: {
566
+ dw: 0,
567
+ dh: 10
568
+ }
569
+ },
570
+ {
571
+ keybinding: kb(KeyCode.UpArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
572
+ command: "selection.nudge_resize",
573
+ args: {
574
+ dw: 0,
575
+ dh: -10
576
+ }
577
+ },
462
578
  {
463
579
  keybinding: kb(KeyCode.KeyV),
464
580
  command: TOOL_SET,
@@ -755,824 +871,175 @@ function create_defs(doc) {
755
871
  return { gradients: new GradientsRegistry(doc) };
756
872
  }
757
873
  //#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
- };
874
+ //#region src/core/clipboard.ts
794
875
  /**
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.
876
+ * Clipboard payload extraction selection standalone SVG document.
877
+ *
878
+ * Implements the copy side of the clipboard FRD
879
+ * ([docs/wg/feat-svg-editor/clipboard.md](../../../../docs/wg/feat-svg-editor/clipboard.md)):
880
+ * the payload is a standalone, namespace-well-formed SVG document — not a
881
+ * private envelope. Assembly is a pure function of (document, selection):
882
+ * no geometry, no environment, no randomness (FRD R6 the same selection
883
+ * yields the same bytes headless or surface-attached).
813
884
  *
814
- * Includes text-shaping attributes (font-*) because they re-shape glyph
815
- * runs and change `<text>` bbox.
885
+ * What the payload carries, and what it deliberately does not
886
+ * (FRD §Extraction five context kinds):
887
+ *
888
+ * 1. Referenced resources — CARRIED. The outbound `url(#…)` / `href`
889
+ * closure is walked from the closed carrier list below and emitted
890
+ * verbatim in one `<defs>` block.
891
+ * 2. Namespace declarations — CARRIED. Prefixes a subtree borrows from
892
+ * ancestor scope are declared on the payload shell (an undeclared
893
+ * prefix is a well-formedness error, so this includes the deliberate
894
+ * well-known-table repair for e.g. `xlink`).
895
+ * 3. Ancestor transforms — NOT carried (verbatim policy).
896
+ * 4. Inherited presentation / cascade — NOT carried (verbatim policy).
897
+ * 5. Viewport — NOT carried (no `viewBox`, no sizing on the shell).
898
+ *
899
+ * This module is the **payload extraction** operation only. The sibling
900
+ * operation the FRD names — in-document subtree CLONE (duplicate /
901
+ * clone-drag), which must NOT carry the closure — lives in `./subtree`.
902
+ * The two share exactly selection normalization (`subtree.normalize_roots`)
903
+ * and verbatim subtree serialization (`doc.serialize_node`).
816
904
  */
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
- }
905
+ let clipboard;
906
+ (function(_clipboard) {
1102
907
  /**
1103
- * Whether `id` can be opened in the flat-string text editor.
908
+ * Presentation carriers that may hold `url(#…)` references, read both as
909
+ * a presentation attribute and as an inline `style=""` declaration.
1104
910
  *
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).
911
+ * CLOSED LIST extending it is a spec change to the FRD's §Extraction 1
912
+ * carrier list, not a bug fix. Deliberately NOT walked in v1 (documented
913
+ * degradations): `<style>` element rules (CSS parsing is the deferred
914
+ * cascade capability), SMIL timing/value references, `cursor`, SVG 2
915
+ * text-layout properties.
1150
916
  */
917
+ const URL_REF_PROPS = [
918
+ "fill",
919
+ "stroke",
920
+ "filter",
921
+ "clip-path",
922
+ "mask",
923
+ "marker-start",
924
+ "marker-mid",
925
+ "marker-end",
926
+ "marker"
927
+ ];
928
+ /** Set view of {@link URL_REF_PROPS} for membership tests over parsed
929
+ * style declarations. */
930
+ const URL_REF_PROP_SET = new Set(URL_REF_PROPS);
1151
931
  /**
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.
932
+ * Elements whose `href` / `xlink:href` is a same-document resource
933
+ * reference the closure follows. CLOSED LIST `<a href>` is navigation,
934
+ * `<image href>` is content, SMIL `href` is an animation target; none of
935
+ * them are walked.
1162
936
  */
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
- }
937
+ const HREF_TAGS = new Set([
938
+ "use",
939
+ "textPath",
940
+ "mpath",
941
+ "feImage",
942
+ "pattern",
943
+ "linearGradient",
944
+ "radialGradient",
945
+ "filter"
946
+ ]);
1270
947
  /**
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)
948
+ * `url(#id)` extractor. Global a single value can carry several
949
+ * references (`filter: url(#a) blur(2px) url(#b)` is a legal filter
950
+ * function list). Same quoting tolerance as the defs registry's
951
+ * ref-counting pattern.
1288
952
  */
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
- }
953
+ const URL_REF_RE = /url\(\s*["']?#([^"')\s]+)["']?\s*\)/g;
954
+ function extract_payload(doc, selection) {
955
+ const order = subtree.by_document_order(doc);
956
+ const roots = subtree.normalize_roots(doc, selection, order);
957
+ if (roots.length === 0) return null;
958
+ const closure = collect_reference_closure(doc, roots);
959
+ const shell_ns = /* @__PURE__ */ new Map();
960
+ for (const member of [...closure, ...roots].sort(order)) for (const prefix of doc.undeclared_ns_prefixes(member)) {
961
+ if (shell_ns.has(prefix)) continue;
962
+ const uri = resolve_prefix(doc, member, prefix);
963
+ if (uri !== null) shell_ns.set(prefix, uri);
1306
964
  }
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;
965
+ let shell = `<svg xmlns="${SVG_NS}"`;
966
+ for (const [prefix, uri] of shell_ns) shell += ` xmlns:${prefix}="${encode_attr_value(uri, "\"")}"`;
967
+ shell += ">";
968
+ const defs_block = closure.length > 0 ? `<defs>${closure.map((id) => doc.serialize_node(id)).join("")}</defs>` : "";
969
+ const content = roots.map((id) => doc.serialize_node(id)).join("");
970
+ return `${shell}${defs_block}${content}</svg>`;
971
+ }
972
+ _clipboard.extract_payload = extract_payload;
973
+ function collect_reference_closure(doc, roots) {
974
+ const id_map = /* @__PURE__ */ new Map();
975
+ for (const el of doc.all_elements()) {
976
+ const id_attr = doc.get_attr(el, "id");
977
+ if (id_attr !== null && !id_map.has(id_attr)) id_map.set(id_attr, el);
1333
978
  }
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;
979
+ const in_forest = (target) => roots.some((r) => doc.contains(r, target));
980
+ const collected = /* @__PURE__ */ new Set();
981
+ const pending = [...roots];
982
+ while (pending.length > 0) {
983
+ const subtree = pending.pop();
984
+ for (const el of elements_of_subtree(doc, subtree)) for (const ref of refs_of(doc, el)) {
985
+ const target = id_map.get(ref);
986
+ if (target === void 0) continue;
987
+ if (in_forest(target)) continue;
988
+ if (collected.has(target)) continue;
989
+ collected.add(target);
990
+ pending.push(target);
1366
991
  }
1367
992
  }
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;
993
+ return doc.prune_nested_nodes([...collected]).sort(subtree.by_document_order(doc));
1406
994
  }
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
- }
995
+ _clipboard.collect_reference_closure = collect_reference_closure;
996
+ /** Preorder element walk of `root`'s subtree, root included. */
997
+ function elements_of_subtree(doc, root) {
998
+ const out = [];
999
+ const walk = (id) => {
1000
+ if (!doc.is_element(id)) return;
1001
+ out.push(id);
1002
+ for (const c of doc.children_of(id)) walk(c);
1003
+ };
1004
+ walk(root);
1415
1005
  return out;
1416
1006
  }
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);
1007
+ /** Same-document reference ids carried by one element, per the closed
1008
+ * carrier list. */
1009
+ function refs_of(doc, id) {
1010
+ const out = [];
1011
+ for (const prop of URL_REF_PROPS) {
1012
+ const value = doc.get_attr(id, prop);
1013
+ if (!value) continue;
1014
+ for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
1432
1015
  }
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
- }
1016
+ for (const { property, value } of doc.get_all_styles(id)) {
1017
+ if (!URL_REF_PROP_SET.has(property)) continue;
1018
+ for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
1019
+ }
1020
+ if (HREF_TAGS.has(doc.tag_of(id))) {
1021
+ const href = doc.get_attr(id, "href");
1022
+ if (href !== null && href.startsWith("#") && href.length > 1) out.push(href.slice(1));
1447
1023
  }
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
1024
  return out;
1497
1025
  }
1498
1026
  /**
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.
1027
+ * Resolve a prefix a member's subtree borrows from ancestor scope:
1028
+ * nearest ancestor `xmlns:<prefix>` declaration wins (correct XML
1029
+ * scoping), falling back to the well-known table — the deliberate
1030
+ * repair that keeps the payload namespace-well-formed even when the
1031
+ * source never declared the prefix (FRD §Extraction 2).
1521
1032
  */
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
- }
1033
+ function resolve_prefix(doc, member, prefix) {
1034
+ let cur = doc.parent_of(member);
1035
+ while (cur !== null) {
1036
+ const uri = doc.get_attr(cur, prefix, XMLNS_NS);
1037
+ if (uri !== null) return uri;
1038
+ cur = doc.parent_of(cur);
1555
1039
  }
1040
+ return WELL_KNOWN_NS_PREFIXES.get(prefix) ?? null;
1556
1041
  }
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
- }
1042
+ })(clipboard || (clipboard = {}));
1576
1043
  //#endregion
1577
1044
  //#region src/core/align.ts
1578
1045
  /**
@@ -1789,10 +1256,11 @@ function _create_svg_editor_internal(opts) {
1789
1256
  let mode = "select";
1790
1257
  let tool = TOOL_CURSOR;
1791
1258
  let version = 0;
1792
- /** Document-edit counter only bumps on actual mutations, not selection. */
1793
- let doc_version = 0;
1794
- /** doc_version at the last load()/serialize(); compared to derive `dirty`. */
1795
- let baseline_doc_version = 0;
1259
+ /** `doc.revision` at the last load()/reset(); compared to derive `dirty`.
1260
+ * The doc's own total mutation counter is the single edit-version
1261
+ * source `content_version`, `dirty`, and the typed-read memo caches
1262
+ * all derive from it (no editor-side shadow counter to drift). */
1263
+ let baseline_revision = doc.revision;
1796
1264
  /**
1797
1265
  * Bumps once per `editor.load(svg)` call. The constructor's initial parse
1798
1266
  * does NOT count — it's the "factory" state. Hosts subscribe via
@@ -1805,6 +1273,23 @@ function _create_svg_editor_internal(opts) {
1805
1273
  ...opts.style
1806
1274
  };
1807
1275
  const providers = opts.providers ?? {};
1276
+ /**
1277
+ * In-memory clipboard buffer — the transport floor (FRD R1: the buffer
1278
+ * write cannot fail; external channels are best-effort on top). NOT part
1279
+ * of `EditorState` and NOT history-managed: it survives `load()` /
1280
+ * `reset()` / undo, like the OS clipboard it mirrors.
1281
+ */
1282
+ let clipboard_buffer = null;
1283
+ /**
1284
+ * The last committed duplication — read by the NEXT `duplicate()` to
1285
+ * repeat the user's translate delta (gridaco/grida#825; spec
1286
+ * §Repeating offset). Session state like `clipboard_buffer`: not in
1287
+ * `EditorState`, not history-managed (undo/redo replay never re-arms
1288
+ * it — only a user-initiated ⌘D or cloned-drag commit does). Staleness
1289
+ * is caught at use by `subtree.repeat_delta`; the only eager clears are
1290
+ * `load()` / `reset()`, where every NodeId dies wholesale.
1291
+ */
1292
+ let active_duplication = null;
1808
1293
  const listeners = /* @__PURE__ */ new Set();
1809
1294
  let attached_surface = null;
1810
1295
  /**
@@ -1820,11 +1305,11 @@ function _create_svg_editor_internal(opts) {
1820
1305
  scope,
1821
1306
  mode,
1822
1307
  tool,
1823
- dirty: doc_version !== baseline_doc_version,
1308
+ dirty: doc.revision !== baseline_revision,
1824
1309
  can_undo: history.stack.canUndo,
1825
1310
  can_redo: history.stack.canRedo,
1826
1311
  version,
1827
- content_version: doc_version,
1312
+ content_version: doc.revision,
1828
1313
  structure_version: doc.structure_version,
1829
1314
  geometry_version: doc.geometry_version,
1830
1315
  load_version
@@ -1844,12 +1329,21 @@ function _create_svg_editor_internal(opts) {
1844
1329
  const notify_translate_commit = () => {
1845
1330
  for (const cb of translate_commit_listeners) cb();
1846
1331
  };
1847
- doc.on_change(() => {
1848
- doc_version++;
1332
+ /**
1333
+ * Fan out the geometry channel iff the doc's `geometry_version` has
1334
+ * moved since we last fired. Shared by the `doc.on_change` handler
1335
+ * (mutation-driven bumps) and the surface-driven `bump_geometry` seam
1336
+ * (font-load reflow). Idempotent against a stale version — never
1337
+ * double-fires for the same value.
1338
+ */
1339
+ function fire_geometry_listeners_if_advanced() {
1849
1340
  if (doc.geometry_version !== last_emitted_geometry_version) {
1850
1341
  last_emitted_geometry_version = doc.geometry_version;
1851
1342
  for (const cb of geometry_listeners) cb();
1852
1343
  }
1344
+ }
1345
+ doc.on_change(() => {
1346
+ fire_geometry_listeners_if_advanced();
1853
1347
  });
1854
1348
  function subscribe(fn) {
1855
1349
  listeners.add(fn);
@@ -1973,14 +1467,14 @@ function _create_svg_editor_internal(opts) {
1973
1467
  function node_property_cached(id, name) {
1974
1468
  const key = `${id}${name}`;
1975
1469
  const cached = property_cache.get(key);
1976
- if (cached && cached.doc_version === doc_version) return cached.value;
1470
+ if (cached && cached.revision === doc.revision) return cached.value;
1977
1471
  const next = properties.read(doc, id, name);
1978
1472
  if (cached && properties.value_equals(cached.value, next)) {
1979
- cached.doc_version = doc_version;
1473
+ cached.revision = doc.revision;
1980
1474
  return cached.value;
1981
1475
  }
1982
1476
  property_cache.set(key, {
1983
- doc_version,
1477
+ revision: doc.revision,
1984
1478
  value: next
1985
1479
  });
1986
1480
  return next;
@@ -1988,7 +1482,7 @@ function _create_svg_editor_internal(opts) {
1988
1482
  function node_properties(id, names) {
1989
1483
  const key = `${id}${names.join("")}`;
1990
1484
  const cached = properties_cache.get(key);
1991
- if (cached && cached.doc_version === doc_version) return cached.value;
1485
+ if (cached && cached.revision === doc.revision) return cached.value;
1992
1486
  const next = {};
1993
1487
  let changed = !cached;
1994
1488
  for (const name of names) {
@@ -1997,12 +1491,12 @@ function _create_svg_editor_internal(opts) {
1997
1491
  if (cached && cached.value[name] !== v) changed = true;
1998
1492
  }
1999
1493
  if (cached && !changed) {
2000
- cached.doc_version = doc_version;
1494
+ cached.revision = doc.revision;
2001
1495
  return cached.value;
2002
1496
  }
2003
1497
  const frozen = Object.freeze(next);
2004
1498
  properties_cache.set(key, {
2005
- doc_version,
1499
+ revision: doc.revision,
2006
1500
  value: frozen
2007
1501
  });
2008
1502
  return frozen;
@@ -2010,7 +1504,7 @@ function _create_svg_editor_internal(opts) {
2010
1504
  function node_paint(id, channel) {
2011
1505
  const key = `${id}${channel}`;
2012
1506
  const cached = paint_cache.get(key);
2013
- if (cached && cached.doc_version === doc_version) return cached.value;
1507
+ if (cached && cached.revision === doc.revision) return cached.value;
2014
1508
  const { declared, provenance } = properties.resolve_declared(doc, id, channel);
2015
1509
  const next = {
2016
1510
  declared,
@@ -2018,11 +1512,11 @@ function _create_svg_editor_internal(opts) {
2018
1512
  provenance
2019
1513
  };
2020
1514
  if (cached && paint.value_equals(cached.value, next)) {
2021
- cached.doc_version = doc_version;
1515
+ cached.revision = doc.revision;
2022
1516
  return cached.value;
2023
1517
  }
2024
1518
  paint_cache.set(key, {
2025
- doc_version,
1519
+ revision: doc.revision,
2026
1520
  value: next
2027
1521
  });
2028
1522
  return next;
@@ -2111,6 +1605,13 @@ function _create_svg_editor_internal(opts) {
2111
1605
  });
2112
1606
  return { gradient_id };
2113
1607
  }
1608
+ /** World→local delta projection shared by every one-shot translate
1609
+ * writer (translate / nudge via `prepare_rpc`, align). Re-expresses a
1610
+ * world-space delta in the frame the target's position attributes are
1611
+ * written in — nested-viewport / transformed-ancestor correctness.
1612
+ * Identity for flat docs and DOM-less hosts (no provider, or a
1613
+ * provider without a layout engine). */
1614
+ const project_world_delta = (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d;
2114
1615
  /** Shared one-shot translate runner. `stages` selects semantics — see
2115
1616
  * `core/translate-pipeline/README.md`'s "Stage lists per entry point". */
2116
1617
  function do_translate_oneshot(delta, stages, label) {
@@ -2130,7 +1631,7 @@ function _create_svg_editor_internal(opts) {
2130
1631
  },
2131
1632
  emit,
2132
1633
  stages,
2133
- project: (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d
1634
+ project: project_world_delta
2134
1635
  });
2135
1636
  apply();
2136
1637
  history.atomic(label, (tx) => {
@@ -2149,66 +1650,79 @@ function _create_svg_editor_internal(opts) {
2149
1650
  if (do_translate_oneshot(delta, translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
2150
1651
  }
2151
1652
  /**
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.
1653
+ * Gate + capture for a resize gesture. Returns the resizable members (with
1654
+ * captured baseline / pre-transform / bbox), or `null` if the gesture can't
1655
+ * run: no geometry provider, empty selection, or in `all_or_nothing` mode
1656
+ * any member fails the gate.
2157
1657
  *
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.
2162
- *
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.
1658
+ * `mode`:
1659
+ * - `"skip"` drop members failing the `is_resizable_node` gate
1660
+ * (tag + transform class) or lacking a bbox; resize the rest. Used by the
1661
+ * inspector `resize_to` (set-bbox) path.
1662
+ * - `"all_or_nothing"` — refuse the WHOLE gesture (return `null`) if ANY
1663
+ * member fails. Used by keyboard `resize_by` (nudge), matching the resize
1664
+ * HUD, whose handle-drag is rejected when any member is unsafe.
2167
1665
  */
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;
1666
+ function collect_resize_members(ids, mode) {
1667
+ if (ids.length === 0) return null;
1668
+ if (!geometry_provider) return null;
2172
1669
  const members = [];
2173
1670
  for (const id of ids) {
2174
- if (!resize_pipeline.intent.is_resizable(doc.tag_of(id))) continue;
1671
+ if (!resize_pipeline.intent.is_resizable_node(doc, id)) {
1672
+ if (mode === "all_or_nothing") return null;
1673
+ continue;
1674
+ }
2175
1675
  const bbox = geometry_provider.bounds_of(id);
2176
- if (!bbox) continue;
1676
+ if (!bbox) {
1677
+ if (mode === "all_or_nothing") return null;
1678
+ continue;
1679
+ }
2177
1680
  members.push({
2178
1681
  id,
2179
1682
  rz: resize_pipeline.intent.capture_baseline(doc, id, bbox),
2180
- tx_pre: translate_pipeline.intent.capture_baseline(doc, id),
2181
1683
  transform_pre: doc.get_attr(id, "transform"),
2182
1684
  bbox
2183
1685
  });
2184
1686
  }
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;
1687
+ return members.length === 0 ? null : members;
1688
+ }
1689
+ /**
1690
+ * Apply a resize to each member, optionally followed by a uniform group
1691
+ * translate, as ONE atomic history step. `op` resolves each member's scale
1692
+ * factors + scale origin; `group_translate` is the post-scale envelope shift
1693
+ * (group resize only — `null` for per-element). Callers guarantee `members`
1694
+ * is non-empty. Returns `true` when a history step was pushed; `false` when
1695
+ * the gesture is geometrically identity (no member scales and no group
1696
+ * translate) so undo isn't polluted with an empty step. NOTE: a per-tag
1697
+ * constraint that collapses a non-1 factor to identity *inside* the handler
1698
+ * (e.g. `<circle>` uniform `min` on a single-axis nudge) is not detected
1699
+ * here — the op-level factor is still ≠ 1, so that case still pushes a step.
1700
+ */
1701
+ function commit_resize(members, op, group_translate, label) {
1702
+ const ops = members.map((m) => ({
1703
+ m,
1704
+ ...op(m)
1705
+ }));
1706
+ const scales = ops.some(({ sx, sy }) => sx !== 1 || sy !== 1);
1707
+ const translates = !!group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0);
1708
+ if (!scales && !translates) return false;
2195
1709
  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) {
1710
+ for (const { m, sx, sy, origin } of ops) resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
1711
+ if (group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0)) for (const m of members) {
2198
1712
  const tx_after = translate_pipeline.intent.capture_baseline(doc, m.id);
2199
- translate_pipeline.intent.apply(doc, m.id, tx_after, dx, dy);
1713
+ translate_pipeline.intent.apply(doc, m.id, tx_after, group_translate.dx, group_translate.dy);
2200
1714
  }
2201
1715
  emit();
2202
1716
  };
2203
1717
  const revert = () => {
2204
- for (const m of members) {
1718
+ for (const { m, origin } of ops) {
2205
1719
  resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
2206
1720
  doc.set_attr(m.id, "transform", m.transform_pre);
2207
1721
  }
2208
1722
  emit();
2209
1723
  };
2210
1724
  apply();
2211
- history.atomic("resize-to", (tx) => {
1725
+ history.atomic(label, (tx) => {
2212
1726
  tx.push({
2213
1727
  providerId: PROVIDER_ID,
2214
1728
  apply,
@@ -2217,6 +1731,73 @@ function _create_svg_editor_internal(opts) {
2217
1731
  });
2218
1732
  return true;
2219
1733
  }
1734
+ /**
1735
+ * One-shot multi-member resize to an explicit target rect. Mirrors a
1736
+ * drag-resize gesture in mechanics — capture per-member baselines,
1737
+ * scale around the union's NW corner, translate the result so the
1738
+ * union NW lands at the requested position — but as a single
1739
+ * atomic step rather than a preview session. This is the GROUP path:
1740
+ * the whole selection is treated as one envelope.
1741
+ *
1742
+ * The function does its own geometry lookup via the
1743
+ * `geometry_provider` registered by the DOM surface. When no surface
1744
+ * is attached, the call is a no-op (returns `false`). Members that fail
1745
+ * the `is_resizable_node` gate — an unresizable tag (e.g. `<g>`) OR a
1746
+ * non-trivially-transformed element — are silently skipped (see
1747
+ * `collect_resize_members`).
1748
+ *
1749
+ * Revert restores the captured `transform` attribute and all
1750
+ * geometry attrs the apply step wrote — so a `<rect>` with an
1751
+ * existing `transform` round-trips cleanly. See `apply_translate`'s
1752
+ * `viaTransform` arm for why this matters.
1753
+ */
1754
+ function resize_to(target, opts) {
1755
+ const members = collect_resize_members(opts?.ids ?? selection, "skip");
1756
+ if (!members) return false;
1757
+ const union = cmath.rect.union(members.map((m) => m.bbox));
1758
+ const sx = union.width === 0 ? 1 : target.width / union.width;
1759
+ const sy = union.height === 0 ? 1 : target.height / union.height;
1760
+ const origin = {
1761
+ x: union.x,
1762
+ y: union.y
1763
+ };
1764
+ return commit_resize(members, () => ({
1765
+ sx,
1766
+ sy,
1767
+ origin
1768
+ }), {
1769
+ dx: target.x - union.x,
1770
+ dy: target.y - union.y
1771
+ }, opts?.label ?? "resize-to");
1772
+ }
1773
+ /**
1774
+ * Resize by a `{dw, dh}` delta — the core verb behind keyboard nudge-resize
1775
+ * (`Ctrl+Alt+Arrow`). This is the PER-ELEMENT path: each selected member
1776
+ * grows/shrinks by the delta around ITS OWN NW corner, so members keep their
1777
+ * positions relative to one another. This deliberately differs from
1778
+ * {@link resize_to} (the group/envelope path): a HUD group-resize scales the
1779
+ * whole selection around the shared union origin, translating off-origin
1780
+ * members — correct for a drag handle, wrong for a keyboard nudge, whose UX
1781
+ * is "apply the delta to each".
1782
+ *
1783
+ * ALL-OR-NOTHING gate (`collect_resize_members("all_or_nothing")`): refuses
1784
+ * (returns `false`, no history step) on empty selection, no geometry
1785
+ * provider, or any member failing the `is_resizable_node` gate — matching
1786
+ * the resize HUD rather than `resize_to`'s per-member skip.
1787
+ */
1788
+ function resize_by(delta, opts) {
1789
+ const members = collect_resize_members(opts?.ids ?? selection, "all_or_nothing");
1790
+ if (!members) return false;
1791
+ const axis = (size, d) => size === 0 ? 1 : Math.max(0, size + d) / size;
1792
+ return commit_resize(members, (m) => ({
1793
+ sx: axis(m.bbox.width, delta.dw),
1794
+ sy: axis(m.bbox.height, delta.dh),
1795
+ origin: {
1796
+ x: m.bbox.x,
1797
+ y: m.bbox.y
1798
+ }
1799
+ }), null, "nudge-resize");
1800
+ }
2220
1801
  /** Shared helper: compute a default rotation pivot from the live
2221
1802
  * geometry_provider when the caller omitted one. Falls back to (0,0)
2222
1803
  * if no surface is attached. */
@@ -2298,6 +1879,70 @@ function _create_svg_editor_internal(opts) {
2298
1879
  });
2299
1880
  return true;
2300
1881
  }
1882
+ /**
1883
+ * Relative affine compose about a pivot. See the `Commands.transform`
1884
+ * doc for the full contract. This function owns ONLY the pivot/effective-
1885
+ * matrix computation (which needs `geometry_provider`); the parse→fold→
1886
+ * emit round-trip is delegated per-member to the pure
1887
+ * `transform.apply_affine` helper.
1888
+ */
1889
+ function apply_transform(matrix, opts) {
1890
+ const ids = opts?.ids ?? selection;
1891
+ if (ids.length === 0) return false;
1892
+ if (!geometry_provider) return false;
1893
+ for (const id of ids) if (rotate_pipeline.intent.is_transformable(doc, id).kind === "refuse") return false;
1894
+ const pivot = opts?.pivot ?? default_rotate_pivot(ids);
1895
+ const [a, b, c, d, e, f] = matrix;
1896
+ const requested = [[
1897
+ a,
1898
+ c,
1899
+ e
1900
+ ], [
1901
+ b,
1902
+ d,
1903
+ f
1904
+ ]];
1905
+ const t_pivot = [[
1906
+ 1,
1907
+ 0,
1908
+ pivot.x
1909
+ ], [
1910
+ 0,
1911
+ 1,
1912
+ pivot.y
1913
+ ]];
1914
+ const t_neg_pivot = [[
1915
+ 1,
1916
+ 0,
1917
+ -pivot.x
1918
+ ], [
1919
+ 0,
1920
+ 1,
1921
+ -pivot.y
1922
+ ]];
1923
+ const effective = cmath.transform.multiply(cmath.transform.multiply(t_pivot, requested), t_neg_pivot);
1924
+ const members = ids.map((id) => ({
1925
+ id,
1926
+ transform_pre: doc.get_attr(id, "transform")
1927
+ }));
1928
+ const apply = () => {
1929
+ for (const m of members) doc.set_attr(m.id, "transform", transform.apply_affine(m.transform_pre, effective));
1930
+ emit();
1931
+ };
1932
+ const revert = () => {
1933
+ for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
1934
+ emit();
1935
+ };
1936
+ apply();
1937
+ history.atomic("transform", (tx) => {
1938
+ tx.push({
1939
+ providerId: PROVIDER_ID,
1940
+ apply,
1941
+ revert
1942
+ });
1943
+ });
1944
+ return true;
1945
+ }
2301
1946
  function flatten_transform(opts) {
2302
1947
  const ids = opts?.ids ?? selection;
2303
1948
  if (ids.length === 0) return false;
@@ -2350,7 +1995,10 @@ function _create_svg_editor_internal(opts) {
2350
1995
  * center of a reference rect. Same mechanics as `resize_to`: per-member
2351
1996
  * translate baselines (so `<g>`, transformed, and natively-attributed
2352
1997
  * nodes all write the cleanest in-place representation), one atomic
2353
- * history step.
1998
+ * history step. Deltas are computed in world space and re-expressed in
1999
+ * each member's local frame before writing (`world_delta_to_local`),
2000
+ * so members under scaled/rotated ancestors land exactly and a repeat
2001
+ * invocation is a no-op.
2354
2002
  *
2355
2003
  * Reference rect is selection-size dependent:
2356
2004
  * - multi-selection: union of member bboxes
@@ -2386,8 +2034,10 @@ function _create_svg_editor_internal(opts) {
2386
2034
  if (!parent_bbox) return false;
2387
2035
  target = parent_bbox;
2388
2036
  } else target = cmath.rect.union(members.map((m) => m.bbox));
2389
- const deltas = compute_align_deltas(members, target, direction);
2390
- if (deltas.size === 0) return false;
2037
+ const world_deltas = compute_align_deltas(members, target, direction);
2038
+ if (world_deltas.size === 0) return false;
2039
+ const deltas = /* @__PURE__ */ new Map();
2040
+ for (const [id, d] of world_deltas) deltas.set(id, project_world_delta(id, d));
2391
2041
  const apply = () => {
2392
2042
  for (const m of members) {
2393
2043
  const d = deltas.get(m.id);
@@ -2489,13 +2139,19 @@ function _create_svg_editor_internal(opts) {
2489
2139
  });
2490
2140
  }
2491
2141
  function remove() {
2142
+ remove_selection("remove");
2143
+ }
2144
+ /**
2145
+ * Shared deletion body for `remove` and `cut` — identical
2146
+ * capture/revert semantics, differing only in the history label
2147
+ * (`verb`), so undo attribution names the gesture that caused the
2148
+ * deletion.
2149
+ */
2150
+ function remove_selection(verb) {
2492
2151
  if (selection.length === 0) return;
2493
2152
  const filtered = doc.prune_nested_nodes(selection).filter((id) => doc.parent_of(id) !== null);
2494
2153
  if (filtered.length === 0) return;
2495
- const doc_order = doc.all_elements();
2496
- const index_of = /* @__PURE__ */ new Map();
2497
- for (let i = 0; i < doc_order.length; i++) index_of.set(doc_order[i], i);
2498
- const captures = [...filtered].sort((a, b) => (index_of.get(a) ?? 0) - (index_of.get(b) ?? 0)).map((id) => ({
2154
+ const captures = [...filtered].sort(subtree.by_document_order(doc)).map((id) => ({
2499
2155
  id,
2500
2156
  parent: doc.parent_of(id),
2501
2157
  next_sibling: doc.next_element_sibling_of(id)
@@ -2513,7 +2169,7 @@ function _create_svg_editor_internal(opts) {
2513
2169
  set_selection(old_selection);
2514
2170
  };
2515
2171
  apply();
2516
- history.atomic(captures.length === 1 ? "remove" : `remove ${captures.length}`, (tx) => {
2172
+ history.atomic(captures.length === 1 ? verb : `${verb} ${captures.length}`, (tx) => {
2517
2173
  tx.push({
2518
2174
  providerId: PROVIDER_ID,
2519
2175
  apply,
@@ -2549,6 +2205,47 @@ function _create_svg_editor_internal(opts) {
2549
2205
  });
2550
2206
  return true;
2551
2207
  }
2208
+ function ungroup(opts) {
2209
+ let target;
2210
+ if (opts?.id !== void 0) target = opts.id;
2211
+ else {
2212
+ if (selection.length !== 1) return false;
2213
+ target = selection[0];
2214
+ }
2215
+ const plan = group.plan_ungroup(doc, target);
2216
+ if (!plan) return false;
2217
+ const group_id = plan.group_id;
2218
+ const group_next_sibling = doc.next_element_sibling_of(group_id);
2219
+ const original_child_transforms = /* @__PURE__ */ new Map();
2220
+ for (const child of plan.children) original_child_transforms.set(child, doc.get_attr(child, "transform"));
2221
+ const group_ops = plan.group_transform === null ? [] : transform.parse(plan.group_transform) ?? [];
2222
+ const original_selection = selection;
2223
+ const apply = () => {
2224
+ if (group_ops.length > 0) for (const child of plan.children) {
2225
+ const child_ops = transform.parse(doc.get_attr(child, "transform")) ?? [];
2226
+ const next = transform.emit([...group_ops, ...child_ops]);
2227
+ doc.set_attr(child, "transform", next === "" ? null : next);
2228
+ }
2229
+ for (const child of plan.children) doc.insert(child, plan.parent, group_id);
2230
+ doc.remove(group_id);
2231
+ set_selection(plan.children);
2232
+ };
2233
+ const revert = () => {
2234
+ doc.insert(group_id, plan.parent, group_next_sibling);
2235
+ for (const child of plan.children) doc.insert(child, group_id, null);
2236
+ if (group_ops.length > 0) for (const child of plan.children) doc.set_attr(child, "transform", original_child_transforms.get(child) ?? null);
2237
+ set_selection(original_selection);
2238
+ };
2239
+ apply();
2240
+ history.atomic("ungroup", (tx) => {
2241
+ tx.push({
2242
+ providerId: PROVIDER_ID,
2243
+ apply,
2244
+ revert
2245
+ });
2246
+ });
2247
+ return true;
2248
+ }
2552
2249
  /**
2553
2250
  * Atomic one-shot insertion. Used by paste, programmatic RPC, and the
2554
2251
  * click-no-drag commit path inside the insertion gesture driver. One
@@ -2558,11 +2255,20 @@ function _create_svg_editor_internal(opts) {
2558
2255
  * win. `opts.parent` defaults to root; `opts.index` (insert-before
2559
2256
  * sibling index) defaults to append; `opts.select` defaults to `true`.
2560
2257
  */
2258
+ /**
2259
+ * Resolve an optional `index` (position in `parent`'s element-children
2260
+ * list to insert AT — anything at or after it shifts; out-of-range or
2261
+ * `undefined` appends) to an insert-before anchor. Shared by `insert`,
2262
+ * `insert_fragment`, and `insert_preview`.
2263
+ */
2264
+ function resolve_insert_before(parent, index) {
2265
+ if (index === void 0) return null;
2266
+ return doc.element_children_of(parent)[index] ?? null;
2267
+ }
2561
2268
  function insert(tag, attrs, opts) {
2562
2269
  const parent = opts?.parent ?? doc.root;
2563
2270
  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;
2271
+ const insert_before = resolve_insert_before(parent, opts?.index);
2566
2272
  const id = doc.create_element(tag);
2567
2273
  const merged_attrs = {
2568
2274
  ...default_paint_attrs_for(tag),
@@ -2590,6 +2296,153 @@ function _create_svg_editor_internal(opts) {
2590
2296
  return id;
2591
2297
  }
2592
2298
  /**
2299
+ * Atomic fragment insertion — contract in {@link Commands.insert_fragment}.
2300
+ * Parses + adopts via `doc.create_fragment` (subtrees registered but
2301
+ * detached, like `create_element` — history.redo finds them via
2302
+ * closure), computes the namespace hoist plan, then brackets inserts +
2303
+ * hoisted declarations + selection in ONE history step.
2304
+ */
2305
+ function insert_fragment(svg, opts) {
2306
+ return insert_fragment_impl(svg, opts, "insert fragment");
2307
+ }
2308
+ /**
2309
+ * Label-bearing body shared by `insert_fragment` and `paste` — same
2310
+ * atomic insertion, differing only in history attribution (undo for a
2311
+ * paste gesture should read "paste", not "insert fragment").
2312
+ */
2313
+ function insert_fragment_impl(svg, opts, label) {
2314
+ const parent = opts?.parent ?? doc.root;
2315
+ 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`);
2316
+ const select_after = opts?.select !== false;
2317
+ const { roots, xmlns } = doc.create_fragment(svg);
2318
+ if (roots.length === 0) return [];
2319
+ const known_uri = new Map(WELL_KNOWN_NS_PREFIXES);
2320
+ for (const d of xmlns) known_uri.set(d.prefix, d.uri);
2321
+ const hoist = [];
2322
+ const considered = /* @__PURE__ */ new Set();
2323
+ for (const id of roots) for (const prefix of doc.undeclared_ns_prefixes(id)) {
2324
+ if (considered.has(prefix)) continue;
2325
+ considered.add(prefix);
2326
+ if (doc.get_attr(doc.root, prefix, XMLNS_NS) !== null) continue;
2327
+ const uri = known_uri.get(prefix);
2328
+ if (uri === void 0) continue;
2329
+ hoist.push({
2330
+ prefix,
2331
+ uri
2332
+ });
2333
+ }
2334
+ const insert_before = resolve_insert_before(parent, opts?.index);
2335
+ const previous_selection = selection;
2336
+ const apply = () => {
2337
+ for (const { prefix, uri } of hoist) doc.declare_xmlns(prefix, uri);
2338
+ for (const id of roots) doc.insert(id, parent, insert_before);
2339
+ if (select_after) set_selection(roots);
2340
+ };
2341
+ const revert = () => {
2342
+ for (let i = roots.length - 1; i >= 0; i--) doc.remove(roots[i]);
2343
+ for (const { prefix } of hoist) doc.set_attr(doc.root, prefix, null, XMLNS_NS);
2344
+ if (select_after) set_selection(previous_selection);
2345
+ };
2346
+ apply();
2347
+ history.atomic(label, (tx) => {
2348
+ tx.push({
2349
+ providerId: PROVIDER_ID,
2350
+ apply,
2351
+ revert
2352
+ });
2353
+ });
2354
+ return roots;
2355
+ }
2356
+ function copy_impl(deliver_external) {
2357
+ const payload = clipboard.extract_payload(doc, selection);
2358
+ if (payload === null) return null;
2359
+ clipboard_buffer = payload;
2360
+ if (deliver_external && providers.clipboard) providers.clipboard.write(payload).catch((err) => {
2361
+ console.warn("[svg-editor] clipboard provider write failed:", err);
2362
+ });
2363
+ return payload;
2364
+ }
2365
+ function copy() {
2366
+ return copy_impl(true);
2367
+ }
2368
+ function cut_impl(deliver_external) {
2369
+ const payload = copy_impl(deliver_external);
2370
+ if (payload === null) return null;
2371
+ remove_selection("cut");
2372
+ return payload;
2373
+ }
2374
+ function cut() {
2375
+ return cut_impl(true);
2376
+ }
2377
+ function paste(text) {
2378
+ if (text !== void 0 && typeof text !== "string") throw new TypeError(`paste(text) requires a string when provided, got ${text === null ? "null" : typeof text}`);
2379
+ const source = text ?? clipboard_buffer;
2380
+ if (source === null) return [];
2381
+ try {
2382
+ return insert_fragment_impl(source, void 0, "paste");
2383
+ } catch (err) {
2384
+ if (err instanceof TypeError) throw err;
2385
+ return [];
2386
+ }
2387
+ }
2388
+ /**
2389
+ * Duplicate over `subtree.clone_plan` — see the `Commands` doc. Same
2390
+ * atomic shape as `insert_fragment_impl`: closures own the
2391
+ * insert/remove pair so redo re-inserts the same NodeIds.
2392
+ *
2393
+ * Repeating offset (gridaco/grida#825, spec §Repeating offset): when
2394
+ * the targets are exactly the previous duplication's clones and
2395
+ * geometry witnesses a rigid translate between that record's origins
2396
+ * and clones, the fresh clones land displaced by the same delta. The
2397
+ * offset rides the translate pipeline INSIDE the same atomic step —
2398
+ * one undo removes copy + offset together. Clone baselines are
2399
+ * key-swapped from the origins (a clone is a verbatim copy at rest —
2400
+ * the orchestrator's `enter_clone` trick), so nothing reads the
2401
+ * detached clones. Any failed precondition degrades to plain
2402
+ * duplicate-in-place; never an error.
2403
+ */
2404
+ function duplicate() {
2405
+ const plan = subtree.clone_plan(doc, selection);
2406
+ if (plan.length === 0) return [];
2407
+ const clones = plan.map((p) => p.clone);
2408
+ const origins = plan.map((p) => p.origin);
2409
+ const previous_selection = selection;
2410
+ const delta = subtree.repeat_delta(active_duplication, origins, (id) => geometry_provider ? geometry_provider.bounds_of(id) : null);
2411
+ let offset_plan = null;
2412
+ if (delta) {
2413
+ const baselines = /* @__PURE__ */ new Map();
2414
+ for (const p of plan) baselines.set(p.clone, translate_pipeline.intent.capture_baseline(doc, p.origin));
2415
+ offset_plan = {
2416
+ ids: clones,
2417
+ baselines,
2418
+ delta
2419
+ };
2420
+ }
2421
+ const apply = () => {
2422
+ subtree.insert_plan(doc, plan);
2423
+ if (offset_plan) translate_pipeline.apply(doc, offset_plan, project_world_delta);
2424
+ set_selection(clones);
2425
+ };
2426
+ const revert = () => {
2427
+ if (offset_plan) translate_pipeline.revert(doc, offset_plan);
2428
+ subtree.remove_plan(doc, plan);
2429
+ set_selection(previous_selection);
2430
+ };
2431
+ apply();
2432
+ history.atomic("duplicate", (tx) => {
2433
+ tx.push({
2434
+ providerId: PROVIDER_ID,
2435
+ apply,
2436
+ revert
2437
+ });
2438
+ });
2439
+ active_duplication = {
2440
+ origins,
2441
+ clones
2442
+ };
2443
+ return clones;
2444
+ }
2445
+ /**
2593
2446
  * Preview-bracketed insertion. Used by the pointer-driven drag gesture
2594
2447
  * in the DOM surface. Per-frame attr writes call `update(attrs)`; one
2595
2448
  * undo step on `commit()`; clean rollback on `discard()`.
@@ -2600,8 +2453,7 @@ function _create_svg_editor_internal(opts) {
2600
2453
  */
2601
2454
  function insert_preview(tag, initial, opts) {
2602
2455
  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;
2456
+ const insert_before = resolve_insert_before(parent, opts?.index);
2605
2457
  const id = doc.create_element(tag);
2606
2458
  const previous_selection = selection;
2607
2459
  const live_attrs = {
@@ -2759,6 +2611,10 @@ function _create_svg_editor_internal(opts) {
2759
2611
  current_surface_hover = id;
2760
2612
  notify_surface_hover();
2761
2613
  }
2614
+ const pick_listeners = /* @__PURE__ */ new Set();
2615
+ function notify_pick(e) {
2616
+ for (const cb of pick_listeners) cb(e);
2617
+ }
2762
2618
  function enter_content_edit(target) {
2763
2619
  const id = target ?? (selection.length === 1 ? selection[0] : null);
2764
2620
  if (!id) return false;
@@ -2773,7 +2629,8 @@ function _create_svg_editor_internal(opts) {
2773
2629
  mode = "select";
2774
2630
  tool = TOOL_CURSOR;
2775
2631
  history.clear();
2776
- baseline_doc_version = doc_version;
2632
+ active_duplication = null;
2633
+ baseline_revision = doc.revision;
2777
2634
  load_version++;
2778
2635
  emit();
2779
2636
  }
@@ -2804,14 +2661,22 @@ function _create_svg_editor_internal(opts) {
2804
2661
  translate,
2805
2662
  nudge,
2806
2663
  resize_to,
2664
+ resize_by,
2807
2665
  rotate,
2808
2666
  rotate_to,
2667
+ transform: apply_transform,
2809
2668
  flatten_transform,
2810
2669
  align,
2811
2670
  reorder,
2812
2671
  remove,
2672
+ copy,
2673
+ cut,
2674
+ paste,
2675
+ duplicate,
2813
2676
  group: group$1,
2677
+ ungroup,
2814
2678
  insert,
2679
+ insert_fragment,
2815
2680
  insert_preview,
2816
2681
  set_text,
2817
2682
  load_svg,
@@ -2835,7 +2700,8 @@ function _create_svg_editor_internal(opts) {
2835
2700
  scope = null;
2836
2701
  mode = "select";
2837
2702
  tool = TOOL_CURSOR;
2838
- baseline_doc_version = doc_version;
2703
+ active_duplication = null;
2704
+ baseline_revision = doc.revision;
2839
2705
  emit();
2840
2706
  }
2841
2707
  function attach(surface) {
@@ -2857,6 +2723,10 @@ function _create_svg_editor_internal(opts) {
2857
2723
  function dispose() {
2858
2724
  detach();
2859
2725
  listeners.clear();
2726
+ surface_hover_listeners.clear();
2727
+ geometry_listeners.clear();
2728
+ translate_commit_listeners.clear();
2729
+ pick_listeners.clear();
2860
2730
  }
2861
2731
  function set_style(partial) {
2862
2732
  style = {
@@ -2948,6 +2818,22 @@ function _create_svg_editor_internal(opts) {
2948
2818
  };
2949
2819
  },
2950
2820
  /**
2821
+ * Subscribe to pick (tap) outcomes — a discrete click on the canvas,
2822
+ * reporting the document-space point and the node under it (`null` for
2823
+ * empty canvas), plus the button and modifier snapshot. Fires once per
2824
+ * tap, after the editor's own selection handling. Observe-only: a pick
2825
+ * cannot alter selection, and the channel does NOT bump `state.version`.
2826
+ * See {@link PickEvent}.
2827
+ *
2828
+ * @unstable
2829
+ */
2830
+ subscribe_pick(cb) {
2831
+ pick_listeners.add(cb);
2832
+ return () => {
2833
+ pick_listeners.delete(cb);
2834
+ };
2835
+ },
2836
+ /**
2951
2837
  * Subscribe to bounds-affecting changes. Fires when any document
2952
2838
  * mutation advances `state.geometry_version` — drag, resize, text
2953
2839
  * edit, structural insert/remove. Skips presentation-only writes
@@ -2998,7 +2884,14 @@ function _create_svg_editor_internal(opts) {
2998
2884
  providers,
2999
2885
  _internal: {
3000
2886
  doc,
3001
- history: { preview: (label) => history.preview(label) },
2887
+ history: {
2888
+ preview: (label) => history.preview(label),
2889
+ undo_label: () => history.stack.undoLabel
2890
+ },
2891
+ clipboard: {
2892
+ copy: () => copy_impl(false),
2893
+ cut: () => cut_impl(false)
2894
+ },
3002
2895
  insert_text_preview,
3003
2896
  emit,
3004
2897
  subscribe_translate_commit(cb) {
@@ -3008,6 +2901,9 @@ function _create_svg_editor_internal(opts) {
3008
2901
  };
3009
2902
  },
3010
2903
  notify_translate_commit,
2904
+ seed_duplication(record) {
2905
+ active_duplication = record;
2906
+ },
3011
2907
  set_content_edit_driver(fn) {
3012
2908
  content_edit_driver = fn;
3013
2909
  },
@@ -3018,11 +2914,18 @@ function _create_svg_editor_internal(opts) {
3018
2914
  push_surface_hover(id) {
3019
2915
  _set_current_surface_hover(id);
3020
2916
  },
2917
+ push_pick(e) {
2918
+ notify_pick(e);
2919
+ },
3021
2920
  set_computed_resolver(fn) {
3022
2921
  computed_resolver = fn;
3023
2922
  },
3024
2923
  set_geometry(p) {
3025
2924
  geometry_provider = p;
2925
+ },
2926
+ bump_geometry() {
2927
+ doc.bump_geometry();
2928
+ fire_geometry_listeners_if_advanced();
3026
2929
  }
3027
2930
  },
3028
2931
  keymap