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