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

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,4 +1,4 @@
1
- const require_model = require("./model-D0nU_EkL.js");
1
+ const require_model = require("./model-BLhMJZKJ.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");
@@ -84,6 +84,11 @@ function registerDefaultCommands(reg, editor) {
84
84
  if (editor.state.selection.length === 0) return false;
85
85
  return editor.commands.group();
86
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
+ });
87
92
  reg.register("selection.ungroup", () => {
88
93
  if (editor.state.mode !== "select") return false;
89
94
  if (editor.state.selection.length !== 1) return false;
@@ -133,6 +138,31 @@ function registerDefaultCommands(reg, editor) {
133
138
  if (editor.state.mode !== "select") return false;
134
139
  return editor.commands.align(args);
135
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
+ });
136
166
  reg.register("content.enter", () => editor.enter_content_edit());
137
167
  reg.register("hierarchy.enter", () => {
138
168
  if (editor.state.selection.length !== 1) return false;
@@ -358,6 +388,10 @@ const DEFAULT_BINDINGS = [
358
388
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd | _grida_keybinding.M.Shift),
359
389
  command: "selection.ungroup"
360
390
  },
391
+ {
392
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyD, _grida_keybinding.M.CtrlCmd),
393
+ command: "selection.duplicate"
394
+ },
361
395
  {
362
396
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyA, _grida_keybinding.M.CtrlCmd),
363
397
  command: "selection.all"
@@ -838,6 +872,176 @@ function create_defs(doc) {
838
872
  return { gradients: new GradientsRegistry(doc) };
839
873
  }
840
874
  //#endregion
875
+ //#region src/core/clipboard.ts
876
+ /**
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).
885
+ *
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`).
905
+ */
906
+ let clipboard;
907
+ (function(_clipboard) {
908
+ /**
909
+ * Presentation carriers that may hold `url(#…)` references, read both as
910
+ * a presentation attribute and as an inline `style=""` declaration.
911
+ *
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.
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);
932
+ /**
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.
937
+ */
938
+ const HREF_TAGS = new Set([
939
+ "use",
940
+ "textPath",
941
+ "mpath",
942
+ "feImage",
943
+ "pattern",
944
+ "linearGradient",
945
+ "radialGradient",
946
+ "filter"
947
+ ]);
948
+ /**
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.
953
+ */
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);
965
+ }
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);
979
+ }
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);
992
+ }
993
+ }
994
+ return doc.prune_nested_nodes([...collected]).sort(require_model.subtree.by_document_order(doc));
995
+ }
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);
1006
+ return out;
1007
+ }
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]);
1016
+ }
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));
1024
+ }
1025
+ return out;
1026
+ }
1027
+ /**
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).
1033
+ */
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);
1040
+ }
1041
+ return require_model.WELL_KNOWN_NS_PREFIXES.get(prefix) ?? null;
1042
+ }
1043
+ })(clipboard || (clipboard = {}));
1044
+ //#endregion
841
1045
  //#region src/core/align.ts
842
1046
  /**
843
1047
  * Compute per-member translation deltas to align `members` against `target`.
@@ -1053,10 +1257,11 @@ function _create_svg_editor_internal(opts) {
1053
1257
  let mode = "select";
1054
1258
  let tool = require_model.TOOL_CURSOR;
1055
1259
  let version = 0;
1056
- /** Document-edit counter only bumps on actual mutations, not selection. */
1057
- let doc_version = 0;
1058
- /** doc_version at the last load()/serialize(); compared to derive `dirty`. */
1059
- 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;
1060
1265
  /**
1061
1266
  * Bumps once per `editor.load(svg)` call. The constructor's initial parse
1062
1267
  * does NOT count — it's the "factory" state. Hosts subscribe via
@@ -1069,6 +1274,23 @@ function _create_svg_editor_internal(opts) {
1069
1274
  ...opts.style
1070
1275
  };
1071
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;
1072
1294
  const listeners = /* @__PURE__ */ new Set();
1073
1295
  let attached_surface = null;
1074
1296
  /**
@@ -1084,11 +1306,11 @@ function _create_svg_editor_internal(opts) {
1084
1306
  scope,
1085
1307
  mode,
1086
1308
  tool,
1087
- dirty: doc_version !== baseline_doc_version,
1309
+ dirty: doc.revision !== baseline_revision,
1088
1310
  can_undo: history.stack.canUndo,
1089
1311
  can_redo: history.stack.canRedo,
1090
1312
  version,
1091
- content_version: doc_version,
1313
+ content_version: doc.revision,
1092
1314
  structure_version: doc.structure_version,
1093
1315
  geometry_version: doc.geometry_version,
1094
1316
  load_version
@@ -1122,7 +1344,6 @@ function _create_svg_editor_internal(opts) {
1122
1344
  }
1123
1345
  }
1124
1346
  doc.on_change(() => {
1125
- doc_version++;
1126
1347
  fire_geometry_listeners_if_advanced();
1127
1348
  });
1128
1349
  function subscribe(fn) {
@@ -1247,14 +1468,14 @@ function _create_svg_editor_internal(opts) {
1247
1468
  function node_property_cached(id, name) {
1248
1469
  const key = `${id}${name}`;
1249
1470
  const cached = property_cache.get(key);
1250
- if (cached && cached.doc_version === doc_version) return cached.value;
1471
+ if (cached && cached.revision === doc.revision) return cached.value;
1251
1472
  const next = properties.read(doc, id, name);
1252
1473
  if (cached && properties.value_equals(cached.value, next)) {
1253
- cached.doc_version = doc_version;
1474
+ cached.revision = doc.revision;
1254
1475
  return cached.value;
1255
1476
  }
1256
1477
  property_cache.set(key, {
1257
- doc_version,
1478
+ revision: doc.revision,
1258
1479
  value: next
1259
1480
  });
1260
1481
  return next;
@@ -1262,7 +1483,7 @@ function _create_svg_editor_internal(opts) {
1262
1483
  function node_properties(id, names) {
1263
1484
  const key = `${id}${names.join("")}`;
1264
1485
  const cached = properties_cache.get(key);
1265
- if (cached && cached.doc_version === doc_version) return cached.value;
1486
+ if (cached && cached.revision === doc.revision) return cached.value;
1266
1487
  const next = {};
1267
1488
  let changed = !cached;
1268
1489
  for (const name of names) {
@@ -1271,12 +1492,12 @@ function _create_svg_editor_internal(opts) {
1271
1492
  if (cached && cached.value[name] !== v) changed = true;
1272
1493
  }
1273
1494
  if (cached && !changed) {
1274
- cached.doc_version = doc_version;
1495
+ cached.revision = doc.revision;
1275
1496
  return cached.value;
1276
1497
  }
1277
1498
  const frozen = Object.freeze(next);
1278
1499
  properties_cache.set(key, {
1279
- doc_version,
1500
+ revision: doc.revision,
1280
1501
  value: frozen
1281
1502
  });
1282
1503
  return frozen;
@@ -1284,7 +1505,7 @@ function _create_svg_editor_internal(opts) {
1284
1505
  function node_paint(id, channel) {
1285
1506
  const key = `${id}${channel}`;
1286
1507
  const cached = paint_cache.get(key);
1287
- if (cached && cached.doc_version === doc_version) return cached.value;
1508
+ if (cached && cached.revision === doc.revision) return cached.value;
1288
1509
  const { declared, provenance } = properties.resolve_declared(doc, id, channel);
1289
1510
  const next = {
1290
1511
  declared,
@@ -1292,11 +1513,11 @@ function _create_svg_editor_internal(opts) {
1292
1513
  provenance
1293
1514
  };
1294
1515
  if (cached && require_model.paint.value_equals(cached.value, next)) {
1295
- cached.doc_version = doc_version;
1516
+ cached.revision = doc.revision;
1296
1517
  return cached.value;
1297
1518
  }
1298
1519
  paint_cache.set(key, {
1299
- doc_version,
1520
+ revision: doc.revision,
1300
1521
  value: next
1301
1522
  });
1302
1523
  return next;
@@ -1385,6 +1606,13 @@ function _create_svg_editor_internal(opts) {
1385
1606
  });
1386
1607
  return { gradient_id };
1387
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;
1388
1616
  /** Shared one-shot translate runner. `stages` selects semantics — see
1389
1617
  * `core/translate-pipeline/README.md`'s "Stage lists per entry point". */
1390
1618
  function do_translate_oneshot(delta, stages, label) {
@@ -1404,7 +1632,7 @@ function _create_svg_editor_internal(opts) {
1404
1632
  },
1405
1633
  emit,
1406
1634
  stages,
1407
- project: (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d
1635
+ project: project_world_delta
1408
1636
  });
1409
1637
  apply();
1410
1638
  history.atomic(label, (tx) => {
@@ -1453,7 +1681,6 @@ function _create_svg_editor_internal(opts) {
1453
1681
  members.push({
1454
1682
  id,
1455
1683
  rz: require_model.resize_pipeline.intent.capture_baseline(doc, id, bbox),
1456
- transform_pre: doc.get_attr(id, "transform"),
1457
1684
  bbox
1458
1685
  });
1459
1686
  }
@@ -1488,10 +1715,7 @@ function _create_svg_editor_internal(opts) {
1488
1715
  emit();
1489
1716
  };
1490
1717
  const revert = () => {
1491
- for (const { m, origin } of ops) {
1492
- require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
1493
- doc.set_attr(m.id, "transform", m.transform_pre);
1494
- }
1718
+ for (const { m } of ops) require_model.resize_pipeline.intent.restore(doc, m.id, m.rz);
1495
1719
  emit();
1496
1720
  };
1497
1721
  apply();
@@ -1768,7 +1992,10 @@ function _create_svg_editor_internal(opts) {
1768
1992
  * center of a reference rect. Same mechanics as `resize_to`: per-member
1769
1993
  * translate baselines (so `<g>`, transformed, and natively-attributed
1770
1994
  * nodes all write the cleanest in-place representation), one atomic
1771
- * history step.
1995
+ * history step. Deltas are computed in world space and re-expressed in
1996
+ * each member's local frame before writing (`world_delta_to_local`),
1997
+ * so members under scaled/rotated ancestors land exactly and a repeat
1998
+ * invocation is a no-op.
1772
1999
  *
1773
2000
  * Reference rect is selection-size dependent:
1774
2001
  * - multi-selection: union of member bboxes
@@ -1804,8 +2031,10 @@ function _create_svg_editor_internal(opts) {
1804
2031
  if (!parent_bbox) return false;
1805
2032
  target = parent_bbox;
1806
2033
  } else target = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
1807
- const deltas = compute_align_deltas(members, target, direction);
1808
- if (deltas.size === 0) return false;
2034
+ const world_deltas = compute_align_deltas(members, target, direction);
2035
+ if (world_deltas.size === 0) return false;
2036
+ const deltas = /* @__PURE__ */ new Map();
2037
+ for (const [id, d] of world_deltas) deltas.set(id, project_world_delta(id, d));
1809
2038
  const apply = () => {
1810
2039
  for (const m of members) {
1811
2040
  const d = deltas.get(m.id);
@@ -1907,13 +2136,19 @@ function _create_svg_editor_internal(opts) {
1907
2136
  });
1908
2137
  }
1909
2138
  function remove() {
2139
+ remove_selection("remove");
2140
+ }
2141
+ /**
2142
+ * Shared deletion body for `remove` and `cut` — identical
2143
+ * capture/revert semantics, differing only in the history label
2144
+ * (`verb`), so undo attribution names the gesture that caused the
2145
+ * deletion.
2146
+ */
2147
+ function remove_selection(verb) {
1910
2148
  if (selection.length === 0) return;
1911
2149
  const filtered = doc.prune_nested_nodes(selection).filter((id) => doc.parent_of(id) !== null);
1912
2150
  if (filtered.length === 0) return;
1913
- const doc_order = doc.all_elements();
1914
- const index_of = /* @__PURE__ */ new Map();
1915
- for (let i = 0; i < doc_order.length; i++) index_of.set(doc_order[i], i);
1916
- const captures = [...filtered].sort((a, b) => (index_of.get(a) ?? 0) - (index_of.get(b) ?? 0)).map((id) => ({
2151
+ const captures = [...filtered].sort(require_model.subtree.by_document_order(doc)).map((id) => ({
1917
2152
  id,
1918
2153
  parent: doc.parent_of(id),
1919
2154
  next_sibling: doc.next_element_sibling_of(id)
@@ -1931,7 +2166,7 @@ function _create_svg_editor_internal(opts) {
1931
2166
  set_selection(old_selection);
1932
2167
  };
1933
2168
  apply();
1934
- history.atomic(captures.length === 1 ? "remove" : `remove ${captures.length}`, (tx) => {
2169
+ history.atomic(captures.length === 1 ? verb : `${verb} ${captures.length}`, (tx) => {
1935
2170
  tx.push({
1936
2171
  providerId: PROVIDER_ID,
1937
2172
  apply,
@@ -2065,12 +2300,20 @@ function _create_svg_editor_internal(opts) {
2065
2300
  * hoisted declarations + selection in ONE history step.
2066
2301
  */
2067
2302
  function insert_fragment(svg, opts) {
2303
+ return insert_fragment_impl(svg, opts, "insert fragment");
2304
+ }
2305
+ /**
2306
+ * Label-bearing body shared by `insert_fragment` and `paste` — same
2307
+ * atomic insertion, differing only in history attribution (undo for a
2308
+ * paste gesture should read "paste", not "insert fragment").
2309
+ */
2310
+ function insert_fragment_impl(svg, opts, label) {
2068
2311
  const parent = opts?.parent ?? doc.root;
2069
2312
  if (!doc.is_element(parent) || !doc.contains(doc.root, parent)) throw new Error(`insert_fragment: parent ${JSON.stringify(parent)} is not an element in the current document`);
2070
2313
  const select_after = opts?.select !== false;
2071
2314
  const { roots, xmlns } = doc.create_fragment(svg);
2072
2315
  if (roots.length === 0) return [];
2073
- const known_uri = new Map([["xlink", _grida_svg_parser.XLINK_NS]]);
2316
+ const known_uri = new Map(require_model.WELL_KNOWN_NS_PREFIXES);
2074
2317
  for (const d of xmlns) known_uri.set(d.prefix, d.uri);
2075
2318
  const hoist = [];
2076
2319
  const considered = /* @__PURE__ */ new Set();
@@ -2098,7 +2341,7 @@ function _create_svg_editor_internal(opts) {
2098
2341
  if (select_after) set_selection(previous_selection);
2099
2342
  };
2100
2343
  apply();
2101
- history.atomic("insert fragment", (tx) => {
2344
+ history.atomic(label, (tx) => {
2102
2345
  tx.push({
2103
2346
  providerId: PROVIDER_ID,
2104
2347
  apply,
@@ -2107,6 +2350,95 @@ function _create_svg_editor_internal(opts) {
2107
2350
  });
2108
2351
  return roots;
2109
2352
  }
2353
+ function copy_impl(deliver_external) {
2354
+ const payload = clipboard.extract_payload(doc, selection);
2355
+ if (payload === null) return null;
2356
+ clipboard_buffer = payload;
2357
+ if (deliver_external && providers.clipboard) providers.clipboard.write(payload).catch((err) => {
2358
+ console.warn("[svg-editor] clipboard provider write failed:", err);
2359
+ });
2360
+ return payload;
2361
+ }
2362
+ function copy() {
2363
+ return copy_impl(true);
2364
+ }
2365
+ function cut_impl(deliver_external) {
2366
+ const payload = copy_impl(deliver_external);
2367
+ if (payload === null) return null;
2368
+ remove_selection("cut");
2369
+ return payload;
2370
+ }
2371
+ function cut() {
2372
+ return cut_impl(true);
2373
+ }
2374
+ function paste(text) {
2375
+ if (text !== void 0 && typeof text !== "string") throw new TypeError(`paste(text) requires a string when provided, got ${text === null ? "null" : typeof text}`);
2376
+ const source = text ?? clipboard_buffer;
2377
+ if (source === null) return [];
2378
+ try {
2379
+ return insert_fragment_impl(source, void 0, "paste");
2380
+ } catch (err) {
2381
+ if (err instanceof TypeError) throw err;
2382
+ return [];
2383
+ }
2384
+ }
2385
+ /**
2386
+ * Duplicate over `subtree.clone_plan` — see the `Commands` doc. Same
2387
+ * atomic shape as `insert_fragment_impl`: closures own the
2388
+ * insert/remove pair so redo re-inserts the same NodeIds.
2389
+ *
2390
+ * Repeating offset (gridaco/grida#825, spec §Repeating offset): when
2391
+ * the targets are exactly the previous duplication's clones and
2392
+ * geometry witnesses a rigid translate between that record's origins
2393
+ * and clones, the fresh clones land displaced by the same delta. The
2394
+ * offset rides the translate pipeline INSIDE the same atomic step —
2395
+ * one undo removes copy + offset together. Clone baselines are
2396
+ * key-swapped from the origins (a clone is a verbatim copy at rest —
2397
+ * the orchestrator's `enter_clone` trick), so nothing reads the
2398
+ * detached clones. Any failed precondition degrades to plain
2399
+ * duplicate-in-place; never an error.
2400
+ */
2401
+ function duplicate() {
2402
+ const plan = require_model.subtree.clone_plan(doc, selection);
2403
+ if (plan.length === 0) return [];
2404
+ const clones = plan.map((p) => p.clone);
2405
+ const origins = plan.map((p) => p.origin);
2406
+ const previous_selection = selection;
2407
+ const delta = require_model.subtree.repeat_delta(active_duplication, origins, (id) => geometry_provider ? geometry_provider.bounds_of(id) : null);
2408
+ let offset_plan = null;
2409
+ if (delta) {
2410
+ const baselines = /* @__PURE__ */ new Map();
2411
+ for (const p of plan) baselines.set(p.clone, require_model.translate_pipeline.intent.capture_baseline(doc, p.origin));
2412
+ offset_plan = {
2413
+ ids: clones,
2414
+ baselines,
2415
+ delta
2416
+ };
2417
+ }
2418
+ const apply = () => {
2419
+ require_model.subtree.insert_plan(doc, plan);
2420
+ if (offset_plan) require_model.translate_pipeline.apply(doc, offset_plan, project_world_delta);
2421
+ set_selection(clones);
2422
+ };
2423
+ const revert = () => {
2424
+ if (offset_plan) require_model.translate_pipeline.revert(doc, offset_plan);
2425
+ require_model.subtree.remove_plan(doc, plan);
2426
+ set_selection(previous_selection);
2427
+ };
2428
+ apply();
2429
+ history.atomic("duplicate", (tx) => {
2430
+ tx.push({
2431
+ providerId: PROVIDER_ID,
2432
+ apply,
2433
+ revert
2434
+ });
2435
+ });
2436
+ active_duplication = {
2437
+ origins,
2438
+ clones
2439
+ };
2440
+ return clones;
2441
+ }
2110
2442
  /**
2111
2443
  * Preview-bracketed insertion. Used by the pointer-driven drag gesture
2112
2444
  * in the DOM surface. Per-frame attr writes call `update(attrs)`; one
@@ -2294,7 +2626,8 @@ function _create_svg_editor_internal(opts) {
2294
2626
  mode = "select";
2295
2627
  tool = require_model.TOOL_CURSOR;
2296
2628
  history.clear();
2297
- baseline_doc_version = doc_version;
2629
+ active_duplication = null;
2630
+ baseline_revision = doc.revision;
2298
2631
  load_version++;
2299
2632
  emit();
2300
2633
  }
@@ -2333,6 +2666,10 @@ function _create_svg_editor_internal(opts) {
2333
2666
  align,
2334
2667
  reorder,
2335
2668
  remove,
2669
+ copy,
2670
+ cut,
2671
+ paste,
2672
+ duplicate,
2336
2673
  group: group$1,
2337
2674
  ungroup,
2338
2675
  insert,
@@ -2360,7 +2697,8 @@ function _create_svg_editor_internal(opts) {
2360
2697
  scope = null;
2361
2698
  mode = "select";
2362
2699
  tool = require_model.TOOL_CURSOR;
2363
- baseline_doc_version = doc_version;
2700
+ active_duplication = null;
2701
+ baseline_revision = doc.revision;
2364
2702
  emit();
2365
2703
  }
2366
2704
  function attach(surface) {
@@ -2543,7 +2881,14 @@ function _create_svg_editor_internal(opts) {
2543
2881
  providers,
2544
2882
  _internal: {
2545
2883
  doc,
2546
- history: { preview: (label) => history.preview(label) },
2884
+ history: {
2885
+ preview: (label) => history.preview(label),
2886
+ undo_label: () => history.stack.undoLabel
2887
+ },
2888
+ clipboard: {
2889
+ copy: () => copy_impl(false),
2890
+ cut: () => cut_impl(false)
2891
+ },
2547
2892
  insert_text_preview,
2548
2893
  emit,
2549
2894
  subscribe_translate_commit(cb) {
@@ -2553,6 +2898,9 @@ function _create_svg_editor_internal(opts) {
2553
2898
  };
2554
2899
  },
2555
2900
  notify_translate_commit,
2901
+ seed_duplication(record) {
2902
+ active_duplication = record;
2903
+ },
2556
2904
  set_content_edit_driver(fn) {
2557
2905
  content_edit_driver = fn;
2558
2906
  },