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