@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.
@@ -154,10 +154,20 @@ const GEOMETRY_ATTRS = new Set([
154
154
  ]);
155
155
  /** `transform:` CSS property at the start of a declaration list or after `;`. */
156
156
  const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
157
+ /** Trivia shape for editor-synthesized attribute tokens — parser-authored
158
+ * tokens carry their source trivia verbatim; tokens the editor appends get
159
+ * this one canonical shape (` name="value"`). */
160
+ const SYNTH_ATTR_TRIVIA = {
161
+ pre: " ",
162
+ eq_trivia: "",
163
+ eq_trailing: "",
164
+ quote: "\""
165
+ };
157
166
  var SvgDocument = class SvgDocument {
158
167
  constructor(svg) {
159
168
  this.listeners = /* @__PURE__ */ new Set();
160
169
  this._structure_version = 0;
170
+ this._revision = 0;
161
171
  this._geometry_version = 0;
162
172
  if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
163
173
  this.source = svg;
@@ -205,6 +215,20 @@ var SvgDocument = class SvgDocument {
205
215
  get structure_version() {
206
216
  return this._structure_version;
207
217
  }
218
+ /**
219
+ * Total mutation counter — advances on EVERY listener-visible mutation
220
+ * (attribute, style, text, topology, load/reset), unlike the selective
221
+ * `structure_version` / `geometry_version` channels. The single
222
+ * edit-version source: anything derived from this document — the
223
+ * editor's `content_version` / `dirty`, memoized reads, a rendered
224
+ * projection — answers "am I current?" by comparing values, with no
225
+ * event-ordering dependence. Advances BEFORE listeners fire, so a
226
+ * read issued from inside a change listener already sees the new
227
+ * value.
228
+ */
229
+ get revision() {
230
+ return this._revision;
231
+ }
208
232
  /** See `_geometry_version` for what this counter signals. */
209
233
  get geometry_version() {
210
234
  return this._geometry_version;
@@ -221,15 +245,15 @@ var SvgDocument = class SvgDocument {
221
245
  * settled glyph metrics. See ../../docs/geometry.md §Limitations.
222
246
  *
223
247
  * Deliberately does NOT call `emit()`: this is not a document edit, so
224
- * it must not bump `doc_version` / mark the doc dirty / touch undo
225
- * (the editor's `on_change` handler does all three). The editor's
226
- * `_internal.bump_geometry` advances `geometry_version` here and fans
227
- * out the geometry listeners itself.
248
+ * `revision` must not advance no dirty flag, no undo, no render
249
+ * flush. The editor's `_internal.bump_geometry` advances
250
+ * `geometry_version` here and fans out the geometry listeners itself.
228
251
  */
229
252
  bump_geometry() {
230
253
  this._geometry_version++;
231
254
  }
232
255
  emit() {
256
+ this._revision++;
233
257
  for (const fn of this.listeners) fn();
234
258
  }
235
259
  /** Notify subscribers — for callers that mutate directly via setAttr/etc. */
@@ -380,9 +404,7 @@ var SvgDocument = class SvgDocument {
380
404
  local: name,
381
405
  ns,
382
406
  value,
383
- pre: " ",
384
- eq_trivia: "",
385
- quote: "\""
407
+ ...SYNTH_ATTR_TRIVIA
386
408
  });
387
409
  if (structural) this._structure_version++;
388
410
  if (geometry) this._geometry_version++;
@@ -638,9 +660,7 @@ var SvgDocument = class SvgDocument {
638
660
  local: "d",
639
661
  ns: null,
640
662
  value: d,
641
- pre: " ",
642
- eq_trivia: "",
643
- quote: "\""
663
+ ...SYNTH_ATTR_TRIVIA
644
664
  });
645
665
  let added_fill_none = false;
646
666
  if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
@@ -650,9 +670,7 @@ var SvgDocument = class SvgDocument {
650
670
  local: "fill",
651
671
  ns: null,
652
672
  value: "none",
653
- pre: " ",
654
- eq_trivia: "",
655
- quote: "\""
673
+ ...SYNTH_ATTR_TRIVIA
656
674
  });
657
675
  added_fill_none = true;
658
676
  }
@@ -1019,7 +1037,7 @@ var SvgDocument = class SvgDocument {
1019
1037
  }
1020
1038
  }
1021
1039
  emit_attr(a) {
1022
- return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${(0, _grida_svg_parser.encode_attr_value)(a.value, a.quote)}${a.quote}`;
1040
+ return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.eq_trailing}${a.quote}${(0, _grida_svg_parser.encode_attr_value)(a.value, a.quote)}${a.quote}`;
1023
1041
  }
1024
1042
  };
1025
1043
  function parse_inline_style(s) {
@@ -1037,6 +1055,131 @@ function parse_inline_style(s) {
1037
1055
  }
1038
1056
  return out;
1039
1057
  }
1058
+ /**
1059
+ * Namespace prefixes resolvable without a source declaration. ONE table,
1060
+ * shared by the paste-side hoist (`insert_fragment`'s xmlns plan) and the
1061
+ * copy-side shell repair (clipboard payload extraction) — the two sides
1062
+ * form a round-trip and must agree: a prefix only one side knows would
1063
+ * produce payloads the other can't honor.
1064
+ */
1065
+ const WELL_KNOWN_NS_PREFIXES = new Map([["xlink", _grida_svg_parser.XLINK_NS]]);
1066
+ //#endregion
1067
+ //#region src/core/subtree.ts
1068
+ /**
1069
+ * The selection → subtree algebra the two extraction operations share,
1070
+ * plus the clone-specific member (`clone_plan`).
1071
+ *
1072
+ * The clipboard FRD
1073
+ * ([docs/wg/feat-svg-editor/clipboard.md](../../../../docs/wg/feat-svg-editor/clipboard.md)
1074
+ * §Two extraction operations) names two operations over a normalized
1075
+ * selection: **payload extraction** (copy — `core/clipboard.ts`) and
1076
+ * **subtree clone** (duplicate / clone-drag — this module; design note:
1077
+ * [docs/wg/feat-svg-editor/subtree-clone.md](../../../../docs/wg/feat-svg-editor/subtree-clone.md)).
1078
+ * They share exactly two things — selection normalization
1079
+ * ({@link subtree.normalize_roots}) and verbatim subtree serialization
1080
+ * (`SvgDocument.serialize_node`) — and nothing else.
1081
+ *
1082
+ * Unlike copy's payload, a clone carries **no reference closure and no
1083
+ * namespace shell**: the destination is the source document, every
1084
+ * `url(#…)` / `href` reference still resolves against it, and carrying
1085
+ * definitions would deposit duplicate defs on every duplicate.
1086
+ *
1087
+ * Consumers: `commands.duplicate` (⌘D) and the translate orchestrator's
1088
+ * Alt-drag clone session (gridaco/grida#817).
1089
+ *
1090
+ * Verbatim-id policy: authored `id=""` attributes are cloned verbatim,
1091
+ * NEVER rewritten — same stance as `insert_fragment`. A clone of a node
1092
+ * carrying `id="x"` yields a second `id="x"`; reference resolution follows
1093
+ * the host renderer's first-in-document-order rule (a cloned subtree's
1094
+ * internal self-reference resolves to the ORIGINAL), and deduplication is
1095
+ * the explicit Tidy command's job.
1096
+ */
1097
+ let subtree;
1098
+ (function(_subtree) {
1099
+ function by_document_order(doc) {
1100
+ const index = /* @__PURE__ */ new Map();
1101
+ let i = 0;
1102
+ for (const id of doc.all_nodes()) index.set(id, i++);
1103
+ return (a, b) => (index.get(a) ?? 0) - (index.get(b) ?? 0);
1104
+ }
1105
+ _subtree.by_document_order = by_document_order;
1106
+ function normalize_roots(doc, selection, order) {
1107
+ const live = [...new Set(selection)].filter((id) => doc.is_element(id) && doc.contains(doc.root, id));
1108
+ const roots = doc.prune_nested_nodes(live);
1109
+ if (roots.length > 1) roots.sort(order ?? by_document_order(doc));
1110
+ return roots;
1111
+ }
1112
+ _subtree.normalize_roots = normalize_roots;
1113
+ function clone_plan(doc, selection) {
1114
+ const out = [];
1115
+ for (const origin of normalize_roots(doc, selection)) {
1116
+ const parent = doc.parent_of(origin);
1117
+ if (parent === null) continue;
1118
+ if (doc.tag_of(origin) === "svg") continue;
1119
+ const { roots } = doc.create_fragment(doc.serialize_node(origin));
1120
+ if (roots.length !== 1) throw new Error(`subtree.clone_plan: cloning ${JSON.stringify(origin)} yielded ${roots.length} roots`);
1121
+ out.push({
1122
+ origin,
1123
+ clone: roots[0],
1124
+ parent,
1125
+ before: doc.next_sibling_of(origin)
1126
+ });
1127
+ }
1128
+ return out;
1129
+ }
1130
+ _subtree.clone_plan = clone_plan;
1131
+ function insert_plan(doc, plan) {
1132
+ for (const p of plan) doc.insert(p.clone, p.parent, p.before);
1133
+ }
1134
+ _subtree.insert_plan = insert_plan;
1135
+ function remove_plan(doc, plan) {
1136
+ for (const p of plan) doc.remove(p.clone);
1137
+ }
1138
+ _subtree.remove_plan = remove_plan;
1139
+ /** Per-member rigidity tolerance for the rigid-translate witness in
1140
+ * {@link repeat_delta} — position drift from the shared delta, and
1141
+ * size drift from the origin. Generous against float noise from
1142
+ * re-encoded path data / `getBBox`, far below anything a user would
1143
+ * call a move or a resize. */
1144
+ const REPEAT_RIGID_EPSILON = .01;
1145
+ function repeat_delta(record, targets, bounds_of) {
1146
+ if (record === null) return null;
1147
+ if (record.origins.length === 0) return null;
1148
+ if (record.origins.length !== record.clones.length) return null;
1149
+ if (!array_shallow_equal(record.clones, targets)) return null;
1150
+ const origin_bounds = collect_bounds(record.origins, bounds_of);
1151
+ if (origin_bounds === null) return null;
1152
+ const clone_bounds = collect_bounds(record.clones, bounds_of);
1153
+ if (clone_bounds === null) return null;
1154
+ const a = _grida_cmath.default.rect.union(origin_bounds);
1155
+ const b = _grida_cmath.default.rect.union(clone_bounds);
1156
+ const dx = b.x - a.x;
1157
+ const dy = b.y - a.y;
1158
+ for (let i = 0; i < origin_bounds.length; i++) {
1159
+ const o = origin_bounds[i];
1160
+ const c = clone_bounds[i];
1161
+ 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;
1162
+ }
1163
+ if (Math.abs(dx) <= REPEAT_RIGID_EPSILON && Math.abs(dy) <= REPEAT_RIGID_EPSILON) return null;
1164
+ return {
1165
+ x: dx,
1166
+ y: dy
1167
+ };
1168
+ }
1169
+ _subtree.repeat_delta = repeat_delta;
1170
+ /** All-or-nothing bounds gather for {@link repeat_delta} — one
1171
+ * unmeasurable member and the record can't witness a rigid
1172
+ * translate. */
1173
+ function collect_bounds(ids, bounds_of) {
1174
+ const out = [];
1175
+ for (const id of ids) {
1176
+ const r = bounds_of(id);
1177
+ if (r === null) return null;
1178
+ out.push(r);
1179
+ }
1180
+ return out;
1181
+ }
1182
+ })(subtree || (subtree = {}));
1040
1183
  //#endregion
1041
1184
  //#region src/core/transform.ts
1042
1185
  let transform;
@@ -1804,6 +1947,8 @@ let translate_pipeline;
1804
1947
  })(translate_pipeline || (translate_pipeline = {}));
1805
1948
  //#endregion
1806
1949
  //#region src/core/translate-pipeline/orchestrator.ts
1950
+ const movers = (s) => s.clone?.ids ?? s.ids;
1951
+ const mover_baselines = (s) => s.clone?.baselines ?? s.baselines;
1807
1952
  const PROVIDER_ID$2 = "svg-editor";
1808
1953
  var TranslateOrchestrator = class {
1809
1954
  constructor(deps) {
@@ -1826,25 +1971,33 @@ var TranslateOrchestrator = class {
1826
1971
  drive(input, modifiers, opts) {
1827
1972
  if (this.active === null) this.active = this.open(input.ids, opts.snap, opts.label ?? "move");
1828
1973
  const session = this.active;
1974
+ this.reconcile_clone(session, modifiers);
1829
1975
  const stages = opts.stages ?? translate_pipeline.stages.DEFAULT;
1830
1976
  const result = this.run_pass(session, input.movement, modifiers, opts.policy, stages);
1831
1977
  session.last_movement = input.movement;
1832
1978
  session.last_policy = opts.policy;
1833
1979
  session.last_stages = stages;
1834
- this.write_preview_delta(session, result.plan);
1980
+ if (opts.phase === "commit" && session.clone !== null) this.write_commit_composite(session, result.plan);
1981
+ else this.write_preview_delta(session, result.plan);
1835
1982
  if (opts.phase === "commit") {
1983
+ const cloned = session.clone;
1836
1984
  session.preview.commit();
1837
1985
  this.dispose_session();
1986
+ if (cloned !== null) this.deps.on_clone_commit?.({
1987
+ origins: cloned.plan.map((p) => p.origin),
1988
+ clones: cloned.ids
1989
+ });
1838
1990
  }
1839
1991
  return result;
1840
1992
  }
1841
1993
  /** Re-run the current preview frame with new modifiers, reusing the
1842
1994
  * last-known movement / policy / stages. Used when a modifier key
1843
- * changes between pointer-move events (Shift down/up mid-drag).
1844
- * No-op when no session is active. */
1995
+ * changes between pointer-move events (Shift down/up mid-drag,
1996
+ * Alt down/up → clone toggle). No-op when no session is active. */
1845
1997
  redrive_modifiers(modifiers) {
1846
1998
  if (!this.active) return null;
1847
1999
  const session = this.active;
2000
+ this.reconcile_clone(session, modifiers);
1848
2001
  const result = this.run_pass(session, session.last_movement, modifiers, session.last_policy, session.last_stages);
1849
2002
  this.write_preview_delta(session, result.plan);
1850
2003
  return result;
@@ -1852,15 +2005,22 @@ var TranslateOrchestrator = class {
1852
2005
  /** Cancel an in-flight gesture (Escape, programmatic abort). */
1853
2006
  cancel() {
1854
2007
  if (!this.active) return;
1855
- this.active.preview.discard();
2008
+ const session = this.active;
2009
+ session.preview.discard();
2010
+ if (session.clone !== null) {
2011
+ subtree.remove_plan(this.deps.get_doc(), session.clone.plan);
2012
+ this.deps.set_selection?.(session.ids);
2013
+ this.deps.emit();
2014
+ }
1856
2015
  this.dispose_session();
1857
2016
  }
1858
2017
  /** Build a plan + context, run the pipeline, stash guides. Pure
1859
- * computation — does not touch the preview. */
2018
+ * computation — does not touch the preview. The context's modifiers
2019
+ * are the pipeline's own vocabulary: stages never see `clone`. */
1860
2020
  run_pass(session, movement, modifiers, policy, stages) {
1861
2021
  const plan0 = {
1862
- ids: session.ids,
1863
- baselines: session.baselines,
2022
+ ids: movers(session),
2023
+ baselines: mover_baselines(session),
1864
2024
  delta: {
1865
2025
  x: 0,
1866
2026
  y: 0
@@ -1868,7 +2028,7 @@ var TranslateOrchestrator = class {
1868
2028
  };
1869
2029
  const ctx = {
1870
2030
  input: {
1871
- ids: session.ids,
2031
+ ids: plan0.ids,
1872
2032
  movement
1873
2033
  },
1874
2034
  modifiers,
@@ -1880,6 +2040,69 @@ var TranslateOrchestrator = class {
1880
2040
  this._last_guides = result.guides;
1881
2041
  return result;
1882
2042
  }
2043
+ /**
2044
+ * Clone-modifier state machine (Alt-drag translate-with-clone). Runs
2045
+ * before each pipeline pass so a toggle takes effect on the very frame
2046
+ * it happens — lazy clone on the first frame with the modifier held.
2047
+ *
2048
+ * Both edges and the composite commit below depend on one invariant:
2049
+ * `translate_pipeline.*.revert` writes ABSOLUTE baseline values (incl.
2050
+ * tspan's exact-attr restore), so re-reverting an already-reverted set
2051
+ * is a no-op. A future relative-write baseline kind would break this.
2052
+ */
2053
+ reconcile_clone(session, modifiers) {
2054
+ const want = modifiers.clone === true;
2055
+ const cloned = session.clone !== null;
2056
+ if (want && !cloned) this.enter_clone(session);
2057
+ else if (!want && cloned) this.exit_clone(session);
2058
+ }
2059
+ /** Enter the cloned state: origins back to rest, verbatim clones
2060
+ * inserted next to them, gesture + selection + snap retargeted to the
2061
+ * clones. The origin set stays untouched for the rest of the cloned
2062
+ * gesture — the CLONE moves, the origin stays (Figma convention). */
2063
+ enter_clone(session) {
2064
+ const doc = this.deps.get_doc();
2065
+ translate_pipeline.revert(doc, {
2066
+ ids: session.ids,
2067
+ baselines: session.baselines,
2068
+ delta: {
2069
+ x: 0,
2070
+ y: 0
2071
+ }
2072
+ });
2073
+ const plan = subtree.clone_plan(doc, session.ids);
2074
+ if (plan.length === 0) return;
2075
+ subtree.insert_plan(doc, plan);
2076
+ const baselines = /* @__PURE__ */ new Map();
2077
+ for (const p of plan) baselines.set(p.clone, session.baselines.get(p.origin));
2078
+ session.clone = {
2079
+ plan,
2080
+ ids: plan.map((p) => p.clone),
2081
+ baselines
2082
+ };
2083
+ this.retarget(session, session.clone.ids);
2084
+ }
2085
+ /** Exit the cloned state: clones removed, gesture + selection + snap
2086
+ * retargeted back to the origins, which resume following the cursor
2087
+ * on the next pass. Removed clones stay in the document's id map
2088
+ * (standard removed-node policy) and are never serialized; a stale
2089
+ * preview delta auto-reverting onto them later is harmless. Re-press
2090
+ * = fresh enter with new clones. */
2091
+ exit_clone(session) {
2092
+ subtree.remove_plan(this.deps.get_doc(), session.clone.plan);
2093
+ session.clone = null;
2094
+ this.retarget(session, session.ids);
2095
+ }
2096
+ /** Point selection + snap at the new mover set. Selection + emit run
2097
+ * BEFORE the snap reopen: the surface re-renders on notify, so freshly
2098
+ * inserted clones are measurable when the new snap session captures
2099
+ * its neighborhood (where the origins are legitimate snap targets). */
2100
+ retarget(session, ids) {
2101
+ this.deps.set_selection?.(ids);
2102
+ this.deps.emit();
2103
+ session.snap?.dispose();
2104
+ session.snap = session.snap_requested ? this.deps.open_snap(ids) : null;
2105
+ }
1883
2106
  open(ids, snap, label) {
1884
2107
  const doc = this.deps.get_doc();
1885
2108
  const filtered = doc.prune_nested_nodes(ids);
@@ -1887,10 +2110,12 @@ var TranslateOrchestrator = class {
1887
2110
  ids: filtered,
1888
2111
  baselines: translate_pipeline.intent.capture_baselines(doc, filtered),
1889
2112
  snap: snap ? this.deps.open_snap(filtered) : null,
2113
+ snap_requested: snap,
1890
2114
  preview: this.deps.open_preview(label),
1891
2115
  last_movement: [0, 0],
1892
2116
  last_policy: "engine",
1893
- last_stages: translate_pipeline.stages.DEFAULT
2117
+ last_stages: translate_pipeline.stages.DEFAULT,
2118
+ clone: null
1894
2119
  };
1895
2120
  }
1896
2121
  /** Bind a fresh apply/revert pair (closure over `plan`) into the
@@ -1912,6 +2137,43 @@ var TranslateOrchestrator = class {
1912
2137
  }
1913
2138
  });
1914
2139
  }
2140
+ /** Commit-time delta for a CLONED gesture. Per-frame deltas stay
2141
+ * translate-only; the structural insert happened at the toggle edge,
2142
+ * outside the preview. The one delta that gets committed must own the
2143
+ * whole outcome, so a single undo removes clone + move and a redo
2144
+ * restores both:
2145
+ * apply = insert clones (idempotent reposition when live) +
2146
+ * translate + select clones
2147
+ * revert = un-translate + remove clones + select origins
2148
+ * `preview.set` reverts the previous translate-only delta first
2149
+ * (clones back to rest), then runs this apply — the document passes
2150
+ * through exactly the states the closures describe.
2151
+ * Captures locals only (not the session), so the committed delta
2152
+ * retains the minimum undo/redo needs. */
2153
+ write_commit_composite(session, plan) {
2154
+ const doc = this.deps.get_doc();
2155
+ const emit = this.deps.emit;
2156
+ const project = this.deps.project_delta;
2157
+ const set_selection = this.deps.set_selection;
2158
+ const clone_plan = session.clone.plan;
2159
+ const clone_ids = session.clone.ids;
2160
+ const origin_ids = session.ids;
2161
+ session.preview.set({
2162
+ providerId: PROVIDER_ID$2,
2163
+ apply: () => {
2164
+ subtree.insert_plan(doc, clone_plan);
2165
+ translate_pipeline.apply(doc, plan, project);
2166
+ set_selection?.(clone_ids);
2167
+ emit();
2168
+ },
2169
+ revert: () => {
2170
+ translate_pipeline.revert(doc, plan);
2171
+ subtree.remove_plan(doc, clone_plan);
2172
+ set_selection?.(origin_ids);
2173
+ emit();
2174
+ }
2175
+ });
2176
+ }
1915
2177
  dispose_session() {
1916
2178
  if (!this.active) return;
1917
2179
  this.active.snap?.dispose();
@@ -2772,6 +3034,60 @@ function scale_path_d(d, origin, sx, sy) {
2772
3034
  }
2773
3035
  }
2774
3036
  /**
3037
+ * `dispatch_resize`'s write surface as data: the attribute names each
3038
+ * handler may write, by tag. Capture-side snapshots (the resize
3039
+ * pipeline's `baseline.raw`) are built from this list so revert can
3040
+ * restore exactly what a handler may touch — when a handler starts
3041
+ * writing a new attribute, extend this table or undo will silently
3042
+ * miss it. Cross-checked against the live handlers by
3043
+ * `__tests__/resize-snapshot-coverage.test.ts`.
3044
+ */
3045
+ const RESIZE_WRITE_ATTRS = {
3046
+ rect: [
3047
+ "x",
3048
+ "y",
3049
+ "width",
3050
+ "height"
3051
+ ],
3052
+ image: [
3053
+ "x",
3054
+ "y",
3055
+ "width",
3056
+ "height"
3057
+ ],
3058
+ use: [
3059
+ "x",
3060
+ "y",
3061
+ "width",
3062
+ "height"
3063
+ ],
3064
+ circle: [
3065
+ "cx",
3066
+ "cy",
3067
+ "r"
3068
+ ],
3069
+ ellipse: [
3070
+ "cx",
3071
+ "cy",
3072
+ "rx",
3073
+ "ry"
3074
+ ],
3075
+ line: [
3076
+ "x1",
3077
+ "y1",
3078
+ "x2",
3079
+ "y2"
3080
+ ],
3081
+ polyline: ["points"],
3082
+ polygon: ["points"],
3083
+ path: ["d"],
3084
+ text: [
3085
+ "x",
3086
+ "y",
3087
+ "font-size"
3088
+ ]
3089
+ };
3090
+ /**
2775
3091
  * VertexChain × resize — vertex transport in local space.
2776
3092
  * Line carries its own (x1, y1, x2, y2); polyline / polygon share `points`.
2777
3093
  * Result type is preserved.
@@ -3078,6 +3394,15 @@ let resize_pipeline;
3078
3394
  function num(doc, id, name, fallback = 0) {
3079
3395
  return _grida_svg_parse.svg_parse.parse_number(doc.get_attr(id, name), fallback);
3080
3396
  }
3397
+ /** Attribute names a resize gesture may write for `tag`: the
3398
+ * handler write surface (`RESIZE_WRITE_ATTRS`, owned next to the
3399
+ * handlers) plus `transform` — the pipeline's own write
3400
+ * (commit-phase `renormalize_rotate_pivot`). Drives the
3401
+ * `baseline.raw` snapshot that `restore` writes back. */
3402
+ function writable_attrs(tag) {
3403
+ const handler_writes = RESIZE_WRITE_ATTRS[tag];
3404
+ return handler_writes ? [...handler_writes, "transform"] : [];
3405
+ }
3081
3406
  function is_resizable(tag) {
3082
3407
  switch (tag) {
3083
3408
  case "rect":
@@ -3179,12 +3504,21 @@ let resize_pipeline;
3179
3504
  break;
3180
3505
  default: attrs = { kind: "unsupported" };
3181
3506
  }
3507
+ const raw = writable_attrs(tag).map((name) => ({
3508
+ name,
3509
+ value: doc.get_attr(id, name)
3510
+ }));
3182
3511
  return {
3183
3512
  bbox,
3184
- attrs
3513
+ attrs,
3514
+ raw
3185
3515
  };
3186
3516
  }
3187
3517
  _intent.capture_baseline = capture_baseline;
3518
+ function restore(doc, id, baseline) {
3519
+ for (const a of baseline.raw) doc.set_attr(id, a.name, a.value);
3520
+ }
3521
+ _intent.restore = restore;
3188
3522
  function compute_factors(baseline, dir, dx, dy, shift) {
3189
3523
  const b = baseline.bbox;
3190
3524
  let anchorX = 0;
@@ -3587,12 +3921,11 @@ let resize_pipeline;
3587
3921
  }
3588
3922
  _resize_pipeline.apply = apply;
3589
3923
  function revert(doc, plan) {
3590
- const f = intent.compute_factors(plan.baseline, plan.direction, 0, 0, false);
3591
3924
  const members = plan.members ?? [{
3592
3925
  id: plan.id,
3593
3926
  baseline: plan.baseline
3594
3927
  }];
3595
- for (const m of members) intent.apply(doc, m.id, m.baseline, 1, 1, f.origin, "preview");
3928
+ for (const m of members) intent.restore(doc, m.id, m.baseline);
3596
3929
  }
3597
3930
  _resize_pipeline.revert = revert;
3598
3931
  function synthesize_group_baseline(union) {
@@ -3609,7 +3942,8 @@ let resize_pipeline;
3609
3942
  y: union.y,
3610
3943
  w: union.width,
3611
3944
  h: union.height
3612
- }
3945
+ },
3946
+ raw: []
3613
3947
  };
3614
3948
  }
3615
3949
  _resize_pipeline.synthesize_group_baseline = synthesize_group_baseline;
@@ -4915,6 +5249,12 @@ Object.defineProperty(exports, "TranslateOrchestrator", {
4915
5249
  return TranslateOrchestrator;
4916
5250
  }
4917
5251
  });
5252
+ Object.defineProperty(exports, "WELL_KNOWN_NS_PREFIXES", {
5253
+ enumerable: true,
5254
+ get: function() {
5255
+ return WELL_KNOWN_NS_PREFIXES;
5256
+ }
5257
+ });
4918
5258
  Object.defineProperty(exports, "__exportAll", {
4919
5259
  enumerable: true,
4920
5260
  get: function() {
@@ -4975,6 +5315,12 @@ Object.defineProperty(exports, "rotate_pipeline", {
4975
5315
  return rotate_pipeline;
4976
5316
  }
4977
5317
  });
5318
+ Object.defineProperty(exports, "subtree", {
5319
+ enumerable: true,
5320
+ get: function() {
5321
+ return subtree;
5322
+ }
5323
+ });
4978
5324
  Object.defineProperty(exports, "transform", {
4979
5325
  enumerable: true,
4980
5326
  get: function() {