@grida/svg-editor 1.0.0-alpha.1 → 1.0.0-alpha.12

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,7 +1,9 @@
1
- require("./dom-CfP_ZURh.js");
2
- const require_paint = require("./paint-DHq_3iwU.js");
1
+ const require_insertions = require("./insertions-BJ-6o6o5.js");
3
2
  let _grida_history = require("@grida/history");
4
3
  let _grida_keybinding = require("@grida/keybinding");
4
+ let _grida_cmath = require("@grida/cmath");
5
+ _grida_cmath = require_insertions.__toESM(_grida_cmath);
6
+ let _grida_svg_parser = require("@grida/svg/parser");
5
7
  //#region src/commands/registry.ts
6
8
  var CommandRegistry = class {
7
9
  constructor() {
@@ -37,6 +39,25 @@ var CommandRegistry = class {
37
39
  };
38
40
  //#endregion
39
41
  //#region src/commands/defaults.ts
42
+ /** Command id for `tool.set`. Bound to V/R/O/L in `keymap/defaults.ts`. */
43
+ const TOOL_SET = "tool.set";
44
+ /**
45
+ * The headless default `transform.nudge` handler. Exported so a host
46
+ * surface that overrides nudge (e.g. for faux-snap UX) can restore the
47
+ * default on teardown — the registry doesn't stack handlers, so a plain
48
+ * unregister leaves the slot empty.
49
+ */
50
+ function default_nudge_handler(editor) {
51
+ return (args) => {
52
+ if (editor.state.selection.length === 0) return false;
53
+ const { dx, dy } = args;
54
+ editor.commands.nudge({
55
+ dx,
56
+ dy
57
+ });
58
+ return true;
59
+ };
60
+ }
40
61
  function registerDefaultCommands(reg, editor) {
41
62
  reg.register("history.undo", () => {
42
63
  if (!editor.state.can_undo) return false;
@@ -58,6 +79,46 @@ function registerDefaultCommands(reg, editor) {
58
79
  editor.commands.remove();
59
80
  return true;
60
81
  });
82
+ reg.register("selection.group", () => {
83
+ if (editor.state.mode !== "select") return false;
84
+ if (editor.state.selection.length === 0) return false;
85
+ return editor.commands.group();
86
+ });
87
+ reg.register("selection.resize_to", (args) => {
88
+ if (editor.state.mode !== "select") return false;
89
+ if (editor.state.selection.length === 0) return false;
90
+ const target = args;
91
+ return editor.commands.resize_to(target);
92
+ });
93
+ reg.register("selection.rotate", (args) => {
94
+ if (editor.state.mode !== "select") return false;
95
+ if (editor.state.selection.length === 0) return false;
96
+ const a = args;
97
+ return editor.commands.rotate(a.angle, { pivot: a.pivot });
98
+ });
99
+ reg.register("selection.rotate_to", (args) => {
100
+ if (editor.state.mode !== "select") return false;
101
+ if (editor.state.selection.length === 0) return false;
102
+ const a = args;
103
+ return editor.commands.rotate_to(a.angle, { pivot: a.pivot });
104
+ });
105
+ reg.register("selection.flatten_transform", () => {
106
+ if (editor.state.mode !== "select") return false;
107
+ if (editor.state.selection.length === 0) return false;
108
+ return editor.commands.flatten_transform();
109
+ });
110
+ reg.register("selection.all", () => {
111
+ if (editor.state.mode !== "select") return false;
112
+ return editor.commands.select_all();
113
+ });
114
+ reg.register("selection.sibling", (args) => {
115
+ if (editor.state.mode !== "select") return false;
116
+ return editor.commands.select_sibling(args);
117
+ });
118
+ reg.register("selection.align", (args) => {
119
+ if (editor.state.mode !== "select") return false;
120
+ return editor.commands.align(args);
121
+ });
61
122
  reg.register("hierarchy.enter", () => {
62
123
  if (editor.state.selection.length !== 1) return false;
63
124
  const id = editor.state.selection[0];
@@ -75,20 +136,17 @@ function registerDefaultCommands(reg, editor) {
75
136
  editor.commands.select(node.parent);
76
137
  return true;
77
138
  });
78
- reg.register("transform.nudge", (args) => {
79
- if (editor.state.selection.length === 0) return false;
80
- const { dx, dy } = args;
81
- editor.commands.translate({
82
- dx,
83
- dy
84
- });
85
- return true;
86
- });
139
+ reg.register("transform.nudge", default_nudge_handler(editor));
87
140
  reg.register("reorder", (args) => {
88
141
  if (editor.state.selection.length !== 1) return false;
89
142
  editor.commands.reorder(args);
90
143
  return true;
91
144
  });
145
+ reg.register(TOOL_SET, (args) => {
146
+ if (editor.state.mode !== "select") return false;
147
+ editor.set_tool(args);
148
+ return true;
149
+ });
92
150
  }
93
151
  //#endregion
94
152
  //#region src/keymap/keymap.ts
@@ -112,15 +170,6 @@ const TEXT_INPUT_SAFE_MODS = new Set([
112
170
  _grida_keybinding.KeyCode.Ctrl,
113
171
  _grida_keybinding.KeyCode.Alt
114
172
  ]);
115
- function is_text_input_focused() {
116
- if (typeof document === "undefined") return false;
117
- const el = document.activeElement;
118
- if (!el) return false;
119
- const tag = el.tagName;
120
- if (tag === "INPUT" || tag === "TEXTAREA") return true;
121
- if (el.isContentEditable) return true;
122
- return false;
123
- }
124
173
  var Keymap = class {
125
174
  constructor(commands, platformGetter = _grida_keybinding.getKeyboardOS) {
126
175
  this.commands = commands;
@@ -178,10 +227,35 @@ var Keymap = class {
178
227
  return Array.from(seen);
179
228
  }
180
229
  /**
230
+ * Does the keymap have a binding that matches this event's chord —
231
+ * regardless of whether any handler would consume it? Hosts use this
232
+ * to decide whether to swallow the platform's default action (e.g.
233
+ * `event.preventDefault()` in the browser), so that an advertised
234
+ * shortcut like `Cmd+G` doesn't fall through to the browser's find
235
+ * bar even when the binding's handler rejects.
236
+ *
237
+ * Pure read; runs no handlers, no side effects. Honors the same
238
+ * text-input-focused guard `dispatch` uses, so a typing user's
239
+ * keystroke isn't "claimed" by an unrelated unmodified key.
240
+ */
241
+ claims(event) {
242
+ const chunk = (0, _grida_keybinding.eventToChunk)(event);
243
+ if (chunk.keys.length === 0) return false;
244
+ const list = this.buckets.get((0, _grida_keybinding.chunkKey)(chunk));
245
+ if (!list || list.length === 0) return false;
246
+ if (require_insertions.is_text_input_focused() && !this.has_safe_mod(chunk.mods)) return false;
247
+ return true;
248
+ }
249
+ /**
181
250
  * Match the event against bound chunks, then run candidates in chain
182
- * order. Returns `true` and calls `preventDefault()` on the first
183
- * handler that consumes; returns `false` if nothing matched or all
184
- * matches fell through.
251
+ * order. Returns `true` on the first handler that consumes; returns
252
+ * `false` if nothing matched or all matches fell through.
253
+ *
254
+ * `dispatch` is browser-agnostic: it does NOT call `preventDefault()`
255
+ * or touch the event in any way. The host decides what to do with the
256
+ * platform default — typically `if (keymap.claims(e)) e.preventDefault()`,
257
+ * which prevents the platform default for advertised shortcuts even
258
+ * when the chain rejects. See README → `editor.keymap`.
185
259
  */
186
260
  dispatch(event) {
187
261
  const chunk = (0, _grida_keybinding.eventToChunk)(event);
@@ -189,13 +263,10 @@ var Keymap = class {
189
263
  const hash = (0, _grida_keybinding.chunkKey)(chunk);
190
264
  const list = this.buckets.get(hash);
191
265
  if (!list || list.length === 0) return false;
192
- const text_focused = is_text_input_focused();
266
+ const text_focused = require_insertions.is_text_input_focused();
193
267
  for (const { binding } of list) {
194
268
  if (text_focused && !this.has_safe_mod(chunk.mods)) continue;
195
- if (this.commands.invoke(binding.command, binding.args)) {
196
- event.preventDefault();
197
- return true;
198
- }
269
+ if (this.commands.invoke(binding.command, binding.args)) return true;
199
270
  }
200
271
  return false;
201
272
  }
@@ -238,6 +309,7 @@ function compareEntries(a, b) {
238
309
  * Same key, multiple meanings? Add multiple rows. The chain semantics
239
310
  * (handler returns `false` when not applicable) handle the rest.
240
311
  */
312
+ const NUDGE_MEANINGFUL = _grida_keybinding.M.Shift;
241
313
  const DEFAULT_BINDINGS = [
242
314
  {
243
315
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyZ, _grida_keybinding.M.CtrlCmd),
@@ -263,6 +335,54 @@ const DEFAULT_BINDINGS = [
263
335
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.Delete),
264
336
  command: "selection.remove"
265
337
  },
338
+ {
339
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd),
340
+ command: "selection.group"
341
+ },
342
+ {
343
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyA, _grida_keybinding.M.CtrlCmd),
344
+ command: "selection.all"
345
+ },
346
+ {
347
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.Tab),
348
+ command: "selection.sibling",
349
+ args: "next"
350
+ },
351
+ {
352
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.Tab, _grida_keybinding.M.Shift),
353
+ command: "selection.sibling",
354
+ args: "prev"
355
+ },
356
+ {
357
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyA, _grida_keybinding.M.Alt),
358
+ command: "selection.align",
359
+ args: "left"
360
+ },
361
+ {
362
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyD, _grida_keybinding.M.Alt),
363
+ command: "selection.align",
364
+ args: "right"
365
+ },
366
+ {
367
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyW, _grida_keybinding.M.Alt),
368
+ command: "selection.align",
369
+ args: "top"
370
+ },
371
+ {
372
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyS, _grida_keybinding.M.Alt),
373
+ command: "selection.align",
374
+ args: "bottom"
375
+ },
376
+ {
377
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyH, _grida_keybinding.M.Alt),
378
+ command: "selection.align",
379
+ args: "horizontal_centers"
380
+ },
381
+ {
382
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyV, _grida_keybinding.M.Alt),
383
+ command: "selection.align",
384
+ args: "vertical_centers"
385
+ },
266
386
  {
267
387
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.Enter),
268
388
  command: "hierarchy.enter"
@@ -272,7 +392,7 @@ const DEFAULT_BINDINGS = [
272
392
  command: "hierarchy.exit"
273
393
  },
274
394
  {
275
- keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow),
395
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, 0, NUDGE_MEANINGFUL),
276
396
  command: "transform.nudge",
277
397
  args: {
278
398
  dx: -1,
@@ -280,7 +400,7 @@ const DEFAULT_BINDINGS = [
280
400
  }
281
401
  },
282
402
  {
283
- keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow),
403
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, 0, NUDGE_MEANINGFUL),
284
404
  command: "transform.nudge",
285
405
  args: {
286
406
  dx: 1,
@@ -288,7 +408,7 @@ const DEFAULT_BINDINGS = [
288
408
  }
289
409
  },
290
410
  {
291
- keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow),
411
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, 0, NUDGE_MEANINGFUL),
292
412
  command: "transform.nudge",
293
413
  args: {
294
414
  dx: 0,
@@ -296,7 +416,7 @@ const DEFAULT_BINDINGS = [
296
416
  }
297
417
  },
298
418
  {
299
- keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow),
419
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, 0, NUDGE_MEANINGFUL),
300
420
  command: "transform.nudge",
301
421
  args: {
302
422
  dx: 0,
@@ -304,7 +424,7 @@ const DEFAULT_BINDINGS = [
304
424
  }
305
425
  },
306
426
  {
307
- keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Shift),
427
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Shift, NUDGE_MEANINGFUL),
308
428
  command: "transform.nudge",
309
429
  args: {
310
430
  dx: -10,
@@ -312,7 +432,7 @@ const DEFAULT_BINDINGS = [
312
432
  }
313
433
  },
314
434
  {
315
- keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Shift),
435
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Shift, NUDGE_MEANINGFUL),
316
436
  command: "transform.nudge",
317
437
  args: {
318
438
  dx: 10,
@@ -320,7 +440,7 @@ const DEFAULT_BINDINGS = [
320
440
  }
321
441
  },
322
442
  {
323
- keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Shift),
443
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Shift, NUDGE_MEANINGFUL),
324
444
  command: "transform.nudge",
325
445
  args: {
326
446
  dx: 0,
@@ -328,13 +448,42 @@ const DEFAULT_BINDINGS = [
328
448
  }
329
449
  },
330
450
  {
331
- keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Shift),
451
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Shift, NUDGE_MEANINGFUL),
332
452
  command: "transform.nudge",
333
453
  args: {
334
454
  dx: 0,
335
455
  dy: 10
336
456
  }
337
457
  },
458
+ {
459
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyV),
460
+ command: TOOL_SET,
461
+ args: { type: "cursor" }
462
+ },
463
+ {
464
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyR),
465
+ command: TOOL_SET,
466
+ args: {
467
+ type: "insert",
468
+ tag: "rect"
469
+ }
470
+ },
471
+ {
472
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyO),
473
+ command: TOOL_SET,
474
+ args: {
475
+ type: "insert",
476
+ tag: "ellipse"
477
+ }
478
+ },
479
+ {
480
+ keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyL),
481
+ command: TOOL_SET,
482
+ args: {
483
+ type: "insert",
484
+ tag: "line"
485
+ }
486
+ },
338
487
  {
339
488
  keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.BracketRight),
340
489
  command: "reorder",
@@ -361,16 +510,33 @@ function applyDefaultBindings(keymap) {
361
510
  for (const b of DEFAULT_BINDINGS) keymap.bind(b);
362
511
  }
363
512
  //#endregion
513
+ //#region src/util/equal.ts
514
+ function array_shallow_equal(a, b) {
515
+ if (a === b) return true;
516
+ if (a.length !== b.length) return false;
517
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
518
+ return true;
519
+ }
520
+ //#endregion
364
521
  //#region src/core/defs.ts
365
522
  var GradientsRegistry = class {
366
523
  constructor(doc) {
367
524
  this.doc = doc;
368
525
  this.listeners = /* @__PURE__ */ new Set();
369
526
  this.counter = 0;
370
- doc.on_change(() => this.emit());
527
+ this._cached = null;
528
+ this._cached_by_id = /* @__PURE__ */ new Map();
529
+ this._dirty = true;
530
+ doc.on_change(() => {
531
+ this._dirty = true;
532
+ this.emit();
533
+ });
371
534
  }
372
535
  list() {
536
+ if (!this._dirty && this._cached) return this._cached;
373
537
  const out = [];
538
+ let any_change = !this._cached;
539
+ const seen = /* @__PURE__ */ new Set();
374
540
  const defs = this.find_defs_elements();
375
541
  for (const def_id of defs) for (const child of this.doc.element_children_of(def_id)) {
376
542
  const tag = this.doc.tag_of(child);
@@ -379,14 +545,34 @@ var GradientsRegistry = class {
379
545
  if (!id) continue;
380
546
  const definition = this.read_gradient(child, tag);
381
547
  if (!definition) continue;
382
- out.push({
383
- id,
384
- definition,
385
- ref_count: this.count_refs(id)
386
- });
548
+ const ref_count = this.count_refs(id);
549
+ const prev = this._cached_by_id.get(id);
550
+ if (prev && prev.ref_count === ref_count && gradient_definition_equals(prev.definition, definition)) out.push(prev);
551
+ else {
552
+ const entry = {
553
+ id,
554
+ definition,
555
+ ref_count
556
+ };
557
+ this._cached_by_id.set(id, entry);
558
+ out.push(entry);
559
+ any_change = true;
560
+ }
561
+ seen.add(id);
387
562
  }
388
563
  }
389
- return out;
564
+ for (const id of this._cached_by_id.keys()) if (!seen.has(id)) {
565
+ this._cached_by_id.delete(id);
566
+ any_change = true;
567
+ }
568
+ if (!any_change && this._cached && array_shallow_equal(this._cached, out)) {
569
+ this._dirty = false;
570
+ return this._cached;
571
+ }
572
+ const frozen = Object.freeze(out);
573
+ this._cached = frozen;
574
+ this._dirty = false;
575
+ return frozen;
390
576
  }
391
577
  get(id) {
392
578
  return this.list().find((g) => g.id === id) ?? null;
@@ -499,7 +685,7 @@ var GradientsRegistry = class {
499
685
  };
500
686
  }
501
687
  write_gradient(node, def) {
502
- for (const c of [...this.doc.children_of(node)]) this.doc.remove(c);
688
+ for (const c of this.doc.children_of(node).slice()) this.doc.remove(c);
503
689
  const set_num = (name, v) => {
504
690
  this.doc.set_attr(node, name, v === void 0 ? null : String(v));
505
691
  };
@@ -546,292 +732,73 @@ var GradientsRegistry = class {
546
732
  function escape_regex(s) {
547
733
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
548
734
  }
735
+ function gradient_definition_equals(a, b) {
736
+ if (a === b) return true;
737
+ if (a.kind !== b.kind) return false;
738
+ if (a.stops.length !== b.stops.length) return false;
739
+ for (let i = 0; i < a.stops.length; i++) {
740
+ const sa = a.stops[i];
741
+ const sb = b.stops[i];
742
+ if (sa.offset !== sb.offset || sa.color !== sb.color || sa.opacity !== sb.opacity) return false;
743
+ }
744
+ if (a.kind === "linear" && b.kind === "linear") return a.x1 === b.x1 && a.y1 === b.y1 && a.x2 === b.x2 && a.y2 === b.y2 && a.gradient_units === b.gradient_units && a.spread_method === b.spread_method;
745
+ if (a.kind === "radial" && b.kind === "radial") return a.cx === b.cx && a.cy === b.cy && a.r === b.r && a.fx === b.fx && a.fy === b.fy && a.gradient_units === b.gradient_units && a.spread_method === b.spread_method;
746
+ return false;
747
+ }
549
748
  function create_defs(doc) {
550
749
  return { gradients: new GradientsRegistry(doc) };
551
750
  }
552
- const XML_NS = "http://www.w3.org/XML/1998/namespace";
553
- const XMLNS_NS = "http://www.w3.org/2000/xmlns/";
554
- let id_counter = 0;
555
- function fresh_id() {
556
- return `n${id_counter++}`;
557
- }
558
- function reset_id_counter() {
559
- id_counter = 0;
560
- }
561
- function parse_svg(src) {
562
- reset_id_counter();
563
- const nodes = /* @__PURE__ */ new Map();
564
- const prolog = [];
565
- const epilog = [];
566
- let i = 0;
567
- const n = src.length;
568
- let root = null;
569
- const open_stack = [];
570
- /** ns prefix → uri, per ancestor scope (top of stack). */
571
- const ns_stack = [new Map([["xml", XML_NS], ["xmlns", XMLNS_NS]])];
572
- /** default ns per ancestor scope (top of stack). */
573
- const default_ns_stack = [null];
574
- function push_to_parent(node) {
575
- nodes.set(node.id, node);
576
- if (open_stack.length === 0) {
577
- if (node.kind === "element" && root === null) return;
578
- if (root === null) prolog.push(node);
579
- else epilog.push(node);
580
- return;
581
- }
582
- const parent = open_stack[open_stack.length - 1];
583
- node.parent = parent.id;
584
- parent.children.push(node.id);
585
- }
586
- while (i < n) {
587
- if (src[i] === "<") {
588
- if (src.startsWith("<!--", i)) {
589
- const end = src.indexOf("-->", i + 4);
590
- if (end === -1) throw new Error("unterminated comment");
591
- const value = src.slice(i + 4, end);
592
- push_to_parent({
593
- kind: "comment",
594
- id: fresh_id(),
595
- parent: null,
596
- value
597
- });
598
- i = end + 3;
599
- continue;
600
- }
601
- if (src.startsWith("<![CDATA[", i)) {
602
- const end = src.indexOf("]]>", i + 9);
603
- if (end === -1) throw new Error("unterminated CDATA");
604
- const value = src.slice(i + 9, end);
605
- push_to_parent({
606
- kind: "cdata",
607
- id: fresh_id(),
608
- parent: null,
609
- value
610
- });
611
- i = end + 3;
612
- continue;
613
- }
614
- if (src.startsWith("<!DOCTYPE", i) || src.startsWith("<!doctype", i)) {
615
- let depth = 1;
616
- let j = i + 9;
617
- while (j < n && depth > 0) {
618
- const c = src[j];
619
- if (c === "<") depth++;
620
- else if (c === ">") depth--;
621
- if (depth === 0) break;
622
- j++;
623
- }
624
- if (j >= n) throw new Error("unterminated doctype");
625
- push_to_parent({
626
- kind: "doctype",
627
- id: fresh_id(),
628
- parent: null,
629
- value: src.slice(i + 9, j)
630
- });
631
- i = j + 1;
632
- continue;
633
- }
634
- if (src.startsWith("<?", i)) {
635
- const end = src.indexOf("?>", i + 2);
636
- if (end === -1) throw new Error("unterminated PI");
637
- const body = src.slice(i + 2, end);
638
- const space = body.search(/\s/);
639
- const target = space === -1 ? body : body.slice(0, space);
640
- const value = space === -1 ? "" : body.slice(space + 1);
641
- push_to_parent({
642
- kind: "pi",
643
- id: fresh_id(),
644
- parent: null,
645
- target,
646
- value
647
- });
648
- i = end + 2;
649
- continue;
650
- }
651
- if (src[i + 1] === "/") {
652
- const end = src.indexOf(">", i + 2);
653
- if (end === -1) throw new Error("unterminated end tag");
654
- const open = open_stack.pop();
655
- if (!open) throw new Error("unexpected end tag at " + i);
656
- ns_stack.pop();
657
- default_ns_stack.pop();
658
- const m = src.slice(i + 2, end).match(/^(\s*)([^\s]+)(\s*)$/);
659
- if (m) {
660
- open.close_tag_leading = m[1];
661
- open.close_tag_trailing = m[3];
662
- }
663
- i = end + 1;
664
- continue;
665
- }
666
- const start = i + 1;
667
- let j = start;
668
- while (j < n && !/[\s/>]/.test(src[j])) j++;
669
- const raw_tag = src.slice(start, j);
670
- const [prefix, local] = split_qname(raw_tag);
671
- const { attrs, end_index, self_closing, trailing } = parse_attrs(src, j);
672
- const new_ns_map = new Map(ns_stack[ns_stack.length - 1]);
673
- let new_default_ns = default_ns_stack[default_ns_stack.length - 1];
674
- for (const a of attrs) if (a.prefix === "xmlns") new_ns_map.set(a.local, a.value);
675
- else if (a.prefix === null && a.local === "xmlns") new_default_ns = a.value;
676
- for (const a of attrs) if (a.prefix === "xmlns" || a.prefix === null && a.local === "xmlns") a.ns = XMLNS_NS;
677
- else if (a.prefix) a.ns = new_ns_map.get(a.prefix) ?? null;
678
- else a.ns = null;
679
- const element_ns = prefix ? new_ns_map.get(prefix) ?? null : new_default_ns;
680
- const elem = {
681
- kind: "element",
682
- id: fresh_id(),
683
- parent: null,
684
- raw_tag,
685
- prefix,
686
- local,
687
- ns: element_ns,
688
- attrs,
689
- children: [],
690
- self_closing,
691
- open_tag_trailing: trailing,
692
- close_tag_leading: "",
693
- close_tag_trailing: ""
694
- };
695
- push_to_parent(elem);
696
- if (root === null) root = elem.id;
697
- if (!self_closing) {
698
- open_stack.push(elem);
699
- ns_stack.push(new_ns_map);
700
- default_ns_stack.push(new_default_ns);
701
- }
702
- i = end_index;
703
- continue;
704
- }
705
- const next = src.indexOf("<", i);
706
- const end = next === -1 ? n : next;
707
- const value = decode_entities(src.slice(i, end));
708
- push_to_parent({
709
- kind: "text",
710
- id: fresh_id(),
711
- parent: null,
712
- value
713
- });
714
- i = end;
715
- }
716
- if (open_stack.length > 0) throw new Error(`unclosed element <${open_stack[open_stack.length - 1].raw_tag}>`);
717
- if (root === null) throw new Error("no root element");
718
- return {
719
- prolog,
720
- root,
721
- epilog,
722
- nodes
723
- };
724
- }
725
- function split_qname(qname) {
726
- const idx = qname.indexOf(":");
727
- if (idx === -1) return [null, qname];
728
- return [qname.slice(0, idx), qname.slice(idx + 1)];
729
- }
730
- function parse_attrs(src, from) {
731
- const attrs = [];
732
- let i = from;
733
- let pre = "";
734
- const n = src.length;
735
- while (i < n) {
736
- const ws_start = i;
737
- while (i < n && /\s/.test(src[i])) i++;
738
- pre += src.slice(ws_start, i);
739
- if (i >= n) throw new Error("unterminated start tag");
740
- const c = src[i];
741
- if (c === "/") {
742
- if (src[i + 1] !== ">") throw new Error("expected '/>' at " + i);
743
- pre + "";
744
- return {
745
- attrs,
746
- end_index: i + 2,
747
- self_closing: true,
748
- trailing: pre
749
- };
750
- }
751
- if (c === ">") return {
752
- attrs,
753
- end_index: i + 1,
754
- self_closing: false,
755
- trailing: pre
756
- };
757
- const name_start = i;
758
- while (i < n && !/[\s=/>]/.test(src[i])) i++;
759
- const raw_name = src.slice(name_start, i);
760
- let eq_trivia = "";
761
- while (i < n && /\s/.test(src[i])) {
762
- eq_trivia += src[i];
763
- i++;
764
- }
765
- if (src[i] !== "=") {
766
- const [prefix, local] = split_qname(raw_name);
767
- attrs.push({
768
- raw_name,
769
- prefix,
770
- local,
771
- ns: null,
772
- value: "",
773
- pre,
774
- eq_trivia,
775
- quote: "\""
776
- });
777
- pre = "";
778
- continue;
779
- }
780
- i++;
781
- while (i < n && /\s/.test(src[i])) i++;
782
- const quote = src[i];
783
- if (quote !== "\"" && quote !== "'") throw new Error("expected attribute quote at " + i);
784
- i++;
785
- const val_start = i;
786
- while (i < n && src[i] !== quote) i++;
787
- if (i >= n) throw new Error("unterminated attribute value");
788
- const raw_value = src.slice(val_start, i);
789
- i++;
790
- const [prefix, local] = split_qname(raw_name);
791
- attrs.push({
792
- raw_name,
793
- prefix,
794
- local,
795
- ns: null,
796
- value: decode_entities(raw_value),
797
- pre,
798
- eq_trivia,
799
- quote
800
- });
801
- pre = "";
802
- }
803
- throw new Error("unterminated start tag");
804
- }
805
- const NAMED_ENTITIES = {
806
- amp: "&",
807
- lt: "<",
808
- gt: ">",
809
- quot: "\"",
810
- apos: "'"
811
- };
812
- function decode_entities(s) {
813
- return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]*);/g, (_, ent) => {
814
- if (ent.startsWith("#x") || ent.startsWith("#X")) return String.fromCodePoint(parseInt(ent.slice(2), 16));
815
- if (ent.startsWith("#")) return String.fromCodePoint(parseInt(ent.slice(1), 10));
816
- return NAMED_ENTITIES[ent] ?? `&${ent};`;
817
- });
818
- }
819
- function encode_attr_value(value, quote) {
820
- let out = value.replace(/&/g, "&amp;").replace(/</g, "&lt;");
821
- out = quote === "\"" ? out.replace(/"/g, "&quot;") : out.replace(/'/g, "&apos;");
822
- return out;
823
- }
824
- function encode_text(value) {
825
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;");
826
- }
827
751
  //#endregion
828
752
  //#region src/core/document.ts
753
+ /**
754
+ * Attribute names whose writes can shift a node's rendered bounds.
755
+ * Membership drives `_geometry_version` bumps in `set_attr`. Only
756
+ * non-namespaced attribute names — namespaced writes (xlink:href, etc.)
757
+ * never bump because they're references, not geometry.
758
+ *
759
+ * Includes text-shaping attributes (font-*) because they re-shape glyph
760
+ * runs and change `<text>` bbox.
761
+ */
762
+ const GEOMETRY_ATTRS = new Set([
763
+ "x",
764
+ "y",
765
+ "x1",
766
+ "y1",
767
+ "x2",
768
+ "y2",
769
+ "cx",
770
+ "cy",
771
+ "width",
772
+ "height",
773
+ "r",
774
+ "rx",
775
+ "ry",
776
+ "points",
777
+ "d",
778
+ "transform",
779
+ "viewBox",
780
+ "font-size",
781
+ "font-family",
782
+ "font-weight",
783
+ "font-style",
784
+ "text-anchor",
785
+ "dx",
786
+ "dy",
787
+ "rotate",
788
+ "textLength",
789
+ "lengthAdjust",
790
+ "pathLength",
791
+ "marker-start",
792
+ "marker-mid",
793
+ "marker-end"
794
+ ]);
829
795
  var SvgDocument = class SvgDocument {
830
796
  constructor(svg) {
831
797
  this.listeners = /* @__PURE__ */ new Set();
832
798
  this._structure_version = 0;
799
+ this._geometry_version = 0;
833
800
  this.source = svg;
834
- const parsed = parse_svg(svg);
801
+ const parsed = (0, _grida_svg_parser.parse_svg)(svg);
835
802
  this.original = parsed;
836
803
  this.nodes = parsed.nodes;
837
804
  this.prolog = parsed.prolog;
@@ -843,25 +810,27 @@ var SvgDocument = class SvgDocument {
843
810
  }
844
811
  /** Reload from the original parse, discarding all edits. */
845
812
  reset_to_original() {
846
- const parsed = parse_svg(this.source);
813
+ const parsed = (0, _grida_svg_parser.parse_svg)(this.source);
847
814
  this.original = parsed;
848
815
  this.nodes = parsed.nodes;
849
816
  this.prolog = parsed.prolog;
850
817
  this.epilog = parsed.epilog;
851
818
  this.root = parsed.root;
852
819
  this._structure_version++;
820
+ this._geometry_version++;
853
821
  this.emit();
854
822
  }
855
823
  /** Replace document with new svg source (clears edits + history-owned state). */
856
824
  load(svg) {
857
825
  this.source = svg;
858
- const parsed = parse_svg(svg);
826
+ const parsed = (0, _grida_svg_parser.parse_svg)(svg);
859
827
  this.original = parsed;
860
828
  this.nodes = parsed.nodes;
861
829
  this.prolog = parsed.prolog;
862
830
  this.epilog = parsed.epilog;
863
831
  this.root = parsed.root;
864
832
  this._structure_version++;
833
+ this._geometry_version++;
865
834
  this.emit();
866
835
  }
867
836
  on_change(fn) {
@@ -872,6 +841,10 @@ var SvgDocument = class SvgDocument {
872
841
  get structure_version() {
873
842
  return this._structure_version;
874
843
  }
844
+ /** See `_geometry_version` for what this counter signals. */
845
+ get geometry_version() {
846
+ return this._geometry_version;
847
+ }
875
848
  emit() {
876
849
  for (const fn of this.listeners) fn();
877
850
  }
@@ -924,6 +897,48 @@ var SvgDocument = class SvgDocument {
924
897
  }
925
898
  return false;
926
899
  }
900
+ /**
901
+ * Filter a selection down to its **subtree roots** — drop any id whose
902
+ * ancestor is also in the input set.
903
+ *
904
+ * Mirrors `pruneNestedNodes` in the main canvas editor's query module
905
+ * ([editor/grida-canvas/query/index.ts:138](../../../../editor/grida-canvas/query/index.ts)) and shares its UX motivation:
906
+ * when a parent and a descendant are both selected, only the parent
907
+ * should drive multi-node mutations — otherwise the descendant
908
+ * accumulates the transform twice (once via the parent's `transform`,
909
+ * once via its own attribute write). Required for `commands.remove`
910
+ * (avoids re-attaching detached descendants on undo) and any multi-
911
+ * member translate path (avoids 2× drift for the Bar-chart marquee
912
+ * case).
913
+ *
914
+ * Order: preserves the input order for retained ids. Duplicates in
915
+ * the input are not deduplicated — callers are responsible (the
916
+ * editor's `commands.select` already dedupes).
917
+ *
918
+ * Performance: `O(n × depth)`. Builds a `Set` over the input once,
919
+ * then walks each id's ancestor chain at most once. The main editor's
920
+ * version is `O(n² × depth)` (per-pair `isAncestor`) — fine at typical
921
+ * selection sizes (a few dozen), worth winning here for free since
922
+ * `parent_of` is `O(1)` on our parent-map.
923
+ */
924
+ prune_nested_nodes(ids) {
925
+ if (ids.length <= 1) return [...ids];
926
+ const set = new Set(ids);
927
+ const out = [];
928
+ for (const id of ids) {
929
+ let nested = false;
930
+ let cur = this.parent_of(id);
931
+ while (cur !== null) {
932
+ if (set.has(cur)) {
933
+ nested = true;
934
+ break;
935
+ }
936
+ cur = this.parent_of(cur);
937
+ }
938
+ if (!nested) out.push(id);
939
+ }
940
+ return out;
941
+ }
927
942
  all_nodes() {
928
943
  const out = [];
929
944
  const walk = (id) => {
@@ -961,18 +976,20 @@ var SvgDocument = class SvgDocument {
961
976
  const n = this.nodes.get(id);
962
977
  if (!n || n.kind !== "element") return;
963
978
  const structural = name === "id";
979
+ const geometry = ns === null && GEOMETRY_ATTRS.has(name);
964
980
  for (let i = 0; i < n.attrs.length; i++) {
965
981
  const a = n.attrs[i];
966
982
  if (a.local === name && (ns === null || a.ns === ns)) {
967
983
  if (value === null) n.attrs.splice(i, 1);
968
984
  else a.value = value;
969
985
  if (structural) this._structure_version++;
986
+ if (geometry) this._geometry_version++;
970
987
  this.emit();
971
988
  return;
972
989
  }
973
990
  }
974
991
  if (value !== null) {
975
- const prefix = ns === "http://www.w3.org/1999/xlink" ? "xlink" : null;
992
+ const prefix = ns === _grida_svg_parser.XLINK_NS ? "xlink" : null;
976
993
  n.attrs.push({
977
994
  raw_name: prefix ? `${prefix}:${name}` : name,
978
995
  prefix,
@@ -984,6 +1001,7 @@ var SvgDocument = class SvgDocument {
984
1001
  quote: "\""
985
1002
  });
986
1003
  if (structural) this._structure_version++;
1004
+ if (geometry) this._geometry_version++;
987
1005
  this.emit();
988
1006
  }
989
1007
  }
@@ -1022,6 +1040,25 @@ var SvgDocument = class SvgDocument {
1022
1040
  if (!style) return [];
1023
1041
  return parse_inline_style(style);
1024
1042
  }
1043
+ /**
1044
+ * Whether `id` can be opened in the flat-string text editor.
1045
+ *
1046
+ * v1 contract: the editor only operates on a *single flat text run*. That
1047
+ * means the target must be a `<text>` or `<tspan>` whose direct children
1048
+ * are all text nodes (or it has no children). A `<text>` containing a
1049
+ * `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
1050
+ * content from the editor's view, and a flat-text write would leave the
1051
+ * tspan dangling. Tspan-as-target is fine and well-defined when it's a
1052
+ * leaf; only the host decides whether to route double-click to a tspan
1053
+ * or its parent text.
1054
+ */
1055
+ is_text_edit_target(id) {
1056
+ const n = this.nodes.get(id);
1057
+ if (!n || n.kind !== "element") return false;
1058
+ if (n.local !== "text" && n.local !== "tspan") return false;
1059
+ for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
1060
+ return true;
1061
+ }
1025
1062
  text_of(id) {
1026
1063
  const n = this.nodes.get(id);
1027
1064
  if (!n || n.kind !== "element") return "";
@@ -1049,6 +1086,7 @@ var SvgDocument = class SvgDocument {
1049
1086
  n.children.push(text_id);
1050
1087
  }
1051
1088
  this._structure_version++;
1089
+ this._geometry_version++;
1052
1090
  this.emit();
1053
1091
  }
1054
1092
  insert(id, parent, before) {
@@ -1067,6 +1105,7 @@ var SvgDocument = class SvgDocument {
1067
1105
  else parent_node.children.splice(ix, 0, id);
1068
1106
  node.parent = parent;
1069
1107
  this._structure_version++;
1108
+ this._geometry_version++;
1070
1109
  this.emit();
1071
1110
  }
1072
1111
  remove(id) {
@@ -1078,6 +1117,7 @@ var SvgDocument = class SvgDocument {
1078
1117
  if (i >= 0) parent.children.splice(i, 1);
1079
1118
  n.parent = null;
1080
1119
  this._structure_version++;
1120
+ this._geometry_version++;
1081
1121
  this.emit();
1082
1122
  }
1083
1123
  /** Create a new element node and register it (not yet inserted). */
@@ -1112,7 +1152,7 @@ var SvgDocument = class SvgDocument {
1112
1152
  }
1113
1153
  emit_node(n) {
1114
1154
  switch (n.kind) {
1115
- case "text": return encode_text(n.value);
1155
+ case "text": return (0, _grida_svg_parser.encode_text)(n.value);
1116
1156
  case "comment": return `<!--${n.value}-->`;
1117
1157
  case "cdata": return `<![CDATA[${n.value}]]>`;
1118
1158
  case "pi": {
@@ -1139,7 +1179,7 @@ var SvgDocument = class SvgDocument {
1139
1179
  }
1140
1180
  }
1141
1181
  emit_attr(a) {
1142
- return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${encode_attr_value(a.value, a.quote)}${a.quote}`;
1182
+ return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${(0, _grida_svg_parser.encode_attr_value)(a.value, a.quote)}${a.quote}`;
1143
1183
  }
1144
1184
  };
1145
1185
  function parse_inline_style(s) {
@@ -1158,6 +1198,56 @@ function parse_inline_style(s) {
1158
1198
  return out;
1159
1199
  }
1160
1200
  //#endregion
1201
+ //#region src/core/align.ts
1202
+ /**
1203
+ * Compute per-member translation deltas to align `members` against `target`.
1204
+ *
1205
+ * Convention (matches Figma menu labels and `cmath.rect.alignA`):
1206
+ * - `left` / `right` → shift X so each left/right edge matches target's
1207
+ * - `top` / `bottom` → shift Y so each top/bottom edge matches target's
1208
+ * - `horizontal_centers` → shift X so each center-X matches target's center-X
1209
+ * - `vertical_centers` → shift Y so each center-Y matches target's center-Y
1210
+ *
1211
+ * Members already at the target position are omitted from the returned map —
1212
+ * callers iterate non-zero deltas only.
1213
+ */
1214
+ function compute_align_deltas(members, target, direction) {
1215
+ const out = /* @__PURE__ */ new Map();
1216
+ for (const m of members) {
1217
+ const d = delta_for(m.bbox, target, direction);
1218
+ if (d.x !== 0 || d.y !== 0) out.set(m.id, d);
1219
+ }
1220
+ return out;
1221
+ }
1222
+ function delta_for(bbox, target, direction) {
1223
+ switch (direction) {
1224
+ case "left": return {
1225
+ x: target.x - bbox.x,
1226
+ y: 0
1227
+ };
1228
+ case "right": return {
1229
+ x: target.x + target.width - (bbox.x + bbox.width),
1230
+ y: 0
1231
+ };
1232
+ case "top": return {
1233
+ x: 0,
1234
+ y: target.y - bbox.y
1235
+ };
1236
+ case "bottom": return {
1237
+ x: 0,
1238
+ y: target.y + target.height - (bbox.y + bbox.height)
1239
+ };
1240
+ case "horizontal_centers": return {
1241
+ x: target.x + target.width / 2 - (bbox.x + bbox.width / 2),
1242
+ y: 0
1243
+ };
1244
+ case "vertical_centers": return {
1245
+ x: 0,
1246
+ y: target.y + target.height / 2 - (bbox.y + bbox.height / 2)
1247
+ };
1248
+ }
1249
+ }
1250
+ //#endregion
1161
1251
  //#region src/core/properties.ts
1162
1252
  /** SVG properties that inherit per SVG 2 §6 (subset; the common ones). */
1163
1253
  const INHERITED = new Set([
@@ -1301,17 +1391,6 @@ function choose_write_carrier(doc, id, property) {
1301
1391
  return "presentation_attribute";
1302
1392
  }
1303
1393
  //#endregion
1304
- //#region src/types.ts
1305
- const DEFAULT_STYLE = {
1306
- chrome_color: "#2563eb",
1307
- handle_size: 8,
1308
- handle_fill: "#ffffff",
1309
- handle_stroke: "#2563eb",
1310
- endpoint_dot_radius: 5,
1311
- selection_outline_width: 2,
1312
- measurement_color: "#ff3a30"
1313
- };
1314
- //#endregion
1315
1394
  //#region src/core/editor.ts
1316
1395
  const PROVIDER_ID = "svg-editor";
1317
1396
  /** Max characters in a synthesized display label before truncation. */
@@ -1323,29 +1402,46 @@ function createSvgEditor(opts) {
1323
1402
  let selection = [];
1324
1403
  let scope = null;
1325
1404
  let mode = "select";
1405
+ let tool = require_insertions.TOOL_CURSOR;
1326
1406
  let version = 0;
1327
1407
  /** Document-edit counter — only bumps on actual mutations, not selection. */
1328
1408
  let doc_version = 0;
1329
1409
  /** doc_version at the last load()/serialize(); compared to derive `dirty`. */
1330
1410
  let baseline_doc_version = 0;
1411
+ /**
1412
+ * Bumps once per `editor.load(svg)` call. The constructor's initial parse
1413
+ * does NOT count — it's the "factory" state. Hosts subscribe via
1414
+ * `subscribe_with_selector(s => s.load_version, ...)` to react to fresh
1415
+ * document loads without firing on every edit.
1416
+ */
1417
+ let load_version = 0;
1331
1418
  let style = {
1332
- ...DEFAULT_STYLE,
1333
- ...opts.style ?? {}
1419
+ ...require_insertions.DEFAULT_STYLE,
1420
+ ...opts.style
1334
1421
  };
1335
1422
  const providers = opts.providers ?? {};
1336
1423
  const listeners = /* @__PURE__ */ new Set();
1337
1424
  let attached_surface = null;
1425
+ /**
1426
+ * World-space geometry query provider. Set by the DOM surface on
1427
+ * attach (`editor._internal.set_geometry`); cleared on detach. Null
1428
+ * means no renderer is attached — bounds queries cannot be answered.
1429
+ */
1430
+ let geometry_provider = null;
1338
1431
  const modes = ["select", "edit-content"];
1339
1432
  function snapshot() {
1340
1433
  return Object.freeze({
1341
1434
  selection,
1342
1435
  scope,
1343
1436
  mode,
1437
+ tool,
1344
1438
  dirty: doc_version !== baseline_doc_version,
1345
1439
  can_undo: history.stack.canUndo,
1346
1440
  can_redo: history.stack.canRedo,
1347
1441
  version,
1348
- structure_version: doc.structure_version
1442
+ structure_version: doc.structure_version,
1443
+ geometry_version: doc.geometry_version,
1444
+ load_version
1349
1445
  });
1350
1446
  }
1351
1447
  function emit() {
@@ -1356,8 +1452,18 @@ function createSvgEditor(opts) {
1356
1452
  history.on("onChange", () => emit());
1357
1453
  history.on("onUndo", () => emit());
1358
1454
  history.on("onRedo", () => emit());
1455
+ let last_emitted_geometry_version = doc.geometry_version;
1456
+ const geometry_listeners = /* @__PURE__ */ new Set();
1457
+ const translate_commit_listeners = /* @__PURE__ */ new Set();
1458
+ const notify_translate_commit = () => {
1459
+ for (const cb of translate_commit_listeners) cb();
1460
+ };
1359
1461
  doc.on_change(() => {
1360
1462
  doc_version++;
1463
+ if (doc.geometry_version !== last_emitted_geometry_version) {
1464
+ last_emitted_geometry_version = doc.geometry_version;
1465
+ for (const cb of geometry_listeners) cb();
1466
+ }
1361
1467
  });
1362
1468
  function subscribe(fn) {
1363
1469
  listeners.add(fn);
@@ -1377,16 +1483,29 @@ function createSvgEditor(opts) {
1377
1483
  });
1378
1484
  }
1379
1485
  function set_selection(next) {
1486
+ if (next.length === selection.length) {
1487
+ let same = true;
1488
+ for (let i = 0; i < next.length; i++) if (next[i] !== selection[i]) {
1489
+ same = false;
1490
+ break;
1491
+ }
1492
+ if (same) return;
1493
+ }
1380
1494
  selection = Object.freeze([...next]);
1381
1495
  emit();
1382
1496
  }
1383
1497
  function select(target, opts) {
1384
1498
  const ids = typeof target === "string" ? [target] : [...target];
1385
- if (opts?.additive) {
1386
- const merged = new Set(selection);
1387
- for (const id of ids) merged.add(id);
1388
- set_selection([...merged]);
1389
- } else set_selection(ids);
1499
+ const mode = opts?.mode ?? "replace";
1500
+ if (mode === "replace") {
1501
+ set_selection(ids);
1502
+ return;
1503
+ }
1504
+ const next = new Set(selection);
1505
+ if (mode === "add") for (const id of ids) next.add(id);
1506
+ else for (const id of ids) if (next.has(id)) next.delete(id);
1507
+ else next.add(id);
1508
+ set_selection([...next]);
1390
1509
  }
1391
1510
  function deselect() {
1392
1511
  set_selection([]);
@@ -1406,18 +1525,121 @@ function createSvgEditor(opts) {
1406
1525
  mode = next;
1407
1526
  emit();
1408
1527
  }
1528
+ function tools_equal(a, b) {
1529
+ if (a.type !== b.type) return false;
1530
+ if (a.type === "cursor") return true;
1531
+ return b.type === "insert" && a.tag === b.tag;
1532
+ }
1533
+ function set_tool(next) {
1534
+ if (tools_equal(tool, next)) return;
1535
+ tool = next;
1536
+ emit();
1537
+ }
1538
+ const paint_cache = /* @__PURE__ */ new Map();
1539
+ const property_cache = /* @__PURE__ */ new Map();
1540
+ const properties_cache = /* @__PURE__ */ new Map();
1541
+ let tree_cache = null;
1542
+ const tree_node_pool = /* @__PURE__ */ new Map();
1543
+ function tree_snapshot() {
1544
+ const sv = doc.structure_version;
1545
+ if (tree_cache && tree_cache.structure_version === sv) return tree_cache.value;
1546
+ const map = /* @__PURE__ */ new Map();
1547
+ let any_change = !tree_cache;
1548
+ for (const id of doc.all_elements()) {
1549
+ const tag = doc.tag_of(id);
1550
+ const name = doc.get_attr(id, "id") ?? void 0;
1551
+ const parent = doc.parent_of(id);
1552
+ const children = doc.element_children_of(id);
1553
+ const pooled = tree_node_pool.get(id);
1554
+ if (pooled && pooled.tag === tag && pooled.name === name && pooled.parent === parent && array_shallow_equal(pooled.children, children)) {
1555
+ map.set(id, pooled);
1556
+ continue;
1557
+ }
1558
+ const node = {
1559
+ id,
1560
+ tag,
1561
+ name,
1562
+ parent,
1563
+ children
1564
+ };
1565
+ tree_node_pool.set(id, node);
1566
+ map.set(id, node);
1567
+ any_change = true;
1568
+ }
1569
+ for (const id of tree_node_pool.keys()) if (!map.has(id)) {
1570
+ tree_node_pool.delete(id);
1571
+ any_change = true;
1572
+ }
1573
+ if (!any_change && tree_cache) {
1574
+ tree_cache.structure_version = sv;
1575
+ return tree_cache.value;
1576
+ }
1577
+ const snap = {
1578
+ root: doc.root,
1579
+ nodes: map
1580
+ };
1581
+ tree_cache = {
1582
+ structure_version: sv,
1583
+ value: snap
1584
+ };
1585
+ return snap;
1586
+ }
1587
+ function node_property_cached(id, name) {
1588
+ const key = `${id}${name}`;
1589
+ const cached = property_cache.get(key);
1590
+ if (cached && cached.doc_version === doc_version) return cached.value;
1591
+ const next = read_property(doc, id, name);
1592
+ if (cached && property_value_equals(cached.value, next)) {
1593
+ cached.doc_version = doc_version;
1594
+ return cached.value;
1595
+ }
1596
+ property_cache.set(key, {
1597
+ doc_version,
1598
+ value: next
1599
+ });
1600
+ return next;
1601
+ }
1409
1602
  function node_properties(id, names) {
1410
- const out = {};
1411
- for (const name of names) out[name] = read_property(doc, id, name);
1412
- return out;
1603
+ const key = `${id}${names.join("")}`;
1604
+ const cached = properties_cache.get(key);
1605
+ if (cached && cached.doc_version === doc_version) return cached.value;
1606
+ const next = {};
1607
+ let changed = !cached;
1608
+ for (const name of names) {
1609
+ const v = node_property_cached(id, name);
1610
+ next[name] = v;
1611
+ if (cached && cached.value[name] !== v) changed = true;
1612
+ }
1613
+ if (cached && !changed) {
1614
+ cached.doc_version = doc_version;
1615
+ return cached.value;
1616
+ }
1617
+ const frozen = Object.freeze(next);
1618
+ properties_cache.set(key, {
1619
+ doc_version,
1620
+ value: frozen
1621
+ });
1622
+ return frozen;
1413
1623
  }
1414
1624
  function node_paint(id, channel) {
1625
+ const key = `${id}${channel}`;
1626
+ const cached = paint_cache.get(key);
1627
+ if (cached && cached.doc_version === doc_version) return cached.value;
1415
1628
  const { declared, provenance } = resolve_declared(doc, id, channel);
1416
- return {
1629
+ const next = {
1417
1630
  declared,
1418
- computed: require_paint.parse_paint(declared),
1631
+ computed: require_insertions.parse_paint(declared),
1419
1632
  provenance
1420
1633
  };
1634
+ if (cached && paint_value_equals(cached.value, next)) {
1635
+ cached.doc_version = doc_version;
1636
+ return cached.value;
1637
+ }
1638
+ paint_cache.set(key, {
1639
+ doc_version,
1640
+ value: next
1641
+ });
1642
+ return next;
1421
1643
  }
1422
1644
  function write_property(id, name, value) {
1423
1645
  if (choose_write_carrier(doc, id, name) === "inline_style") doc.set_style(id, name, value);
@@ -1485,12 +1707,12 @@ function createSvgEditor(opts) {
1485
1707
  }
1486
1708
  function set_paint(channel, paint) {
1487
1709
  if (selection.length === 0) return;
1488
- set_property(channel, require_paint.serialize_paint(paint));
1710
+ set_property(channel, require_insertions.serialize_paint(paint));
1489
1711
  }
1490
1712
  function preview_paint(channel) {
1491
1713
  const session = preview_property(channel);
1492
1714
  return {
1493
- update: (paint) => session.update(require_paint.serialize_paint(paint)),
1715
+ update: (paint) => session.update(require_insertions.serialize_paint(paint)),
1494
1716
  commit: () => session.commit(),
1495
1717
  discard: () => session.discard()
1496
1718
  };
@@ -1503,29 +1725,427 @@ function createSvgEditor(opts) {
1503
1725
  });
1504
1726
  return { gradient_id };
1505
1727
  }
1728
+ /** Shared one-shot translate runner. `stages` selects semantics — see
1729
+ * `core/translate-pipeline/README.md`'s "Stage lists per entry point". */
1730
+ function do_translate_oneshot(delta, stages, label) {
1731
+ if (selection.length === 0) return false;
1732
+ if (delta.dx === 0 && delta.dy === 0) return false;
1733
+ const { apply, revert } = require_insertions.prepare_translate_rpc({
1734
+ doc,
1735
+ ids: selection,
1736
+ delta: {
1737
+ x: delta.dx,
1738
+ y: delta.dy
1739
+ },
1740
+ options: {
1741
+ pixel_grid_quantum: style.snap_to_pixel_grid ? style.pixel_grid_size : null,
1742
+ snap_enabled: style.snap_enabled,
1743
+ snap_threshold_px: style.snap_threshold_px
1744
+ },
1745
+ emit,
1746
+ stages
1747
+ });
1748
+ apply();
1749
+ history.atomic(label, (tx) => {
1750
+ tx.push({
1751
+ providerId: PROVIDER_ID,
1752
+ apply,
1753
+ revert
1754
+ });
1755
+ });
1756
+ return true;
1757
+ }
1506
1758
  function translate(delta) {
1507
- if (selection.length === 0) return;
1508
- if (delta.dx === 0 && delta.dy === 0) return;
1509
- const baselines = selection.map((id) => ({
1510
- id,
1511
- baseline: require_paint.capture_translate_baseline(doc, id)
1512
- }));
1759
+ if (do_translate_oneshot(delta, void 0, "translate")) notify_translate_commit();
1760
+ }
1761
+ function nudge(delta) {
1762
+ if (do_translate_oneshot(delta, require_insertions.STAGES_NUDGE, "nudge")) notify_translate_commit();
1763
+ }
1764
+ /**
1765
+ * One-shot multi-member resize to an explicit target rect. Mirrors a
1766
+ * drag-resize gesture in mechanics — capture per-member baselines,
1767
+ * scale around the union's NW corner, translate the result so the
1768
+ * union NW lands at the requested position — but as a single
1769
+ * atomic step rather than a preview session.
1770
+ *
1771
+ * The function does its own geometry lookup via the
1772
+ * `geometry_provider` registered by the DOM surface. When no surface
1773
+ * is attached, the call is a no-op (returns `false`). Members whose
1774
+ * tag is not resizable are silently filtered.
1775
+ *
1776
+ * Revert restores the captured `transform` attribute and all
1777
+ * geometry attrs the apply step wrote — so a `<rect>` with an
1778
+ * existing `transform` round-trips cleanly. See `apply_translate`'s
1779
+ * `viaTransform` arm for why this matters.
1780
+ */
1781
+ function resize_to(target, opts) {
1782
+ const ids = opts?.ids ?? selection;
1783
+ if (ids.length === 0) return false;
1784
+ if (!geometry_provider) return false;
1785
+ const members = [];
1786
+ for (const id of ids) {
1787
+ if (!require_insertions.is_resizable(doc.tag_of(id))) continue;
1788
+ const bbox = geometry_provider.bounds_of(id);
1789
+ if (!bbox) continue;
1790
+ members.push({
1791
+ id,
1792
+ rz: require_insertions.capture_resize_baseline(doc, id, bbox),
1793
+ tx_pre: require_insertions.capture_translate_baseline(doc, id),
1794
+ transform_pre: doc.get_attr(id, "transform"),
1795
+ bbox
1796
+ });
1797
+ }
1798
+ if (members.length === 0) return false;
1799
+ const union = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
1800
+ const sx = union.width === 0 ? 1 : target.width / union.width;
1801
+ const sy = union.height === 0 ? 1 : target.height / union.height;
1802
+ const origin = {
1803
+ x: union.x,
1804
+ y: union.y
1805
+ };
1806
+ const dx = target.x - union.x;
1807
+ const dy = target.y - union.y;
1808
+ const apply = () => {
1809
+ for (const m of members) require_insertions.apply_resize(doc, m.id, m.rz, sx, sy, origin);
1810
+ if (dx !== 0 || dy !== 0) for (const m of members) {
1811
+ const tx_after = require_insertions.capture_translate_baseline(doc, m.id);
1812
+ require_insertions.apply_translate(doc, m.id, tx_after, dx, dy);
1813
+ }
1814
+ emit();
1815
+ };
1816
+ const revert = () => {
1817
+ for (const m of members) {
1818
+ require_insertions.apply_resize(doc, m.id, m.rz, 1, 1, origin);
1819
+ doc.set_attr(m.id, "transform", m.transform_pre);
1820
+ }
1821
+ emit();
1822
+ };
1823
+ apply();
1824
+ history.atomic("resize-to", (tx) => {
1825
+ tx.push({
1826
+ providerId: PROVIDER_ID,
1827
+ apply,
1828
+ revert
1829
+ });
1830
+ });
1831
+ return true;
1832
+ }
1833
+ /** Shared helper: compute a default rotation pivot from the live
1834
+ * geometry_provider when the caller omitted one. Falls back to (0,0)
1835
+ * if no surface is attached. */
1836
+ function default_rotate_pivot(ids) {
1837
+ if (!geometry_provider || ids.length === 0) return {
1838
+ x: 0,
1839
+ y: 0
1840
+ };
1841
+ const rects = [];
1842
+ for (const id of ids) {
1843
+ const b = geometry_provider.bounds_of(id);
1844
+ if (b) rects.push(b);
1845
+ }
1846
+ if (rects.length === 0) return {
1847
+ x: 0,
1848
+ y: 0
1849
+ };
1850
+ const u = _grida_cmath.default.rect.union(rects);
1851
+ return {
1852
+ x: u.x + u.width / 2,
1853
+ y: u.y + u.height / 2
1854
+ };
1855
+ }
1856
+ function rotate(angle, opts) {
1857
+ const ids = opts?.ids ?? selection;
1858
+ if (ids.length === 0) return false;
1859
+ const prepared = require_insertions.prepare_rotate_rpc({
1860
+ doc,
1861
+ ids,
1862
+ pivot: opts?.pivot ?? default_rotate_pivot(ids),
1863
+ angle_radians: angle,
1864
+ options: { angle_snap_step_radians: style.angle_snap_step_radians },
1865
+ emit
1866
+ });
1867
+ for (const v of prepared.verdicts.values()) if (v.kind === "refuse") return false;
1868
+ prepared.apply();
1869
+ history.atomic("rotate", (tx) => {
1870
+ tx.push({
1871
+ providerId: PROVIDER_ID,
1872
+ apply: prepared.apply,
1873
+ revert: prepared.revert
1874
+ });
1875
+ });
1876
+ return true;
1877
+ }
1878
+ function rotate_to(angle, opts) {
1879
+ const ids = opts?.ids ?? selection;
1880
+ if (ids.length === 0) return false;
1881
+ const probe = require_insertions.prepare_rotate_rpc({
1882
+ doc,
1883
+ ids,
1884
+ pivot: opts?.pivot ?? default_rotate_pivot(ids),
1885
+ angle_radians: 0,
1886
+ options: { angle_snap_step_radians: style.angle_snap_step_radians },
1887
+ emit: () => {}
1888
+ });
1889
+ for (const v of probe.verdicts.values()) if (v.kind === "refuse") return false;
1890
+ const DEG_TO_RAD = Math.PI / 180;
1513
1891
  const apply = () => {
1514
- for (const { id, baseline } of baselines) require_paint.apply_translate(doc, id, baseline, delta.dx, delta.dy);
1892
+ for (const m of probe.plan.members) {
1893
+ const delta = angle - m.baseline.current_rotation_deg * DEG_TO_RAD;
1894
+ require_insertions.apply_rotate(doc, m.id, m.baseline, delta);
1895
+ }
1515
1896
  emit();
1516
1897
  };
1517
1898
  const revert = () => {
1518
- for (const { id, baseline } of baselines) require_paint.apply_translate(doc, id, baseline, 0, 0);
1899
+ for (const m of probe.plan.members) require_insertions.apply_rotate(doc, m.id, m.baseline, 0);
1519
1900
  emit();
1520
1901
  };
1521
1902
  apply();
1522
- history.atomic("translate", (tx) => {
1903
+ history.atomic("rotate-to", (tx) => {
1523
1904
  tx.push({
1524
1905
  providerId: PROVIDER_ID,
1525
1906
  apply,
1526
1907
  revert
1527
1908
  });
1528
1909
  });
1910
+ return true;
1911
+ }
1912
+ function flatten_transform(opts) {
1913
+ const ids = opts?.ids ?? selection;
1914
+ if (ids.length === 0) return false;
1915
+ const members = [];
1916
+ for (const id of ids) {
1917
+ const pre = doc.get_attr(id, "transform");
1918
+ if (pre === null) continue;
1919
+ const ops = require_insertions.parse_transform_list(pre);
1920
+ if (ops === null) continue;
1921
+ if (ops.length === 1 && ops[0].type === "matrix") continue;
1922
+ members.push({
1923
+ id,
1924
+ transform_pre: pre,
1925
+ ops
1926
+ });
1927
+ }
1928
+ if (members.length === 0) return false;
1929
+ const IDENT = [
1930
+ 1,
1931
+ 0,
1932
+ 0,
1933
+ 1,
1934
+ 0,
1935
+ 0
1936
+ ];
1937
+ function mul(m1, m2) {
1938
+ const [a1, b1, c1, d1, e1, f1] = m1;
1939
+ const [a2, b2, c2, d2, e2, f2] = m2;
1940
+ return [
1941
+ a1 * a2 + c1 * b2,
1942
+ b1 * a2 + d1 * b2,
1943
+ a1 * c2 + c1 * d2,
1944
+ b1 * c2 + d1 * d2,
1945
+ a1 * e2 + c1 * f2 + e1,
1946
+ b1 * e2 + d1 * f2 + f1
1947
+ ];
1948
+ }
1949
+ function op_to_mat(op) {
1950
+ switch (op.type) {
1951
+ case "matrix": return [
1952
+ op.a,
1953
+ op.b,
1954
+ op.c,
1955
+ op.d,
1956
+ op.e,
1957
+ op.f
1958
+ ];
1959
+ case "translate": return [
1960
+ 1,
1961
+ 0,
1962
+ 0,
1963
+ 1,
1964
+ op.tx,
1965
+ op.ty
1966
+ ];
1967
+ case "rotate": {
1968
+ const rad = op.angle * Math.PI / 180;
1969
+ const c = Math.cos(rad);
1970
+ const s = Math.sin(rad);
1971
+ if (op.cx === 0 && op.cy === 0) return [
1972
+ c,
1973
+ s,
1974
+ -s,
1975
+ c,
1976
+ 0,
1977
+ 0
1978
+ ];
1979
+ const e = op.cx - c * op.cx + s * op.cy;
1980
+ const f = op.cy - s * op.cx - c * op.cy;
1981
+ return [
1982
+ c,
1983
+ s,
1984
+ -s,
1985
+ c,
1986
+ e,
1987
+ f
1988
+ ];
1989
+ }
1990
+ case "scale": return [
1991
+ op.sx,
1992
+ 0,
1993
+ 0,
1994
+ op.sy,
1995
+ 0,
1996
+ 0
1997
+ ];
1998
+ case "skewX": {
1999
+ const rad = op.angle * Math.PI / 180;
2000
+ return [
2001
+ 1,
2002
+ 0,
2003
+ Math.tan(rad),
2004
+ 1,
2005
+ 0,
2006
+ 0
2007
+ ];
2008
+ }
2009
+ case "skewY": {
2010
+ const rad = op.angle * Math.PI / 180;
2011
+ return [
2012
+ 1,
2013
+ Math.tan(rad),
2014
+ 0,
2015
+ 1,
2016
+ 0,
2017
+ 0
2018
+ ];
2019
+ }
2020
+ }
2021
+ }
2022
+ const apply = () => {
2023
+ for (const m of members) {
2024
+ let mat = IDENT;
2025
+ for (const op of m.ops) mat = mul(mat, op_to_mat(op));
2026
+ doc.set_attr(m.id, "transform", require_insertions.emit_transform_list([{
2027
+ type: "matrix",
2028
+ a: mat[0],
2029
+ b: mat[1],
2030
+ c: mat[2],
2031
+ d: mat[3],
2032
+ e: mat[4],
2033
+ f: mat[5]
2034
+ }]));
2035
+ }
2036
+ emit();
2037
+ };
2038
+ const revert = () => {
2039
+ for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
2040
+ emit();
2041
+ };
2042
+ apply();
2043
+ history.atomic("flatten-transform", (tx) => {
2044
+ tx.push({
2045
+ providerId: PROVIDER_ID,
2046
+ apply,
2047
+ revert
2048
+ });
2049
+ });
2050
+ return true;
2051
+ }
2052
+ /**
2053
+ * Translate selected members so they line up along the requested edge or
2054
+ * center of a reference rect. Same mechanics as `resize_to`: per-member
2055
+ * translate baselines (so `<g>`, transformed, and natively-attributed
2056
+ * nodes all write the cleanest in-place representation), one atomic
2057
+ * history step.
2058
+ *
2059
+ * Reference rect is selection-size dependent:
2060
+ * - multi-selection: union of member bboxes
2061
+ * - single selection: the parent's bbox (root → `<svg>` viewport,
2062
+ * inside a `<g>` → that group's bbox). Refuses when the selected
2063
+ * node IS the root (no container to align against).
2064
+ *
2065
+ * Refuses when `geometry_provider` is null (no surface attached) or when
2066
+ * no member has a resolvable bbox.
2067
+ */
2068
+ function align(direction, opts) {
2069
+ const ids = opts?.ids ?? selection;
2070
+ if (ids.length === 0) return false;
2071
+ if (!geometry_provider) return false;
2072
+ const members = [];
2073
+ for (const id of ids) {
2074
+ const bbox = geometry_provider.bounds_of(id);
2075
+ if (!bbox) continue;
2076
+ const baseline = require_insertions.capture_translate_baseline(doc, id);
2077
+ if (baseline.type === "unsupported") continue;
2078
+ members.push({
2079
+ id,
2080
+ bbox,
2081
+ baseline
2082
+ });
2083
+ }
2084
+ if (members.length === 0) return false;
2085
+ let target;
2086
+ if (members.length === 1) {
2087
+ const parent_id = doc.parent_of(members[0].id);
2088
+ if (parent_id === null) return false;
2089
+ const parent_bbox = geometry_provider.bounds_of(parent_id);
2090
+ if (!parent_bbox) return false;
2091
+ target = parent_bbox;
2092
+ } else target = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
2093
+ const deltas = compute_align_deltas(members, target, direction);
2094
+ if (deltas.size === 0) return false;
2095
+ const apply = () => {
2096
+ for (const m of members) {
2097
+ const d = deltas.get(m.id);
2098
+ if (d) require_insertions.apply_translate(doc, m.id, m.baseline, d.x, d.y);
2099
+ }
2100
+ emit();
2101
+ };
2102
+ const revert = () => {
2103
+ for (const m of members) if (deltas.has(m.id)) require_insertions.apply_translate(doc, m.id, m.baseline, 0, 0);
2104
+ emit();
2105
+ };
2106
+ apply();
2107
+ history.atomic(`align ${direction}`, (tx) => {
2108
+ tx.push({
2109
+ providerId: PROVIDER_ID,
2110
+ apply,
2111
+ revert
2112
+ });
2113
+ });
2114
+ return true;
2115
+ }
2116
+ function select_all() {
2117
+ const parent = scope ?? doc.root;
2118
+ const children = doc.element_children_of(parent);
2119
+ if (children.length === 0) return false;
2120
+ set_selection(children);
2121
+ return true;
2122
+ }
2123
+ /**
2124
+ * Cycle the selection to the next / previous sibling. Single-selection
2125
+ * path uses the selected node's parent; empty / multi-selection falls
2126
+ * back to the current scope's first / last child. Wraps at edges.
2127
+ */
2128
+ function select_sibling(direction) {
2129
+ let parent;
2130
+ let anchor_index;
2131
+ let siblings;
2132
+ if (selection.length === 1) {
2133
+ const current = selection[0];
2134
+ parent = doc.parent_of(current);
2135
+ if (parent === null) return false;
2136
+ siblings = doc.element_children_of(parent);
2137
+ anchor_index = siblings.indexOf(current);
2138
+ if (anchor_index < 0) return false;
2139
+ } else {
2140
+ parent = scope ?? doc.root;
2141
+ siblings = doc.element_children_of(parent);
2142
+ if (siblings.length === 0) return false;
2143
+ anchor_index = direction === "next" ? -1 : siblings.length;
2144
+ }
2145
+ const n = siblings.length;
2146
+ const next = direction === "next" ? (anchor_index + 1) % n : (anchor_index - 1 + n) % n;
2147
+ set_selection([siblings[next]]);
2148
+ return true;
1529
2149
  }
1530
2150
  function reorder(direction) {
1531
2151
  if (selection.length !== 1) return;
@@ -1573,22 +2193,31 @@ function createSvgEditor(opts) {
1573
2193
  });
1574
2194
  }
1575
2195
  function remove() {
1576
- if (selection.length !== 1) return;
1577
- const target = selection[0];
1578
- const parent = doc.parent_of(target);
1579
- if (parent === null) return;
1580
- const next_sibling = doc.next_element_sibling_of(target);
2196
+ if (selection.length === 0) return;
2197
+ const filtered = doc.prune_nested_nodes(selection).filter((id) => doc.parent_of(id) !== null);
2198
+ if (filtered.length === 0) return;
2199
+ const doc_order = doc.all_elements();
2200
+ const index_of = /* @__PURE__ */ new Map();
2201
+ for (let i = 0; i < doc_order.length; i++) index_of.set(doc_order[i], i);
2202
+ const captures = [...filtered].sort((a, b) => (index_of.get(a) ?? 0) - (index_of.get(b) ?? 0)).map((id) => ({
2203
+ id,
2204
+ parent: doc.parent_of(id),
2205
+ next_sibling: doc.next_element_sibling_of(id)
2206
+ }));
1581
2207
  const old_selection = selection;
1582
2208
  const apply = () => {
1583
- doc.remove(target);
2209
+ for (const c of captures) doc.remove(c.id);
1584
2210
  set_selection([]);
1585
2211
  };
1586
2212
  const revert = () => {
1587
- doc.insert(target, parent, next_sibling);
2213
+ for (let i = captures.length - 1; i >= 0; i--) {
2214
+ const c = captures[i];
2215
+ doc.insert(c.id, c.parent, c.next_sibling);
2216
+ }
1588
2217
  set_selection(old_selection);
1589
2218
  };
1590
2219
  apply();
1591
- history.atomic("remove", (tx) => {
2220
+ history.atomic(captures.length === 1 ? "remove" : `remove ${captures.length}`, (tx) => {
1592
2221
  tx.push({
1593
2222
  providerId: PROVIDER_ID,
1594
2223
  apply,
@@ -1596,10 +2225,147 @@ function createSvgEditor(opts) {
1596
2225
  });
1597
2226
  });
1598
2227
  }
2228
+ function group() {
2229
+ const plan = require_insertions.plan_group(doc, selection);
2230
+ if (!plan) return false;
2231
+ const group_id = doc.create_element("g");
2232
+ const original_selection = selection;
2233
+ const apply = () => {
2234
+ doc.insert(group_id, plan.parent, plan.insert_before);
2235
+ for (const child of plan.children) doc.insert(child, group_id, null);
2236
+ set_selection([group_id]);
2237
+ };
2238
+ const revert = () => {
2239
+ for (let i = plan.children.length - 1; i >= 0; i--) {
2240
+ const child = plan.children[i];
2241
+ doc.insert(child, plan.parent, plan.original_positions.get(child) ?? null);
2242
+ }
2243
+ doc.remove(group_id);
2244
+ set_selection(original_selection);
2245
+ };
2246
+ apply();
2247
+ history.atomic("group", (tx) => {
2248
+ tx.push({
2249
+ providerId: PROVIDER_ID,
2250
+ apply,
2251
+ revert
2252
+ });
2253
+ });
2254
+ return true;
2255
+ }
2256
+ /**
2257
+ * Atomic one-shot insertion. Used by paste, programmatic RPC, and the
2258
+ * click-no-drag commit path inside the insertion gesture driver. One
2259
+ * undo step. Returns the new node id.
2260
+ *
2261
+ * `attrs` are merged on top of `default_paint_attrs(tag)` — caller attrs
2262
+ * win. `opts.parent` defaults to root; `opts.index` (insert-before
2263
+ * sibling index) defaults to append; `opts.select` defaults to `true`.
2264
+ */
2265
+ function insert(tag, attrs, opts) {
2266
+ const parent = opts?.parent ?? doc.root;
2267
+ const select_after = opts?.select !== false;
2268
+ let insert_before = null;
2269
+ if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
2270
+ const id = doc.create_element(tag);
2271
+ const merged_attrs = {
2272
+ ...default_paint_attrs_for(tag),
2273
+ ...attrs
2274
+ };
2275
+ const attr_pairs = Object.entries(merged_attrs);
2276
+ const previous_selection = selection;
2277
+ const apply = () => {
2278
+ for (const [name, value] of attr_pairs) doc.set_attr(id, name, value);
2279
+ doc.insert(id, parent, insert_before);
2280
+ if (select_after) set_selection([id]);
2281
+ };
2282
+ const revert = () => {
2283
+ doc.remove(id);
2284
+ if (select_after) set_selection(previous_selection);
2285
+ };
2286
+ apply();
2287
+ history.atomic(`insert ${tag}`, (tx) => {
2288
+ tx.push({
2289
+ providerId: PROVIDER_ID,
2290
+ apply,
2291
+ revert
2292
+ });
2293
+ });
2294
+ return id;
2295
+ }
2296
+ /**
2297
+ * Preview-bracketed insertion. Used by the pointer-driven drag gesture
2298
+ * in the DOM surface. Per-frame attr writes call `update(attrs)`; one
2299
+ * undo step on `commit()`; clean rollback on `discard()`.
2300
+ *
2301
+ * The node is created and inserted on open so the HUD selection chrome
2302
+ * can render the in-progress shape immediately. On `discard()` the
2303
+ * preview's revert removes the node entirely.
2304
+ */
2305
+ function insert_preview(tag, initial, opts) {
2306
+ const parent = opts?.parent ?? doc.root;
2307
+ let insert_before = null;
2308
+ if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
2309
+ const id = doc.create_element(tag);
2310
+ const previous_selection = selection;
2311
+ const live_attrs = {
2312
+ ...default_paint_attrs_for(tag),
2313
+ ...initial
2314
+ };
2315
+ for (const name in live_attrs) doc.set_attr(id, name, live_attrs[name]);
2316
+ doc.insert(id, parent, insert_before);
2317
+ set_selection([id]);
2318
+ const preview = history.preview(`insert ${tag}`);
2319
+ let active = true;
2320
+ const apply = () => {
2321
+ for (const name in live_attrs) doc.set_attr(id, name, live_attrs[name]);
2322
+ if (doc.parent_of(id) === null) doc.insert(id, parent, insert_before);
2323
+ set_selection([id]);
2324
+ };
2325
+ const revert = () => {
2326
+ doc.remove(id);
2327
+ set_selection(previous_selection);
2328
+ };
2329
+ const entry = {
2330
+ providerId: PROVIDER_ID,
2331
+ apply,
2332
+ revert
2333
+ };
2334
+ preview.set(entry);
2335
+ return {
2336
+ id,
2337
+ update(attrs) {
2338
+ if (!active) return;
2339
+ for (const name in attrs) {
2340
+ live_attrs[name] = attrs[name];
2341
+ doc.set_attr(id, name, attrs[name]);
2342
+ }
2343
+ preview.set(entry);
2344
+ },
2345
+ commit() {
2346
+ if (!active) return;
2347
+ active = false;
2348
+ preview.commit();
2349
+ },
2350
+ discard() {
2351
+ if (!active) return;
2352
+ active = false;
2353
+ preview.discard();
2354
+ }
2355
+ };
2356
+ }
2357
+ /** Per-tag default paint attrs. Wrapped so callers don't need to depend
2358
+ * on the InsertableTag type — `insert()` accepts arbitrary string tags
2359
+ * (so `commands.insert("path", ...)` works for paste / RPC) but only
2360
+ * the closed insertable set gets default paint. */
2361
+ function default_paint_attrs_for(tag) {
2362
+ if (tag === "rect" || tag === "ellipse" || tag === "line") return require_insertions.default_paint_attrs(tag);
2363
+ return {};
2364
+ }
1599
2365
  function set_text(value) {
1600
2366
  if (selection.length !== 1) return;
1601
2367
  const target = selection[0];
1602
- if (doc.tag_of(target) !== "text") return;
2368
+ if (!doc.is_text_edit_target(target)) return;
1603
2369
  const original = doc.text_of(target);
1604
2370
  if (original === value) return;
1605
2371
  const apply = () => {
@@ -1642,7 +2408,7 @@ function createSvgEditor(opts) {
1642
2408
  function enter_content_edit(target) {
1643
2409
  const id = target ?? (selection.length === 1 ? selection[0] : null);
1644
2410
  if (!id) return false;
1645
- if (doc.tag_of(id) !== "text") return false;
2411
+ if (!doc.is_text_edit_target(id)) return false;
1646
2412
  if (!content_edit_driver) return false;
1647
2413
  return content_edit_driver(id);
1648
2414
  }
@@ -1651,8 +2417,10 @@ function createSvgEditor(opts) {
1651
2417
  selection = [];
1652
2418
  scope = null;
1653
2419
  mode = "select";
2420
+ tool = require_insertions.TOOL_CURSOR;
1654
2421
  history.clear();
1655
2422
  baseline_doc_version = doc_version;
2423
+ load_version++;
1656
2424
  emit();
1657
2425
  }
1658
2426
  function serialize_svg() {
@@ -1669,6 +2437,8 @@ function createSvgEditor(opts) {
1669
2437
  const commands = {
1670
2438
  select,
1671
2439
  deselect,
2440
+ select_all,
2441
+ select_sibling,
1672
2442
  enter_scope,
1673
2443
  exit_scope,
1674
2444
  set_mode,
@@ -1678,8 +2448,17 @@ function createSvgEditor(opts) {
1678
2448
  preview_paint,
1679
2449
  set_paint_from_gradient,
1680
2450
  translate,
2451
+ nudge,
2452
+ resize_to,
2453
+ rotate,
2454
+ rotate_to,
2455
+ flatten_transform,
2456
+ align,
1681
2457
  reorder,
1682
2458
  remove,
2459
+ group,
2460
+ insert,
2461
+ insert_preview,
1683
2462
  set_text,
1684
2463
  load_svg,
1685
2464
  serialize_svg,
@@ -1701,6 +2480,7 @@ function createSvgEditor(opts) {
1701
2480
  selection = [];
1702
2481
  scope = null;
1703
2482
  mode = "select";
2483
+ tool = require_insertions.TOOL_CURSOR;
1704
2484
  baseline_doc_version = doc_version;
1705
2485
  emit();
1706
2486
  }
@@ -1757,18 +2537,7 @@ function createSvgEditor(opts) {
1757
2537
  return elem_id && elem_id.length > 0 ? `${head} #${elem_id}` : head;
1758
2538
  },
1759
2539
  tree() {
1760
- const map = /* @__PURE__ */ new Map();
1761
- for (const id of doc.all_elements()) map.set(id, {
1762
- id,
1763
- tag: doc.tag_of(id),
1764
- name: doc.get_attr(id, "id") ?? void 0,
1765
- parent: doc.parent_of(id),
1766
- children: doc.element_children_of(id)
1767
- });
1768
- return {
1769
- root: doc.root,
1770
- nodes: map
1771
- };
2540
+ return tree_snapshot();
1772
2541
  },
1773
2542
  surface_hover() {
1774
2543
  return current_surface_hover;
@@ -1784,7 +2553,17 @@ function createSvgEditor(opts) {
1784
2553
  surface_hover_listeners.delete(cb);
1785
2554
  };
1786
2555
  },
2556
+ subscribe_geometry(cb) {
2557
+ geometry_listeners.add(cb);
2558
+ return () => {
2559
+ geometry_listeners.delete(cb);
2560
+ };
2561
+ },
2562
+ get geometry() {
2563
+ return geometry_provider;
2564
+ },
1787
2565
  modes,
2566
+ set_tool,
1788
2567
  get style() {
1789
2568
  return style;
1790
2569
  },
@@ -1798,6 +2577,15 @@ function createSvgEditor(opts) {
1798
2577
  providers,
1799
2578
  _internal: {
1800
2579
  doc,
2580
+ history: { preview: (label) => history.preview(label) },
2581
+ emit,
2582
+ subscribe_translate_commit(cb) {
2583
+ translate_commit_listeners.add(cb);
2584
+ return () => {
2585
+ translate_commit_listeners.delete(cb);
2586
+ };
2587
+ },
2588
+ notify_translate_commit,
1801
2589
  set_content_edit_driver(fn) {
1802
2590
  content_edit_driver = fn;
1803
2591
  },
@@ -1810,6 +2598,9 @@ function createSvgEditor(opts) {
1810
2598
  },
1811
2599
  set_computed_resolver(fn) {
1812
2600
  computed_resolver = fn;
2601
+ },
2602
+ set_geometry(p) {
2603
+ geometry_provider = p;
1813
2604
  }
1814
2605
  },
1815
2606
  keymap
@@ -1818,13 +2609,39 @@ function createSvgEditor(opts) {
1818
2609
  applyDefaultBindings(keymap);
1819
2610
  return public_editor;
1820
2611
  }
1821
- //#endregion
1822
- Object.defineProperty(exports, "DEFAULT_STYLE", {
1823
- enumerable: true,
1824
- get: function() {
1825
- return DEFAULT_STYLE;
2612
+ function paint_value_equals(a, b) {
2613
+ if (a === b) return true;
2614
+ if (a.declared !== b.declared) return false;
2615
+ if (a.provenance.carrier !== b.provenance.carrier) return false;
2616
+ if (a.provenance.origin !== b.provenance.origin) return false;
2617
+ return paint_equals(a.computed, b.computed);
2618
+ }
2619
+ function paint_equals(a, b) {
2620
+ if (a === b) return true;
2621
+ if (a == null || b == null) return false;
2622
+ if ("error" in a || "error" in b) return "error" in a && "error" in b && a.error === b.error && a.reason === b.reason;
2623
+ if (a.kind !== b.kind) return false;
2624
+ if (a.kind === "color" && b.kind === "color") {
2625
+ if (a.value.kind !== b.value.kind) return false;
2626
+ if (a.value.kind === "rgb" && b.value.kind === "rgb") return a.value.value === b.value.value;
2627
+ return true;
1826
2628
  }
1827
- });
2629
+ if (a.kind === "ref" && b.kind === "ref") return a.id === b.id;
2630
+ if (a.kind === "none" && b.kind === "none") return true;
2631
+ if (a.kind === "context_fill" && b.kind === "context_fill") return true;
2632
+ if (a.kind === "context_stroke" && b.kind === "context_stroke") return true;
2633
+ return false;
2634
+ }
2635
+ function property_value_equals(a, b) {
2636
+ if (a === b) return true;
2637
+ if (a.declared !== b.declared) return false;
2638
+ if (a.provenance.carrier !== b.provenance.carrier) return false;
2639
+ if (a.provenance.origin !== b.provenance.origin) return false;
2640
+ if (a.computed === b.computed) return true;
2641
+ if (a.computed && b.computed && typeof a.computed === "object" && typeof b.computed === "object" && "error" in a.computed && "error" in b.computed) return a.computed.error === b.computed.error && a.computed.reason === b.computed.reason;
2642
+ return false;
2643
+ }
2644
+ //#endregion
1828
2645
  Object.defineProperty(exports, "createSvgEditor", {
1829
2646
  enumerable: true,
1830
2647
  get: function() {