@grida/svg-editor 1.0.0-alpha.16 → 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,4 +1,4 @@
1
- const require_model = require("./model-D0nU_EkL.js");
1
+ const require_model = require("./model-GpysNbOv.js");
2
2
  let _grida_history = require("@grida/history");
3
3
  let _grida_keybinding = require("@grida/keybinding");
4
4
  let _grida_cmath = require("@grida/cmath");
@@ -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) => {
@@ -1768,7 +1996,10 @@ function _create_svg_editor_internal(opts) {
1768
1996
  * center of a reference rect. Same mechanics as `resize_to`: per-member
1769
1997
  * translate baselines (so `<g>`, transformed, and natively-attributed
1770
1998
  * nodes all write the cleanest in-place representation), one atomic
1771
- * history step.
1999
+ * history step. Deltas are computed in world space and re-expressed in
2000
+ * each member's local frame before writing (`world_delta_to_local`),
2001
+ * so members under scaled/rotated ancestors land exactly and a repeat
2002
+ * invocation is a no-op.
1772
2003
  *
1773
2004
  * Reference rect is selection-size dependent:
1774
2005
  * - multi-selection: union of member bboxes
@@ -1804,8 +2035,10 @@ function _create_svg_editor_internal(opts) {
1804
2035
  if (!parent_bbox) return false;
1805
2036
  target = parent_bbox;
1806
2037
  } 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;
2038
+ const world_deltas = compute_align_deltas(members, target, direction);
2039
+ if (world_deltas.size === 0) return false;
2040
+ const deltas = /* @__PURE__ */ new Map();
2041
+ for (const [id, d] of world_deltas) deltas.set(id, project_world_delta(id, d));
1809
2042
  const apply = () => {
1810
2043
  for (const m of members) {
1811
2044
  const d = deltas.get(m.id);
@@ -1907,13 +2140,19 @@ function _create_svg_editor_internal(opts) {
1907
2140
  });
1908
2141
  }
1909
2142
  function remove() {
2143
+ remove_selection("remove");
2144
+ }
2145
+ /**
2146
+ * Shared deletion body for `remove` and `cut` — identical
2147
+ * capture/revert semantics, differing only in the history label
2148
+ * (`verb`), so undo attribution names the gesture that caused the
2149
+ * deletion.
2150
+ */
2151
+ function remove_selection(verb) {
1910
2152
  if (selection.length === 0) return;
1911
2153
  const filtered = doc.prune_nested_nodes(selection).filter((id) => doc.parent_of(id) !== null);
1912
2154
  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) => ({
2155
+ const captures = [...filtered].sort(require_model.subtree.by_document_order(doc)).map((id) => ({
1917
2156
  id,
1918
2157
  parent: doc.parent_of(id),
1919
2158
  next_sibling: doc.next_element_sibling_of(id)
@@ -1931,7 +2170,7 @@ function _create_svg_editor_internal(opts) {
1931
2170
  set_selection(old_selection);
1932
2171
  };
1933
2172
  apply();
1934
- history.atomic(captures.length === 1 ? "remove" : `remove ${captures.length}`, (tx) => {
2173
+ history.atomic(captures.length === 1 ? verb : `${verb} ${captures.length}`, (tx) => {
1935
2174
  tx.push({
1936
2175
  providerId: PROVIDER_ID,
1937
2176
  apply,
@@ -2065,12 +2304,20 @@ function _create_svg_editor_internal(opts) {
2065
2304
  * hoisted declarations + selection in ONE history step.
2066
2305
  */
2067
2306
  function insert_fragment(svg, opts) {
2307
+ return insert_fragment_impl(svg, opts, "insert fragment");
2308
+ }
2309
+ /**
2310
+ * Label-bearing body shared by `insert_fragment` and `paste` — same
2311
+ * atomic insertion, differing only in history attribution (undo for a
2312
+ * paste gesture should read "paste", not "insert fragment").
2313
+ */
2314
+ function insert_fragment_impl(svg, opts, label) {
2068
2315
  const parent = opts?.parent ?? doc.root;
2069
2316
  if (!doc.is_element(parent) || !doc.contains(doc.root, parent)) throw new Error(`insert_fragment: parent ${JSON.stringify(parent)} is not an element in the current document`);
2070
2317
  const select_after = opts?.select !== false;
2071
2318
  const { roots, xmlns } = doc.create_fragment(svg);
2072
2319
  if (roots.length === 0) return [];
2073
- const known_uri = new Map([["xlink", _grida_svg_parser.XLINK_NS]]);
2320
+ const known_uri = new Map(require_model.WELL_KNOWN_NS_PREFIXES);
2074
2321
  for (const d of xmlns) known_uri.set(d.prefix, d.uri);
2075
2322
  const hoist = [];
2076
2323
  const considered = /* @__PURE__ */ new Set();
@@ -2098,7 +2345,7 @@ function _create_svg_editor_internal(opts) {
2098
2345
  if (select_after) set_selection(previous_selection);
2099
2346
  };
2100
2347
  apply();
2101
- history.atomic("insert fragment", (tx) => {
2348
+ history.atomic(label, (tx) => {
2102
2349
  tx.push({
2103
2350
  providerId: PROVIDER_ID,
2104
2351
  apply,
@@ -2107,6 +2354,95 @@ function _create_svg_editor_internal(opts) {
2107
2354
  });
2108
2355
  return roots;
2109
2356
  }
2357
+ function copy_impl(deliver_external) {
2358
+ const payload = clipboard.extract_payload(doc, selection);
2359
+ if (payload === null) return null;
2360
+ clipboard_buffer = payload;
2361
+ if (deliver_external && providers.clipboard) providers.clipboard.write(payload).catch((err) => {
2362
+ console.warn("[svg-editor] clipboard provider write failed:", err);
2363
+ });
2364
+ return payload;
2365
+ }
2366
+ function copy() {
2367
+ return copy_impl(true);
2368
+ }
2369
+ function cut_impl(deliver_external) {
2370
+ const payload = copy_impl(deliver_external);
2371
+ if (payload === null) return null;
2372
+ remove_selection("cut");
2373
+ return payload;
2374
+ }
2375
+ function cut() {
2376
+ return cut_impl(true);
2377
+ }
2378
+ function paste(text) {
2379
+ if (text !== void 0 && typeof text !== "string") throw new TypeError(`paste(text) requires a string when provided, got ${text === null ? "null" : typeof text}`);
2380
+ const source = text ?? clipboard_buffer;
2381
+ if (source === null) return [];
2382
+ try {
2383
+ return insert_fragment_impl(source, void 0, "paste");
2384
+ } catch (err) {
2385
+ if (err instanceof TypeError) throw err;
2386
+ return [];
2387
+ }
2388
+ }
2389
+ /**
2390
+ * Duplicate over `subtree.clone_plan` — see the `Commands` doc. Same
2391
+ * atomic shape as `insert_fragment_impl`: closures own the
2392
+ * insert/remove pair so redo re-inserts the same NodeIds.
2393
+ *
2394
+ * Repeating offset (gridaco/grida#825, spec §Repeating offset): when
2395
+ * the targets are exactly the previous duplication's clones and
2396
+ * geometry witnesses a rigid translate between that record's origins
2397
+ * and clones, the fresh clones land displaced by the same delta. The
2398
+ * offset rides the translate pipeline INSIDE the same atomic step —
2399
+ * one undo removes copy + offset together. Clone baselines are
2400
+ * key-swapped from the origins (a clone is a verbatim copy at rest —
2401
+ * the orchestrator's `enter_clone` trick), so nothing reads the
2402
+ * detached clones. Any failed precondition degrades to plain
2403
+ * duplicate-in-place; never an error.
2404
+ */
2405
+ function duplicate() {
2406
+ const plan = require_model.subtree.clone_plan(doc, selection);
2407
+ if (plan.length === 0) return [];
2408
+ const clones = plan.map((p) => p.clone);
2409
+ const origins = plan.map((p) => p.origin);
2410
+ const previous_selection = selection;
2411
+ const delta = require_model.subtree.repeat_delta(active_duplication, origins, (id) => geometry_provider ? geometry_provider.bounds_of(id) : null);
2412
+ let offset_plan = null;
2413
+ if (delta) {
2414
+ const baselines = /* @__PURE__ */ new Map();
2415
+ for (const p of plan) baselines.set(p.clone, require_model.translate_pipeline.intent.capture_baseline(doc, p.origin));
2416
+ offset_plan = {
2417
+ ids: clones,
2418
+ baselines,
2419
+ delta
2420
+ };
2421
+ }
2422
+ const apply = () => {
2423
+ require_model.subtree.insert_plan(doc, plan);
2424
+ if (offset_plan) require_model.translate_pipeline.apply(doc, offset_plan, project_world_delta);
2425
+ set_selection(clones);
2426
+ };
2427
+ const revert = () => {
2428
+ if (offset_plan) require_model.translate_pipeline.revert(doc, offset_plan);
2429
+ require_model.subtree.remove_plan(doc, plan);
2430
+ set_selection(previous_selection);
2431
+ };
2432
+ apply();
2433
+ history.atomic("duplicate", (tx) => {
2434
+ tx.push({
2435
+ providerId: PROVIDER_ID,
2436
+ apply,
2437
+ revert
2438
+ });
2439
+ });
2440
+ active_duplication = {
2441
+ origins,
2442
+ clones
2443
+ };
2444
+ return clones;
2445
+ }
2110
2446
  /**
2111
2447
  * Preview-bracketed insertion. Used by the pointer-driven drag gesture
2112
2448
  * in the DOM surface. Per-frame attr writes call `update(attrs)`; one
@@ -2294,7 +2630,8 @@ function _create_svg_editor_internal(opts) {
2294
2630
  mode = "select";
2295
2631
  tool = require_model.TOOL_CURSOR;
2296
2632
  history.clear();
2297
- baseline_doc_version = doc_version;
2633
+ active_duplication = null;
2634
+ baseline_revision = doc.revision;
2298
2635
  load_version++;
2299
2636
  emit();
2300
2637
  }
@@ -2333,6 +2670,10 @@ function _create_svg_editor_internal(opts) {
2333
2670
  align,
2334
2671
  reorder,
2335
2672
  remove,
2673
+ copy,
2674
+ cut,
2675
+ paste,
2676
+ duplicate,
2336
2677
  group: group$1,
2337
2678
  ungroup,
2338
2679
  insert,
@@ -2360,7 +2701,8 @@ function _create_svg_editor_internal(opts) {
2360
2701
  scope = null;
2361
2702
  mode = "select";
2362
2703
  tool = require_model.TOOL_CURSOR;
2363
- baseline_doc_version = doc_version;
2704
+ active_duplication = null;
2705
+ baseline_revision = doc.revision;
2364
2706
  emit();
2365
2707
  }
2366
2708
  function attach(surface) {
@@ -2543,7 +2885,14 @@ function _create_svg_editor_internal(opts) {
2543
2885
  providers,
2544
2886
  _internal: {
2545
2887
  doc,
2546
- history: { preview: (label) => history.preview(label) },
2888
+ history: {
2889
+ preview: (label) => history.preview(label),
2890
+ undo_label: () => history.stack.undoLabel
2891
+ },
2892
+ clipboard: {
2893
+ copy: () => copy_impl(false),
2894
+ cut: () => cut_impl(false)
2895
+ },
2547
2896
  insert_text_preview,
2548
2897
  emit,
2549
2898
  subscribe_translate_commit(cb) {
@@ -2553,6 +2902,9 @@ function _create_svg_editor_internal(opts) {
2553
2902
  };
2554
2903
  },
2555
2904
  notify_translate_commit,
2905
+ seed_duplication(record) {
2906
+ active_duplication = record;
2907
+ },
2556
2908
  set_content_edit_driver(fn) {
2557
2909
  content_edit_driver = fn;
2558
2910
  },
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { $ as RadialGradientDefinition, A as EditorState, B as LinearGradientDefinition, D as ClipboardProvider, F as GradientEntry, G as PaintFallback, H as Mode, I as GradientStop, K as PaintPreviewSession, L as InsertPreviewSession, M as FileIOProvider, N as FontResolver, O as Color, P as GradientDefinition, Q as Providers, R as InsertableTag, S as AlignDirection, U as NodeId, V as Matrix2D, W as Paint, X as PropertyValue, Y as PreviewSession, Z as Provenance, _ as PathModel, a as SelectMode, at as Vec2, b as Verb, c as SvgEditor, et as Rect, it as Unsubscribe, j as EditorStyle, k as DEFAULT_STYLE, l as createSvgEditor, n as CreateSvgEditorOptions, nt as TOOL_CURSOR, o as Surface, q as PaintValue, rt as Tool, s as SurfaceHandle, t as Commands, tt as ReorderDirection, v as PathSnapshot, x as VertexId, y as SegmentId, z as InvalidComputedValue } from "./editor-D2eQe8lB.mjs";
1
+ import { $ as RadialGradientDefinition, A as EditorState, B as LinearGradientDefinition, D as ClipboardProvider, F as GradientEntry, G as PaintFallback, H as Mode, I as GradientStop, K as PaintPreviewSession, L as InsertPreviewSession, M as FileIOProvider, N as FontResolver, O as Color, P as GradientDefinition, Q as Providers, R as InsertableTag, S as AlignDirection, U as NodeId, V as Matrix2D, W as Paint, X as PropertyValue, Y as PreviewSession, Z as Provenance, _ as PathModel, a as SelectMode, at as Vec2, b as Verb, c as SvgEditor, et as Rect, it as Unsubscribe, j as EditorStyle, k as DEFAULT_STYLE, l as createSvgEditor, n as CreateSvgEditorOptions, nt as TOOL_CURSOR, o as Surface, q as PaintValue, rt as Tool, s as SurfaceHandle, t as Commands, tt as ReorderDirection, v as PathSnapshot, x as VertexId, y as SegmentId, z as InvalidComputedValue } from "./editor-KqpIW1qm.mjs";
2
2
  export { type AlignDirection, type ClipboardProvider, type Color, type Commands, type CreateSvgEditorOptions, DEFAULT_STYLE, type EditorState, type EditorStyle, type FileIOProvider, type FontResolver, type GradientDefinition, type GradientEntry, type GradientStop, type InsertPreviewSession, type InsertableTag, type InvalidComputedValue, type LinearGradientDefinition, type Matrix2D, type Mode, type NodeId, type Paint, type PaintFallback, type PaintPreviewSession, type PaintValue, PathModel, type PathSnapshot, type PreviewSession, type PropertyValue, type Provenance, type Providers, type RadialGradientDefinition, type Rect, type ReorderDirection, type SegmentId, type SelectMode, type Surface, type SurfaceHandle, type SvgEditor, TOOL_CURSOR, type Tool, type Unsubscribe, type Vec2, type Verb, type VertexId, createSvgEditor };
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { $ as RadialGradientDefinition, A as EditorState, B as LinearGradientDefinition, D as ClipboardProvider, F as GradientEntry, G as PaintFallback, H as Mode, I as GradientStop, K as PaintPreviewSession, L as InsertPreviewSession, M as FileIOProvider, N as FontResolver, O as Color, P as GradientDefinition, Q as Providers, R as InsertableTag, S as AlignDirection, U as NodeId, V as Matrix2D, W as Paint, X as PropertyValue, Y as PreviewSession, Z as Provenance, _ as PathModel, a as SelectMode, at as Vec2, b as Verb, c as SvgEditor, et as Rect, it as Unsubscribe, j as EditorStyle, k as DEFAULT_STYLE, l as createSvgEditor, n as CreateSvgEditorOptions, nt as TOOL_CURSOR, o as Surface, q as PaintValue, rt as Tool, s as SurfaceHandle, t as Commands, tt as ReorderDirection, v as PathSnapshot, x as VertexId, y as SegmentId, z as InvalidComputedValue } from "./editor-CYoGJ3Hf.js";
1
+ import { $ as RadialGradientDefinition, A as EditorState, B as LinearGradientDefinition, D as ClipboardProvider, F as GradientEntry, G as PaintFallback, H as Mode, I as GradientStop, K as PaintPreviewSession, L as InsertPreviewSession, M as FileIOProvider, N as FontResolver, O as Color, P as GradientDefinition, Q as Providers, R as InsertableTag, S as AlignDirection, U as NodeId, V as Matrix2D, W as Paint, X as PropertyValue, Y as PreviewSession, Z as Provenance, _ as PathModel, a as SelectMode, at as Vec2, b as Verb, c as SvgEditor, et as Rect, it as Unsubscribe, j as EditorStyle, k as DEFAULT_STYLE, l as createSvgEditor, n as CreateSvgEditorOptions, nt as TOOL_CURSOR, o as Surface, q as PaintValue, rt as Tool, s as SurfaceHandle, t as Commands, tt as ReorderDirection, v as PathSnapshot, x as VertexId, y as SegmentId, z as InvalidComputedValue } from "./editor-BSxTUsW_.js";
2
2
  export { type AlignDirection, type ClipboardProvider, type Color, type Commands, type CreateSvgEditorOptions, DEFAULT_STYLE, type EditorState, type EditorStyle, type FileIOProvider, type FontResolver, type GradientDefinition, type GradientEntry, type GradientStop, type InsertPreviewSession, type InsertableTag, type InvalidComputedValue, type LinearGradientDefinition, type Matrix2D, type Mode, type NodeId, type Paint, type PaintFallback, type PaintPreviewSession, type PaintValue, PathModel, type PathSnapshot, type PreviewSession, type PropertyValue, type Provenance, type Providers, type RadialGradientDefinition, type Rect, type ReorderDirection, type SegmentId, type SelectMode, type Surface, type SurfaceHandle, type SvgEditor, TOOL_CURSOR, type Tool, type Unsubscribe, type Vec2, type Verb, type VertexId, createSvgEditor };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_model = require("./model-D0nU_EkL.js");
3
- const require_editor = require("./editor-C6Lj1In-.js");
2
+ const require_model = require("./model-GpysNbOv.js");
3
+ const require_editor = require("./editor-N9af0JD2.js");
4
4
  exports.DEFAULT_STYLE = require_model.DEFAULT_STYLE;
5
5
  exports.PathModel = require_model.PathModel;
6
6
  exports.TOOL_CURSOR = require_model.TOOL_CURSOR;
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as createSvgEditor } from "./editor-DKQOIKuU.mjs";
2
- import { i as TOOL_CURSOR, r as DEFAULT_STYLE, t as PathModel } from "./model-L3t9ixT_.mjs";
1
+ import { t as createSvgEditor } from "./editor-BLsELHSZ.mjs";
2
+ import { i as TOOL_CURSOR, r as DEFAULT_STYLE, t as PathModel } from "./model-DMaN5GnH.mjs";
3
3
  export { DEFAULT_STYLE, PathModel, TOOL_CURSOR, createSvgEditor };