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

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.
@@ -31,6 +31,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
31
31
  //#endregion
32
32
  let _grida_cmath = require("@grida/cmath");
33
33
  _grida_cmath = __toESM(_grida_cmath);
34
+ let _grida_svg_parser = require("@grida/svg/parser");
34
35
  let _grida_svg_parse = require("@grida/svg/parse");
35
36
  let _grida_svg_pathdata = require("@grida/svg/pathdata");
36
37
  let _grida_vn = require("@grida/vn");
@@ -59,75 +60,983 @@ function array_shallow_equal(a, b) {
59
60
  return true;
60
61
  }
61
62
  //#endregion
62
- //#region src/core/group.ts
63
- let group;
64
- (function(_group) {
65
- const STRUCTURAL_GRAPHICS = _group.STRUCTURAL_GRAPHICS = new Set([
66
- "g",
67
- "defs",
68
- "svg",
69
- "use",
70
- "image",
71
- "switch",
72
- "foreignObject",
73
- "path",
74
- "rect",
75
- "circle",
76
- "ellipse",
77
- "line",
78
- "polyline",
79
- "polygon",
80
- "text",
81
- "a"
82
- ]);
83
- const CONSTRAINED_PARENT = _group.CONSTRAINED_PARENT = new Set([
84
- "text",
85
- "tspan",
86
- "defs",
87
- "clipPath",
88
- "mask",
89
- "pattern",
90
- "marker",
91
- "symbol",
92
- "filter",
93
- "linearGradient",
94
- "radialGradient",
95
- "animateMotion",
96
- "switch"
97
- ]);
98
- function plan(doc, ids) {
99
- if (ids.length === 0) return null;
100
- const parent = doc.parent_of(ids[0]);
63
+ //#region src/core/document.ts
64
+ /** The native vector tags `retype_to_path` can re-type, keyed by tag → the
65
+ * native geometry attributes it consumes (so no orphaned geometry attr
66
+ * survives on the resulting `<path>`). Covers the geometry primitives
67
+ * (rect / circle / ellipse — always re-typed) and the vertex tags (line /
68
+ * polyline / polygon — re-typed only when an edit escapes their native
69
+ * form). */
70
+ const RETYPABLE_GEOMETRY_ATTRS = {
71
+ line: new Set([
72
+ "x1",
73
+ "y1",
74
+ "x2",
75
+ "y2"
76
+ ]),
77
+ polyline: new Set(["points"]),
78
+ polygon: new Set(["points"]),
79
+ rect: new Set([
80
+ "x",
81
+ "y",
82
+ "width",
83
+ "height",
84
+ "rx",
85
+ "ry"
86
+ ]),
87
+ circle: new Set([
88
+ "cx",
89
+ "cy",
90
+ "r"
91
+ ]),
92
+ ellipse: new Set([
93
+ "cx",
94
+ "cy",
95
+ "rx",
96
+ "ry"
97
+ ])
98
+ };
99
+ /**
100
+ * Parse a single SVG length attribute as a plain user-unit number. Returns
101
+ * `null` for absent, non-finite, or unit/percentage values (`50%`, `5px`,
102
+ * `5em`) — those are an out-of-scope geometry gap, and refusing them here
103
+ * means the editor never offers a promotion it cannot perform faithfully.
104
+ */
105
+ function parse_user_unit(raw) {
106
+ if (raw === null) return null;
107
+ const s = raw.trim();
108
+ if (s === "") return null;
109
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
110
+ const n = Number(s);
111
+ return Number.isFinite(n) ? n : null;
112
+ }
113
+ /**
114
+ * Attribute names whose writes can shift a node's rendered bounds.
115
+ * Membership drives `_geometry_version` bumps in `set_attr`. Only
116
+ * non-namespaced attribute names — namespaced writes (xlink:href, etc.)
117
+ * never bump because they're references, not geometry.
118
+ *
119
+ * Includes text-shaping attributes (font-*) because they re-shape glyph
120
+ * runs and change `<text>` bbox.
121
+ */
122
+ const GEOMETRY_ATTRS = new Set([
123
+ "x",
124
+ "y",
125
+ "x1",
126
+ "y1",
127
+ "x2",
128
+ "y2",
129
+ "cx",
130
+ "cy",
131
+ "width",
132
+ "height",
133
+ "r",
134
+ "rx",
135
+ "ry",
136
+ "points",
137
+ "d",
138
+ "transform",
139
+ "viewBox",
140
+ "font-size",
141
+ "font-family",
142
+ "font-weight",
143
+ "font-style",
144
+ "text-anchor",
145
+ "dx",
146
+ "dy",
147
+ "rotate",
148
+ "textLength",
149
+ "lengthAdjust",
150
+ "pathLength",
151
+ "marker-start",
152
+ "marker-mid",
153
+ "marker-end"
154
+ ]);
155
+ /** `transform:` CSS property at the start of a declaration list or after `;`. */
156
+ const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
157
+ var SvgDocument = class SvgDocument {
158
+ constructor(svg) {
159
+ this.listeners = /* @__PURE__ */ new Set();
160
+ this._structure_version = 0;
161
+ this._geometry_version = 0;
162
+ if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
163
+ this.source = svg;
164
+ const parsed = (0, _grida_svg_parser.parse_svg)(svg);
165
+ this.original = parsed;
166
+ this.nodes = parsed.nodes;
167
+ this.prolog = parsed.prolog;
168
+ this.epilog = parsed.epilog;
169
+ this.root = parsed.root;
170
+ }
171
+ static parse(svg) {
172
+ return new SvgDocument(svg);
173
+ }
174
+ /** Reload from the original parse, discarding all edits. */
175
+ reset_to_original() {
176
+ const parsed = (0, _grida_svg_parser.parse_svg)(this.source);
177
+ this.original = parsed;
178
+ this.nodes = parsed.nodes;
179
+ this.prolog = parsed.prolog;
180
+ this.epilog = parsed.epilog;
181
+ this.root = parsed.root;
182
+ this._structure_version++;
183
+ this._geometry_version++;
184
+ this.emit();
185
+ }
186
+ /** Replace document with new svg source (clears edits + history-owned state). */
187
+ load(svg) {
188
+ if (typeof svg !== "string") throw new TypeError(`SvgDocument.load(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
189
+ this.source = svg;
190
+ const parsed = (0, _grida_svg_parser.parse_svg)(svg);
191
+ this.original = parsed;
192
+ this.nodes = parsed.nodes;
193
+ this.prolog = parsed.prolog;
194
+ this.epilog = parsed.epilog;
195
+ this.root = parsed.root;
196
+ this._structure_version++;
197
+ this._geometry_version++;
198
+ this.emit();
199
+ }
200
+ on_change(fn) {
201
+ this.listeners.add(fn);
202
+ return () => this.listeners.delete(fn);
203
+ }
204
+ /** See `_structure_version` for what this counter signals. */
205
+ get structure_version() {
206
+ return this._structure_version;
207
+ }
208
+ /** See `_geometry_version` for what this counter signals. */
209
+ get geometry_version() {
210
+ return this._geometry_version;
211
+ }
212
+ /**
213
+ * Advance `_geometry_version` by exactly 1 WITHOUT touching the tree,
214
+ * any attribute, `structure_version`, or the `on_change` listeners.
215
+ *
216
+ * The one geometry mutation with no attribute write: a `<text>` /
217
+ * `<tspan>` reflow the IR cannot see — a web font finishing load AFTER
218
+ * the `font-family` / `font-size` write was already serialized. The DOM
219
+ * surface observes the reflow (`document.fonts` `loadingdone`) and asks
220
+ * the geometry channel to advance so the bounds cache re-reads the
221
+ * settled glyph metrics. See ../../docs/geometry.md §Limitations.
222
+ *
223
+ * 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.
228
+ */
229
+ bump_geometry() {
230
+ this._geometry_version++;
231
+ }
232
+ emit() {
233
+ for (const fn of this.listeners) fn();
234
+ }
235
+ /** Notify subscribers — for callers that mutate directly via setAttr/etc. */
236
+ notify() {
237
+ this.emit();
238
+ }
239
+ get(id) {
240
+ return this.nodes.get(id) ?? null;
241
+ }
242
+ is_element(id) {
243
+ return this.nodes.get(id)?.kind === "element";
244
+ }
245
+ parent_of(id) {
246
+ return this.nodes.get(id)?.parent ?? null;
247
+ }
248
+ children_of(id) {
249
+ const n = this.nodes.get(id);
250
+ if (!n || n.kind !== "element") return [];
251
+ return n.children;
252
+ }
253
+ /** Element children only — text/comment/cdata filtered out. */
254
+ element_children_of(id) {
255
+ return this.children_of(id).filter((c) => this.is_element(c));
256
+ }
257
+ next_sibling_of(id) {
258
+ const parent = this.parent_of(id);
101
259
  if (parent === null) return null;
102
- for (const id of ids) {
103
- if (doc.parent_of(id) !== parent) return null;
104
- if (!STRUCTURAL_GRAPHICS.has(doc.tag_of(id))) return null;
260
+ const siblings = this.children_of(parent);
261
+ const i = siblings.indexOf(id);
262
+ return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
263
+ }
264
+ next_element_sibling_of(id) {
265
+ const parent = this.parent_of(id);
266
+ if (parent === null) return null;
267
+ const siblings = this.element_children_of(parent);
268
+ const i = siblings.indexOf(id);
269
+ return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
270
+ }
271
+ tag_of(id) {
272
+ const n = this.nodes.get(id);
273
+ return n && n.kind === "element" ? n.local : "";
274
+ }
275
+ contains(ancestor, descendant) {
276
+ if (ancestor === descendant) return true;
277
+ let cur = this.parent_of(descendant);
278
+ while (cur !== null) {
279
+ if (cur === ancestor) return true;
280
+ cur = this.parent_of(cur);
105
281
  }
106
- if (CONSTRAINED_PARENT.has(doc.tag_of(parent))) return null;
107
- const siblings = doc.element_children_of(parent);
108
- const sibling_index = /* @__PURE__ */ new Map();
109
- for (let i = 0; i < siblings.length; i++) sibling_index.set(siblings[i], i);
110
- const indices = [];
282
+ return false;
283
+ }
284
+ /**
285
+ * Filter a selection down to its **subtree roots** drop any id whose
286
+ * ancestor is also in the input set.
287
+ *
288
+ * Mirrors `pruneNestedNodes` in the main canvas editor's query module
289
+ * ([editor/grida-canvas/query/index.ts:138](../../../../editor/grida-canvas/query/index.ts)) and shares its UX motivation:
290
+ * when a parent and a descendant are both selected, only the parent
291
+ * should drive multi-node mutations — otherwise the descendant
292
+ * accumulates the transform twice (once via the parent's `transform`,
293
+ * once via its own attribute write). Required for `commands.remove`
294
+ * (avoids re-attaching detached descendants on undo) and any multi-
295
+ * member translate path (avoids 2× drift for the Bar-chart marquee
296
+ * case).
297
+ *
298
+ * Order: preserves the input order for retained ids. Duplicates in
299
+ * the input are not deduplicated — callers are responsible (the
300
+ * editor's `commands.select` already dedupes).
301
+ *
302
+ * Performance: `O(n × depth)`. Builds a `Set` over the input once,
303
+ * then walks each id's ancestor chain at most once. The main editor's
304
+ * version is `O(n² × depth)` (per-pair `isAncestor`) — fine at typical
305
+ * selection sizes (a few dozen), worth winning here for free since
306
+ * `parent_of` is `O(1)` on our parent-map.
307
+ */
308
+ prune_nested_nodes(ids) {
309
+ if (ids.length <= 1) return [...ids];
310
+ const set = new Set(ids);
311
+ const out = [];
111
312
  for (const id of ids) {
112
- const i = sibling_index.get(id);
113
- if (i === void 0) return null;
114
- indices.push(i);
313
+ let nested = false;
314
+ let cur = this.parent_of(id);
315
+ while (cur !== null) {
316
+ if (set.has(cur)) {
317
+ nested = true;
318
+ break;
319
+ }
320
+ cur = this.parent_of(cur);
321
+ }
322
+ if (!nested) out.push(id);
115
323
  }
116
- const sorted = Array.from(new Set(indices)).sort((a, b) => a - b);
117
- for (let i = 1; i < sorted.length; i++) if (sorted[i] !== sorted[i - 1] + 1) return null;
118
- const children = sorted.map((i) => siblings[i]);
119
- const last_index = sorted[sorted.length - 1];
120
- const original_positions = /* @__PURE__ */ new Map();
121
- for (const i of sorted) original_positions.set(siblings[i], siblings[i + 1] ?? null);
324
+ return out;
325
+ }
326
+ all_nodes() {
327
+ const out = [];
328
+ const walk = (id) => {
329
+ out.push(id);
330
+ const c = this.children_of(id);
331
+ for (const ch of c) walk(ch);
332
+ };
333
+ walk(this.root);
334
+ return out;
335
+ }
336
+ all_elements() {
337
+ return this.all_nodes().filter((id) => this.is_element(id));
338
+ }
339
+ find_by_tag(ancestor, tag) {
340
+ const out = [];
341
+ const walk = (id) => {
342
+ if (id !== ancestor && this.is_element(id) && this.tag_of(id) === tag) out.push(id);
343
+ for (const c of this.children_of(id)) walk(c);
344
+ };
345
+ walk(ancestor);
346
+ return out;
347
+ }
348
+ /** Read attribute by local name, optionally namespace-filtered. */
349
+ get_attr(id, name, ns = null) {
350
+ const n = this.nodes.get(id);
351
+ if (!n || n.kind !== "element") return null;
352
+ for (const a of n.attrs) if (a.local === name && (ns === null || a.ns === ns)) return a.value;
353
+ return null;
354
+ }
355
+ /**
356
+ * Set / remove an attribute. If the attribute exists, it is mutated in place
357
+ * (preserving source position). If it doesn't, it's appended.
358
+ */
359
+ set_attr(id, name, value, ns = null) {
360
+ const n = this.nodes.get(id);
361
+ if (!n || n.kind !== "element") return;
362
+ const structural = name === "id";
363
+ const geometry = ns === null && GEOMETRY_ATTRS.has(name);
364
+ for (let i = 0; i < n.attrs.length; i++) {
365
+ const a = n.attrs[i];
366
+ if (a.local === name && (ns === null || a.ns === ns)) {
367
+ if (value === null) n.attrs.splice(i, 1);
368
+ else a.value = value;
369
+ if (structural) this._structure_version++;
370
+ if (geometry) this._geometry_version++;
371
+ this.emit();
372
+ return;
373
+ }
374
+ }
375
+ if (value !== null) {
376
+ const prefix = ns === _grida_svg_parser.XLINK_NS ? "xlink" : ns === _grida_svg_parser.XMLNS_NS ? "xmlns" : null;
377
+ n.attrs.push({
378
+ raw_name: prefix ? `${prefix}:${name}` : name,
379
+ prefix,
380
+ local: name,
381
+ ns,
382
+ value,
383
+ pre: " ",
384
+ eq_trivia: "",
385
+ quote: "\""
386
+ });
387
+ if (structural) this._structure_version++;
388
+ if (geometry) this._geometry_version++;
389
+ this.emit();
390
+ }
391
+ }
392
+ attributes_of(id) {
393
+ const n = this.nodes.get(id);
394
+ if (!n || n.kind !== "element") return [];
395
+ return n.attrs.map((a) => ({
396
+ name: a.local,
397
+ ns: a.ns,
398
+ value: a.value
399
+ }));
400
+ }
401
+ get_style(id, property) {
402
+ const style = this.get_attr(id, "style");
403
+ if (!style) return null;
404
+ const decls = parse_inline_style(style);
405
+ for (const d of decls) if (d.property === property) return d.value;
406
+ return null;
407
+ }
408
+ set_style(id, property, value) {
409
+ const decls = parse_inline_style(this.get_attr(id, "style") ?? "");
410
+ const idx = decls.findIndex((d) => d.property === property);
411
+ if (value === null) {
412
+ if (idx === -1) return;
413
+ decls.splice(idx, 1);
414
+ } else if (idx === -1) decls.push({
415
+ property,
416
+ value
417
+ });
418
+ else decls[idx].value = value;
419
+ const next = decls.map((d) => `${d.property}: ${d.value}`).join("; ");
420
+ this.set_attr(id, "style", next === "" ? null : next);
421
+ }
422
+ get_all_styles(id) {
423
+ const style = this.get_attr(id, "style");
424
+ if (!style) return [];
425
+ return parse_inline_style(style);
426
+ }
427
+ /**
428
+ * Whether `id` can be opened in the flat-string text editor.
429
+ *
430
+ * v1 contract: the editor only operates on a *single flat text run*. That
431
+ * means the target must be a `<text>` or `<tspan>` whose direct children
432
+ * are all text nodes (or it has no children). A `<text>` containing a
433
+ * `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
434
+ * content from the editor's view, and a flat-text write would leave the
435
+ * tspan dangling. Tspan-as-target is fine and well-defined when it's a
436
+ * leaf; only the host decides whether to route double-click to a tspan
437
+ * or its parent text.
438
+ */
439
+ is_text_edit_target(id) {
440
+ const n = this.nodes.get(id);
441
+ if (!n || n.kind !== "element") return false;
442
+ if (n.local !== "text" && n.local !== "tspan") return false;
443
+ for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
444
+ return true;
445
+ }
446
+ /**
447
+ * Returns a tag-discriminated snapshot of the authored geometry attrs
448
+ * if this node is eligible for vector (vertex) editing — else `null`.
449
+ *
450
+ * Eligibility:
451
+ * - `<path>` — requires non-empty `d`.
452
+ * - `<line>` — requires two distinct finite user-unit endpoints.
453
+ * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
454
+ * - `<polygon>` — same as polyline.
455
+ * - `<rect>` — requires finite user-unit `width`/`height` > 0.
456
+ * - `<circle>` — requires finite user-unit `r` > 0.
457
+ * - `<ellipse>` — requires finite user-unit `rx`/`ry` > 0.
458
+ *
459
+ * The vertex tags (`line` / `polyline` / `polygon`) write edits back to
460
+ * their native attributes while the geometry stays expressible there; an
461
+ * edit that escapes the native form (a curve, or a topology change that
462
+ * leaves the canonical chain) re-types the element to `<path>`. The
463
+ * geometry primitives (`rect` / `circle` / `ellipse`) have no native
464
+ * vector form, so any vector edit re-types them. In all cases the native
465
+ * tag is preserved byte-for-byte until the first re-typing edit commits
466
+ * (see `retype_to_path`). Design:
467
+ * `docs/wg/feat-svg-editor/promote-to-path.md`.
468
+ *
469
+ * Geometry that is not a plain user-unit number (`%`, `px`, `em`, …) is
470
+ * an out-of-scope gap, so such an element returns `null` rather than
471
+ * advertising an edit the editor cannot perform faithfully.
472
+ *
473
+ * Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
474
+ * editable outline).
475
+ */
476
+ /**
477
+ * Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
478
+ * endpoints). An **absent** attribute takes the SVG default (`0`); a
479
+ * **present** attribute that is not a plain user-unit number (`%`, `px`,
480
+ * `em`, …) is out of scope and yields `null` so the caller refuses the
481
+ * element — the same gate required attrs (width / radius) already apply.
482
+ *
483
+ * The absent-vs-present distinction is the point: a bare `?? 0` would
484
+ * silently coerce an authored `x1="5px"` to `0`, then the first native
485
+ * writeback would overwrite that authored value. Refusing keeps the
486
+ * editor from misrepresenting geometry it cannot read faithfully.
487
+ */
488
+ optional_user_unit_coord(id, name) {
489
+ const raw = this.get_attr(id, name);
490
+ if (raw === null) return 0;
491
+ return parse_user_unit(raw);
492
+ }
493
+ is_vector_edit_target(id) {
494
+ const n = this.nodes.get(id);
495
+ if (!n || n.kind !== "element") return null;
496
+ if (RETYPABLE_GEOMETRY_ATTRS[n.local] && n.attrs.some((a) => a.prefix === null && a.ns === null && a.local === "d")) return null;
497
+ switch (n.local) {
498
+ case "path": {
499
+ const d = this.get_attr(id, "d");
500
+ if (d === null || d.trim().length === 0) return null;
501
+ return {
502
+ kind: "path",
503
+ d
504
+ };
505
+ }
506
+ case "line": {
507
+ const x1 = this.optional_user_unit_coord(id, "x1");
508
+ const y1 = this.optional_user_unit_coord(id, "y1");
509
+ const x2 = this.optional_user_unit_coord(id, "x2");
510
+ const y2 = this.optional_user_unit_coord(id, "y2");
511
+ if (x1 === null || y1 === null || x2 === null || y2 === null) return null;
512
+ if (x1 === x2 && y1 === y2) return null;
513
+ return {
514
+ kind: "line",
515
+ x1,
516
+ y1,
517
+ x2,
518
+ y2
519
+ };
520
+ }
521
+ case "polyline":
522
+ case "polygon": {
523
+ const raw = this.get_attr(id, "points") ?? "";
524
+ const parsed = _grida_svg_parse.svg_parse.parse_points(raw);
525
+ if (parsed.length < 2) return null;
526
+ const points = parsed.map((p) => [p.x, p.y]);
527
+ return n.local === "polyline" ? {
528
+ kind: "polyline",
529
+ points
530
+ } : {
531
+ kind: "polygon",
532
+ points
533
+ };
534
+ }
535
+ case "rect": {
536
+ const x = this.optional_user_unit_coord(id, "x");
537
+ const y = this.optional_user_unit_coord(id, "y");
538
+ if (x === null || y === null) return null;
539
+ const width = parse_user_unit(this.get_attr(id, "width"));
540
+ const height = parse_user_unit(this.get_attr(id, "height"));
541
+ if (width === null || height === null) return null;
542
+ if (width <= 0 || height <= 0) return null;
543
+ const rx_attr = this.get_attr(id, "rx");
544
+ const ry_attr = this.get_attr(id, "ry");
545
+ const rx_parsed = rx_attr === null ? null : parse_user_unit(rx_attr);
546
+ const ry_parsed = ry_attr === null ? null : parse_user_unit(ry_attr);
547
+ if (rx_attr !== null && rx_parsed === null) return null;
548
+ if (ry_attr !== null && ry_parsed === null) return null;
549
+ let rx = rx_parsed ?? ry_parsed ?? 0;
550
+ let ry = ry_parsed ?? rx_parsed ?? 0;
551
+ rx = Math.max(0, Math.min(rx, width / 2));
552
+ ry = Math.max(0, Math.min(ry, height / 2));
553
+ return {
554
+ kind: "rect",
555
+ x,
556
+ y,
557
+ width,
558
+ height,
559
+ rx,
560
+ ry
561
+ };
562
+ }
563
+ case "circle": {
564
+ const cx = this.optional_user_unit_coord(id, "cx");
565
+ const cy = this.optional_user_unit_coord(id, "cy");
566
+ if (cx === null || cy === null) return null;
567
+ const r = parse_user_unit(this.get_attr(id, "r"));
568
+ if (r === null || r <= 0) return null;
569
+ return {
570
+ kind: "circle",
571
+ cx,
572
+ cy,
573
+ r
574
+ };
575
+ }
576
+ case "ellipse": {
577
+ const cx = this.optional_user_unit_coord(id, "cx");
578
+ const cy = this.optional_user_unit_coord(id, "cy");
579
+ if (cx === null || cy === null) return null;
580
+ const rx = parse_user_unit(this.get_attr(id, "rx"));
581
+ const ry = parse_user_unit(this.get_attr(id, "ry"));
582
+ if (rx === null || ry === null) return null;
583
+ if (rx <= 0 || ry <= 0) return null;
584
+ return {
585
+ kind: "ellipse",
586
+ cx,
587
+ cy,
588
+ rx,
589
+ ry
590
+ };
591
+ }
592
+ default: return null;
593
+ }
594
+ }
595
+ /**
596
+ * Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
597
+ * `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
598
+ * its native geometry attributes and setting `d`. A structural mutation:
599
+ * this layer executes the re-type; it does not decide when one is
600
+ * warranted.
601
+ *
602
+ * Idempotent: returns `null` if `id` is not currently one of those tags
603
+ * (so it is safe to call repeatedly — once re-typed, e.g. already a
604
+ * `<path>`, further calls are no-ops). Otherwise mutates the node and
605
+ * returns an opaque {@link RetypeRecord} reversal token.
606
+ *
607
+ * Identity, children, `self_closing`, non-geometry attributes, and all
608
+ * source trivia are preserved unchanged — only the tag and the geometry
609
+ * attributes move. Pass the token to {@link revert_retype} to restore
610
+ * the original primitive byte-for-byte.
611
+ *
612
+ * (see test/svg-editor-vector-promote-to-path.md)
613
+ */
614
+ retype_to_path(id, d) {
615
+ const n = this.nodes.get(id);
616
+ if (!n || n.kind !== "element") return null;
617
+ const geom = RETYPABLE_GEOMETRY_ATTRS[n.local];
618
+ if (!geom) return null;
619
+ const prev_local = n.local;
620
+ const prev_raw_tag = n.raw_tag;
621
+ const removed = [];
622
+ for (let i = n.attrs.length - 1; i >= 0; i--) {
623
+ const a = n.attrs[i];
624
+ if (a.prefix === null && a.ns === null && geom.has(a.local)) {
625
+ removed.push({
626
+ index: i,
627
+ token: a
628
+ });
629
+ n.attrs.splice(i, 1);
630
+ }
631
+ }
632
+ removed.reverse();
633
+ n.local = "path";
634
+ n.raw_tag = n.prefix ? `${n.prefix}:path` : "path";
635
+ n.attrs.push({
636
+ raw_name: "d",
637
+ prefix: null,
638
+ local: "d",
639
+ ns: null,
640
+ value: d,
641
+ pre: " ",
642
+ eq_trivia: "",
643
+ quote: "\""
644
+ });
645
+ let added_fill_none = false;
646
+ if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
647
+ n.attrs.push({
648
+ raw_name: "fill",
649
+ prefix: null,
650
+ local: "fill",
651
+ ns: null,
652
+ value: "none",
653
+ pre: " ",
654
+ eq_trivia: "",
655
+ quote: "\""
656
+ });
657
+ added_fill_none = true;
658
+ }
659
+ this._structure_version++;
660
+ this._geometry_version++;
661
+ this.emit();
122
662
  return {
123
- parent,
124
- insert_before: siblings[last_index + 1] ?? null,
125
- children,
126
- original_positions
663
+ prev_local,
664
+ prev_raw_tag,
665
+ removed,
666
+ added_fill_none
127
667
  };
128
668
  }
129
- _group.plan = plan;
130
- })(group || (group = {}));
669
+ /**
670
+ * Reverse a {@link retype_to_path}: restore the original tag, remove the
671
+ * `d` attribute the promotion added, and splice the captured geometry
672
+ * attribute tokens back at their original positions (preserving their
673
+ * trivia, so a later `serialize()` is byte-equal to the pre-promotion
674
+ * source).
675
+ */
676
+ revert_retype(id, token) {
677
+ const n = this.nodes.get(id);
678
+ if (!n || n.kind !== "element") return;
679
+ for (let i = n.attrs.length - 1; i >= 0; i--) {
680
+ const a = n.attrs[i];
681
+ if (a.prefix === null && a.ns === null && a.local === "d") {
682
+ n.attrs.splice(i, 1);
683
+ break;
684
+ }
685
+ }
686
+ if (token.added_fill_none) for (let i = n.attrs.length - 1; i >= 0; i--) {
687
+ const a = n.attrs[i];
688
+ if (a.prefix === null && a.ns === null && a.local === "fill") {
689
+ n.attrs.splice(i, 1);
690
+ break;
691
+ }
692
+ }
693
+ n.local = token.prev_local;
694
+ n.raw_tag = token.prev_raw_tag;
695
+ for (const { index, token: t } of token.removed) n.attrs.splice(index, 0, t);
696
+ this._structure_version++;
697
+ this._geometry_version++;
698
+ this.emit();
699
+ }
700
+ /**
701
+ * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
702
+ * per-glyph attribute (which conflicts with element-level rotation).
703
+ */
704
+ has_glyph_rotate(id) {
705
+ const tag = this.tag_of(id);
706
+ if (tag !== "text" && tag !== "tspan") return false;
707
+ const value = this.get_attr(id, "rotate");
708
+ if (value === null) return false;
709
+ return value.trim() !== "";
710
+ }
711
+ /**
712
+ * True iff this element's inline `style=""` declares a `transform:`
713
+ * CSS property (which would shadow the editor's `transform=` writes).
714
+ */
715
+ has_inline_css_transform(id) {
716
+ const style = this.get_attr(id, "style");
717
+ if (!style) return false;
718
+ return CSS_TRANSFORM_PROPERTY.test(style);
719
+ }
720
+ /**
721
+ * True iff this element has a direct `<animateTransform>` child
722
+ * (which produces a time-varying transform invisible to attribute writes).
723
+ * Only direct children are checked — nested cases attach to the nearer ancestor.
724
+ */
725
+ has_animate_transform_child(id) {
726
+ for (const c of this.children_of(id)) {
727
+ const n = this.nodes.get(c);
728
+ if (n?.kind === "element" && n.local === "animateTransform") return true;
729
+ }
730
+ return false;
731
+ }
732
+ text_of(id) {
733
+ const n = this.nodes.get(id);
734
+ if (!n || n.kind !== "element") return "";
735
+ let out = "";
736
+ for (const c of n.children) {
737
+ const cn = this.nodes.get(c);
738
+ if (cn?.kind === "text") out += cn.value;
739
+ }
740
+ return out;
741
+ }
742
+ /** Replace all direct text children with a single text node carrying `value`. */
743
+ set_text(id, value) {
744
+ const n = this.nodes.get(id);
745
+ if (!n || n.kind !== "element") return;
746
+ n.children = n.children.filter((c) => this.nodes.get(c)?.kind !== "text");
747
+ if (value !== "") {
748
+ const text_id = `t${Math.random().toString(36).slice(2, 10)}`;
749
+ const text_node = {
750
+ kind: "text",
751
+ id: text_id,
752
+ parent: id,
753
+ value
754
+ };
755
+ this.nodes.set(text_id, text_node);
756
+ n.children.push(text_id);
757
+ }
758
+ this._structure_version++;
759
+ this._geometry_version++;
760
+ this.emit();
761
+ }
762
+ insert(id, parent, before) {
763
+ const node = this.nodes.get(id);
764
+ const parent_node = this.nodes.get(parent);
765
+ if (!node || !parent_node || parent_node.kind !== "element") return;
766
+ if (node.parent !== null) {
767
+ const old_parent = this.nodes.get(node.parent);
768
+ if (old_parent && old_parent.kind === "element") {
769
+ const i = old_parent.children.indexOf(id);
770
+ if (i >= 0) old_parent.children.splice(i, 1);
771
+ }
772
+ }
773
+ const ix = before === null ? -1 : parent_node.children.indexOf(before);
774
+ if (ix < 0) parent_node.children.push(id);
775
+ else parent_node.children.splice(ix, 0, id);
776
+ node.parent = parent;
777
+ this._structure_version++;
778
+ this._geometry_version++;
779
+ this.emit();
780
+ }
781
+ remove(id) {
782
+ const n = this.nodes.get(id);
783
+ if (!n || n.parent === null) return;
784
+ const parent = this.nodes.get(n.parent);
785
+ if (!parent || parent.kind !== "element") return;
786
+ const i = parent.children.indexOf(id);
787
+ if (i >= 0) parent.children.splice(i, 1);
788
+ n.parent = null;
789
+ this._structure_version++;
790
+ this._geometry_version++;
791
+ this.emit();
792
+ }
793
+ /** Create a new element node and register it (not yet inserted). */
794
+ create_element(local, opts) {
795
+ const id = this.fresh_node_id();
796
+ const prefix = opts?.prefix ?? null;
797
+ const ns = opts?.ns ?? null;
798
+ const node = {
799
+ kind: "element",
800
+ id,
801
+ parent: null,
802
+ raw_tag: prefix ? `${prefix}:${local}` : local,
803
+ prefix,
804
+ local,
805
+ ns,
806
+ attrs: [],
807
+ children: [],
808
+ self_closing: false,
809
+ open_tag_trailing: "",
810
+ close_tag_leading: "",
811
+ close_tag_trailing: ""
812
+ };
813
+ this.nodes.set(id, node);
814
+ return id;
815
+ }
816
+ /** Fresh internal NodeId, guaranteed unique within this document's node
817
+ * map. Shared by `create_element` and fragment adoption — collisions
818
+ * matter for the latter because the parser assigns sequential per-parse
819
+ * ids that a second parse would repeat. */
820
+ fresh_node_id() {
821
+ let id;
822
+ do
823
+ id = `e${Math.random().toString(36).slice(2, 10)}`;
824
+ while (this.nodes.has(id));
825
+ return id;
826
+ }
827
+ /**
828
+ * Parse an SVG **fragment** string and adopt its element subtrees into
829
+ * this document's node store — registered like {@link create_element}
830
+ * but NOT inserted into the tree (no version bump, no emit). Callers
831
+ * attach the returned roots via {@link insert}; the editor's
832
+ * `commands.insert_fragment` is the history-bracketed consumer.
833
+ *
834
+ * Input shapes:
835
+ * - A **bare fragment** — one or more sibling elements
836
+ * (`<path …/><path …/>`, or a single `<g>…</g>`). The top-level
837
+ * elements become the returned roots, in source order.
838
+ * - A **full SVG document** — when the input's only top-level element
839
+ * is an `<svg>`, that element is treated as a document SHELL, not
840
+ * content: its element children become the roots and the shell
841
+ * itself (viewBox, width/height, prolog, doctype) is discarded. Its
842
+ * `xmlns:*` prefix declarations are harvested into `xmlns` so the
843
+ * caller can re-declare prefixes the adopted content still uses.
844
+ * An `<svg>` that appears as one of SEVERAL top-level elements (or
845
+ * anywhere below the top level) is content, adopted as-is.
846
+ *
847
+ * Top-level non-element nodes (whitespace between roots, comments, PIs,
848
+ * doctype) are dropped — adoption takes elements, and the host
849
+ * document's own trivia stays untouched. WITHIN each adopted subtree
850
+ * every byte of source trivia survives verbatim (attribute order, quote
851
+ * styles, whitespace, comments), so the inserted markup serializes back
852
+ * exactly as authored — same rules as the initial parse.
853
+ *
854
+ * Authored `id=""` attributes are adopted verbatim — never rewritten,
855
+ * even when they collide with ids already in the document. Silent id
856
+ * renaming is exactly the proprietary noise this editor refuses (README
857
+ * "What clean means" §3); deduplication belongs to the explicit Tidy
858
+ * command. Internal NodeIds ARE freshly assigned (see
859
+ * {@link fresh_node_id}) so adopted nodes never collide in the id map.
860
+ *
861
+ * Throws `TypeError` on a non-string input and `Error` on markup the
862
+ * parser rejects (unclosed / mismatched tags, malformed attributes). An
863
+ * input with no top-level elements (empty string, whitespace, comments
864
+ * only) returns `{ roots: [], xmlns: [] }`.
865
+ */
866
+ create_fragment(markup) {
867
+ if (typeof markup !== "string") throw new TypeError(`create_fragment(markup) requires a string source, got ${markup === null ? "null" : typeof markup}`);
868
+ const parsed = (0, _grida_svg_parser.parse_svg)(`<svg xmlns="${_grida_svg_parser.SVG_NS}" xmlns:xlink="${_grida_svg_parser.XLINK_NS}">${markup}</svg>`);
869
+ const wrapper = parsed.nodes.get(parsed.root);
870
+ const element_children = (n) => n.children.map((c) => parsed.nodes.get(c)).filter((cn) => cn?.kind === "element");
871
+ let content = element_children(wrapper);
872
+ const xmlns = [];
873
+ if (content.length === 1 && content[0].local === "svg") {
874
+ const shell = content[0];
875
+ for (const a of shell.attrs) if (a.prefix === "xmlns") xmlns.push({
876
+ prefix: a.local,
877
+ uri: a.value
878
+ });
879
+ content = element_children(shell);
880
+ }
881
+ const roots = [];
882
+ for (const node of content) roots.push(this.adopt_parsed_subtree(node, parsed.nodes, null));
883
+ return {
884
+ roots,
885
+ xmlns
886
+ };
887
+ }
888
+ /**
889
+ * Register `node` and its whole subtree (from a foreign parse) into this
890
+ * document's node map under fresh NodeIds. The parser assigns sequential
891
+ * per-parse ids (`n0`, `n1`, …), so adopting without a remap would
892
+ * collide with this document's own nodes. Children links are rewritten;
893
+ * the subtree root arrives detached (`parent: null`), like
894
+ * `create_element`. Mutates the parsed nodes in place — a parse result
895
+ * is single-use.
896
+ */
897
+ adopt_parsed_subtree(node, source, parent) {
898
+ const id = this.fresh_node_id();
899
+ node.id = id;
900
+ node.parent = parent;
901
+ this.nodes.set(id, node);
902
+ if (node.kind === "element") {
903
+ const parsed_children = node.children;
904
+ node.children = [];
905
+ for (const c of parsed_children) {
906
+ const child = source.get(c);
907
+ if (!child) continue;
908
+ node.children.push(this.adopt_parsed_subtree(child, source, id));
909
+ }
910
+ }
911
+ return id;
912
+ }
913
+ /**
914
+ * Namespace prefixes USED within `id`'s subtree (element tags and
915
+ * attribute names) that are not DECLARED within the subtree itself —
916
+ * i.e. prefixes the subtree borrows from ancestor scope. `xml` and
917
+ * `xmlns` are excluded (bound by the XML spec, never declared).
918
+ * Declaration scoping is honored per use-site: a prefix declared on the
919
+ * using element or any of its ancestors up to (and including) the
920
+ * subtree root counts as declared.
921
+ *
922
+ * Structural fact only — the caller decides what an unbound prefix
923
+ * means (e.g. `commands.insert_fragment` hoists a resolvable
924
+ * declaration onto the document root).
925
+ */
926
+ undeclared_ns_prefixes(id) {
927
+ const out = /* @__PURE__ */ new Set();
928
+ const walk = (nid, declared) => {
929
+ const n = this.nodes.get(nid);
930
+ if (!n || n.kind !== "element") return;
931
+ const scope = new Set(declared);
932
+ for (const a of n.attrs) if (a.prefix === "xmlns") scope.add(a.local);
933
+ const need = (p) => {
934
+ if (p === null || p === "xml" || p === "xmlns") return;
935
+ if (!scope.has(p)) out.add(p);
936
+ };
937
+ need(n.prefix);
938
+ for (const a of n.attrs) if (a.prefix !== "xmlns") need(a.prefix);
939
+ for (const c of n.children) walk(c, scope);
940
+ };
941
+ walk(id, /* @__PURE__ */ new Set());
942
+ return out;
943
+ }
944
+ /**
945
+ * Declare a namespace prefix on the ROOT element: appends
946
+ * `xmlns:<prefix>="<uri>"` when the root doesn't already declare that
947
+ * prefix. An authored declaration always wins — this never rebinds.
948
+ * Policy wrapper over {@link set_attr} in the `XMLNS_NS` space; removal
949
+ * works through `set_attr(root, prefix, null, XMLNS_NS)` as usual.
950
+ */
951
+ declare_xmlns(prefix, uri) {
952
+ if (this.get_attr(this.root, prefix, _grida_svg_parser.XMLNS_NS) !== null) return;
953
+ this.set_attr(this.root, prefix, uri, _grida_svg_parser.XMLNS_NS);
954
+ }
955
+ serialize() {
956
+ let out = "";
957
+ for (const p of this.prolog) out += this.emit_node(p);
958
+ out += this.emit_node(this.nodes.get(this.root));
959
+ for (const e of this.epilog) out += this.emit_node(e);
960
+ return out;
961
+ }
962
+ /**
963
+ * Serialize a single element's subtree as an SVG **fragment**, using the
964
+ * same trivia-preserving rules as {@link serialize} (attribute order,
965
+ * quote style, whitespace, comments — emitted exactly as authored).
966
+ *
967
+ * This is NOT {@link serialize} scoped to a node — it is a deliberately
968
+ * weaker output (sdk-design D3, asymmetric outputs stay separate):
969
+ *
970
+ * - `serialize()` emits the whole document and carries the P1
971
+ * whole-document round-trip guarantee.
972
+ * - `serialize_node()` emits a fragment and does NOT. Namespace
973
+ * declarations that live on an ancestor (`xmlns:xlink` and friends,
974
+ * normally on the root `<svg>`) are NOT inlined — a node using
975
+ * `xlink:href` serializes without `xmlns:xlink`. The fragment is the
976
+ * element's markup as authored, not a standalone parseable document.
977
+ *
978
+ * Throws on an unknown id, a non-element node, or a node detached from
979
+ * the live tree: the contract is "the markup for a selected element,"
980
+ * selections are always live elements, and a string return of `""` for a
981
+ * bad id would hide consumer bugs. The detached case matters because
982
+ * `remove()` keeps the node in the id map for undo — a stale id from a
983
+ * removed node would otherwise serialize content no longer in the
984
+ * document, silently feeding a consumer deleted markup.
985
+ */
986
+ serialize_node(id) {
987
+ const n = this.nodes.get(id);
988
+ if (!n) throw new Error(`serialize_node: unknown node id ${JSON.stringify(id)}`);
989
+ if (n.kind !== "element") throw new Error(`serialize_node: node ${JSON.stringify(id)} is a ${n.kind} node, not an element`);
990
+ if (!this.contains(this.root, id)) throw new Error(`serialize_node: node ${JSON.stringify(id)} is detached from the current document`);
991
+ return this.emit_node(n);
992
+ }
993
+ emit_node(n) {
994
+ switch (n.kind) {
995
+ case "text": return (0, _grida_svg_parser.encode_text)(n.value);
996
+ case "comment": return `<!--${n.value}-->`;
997
+ case "cdata": return `<![CDATA[${n.value}]]>`;
998
+ case "pi": {
999
+ const pi = n;
1000
+ return `<?${pi.target}${pi.value ? " " + pi.value : ""}?>`;
1001
+ }
1002
+ case "doctype": return `<!DOCTYPE${n.value}>`;
1003
+ case "element": {
1004
+ const e = n;
1005
+ let s = `<${e.raw_tag}`;
1006
+ for (const a of e.attrs) s += this.emit_attr(a);
1007
+ if (e.children.length === 0 && e.self_closing) {
1008
+ s += `${e.open_tag_trailing}/>`;
1009
+ return s;
1010
+ }
1011
+ s += `${e.open_tag_trailing}>`;
1012
+ for (const cid of e.children) {
1013
+ const cn = this.nodes.get(cid);
1014
+ if (cn) s += this.emit_node(cn);
1015
+ }
1016
+ s += `</${e.close_tag_leading}${e.raw_tag}${e.close_tag_trailing}>`;
1017
+ return s;
1018
+ }
1019
+ }
1020
+ }
1021
+ 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}`;
1023
+ }
1024
+ };
1025
+ function parse_inline_style(s) {
1026
+ const out = [];
1027
+ const decls = s.split(";");
1028
+ for (const decl of decls) {
1029
+ const colon = decl.indexOf(":");
1030
+ if (colon === -1) continue;
1031
+ const property = decl.slice(0, colon).trim();
1032
+ const value = decl.slice(colon + 1).trim();
1033
+ if (property) out.push({
1034
+ property,
1035
+ value
1036
+ });
1037
+ }
1038
+ return out;
1039
+ }
131
1040
  //#endregion
132
1041
  //#region src/core/transform.ts
133
1042
  let transform;
@@ -365,8 +1274,186 @@ let transform;
365
1274
  } : op));
366
1275
  }
367
1276
  _transform.recompose = recompose;
1277
+ /** Epsilon for the identity-leading-matrix drop. Trig/compose noise on
1278
+ * a flip-then-flip round-trip lands well inside 1e-9; authored SVG
1279
+ * coordinates rarely carry more than 4 significant decimals, so this
1280
+ * never collapses a meaningful matrix. */
1281
+ const IDENTITY_EPSILON = 1e-9;
1282
+ /** A `cmath.Transform` (`[[a,c,e],[b,d,f]]`) as a `matrix` op
1283
+ * (`matrix(a b c d e f)` argument order). */
1284
+ function transform_to_matrix_op(m) {
1285
+ return {
1286
+ type: "matrix",
1287
+ a: m[0][0],
1288
+ b: m[1][0],
1289
+ c: m[0][1],
1290
+ d: m[1][1],
1291
+ e: m[0][2],
1292
+ f: m[1][2]
1293
+ };
1294
+ }
1295
+ function is_identity_matrix(m) {
1296
+ const id = _grida_cmath.default.transform.identity;
1297
+ return Math.abs(m[0][0] - id[0][0]) <= IDENTITY_EPSILON && Math.abs(m[0][1] - id[0][1]) <= IDENTITY_EPSILON && Math.abs(m[0][2] - id[0][2]) <= IDENTITY_EPSILON && Math.abs(m[1][0] - id[1][0]) <= IDENTITY_EPSILON && Math.abs(m[1][1] - id[1][1]) <= IDENTITY_EPSILON && Math.abs(m[1][2] - id[1][2]) <= IDENTITY_EPSILON;
1298
+ }
1299
+ function apply_affine(transform_str, effective) {
1300
+ const ops = parse(transform_str);
1301
+ if (ops === null) return transform_str;
1302
+ const has_leading_matrix = ops.length > 0 && ops[0].type === "matrix";
1303
+ const existing_leading = has_leading_matrix ? op_matrix(ops[0]) : _grida_cmath.default.transform.identity;
1304
+ const rest = has_leading_matrix ? ops.slice(1) : ops;
1305
+ const folded = _grida_cmath.default.transform.multiply(effective, existing_leading);
1306
+ if (is_identity_matrix(folded)) {
1307
+ if (rest.length === 0) return null;
1308
+ return emit(rest);
1309
+ }
1310
+ return emit([transform_to_matrix_op(folded), ...rest]);
1311
+ }
1312
+ _transform.apply_affine = apply_affine;
368
1313
  })(transform || (transform = {}));
369
1314
  //#endregion
1315
+ //#region src/core/group.ts
1316
+ let group;
1317
+ (function(_group) {
1318
+ const STRUCTURAL_GRAPHICS = _group.STRUCTURAL_GRAPHICS = new Set([
1319
+ "g",
1320
+ "defs",
1321
+ "svg",
1322
+ "use",
1323
+ "image",
1324
+ "switch",
1325
+ "foreignObject",
1326
+ "path",
1327
+ "rect",
1328
+ "circle",
1329
+ "ellipse",
1330
+ "line",
1331
+ "polyline",
1332
+ "polygon",
1333
+ "text",
1334
+ "a"
1335
+ ]);
1336
+ const CONSTRAINED_PARENT = _group.CONSTRAINED_PARENT = new Set([
1337
+ "text",
1338
+ "tspan",
1339
+ "defs",
1340
+ "clipPath",
1341
+ "mask",
1342
+ "pattern",
1343
+ "marker",
1344
+ "symbol",
1345
+ "filter",
1346
+ "linearGradient",
1347
+ "radialGradient",
1348
+ "animateMotion",
1349
+ "switch"
1350
+ ]);
1351
+ function plan(doc, ids) {
1352
+ if (ids.length === 0) return null;
1353
+ const parent = doc.parent_of(ids[0]);
1354
+ if (parent === null) return null;
1355
+ for (const id of ids) {
1356
+ if (doc.parent_of(id) !== parent) return null;
1357
+ if (!STRUCTURAL_GRAPHICS.has(doc.tag_of(id))) return null;
1358
+ }
1359
+ if (CONSTRAINED_PARENT.has(doc.tag_of(parent))) return null;
1360
+ const siblings = doc.element_children_of(parent);
1361
+ const sibling_index = /* @__PURE__ */ new Map();
1362
+ for (let i = 0; i < siblings.length; i++) sibling_index.set(siblings[i], i);
1363
+ const indices = [];
1364
+ for (const id of ids) {
1365
+ const i = sibling_index.get(id);
1366
+ if (i === void 0) return null;
1367
+ indices.push(i);
1368
+ }
1369
+ const sorted = Array.from(new Set(indices)).sort((a, b) => a - b);
1370
+ for (let i = 1; i < sorted.length; i++) if (sorted[i] !== sorted[i - 1] + 1) return null;
1371
+ const children = sorted.map((i) => siblings[i]);
1372
+ const last_index = sorted[sorted.length - 1];
1373
+ const original_positions = /* @__PURE__ */ new Map();
1374
+ for (const i of sorted) original_positions.set(siblings[i], siblings[i + 1] ?? null);
1375
+ return {
1376
+ parent,
1377
+ insert_before: siblings[last_index + 1] ?? null,
1378
+ children,
1379
+ original_positions
1380
+ };
1381
+ }
1382
+ _group.plan = plan;
1383
+ /**
1384
+ * Own-attribute allowlist for the safe clean-structural ungroup subset.
1385
+ * A group carrying ONLY these attributes is a plain structural wrapper:
1386
+ * dissolving it splices its children into the parent without changing
1387
+ * what renders (modulo baking the `transform`, which `ungroup` does).
1388
+ *
1389
+ * - `transform` — baked into each child by prepending its ops.
1390
+ * - `id` — only allowed when no `<use>` references it (checked below);
1391
+ * the group's own `id` simply disappears (no child inherits it).
1392
+ * - `data-grida-id` — the editor's runtime node identity. Internal; the
1393
+ * group node is removed entirely so the id retires with it.
1394
+ *
1395
+ * ANY other own attribute (`class`, `style`, `opacity`, `fill`,
1396
+ * `stroke`, `filter`, `clip-path`, `mask`, `font-*`, …) carries visual
1397
+ * / cascade / inheritance state that is NOT generally equivalent to the
1398
+ * per-child result of removing the group (TODO §10). Those groups are
1399
+ * refused — never silently mishandled.
1400
+ */
1401
+ const UNGROUP_OWN_ATTR_ALLOWLIST = new Set([
1402
+ "transform",
1403
+ "id",
1404
+ "data-grida-id"
1405
+ ]);
1406
+ /** SVG animation elements (SMIL). A direct animation child targets the
1407
+ * group as its `targetElement`; dissolving the group orphans the
1408
+ * animation. Refuse rather than relocate it. */
1409
+ const ANIMATION_TAGS = new Set([
1410
+ "animate",
1411
+ "animateTransform",
1412
+ "animateMotion",
1413
+ "set"
1414
+ ]);
1415
+ function plan_ungroup(doc, id) {
1416
+ if (doc.tag_of(id) !== "g") return null;
1417
+ const parent = doc.parent_of(id);
1418
+ if (parent === null) return null;
1419
+ {
1420
+ let cur = parent;
1421
+ while (cur !== null) {
1422
+ if (doc.tag_of(cur) === "defs") return null;
1423
+ cur = doc.parent_of(cur);
1424
+ }
1425
+ }
1426
+ const children = doc.element_children_of(id);
1427
+ if (children.length < 1) return null;
1428
+ let has_id = false;
1429
+ for (const a of doc.attributes_of(id)) {
1430
+ if (a.ns !== null) return null;
1431
+ if (!UNGROUP_OWN_ATTR_ALLOWLIST.has(a.name)) return null;
1432
+ if (a.name === "id") has_id = true;
1433
+ }
1434
+ if (has_id) {
1435
+ const own_id = doc.get_attr(id, "id");
1436
+ if (own_id !== null) {
1437
+ const fragment = `#${own_id}`;
1438
+ for (const use_id of doc.find_by_tag(doc.root, "use")) if ((doc.get_attr(use_id, "href") ?? doc.get_attr(use_id, "href", _grida_svg_parser.XLINK_NS)) === fragment) return null;
1439
+ }
1440
+ }
1441
+ for (const child of children) if (ANIMATION_TAGS.has(doc.tag_of(child))) return null;
1442
+ const group_transform = doc.get_attr(id, "transform");
1443
+ if (group_transform !== null) {
1444
+ if (transform.parse(group_transform) === null) return null;
1445
+ for (const child of children) if (transform.parse(doc.get_attr(child, "transform")) === null) return null;
1446
+ }
1447
+ return {
1448
+ group_id: id,
1449
+ parent,
1450
+ children: [...children],
1451
+ group_transform
1452
+ };
1453
+ }
1454
+ _group.plan_ungroup = plan_ungroup;
1455
+ })(group || (group = {}));
1456
+ //#endregion
370
1457
  //#region src/core/translate-pipeline/translate-pipeline.ts
371
1458
  let translate_pipeline;
372
1459
  (function(_translate_pipeline) {
@@ -968,6 +2055,27 @@ let rotate_pipeline;
968
2055
  return { kind: "yes" };
969
2056
  }
970
2057
  _intent.is_rotatable = is_rotatable;
2058
+ function is_transformable(doc, id) {
2059
+ const own_transform = doc.get_attr(id, "transform");
2060
+ if (transform.parse(own_transform) === null) return {
2061
+ kind: "refuse",
2062
+ reason: "non-trivial-transform"
2063
+ };
2064
+ if (doc.has_glyph_rotate(id)) return {
2065
+ kind: "refuse",
2066
+ reason: "text-with-glyph-rotate"
2067
+ };
2068
+ if (doc.has_inline_css_transform(id)) return {
2069
+ kind: "refuse",
2070
+ reason: "css-property-transform"
2071
+ };
2072
+ if (doc.has_animate_transform_child(id)) return {
2073
+ kind: "refuse",
2074
+ reason: "animated-transform"
2075
+ };
2076
+ return { kind: "yes" };
2077
+ }
2078
+ _intent.is_transformable = is_transformable;
971
2079
  function find_op(ops, type) {
972
2080
  for (const op of ops) if (op.type === type) return op;
973
2081
  return null;
@@ -3789,6 +4897,12 @@ Object.defineProperty(exports, "RotateOrchestrator", {
3789
4897
  return RotateOrchestrator;
3790
4898
  }
3791
4899
  });
4900
+ Object.defineProperty(exports, "SvgDocument", {
4901
+ enumerable: true,
4902
+ get: function() {
4903
+ return SvgDocument;
4904
+ }
4905
+ });
3792
4906
  Object.defineProperty(exports, "TOOL_CURSOR", {
3793
4907
  enumerable: true,
3794
4908
  get: function() {