@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.
@@ -121,10 +121,20 @@ const GEOMETRY_ATTRS = new Set([
121
121
  ]);
122
122
  /** `transform:` CSS property at the start of a declaration list or after `;`. */
123
123
  const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
124
+ /** Trivia shape for editor-synthesized attribute tokens — parser-authored
125
+ * tokens carry their source trivia verbatim; tokens the editor appends get
126
+ * this one canonical shape (` name="value"`). */
127
+ const SYNTH_ATTR_TRIVIA = {
128
+ pre: " ",
129
+ eq_trivia: "",
130
+ eq_trailing: "",
131
+ quote: "\""
132
+ };
124
133
  var SvgDocument = class SvgDocument {
125
134
  constructor(svg) {
126
135
  this.listeners = /* @__PURE__ */ new Set();
127
136
  this._structure_version = 0;
137
+ this._revision = 0;
128
138
  this._geometry_version = 0;
129
139
  if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
130
140
  this.source = svg;
@@ -172,6 +182,20 @@ var SvgDocument = class SvgDocument {
172
182
  get structure_version() {
173
183
  return this._structure_version;
174
184
  }
185
+ /**
186
+ * Total mutation counter — advances on EVERY listener-visible mutation
187
+ * (attribute, style, text, topology, load/reset), unlike the selective
188
+ * `structure_version` / `geometry_version` channels. The single
189
+ * edit-version source: anything derived from this document — the
190
+ * editor's `content_version` / `dirty`, memoized reads, a rendered
191
+ * projection — answers "am I current?" by comparing values, with no
192
+ * event-ordering dependence. Advances BEFORE listeners fire, so a
193
+ * read issued from inside a change listener already sees the new
194
+ * value.
195
+ */
196
+ get revision() {
197
+ return this._revision;
198
+ }
175
199
  /** See `_geometry_version` for what this counter signals. */
176
200
  get geometry_version() {
177
201
  return this._geometry_version;
@@ -188,15 +212,15 @@ var SvgDocument = class SvgDocument {
188
212
  * settled glyph metrics. See ../../docs/geometry.md §Limitations.
189
213
  *
190
214
  * Deliberately does NOT call `emit()`: this is not a document edit, so
191
- * it must not bump `doc_version` / mark the doc dirty / touch undo
192
- * (the editor's `on_change` handler does all three). The editor's
193
- * `_internal.bump_geometry` advances `geometry_version` here and fans
194
- * out the geometry listeners itself.
215
+ * `revision` must not advance no dirty flag, no undo, no render
216
+ * flush. The editor's `_internal.bump_geometry` advances
217
+ * `geometry_version` here and fans out the geometry listeners itself.
195
218
  */
196
219
  bump_geometry() {
197
220
  this._geometry_version++;
198
221
  }
199
222
  emit() {
223
+ this._revision++;
200
224
  for (const fn of this.listeners) fn();
201
225
  }
202
226
  /** Notify subscribers — for callers that mutate directly via setAttr/etc. */
@@ -347,9 +371,7 @@ var SvgDocument = class SvgDocument {
347
371
  local: name,
348
372
  ns,
349
373
  value,
350
- pre: " ",
351
- eq_trivia: "",
352
- quote: "\""
374
+ ...SYNTH_ATTR_TRIVIA
353
375
  });
354
376
  if (structural) this._structure_version++;
355
377
  if (geometry) this._geometry_version++;
@@ -605,9 +627,7 @@ var SvgDocument = class SvgDocument {
605
627
  local: "d",
606
628
  ns: null,
607
629
  value: d,
608
- pre: " ",
609
- eq_trivia: "",
610
- quote: "\""
630
+ ...SYNTH_ATTR_TRIVIA
611
631
  });
612
632
  let added_fill_none = false;
613
633
  if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
@@ -617,9 +637,7 @@ var SvgDocument = class SvgDocument {
617
637
  local: "fill",
618
638
  ns: null,
619
639
  value: "none",
620
- pre: " ",
621
- eq_trivia: "",
622
- quote: "\""
640
+ ...SYNTH_ATTR_TRIVIA
623
641
  });
624
642
  added_fill_none = true;
625
643
  }
@@ -986,7 +1004,7 @@ var SvgDocument = class SvgDocument {
986
1004
  }
987
1005
  }
988
1006
  emit_attr(a) {
989
- return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${encode_attr_value(a.value, a.quote)}${a.quote}`;
1007
+ return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.eq_trailing}${a.quote}${encode_attr_value(a.value, a.quote)}${a.quote}`;
990
1008
  }
991
1009
  };
992
1010
  function parse_inline_style(s) {
@@ -1004,6 +1022,131 @@ function parse_inline_style(s) {
1004
1022
  }
1005
1023
  return out;
1006
1024
  }
1025
+ /**
1026
+ * Namespace prefixes resolvable without a source declaration. ONE table,
1027
+ * shared by the paste-side hoist (`insert_fragment`'s xmlns plan) and the
1028
+ * copy-side shell repair (clipboard payload extraction) — the two sides
1029
+ * form a round-trip and must agree: a prefix only one side knows would
1030
+ * produce payloads the other can't honor.
1031
+ */
1032
+ const WELL_KNOWN_NS_PREFIXES = new Map([["xlink", XLINK_NS$1]]);
1033
+ //#endregion
1034
+ //#region src/core/subtree.ts
1035
+ /**
1036
+ * The selection → subtree algebra the two extraction operations share,
1037
+ * plus the clone-specific member (`clone_plan`).
1038
+ *
1039
+ * The clipboard FRD
1040
+ * ([docs/wg/feat-svg-editor/clipboard.md](../../../../docs/wg/feat-svg-editor/clipboard.md)
1041
+ * §Two extraction operations) names two operations over a normalized
1042
+ * selection: **payload extraction** (copy — `core/clipboard.ts`) and
1043
+ * **subtree clone** (duplicate / clone-drag — this module; design note:
1044
+ * [docs/wg/feat-svg-editor/subtree-clone.md](../../../../docs/wg/feat-svg-editor/subtree-clone.md)).
1045
+ * They share exactly two things — selection normalization
1046
+ * ({@link subtree.normalize_roots}) and verbatim subtree serialization
1047
+ * (`SvgDocument.serialize_node`) — and nothing else.
1048
+ *
1049
+ * Unlike copy's payload, a clone carries **no reference closure and no
1050
+ * namespace shell**: the destination is the source document, every
1051
+ * `url(#…)` / `href` reference still resolves against it, and carrying
1052
+ * definitions would deposit duplicate defs on every duplicate.
1053
+ *
1054
+ * Consumers: `commands.duplicate` (⌘D) and the translate orchestrator's
1055
+ * Alt-drag clone session (gridaco/grida#817).
1056
+ *
1057
+ * Verbatim-id policy: authored `id=""` attributes are cloned verbatim,
1058
+ * NEVER rewritten — same stance as `insert_fragment`. A clone of a node
1059
+ * carrying `id="x"` yields a second `id="x"`; reference resolution follows
1060
+ * the host renderer's first-in-document-order rule (a cloned subtree's
1061
+ * internal self-reference resolves to the ORIGINAL), and deduplication is
1062
+ * the explicit Tidy command's job.
1063
+ */
1064
+ let subtree;
1065
+ (function(_subtree) {
1066
+ function by_document_order(doc) {
1067
+ const index = /* @__PURE__ */ new Map();
1068
+ let i = 0;
1069
+ for (const id of doc.all_nodes()) index.set(id, i++);
1070
+ return (a, b) => (index.get(a) ?? 0) - (index.get(b) ?? 0);
1071
+ }
1072
+ _subtree.by_document_order = by_document_order;
1073
+ function normalize_roots(doc, selection, order) {
1074
+ const live = [...new Set(selection)].filter((id) => doc.is_element(id) && doc.contains(doc.root, id));
1075
+ const roots = doc.prune_nested_nodes(live);
1076
+ if (roots.length > 1) roots.sort(order ?? by_document_order(doc));
1077
+ return roots;
1078
+ }
1079
+ _subtree.normalize_roots = normalize_roots;
1080
+ function clone_plan(doc, selection) {
1081
+ const out = [];
1082
+ for (const origin of normalize_roots(doc, selection)) {
1083
+ const parent = doc.parent_of(origin);
1084
+ if (parent === null) continue;
1085
+ if (doc.tag_of(origin) === "svg") continue;
1086
+ const { roots } = doc.create_fragment(doc.serialize_node(origin));
1087
+ if (roots.length !== 1) throw new Error(`subtree.clone_plan: cloning ${JSON.stringify(origin)} yielded ${roots.length} roots`);
1088
+ out.push({
1089
+ origin,
1090
+ clone: roots[0],
1091
+ parent,
1092
+ before: doc.next_sibling_of(origin)
1093
+ });
1094
+ }
1095
+ return out;
1096
+ }
1097
+ _subtree.clone_plan = clone_plan;
1098
+ function insert_plan(doc, plan) {
1099
+ for (const p of plan) doc.insert(p.clone, p.parent, p.before);
1100
+ }
1101
+ _subtree.insert_plan = insert_plan;
1102
+ function remove_plan(doc, plan) {
1103
+ for (const p of plan) doc.remove(p.clone);
1104
+ }
1105
+ _subtree.remove_plan = remove_plan;
1106
+ /** Per-member rigidity tolerance for the rigid-translate witness in
1107
+ * {@link repeat_delta} — position drift from the shared delta, and
1108
+ * size drift from the origin. Generous against float noise from
1109
+ * re-encoded path data / `getBBox`, far below anything a user would
1110
+ * call a move or a resize. */
1111
+ const REPEAT_RIGID_EPSILON = .01;
1112
+ function repeat_delta(record, targets, bounds_of) {
1113
+ if (record === null) return null;
1114
+ if (record.origins.length === 0) return null;
1115
+ if (record.origins.length !== record.clones.length) return null;
1116
+ if (!array_shallow_equal(record.clones, targets)) return null;
1117
+ const origin_bounds = collect_bounds(record.origins, bounds_of);
1118
+ if (origin_bounds === null) return null;
1119
+ const clone_bounds = collect_bounds(record.clones, bounds_of);
1120
+ if (clone_bounds === null) return null;
1121
+ const a = cmath.rect.union(origin_bounds);
1122
+ const b = cmath.rect.union(clone_bounds);
1123
+ const dx = b.x - a.x;
1124
+ const dy = b.y - a.y;
1125
+ for (let i = 0; i < origin_bounds.length; i++) {
1126
+ const o = origin_bounds[i];
1127
+ const c = clone_bounds[i];
1128
+ if (Math.abs(c.x - o.x - dx) > REPEAT_RIGID_EPSILON || Math.abs(c.y - o.y - dy) > REPEAT_RIGID_EPSILON || Math.abs(c.width - o.width) > REPEAT_RIGID_EPSILON || Math.abs(c.height - o.height) > REPEAT_RIGID_EPSILON) return null;
1129
+ }
1130
+ if (Math.abs(dx) <= REPEAT_RIGID_EPSILON && Math.abs(dy) <= REPEAT_RIGID_EPSILON) return null;
1131
+ return {
1132
+ x: dx,
1133
+ y: dy
1134
+ };
1135
+ }
1136
+ _subtree.repeat_delta = repeat_delta;
1137
+ /** All-or-nothing bounds gather for {@link repeat_delta} — one
1138
+ * unmeasurable member and the record can't witness a rigid
1139
+ * translate. */
1140
+ function collect_bounds(ids, bounds_of) {
1141
+ const out = [];
1142
+ for (const id of ids) {
1143
+ const r = bounds_of(id);
1144
+ if (r === null) return null;
1145
+ out.push(r);
1146
+ }
1147
+ return out;
1148
+ }
1149
+ })(subtree || (subtree = {}));
1007
1150
  //#endregion
1008
1151
  //#region src/core/transform.ts
1009
1152
  let transform;
@@ -1771,6 +1914,8 @@ let translate_pipeline;
1771
1914
  })(translate_pipeline || (translate_pipeline = {}));
1772
1915
  //#endregion
1773
1916
  //#region src/core/translate-pipeline/orchestrator.ts
1917
+ const movers = (s) => s.clone?.ids ?? s.ids;
1918
+ const mover_baselines = (s) => s.clone?.baselines ?? s.baselines;
1774
1919
  const PROVIDER_ID$2 = "svg-editor";
1775
1920
  var TranslateOrchestrator = class {
1776
1921
  constructor(deps) {
@@ -1793,25 +1938,33 @@ var TranslateOrchestrator = class {
1793
1938
  drive(input, modifiers, opts) {
1794
1939
  if (this.active === null) this.active = this.open(input.ids, opts.snap, opts.label ?? "move");
1795
1940
  const session = this.active;
1941
+ this.reconcile_clone(session, modifiers);
1796
1942
  const stages = opts.stages ?? translate_pipeline.stages.DEFAULT;
1797
1943
  const result = this.run_pass(session, input.movement, modifiers, opts.policy, stages);
1798
1944
  session.last_movement = input.movement;
1799
1945
  session.last_policy = opts.policy;
1800
1946
  session.last_stages = stages;
1801
- this.write_preview_delta(session, result.plan);
1947
+ if (opts.phase === "commit" && session.clone !== null) this.write_commit_composite(session, result.plan);
1948
+ else this.write_preview_delta(session, result.plan);
1802
1949
  if (opts.phase === "commit") {
1950
+ const cloned = session.clone;
1803
1951
  session.preview.commit();
1804
1952
  this.dispose_session();
1953
+ if (cloned !== null) this.deps.on_clone_commit?.({
1954
+ origins: cloned.plan.map((p) => p.origin),
1955
+ clones: cloned.ids
1956
+ });
1805
1957
  }
1806
1958
  return result;
1807
1959
  }
1808
1960
  /** Re-run the current preview frame with new modifiers, reusing the
1809
1961
  * last-known movement / policy / stages. Used when a modifier key
1810
- * changes between pointer-move events (Shift down/up mid-drag).
1811
- * No-op when no session is active. */
1962
+ * changes between pointer-move events (Shift down/up mid-drag,
1963
+ * Alt down/up → clone toggle). No-op when no session is active. */
1812
1964
  redrive_modifiers(modifiers) {
1813
1965
  if (!this.active) return null;
1814
1966
  const session = this.active;
1967
+ this.reconcile_clone(session, modifiers);
1815
1968
  const result = this.run_pass(session, session.last_movement, modifiers, session.last_policy, session.last_stages);
1816
1969
  this.write_preview_delta(session, result.plan);
1817
1970
  return result;
@@ -1819,15 +1972,22 @@ var TranslateOrchestrator = class {
1819
1972
  /** Cancel an in-flight gesture (Escape, programmatic abort). */
1820
1973
  cancel() {
1821
1974
  if (!this.active) return;
1822
- this.active.preview.discard();
1975
+ const session = this.active;
1976
+ session.preview.discard();
1977
+ if (session.clone !== null) {
1978
+ subtree.remove_plan(this.deps.get_doc(), session.clone.plan);
1979
+ this.deps.set_selection?.(session.ids);
1980
+ this.deps.emit();
1981
+ }
1823
1982
  this.dispose_session();
1824
1983
  }
1825
1984
  /** Build a plan + context, run the pipeline, stash guides. Pure
1826
- * computation — does not touch the preview. */
1985
+ * computation — does not touch the preview. The context's modifiers
1986
+ * are the pipeline's own vocabulary: stages never see `clone`. */
1827
1987
  run_pass(session, movement, modifiers, policy, stages) {
1828
1988
  const plan0 = {
1829
- ids: session.ids,
1830
- baselines: session.baselines,
1989
+ ids: movers(session),
1990
+ baselines: mover_baselines(session),
1831
1991
  delta: {
1832
1992
  x: 0,
1833
1993
  y: 0
@@ -1835,7 +1995,7 @@ var TranslateOrchestrator = class {
1835
1995
  };
1836
1996
  const ctx = {
1837
1997
  input: {
1838
- ids: session.ids,
1998
+ ids: plan0.ids,
1839
1999
  movement
1840
2000
  },
1841
2001
  modifiers,
@@ -1847,6 +2007,69 @@ var TranslateOrchestrator = class {
1847
2007
  this._last_guides = result.guides;
1848
2008
  return result;
1849
2009
  }
2010
+ /**
2011
+ * Clone-modifier state machine (Alt-drag translate-with-clone). Runs
2012
+ * before each pipeline pass so a toggle takes effect on the very frame
2013
+ * it happens — lazy clone on the first frame with the modifier held.
2014
+ *
2015
+ * Both edges and the composite commit below depend on one invariant:
2016
+ * `translate_pipeline.*.revert` writes ABSOLUTE baseline values (incl.
2017
+ * tspan's exact-attr restore), so re-reverting an already-reverted set
2018
+ * is a no-op. A future relative-write baseline kind would break this.
2019
+ */
2020
+ reconcile_clone(session, modifiers) {
2021
+ const want = modifiers.clone === true;
2022
+ const cloned = session.clone !== null;
2023
+ if (want && !cloned) this.enter_clone(session);
2024
+ else if (!want && cloned) this.exit_clone(session);
2025
+ }
2026
+ /** Enter the cloned state: origins back to rest, verbatim clones
2027
+ * inserted next to them, gesture + selection + snap retargeted to the
2028
+ * clones. The origin set stays untouched for the rest of the cloned
2029
+ * gesture — the CLONE moves, the origin stays (Figma convention). */
2030
+ enter_clone(session) {
2031
+ const doc = this.deps.get_doc();
2032
+ translate_pipeline.revert(doc, {
2033
+ ids: session.ids,
2034
+ baselines: session.baselines,
2035
+ delta: {
2036
+ x: 0,
2037
+ y: 0
2038
+ }
2039
+ });
2040
+ const plan = subtree.clone_plan(doc, session.ids);
2041
+ if (plan.length === 0) return;
2042
+ subtree.insert_plan(doc, plan);
2043
+ const baselines = /* @__PURE__ */ new Map();
2044
+ for (const p of plan) baselines.set(p.clone, session.baselines.get(p.origin));
2045
+ session.clone = {
2046
+ plan,
2047
+ ids: plan.map((p) => p.clone),
2048
+ baselines
2049
+ };
2050
+ this.retarget(session, session.clone.ids);
2051
+ }
2052
+ /** Exit the cloned state: clones removed, gesture + selection + snap
2053
+ * retargeted back to the origins, which resume following the cursor
2054
+ * on the next pass. Removed clones stay in the document's id map
2055
+ * (standard removed-node policy) and are never serialized; a stale
2056
+ * preview delta auto-reverting onto them later is harmless. Re-press
2057
+ * = fresh enter with new clones. */
2058
+ exit_clone(session) {
2059
+ subtree.remove_plan(this.deps.get_doc(), session.clone.plan);
2060
+ session.clone = null;
2061
+ this.retarget(session, session.ids);
2062
+ }
2063
+ /** Point selection + snap at the new mover set. Selection + emit run
2064
+ * BEFORE the snap reopen: the surface re-renders on notify, so freshly
2065
+ * inserted clones are measurable when the new snap session captures
2066
+ * its neighborhood (where the origins are legitimate snap targets). */
2067
+ retarget(session, ids) {
2068
+ this.deps.set_selection?.(ids);
2069
+ this.deps.emit();
2070
+ session.snap?.dispose();
2071
+ session.snap = session.snap_requested ? this.deps.open_snap(ids) : null;
2072
+ }
1850
2073
  open(ids, snap, label) {
1851
2074
  const doc = this.deps.get_doc();
1852
2075
  const filtered = doc.prune_nested_nodes(ids);
@@ -1854,10 +2077,12 @@ var TranslateOrchestrator = class {
1854
2077
  ids: filtered,
1855
2078
  baselines: translate_pipeline.intent.capture_baselines(doc, filtered),
1856
2079
  snap: snap ? this.deps.open_snap(filtered) : null,
2080
+ snap_requested: snap,
1857
2081
  preview: this.deps.open_preview(label),
1858
2082
  last_movement: [0, 0],
1859
2083
  last_policy: "engine",
1860
- last_stages: translate_pipeline.stages.DEFAULT
2084
+ last_stages: translate_pipeline.stages.DEFAULT,
2085
+ clone: null
1861
2086
  };
1862
2087
  }
1863
2088
  /** Bind a fresh apply/revert pair (closure over `plan`) into the
@@ -1879,6 +2104,43 @@ var TranslateOrchestrator = class {
1879
2104
  }
1880
2105
  });
1881
2106
  }
2107
+ /** Commit-time delta for a CLONED gesture. Per-frame deltas stay
2108
+ * translate-only; the structural insert happened at the toggle edge,
2109
+ * outside the preview. The one delta that gets committed must own the
2110
+ * whole outcome, so a single undo removes clone + move and a redo
2111
+ * restores both:
2112
+ * apply = insert clones (idempotent reposition when live) +
2113
+ * translate + select clones
2114
+ * revert = un-translate + remove clones + select origins
2115
+ * `preview.set` reverts the previous translate-only delta first
2116
+ * (clones back to rest), then runs this apply — the document passes
2117
+ * through exactly the states the closures describe.
2118
+ * Captures locals only (not the session), so the committed delta
2119
+ * retains the minimum undo/redo needs. */
2120
+ write_commit_composite(session, plan) {
2121
+ const doc = this.deps.get_doc();
2122
+ const emit = this.deps.emit;
2123
+ const project = this.deps.project_delta;
2124
+ const set_selection = this.deps.set_selection;
2125
+ const clone_plan = session.clone.plan;
2126
+ const clone_ids = session.clone.ids;
2127
+ const origin_ids = session.ids;
2128
+ session.preview.set({
2129
+ providerId: PROVIDER_ID$2,
2130
+ apply: () => {
2131
+ subtree.insert_plan(doc, clone_plan);
2132
+ translate_pipeline.apply(doc, plan, project);
2133
+ set_selection?.(clone_ids);
2134
+ emit();
2135
+ },
2136
+ revert: () => {
2137
+ translate_pipeline.revert(doc, plan);
2138
+ subtree.remove_plan(doc, clone_plan);
2139
+ set_selection?.(origin_ids);
2140
+ emit();
2141
+ }
2142
+ });
2143
+ }
1882
2144
  dispose_session() {
1883
2145
  if (!this.active) return;
1884
2146
  this.active.snap?.dispose();
@@ -4834,4 +5096,4 @@ function emitWithVerbs(network, meta) {
4834
5096
  return encodeSVGPath(commands);
4835
5097
  }
4836
5098
  //#endregion
4837
- export { XLINK_NS$1 as _, paint as a, is_text_input_focused as b, hit_shape_svg as c, NudgeDwellWatcher as d, TranslateOrchestrator as f, SvgDocument as g, transform as h, TOOL_CURSOR as i, RotateOrchestrator as l, group as m, insertions as n, ResizeOrchestrator as o, translate_pipeline as p, DEFAULT_STYLE as r, resize_pipeline as s, PathModel as t, rotate_pipeline as u, XMLNS_NS as v, array_shallow_equal as y };
5099
+ export { is_text_input_focused as S, SVG_NS as _, paint as a, XMLNS_NS as b, hit_shape_svg as c, NudgeDwellWatcher as d, TranslateOrchestrator as f, subtree as g, transform as h, TOOL_CURSOR as i, RotateOrchestrator as l, group as m, insertions as n, ResizeOrchestrator as o, translate_pipeline as p, DEFAULT_STYLE as r, resize_pipeline as s, PathModel as t, rotate_pipeline as u, SvgDocument as v, array_shallow_equal as x, WELL_KNOWN_NS_PREFIXES as y };