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