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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -642,6 +642,39 @@ editor.commands.{
642
642
  // groups with visual state — see TODO §10)
643
643
  remove(): void;
644
644
 
645
+ // clipboard — the payload is a STANDALONE SVG DOCUMENT, not a private
646
+ // format (the file is the IR, so the clipboard is the file format).
647
+ // Copy carries the outbound url(#…)/href reference closure in one
648
+ // <defs> block and declares borrowed xmlns prefixes on the payload
649
+ // shell; ancestor transforms / inherited presentation / viewport are
650
+ // deliberately NOT carried (verbatim policy). Cut = copy + remove as
651
+ // ONE history step labeled "cut"; undo restores the document and the
652
+ // clipboard keeps the payload (cut → undo → paste = move). Paste is
653
+ // synchronous over delivered text (`text ?? internal buffer`) and has
654
+ // a gesture-grade refusal table: non-parseable environment input is a
655
+ // no-op `[]`, never a throw (insert_fragment keeps strict semantics).
656
+ // System-clipboard wiring is the DOM surface's native ClipboardEvent
657
+ // transport (text/plain = the markup itself) plus the optional
658
+ // ClipboardProvider seam. Full contract:
659
+ // https://grida.co/docs/wg/feat-svg-editor/clipboard
660
+ copy(): string | null; // payload | null on empty selection; no history
661
+ cut(): string | null; // one undoable step; buffer secured before delete
662
+ paste(text?: string): NodeId[]; // inserted roots (selected); [] = refusal
663
+
664
+ // duplicate — the clipboard FRD's SECOND extraction operation
665
+ // (subtree clone): in-document, so NO defs closure and NO xmlns
666
+ // shell are carried; subtrees and authored ids clone verbatim
667
+ // (colliding ids resolve first-in-document-order; Tidy dedups).
668
+ // Each clone lands as its origin's next sibling (paints above it);
669
+ // selection moves to the clones; ONE history step. Alt-drag
670
+ // translate-with-clone consumes the same operation. Repeating
671
+ // offset: duplicate → move the copy → duplicate repeats the
672
+ // translate delta (an Alt-drag clone commit arms the same memory);
673
+ // still one undo step, degrades to in-place when the preconditions
674
+ // don't hold. Contract:
675
+ // https://grida.co/docs/wg/feat-svg-editor/subtree-clone
676
+ duplicate(): NodeId[]; // clone ids (selected); [] = refusal
677
+
645
678
  // insertion — `tag` is an open string (so paste / RPC can create any element,
646
679
  // e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
647
680
  // draw gesture and default paint.
@@ -1,4 +1,4 @@
1
- import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-CYoGJ3Hf.js";
1
+ import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-BSxTUsW_.js";
2
2
  import cmath from "@grida/cmath";
3
3
  //#region src/core/snap/options.d.ts
4
4
  type SnapOptions = {
@@ -48,6 +48,18 @@ type DomSurfaceOptions = {
48
48
  * carte via `handle.gestures.bind(...)`.
49
49
  */
50
50
  gestures?: boolean;
51
+ /**
52
+ * Wire native ClipboardEvent transport — `copy` / `cut` / `paste`
53
+ * listeners on the owner document, gated by the clipboard attention
54
+ * discipline. Default `true`. Pass `false` to route ALL clipboard
55
+ * traffic through the `ClipboardProvider` seam instead (the
56
+ * configuration under which a host's paste-time screening governs
57
+ * every path) — see docs/wg/feat-svg-editor/clipboard.md §Transport
58
+ * "Host control over the native path". Focus management (the container
59
+ * focusing on pointerdown) stays either way — it is a general canvas
60
+ * mitigation, not a clipboard feature.
61
+ */
62
+ clipboard?: boolean;
51
63
  /**
52
64
  * Auto-fit the document into the viewport on initial attach. Default
53
65
  * `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
@@ -1,4 +1,4 @@
1
- const require_model = require("./model-D0nU_EkL.js");
1
+ const require_model = require("./model-BLhMJZKJ.js");
2
2
  let _grida_cmath = require("@grida/cmath");
3
3
  _grida_cmath = require_model.__toESM(_grida_cmath);
4
4
  let _grida_svg_parse = require("@grida/svg/parse");
@@ -1007,15 +1007,18 @@ function create_attention_tracker(container) {
1007
1007
  };
1008
1008
  container.addEventListener("pointerenter", on_enter);
1009
1009
  container.addEventListener("pointerleave", on_leave);
1010
- const is_attended = () => {
1010
+ const is_focus_within = () => {
1011
1011
  const owner = container.ownerDocument;
1012
- if (!owner) return pointer_over;
1012
+ if (!owner) return false;
1013
1013
  const active = owner.activeElement;
1014
- if (active && active !== owner.body && container.contains(active)) return true;
1015
- return pointer_over;
1014
+ return !!active && active !== owner.body && container.contains(active);
1015
+ };
1016
+ const is_attended = () => {
1017
+ return is_focus_within() || pointer_over;
1016
1018
  };
1017
1019
  return {
1018
1020
  is_attended,
1021
+ is_focus_within,
1019
1022
  dispose: () => {
1020
1023
  container.removeEventListener("pointerenter", on_enter);
1021
1024
  container.removeEventListener("pointerleave", on_leave);
@@ -1707,6 +1710,7 @@ var DomSurface = class DomSurface {
1707
1710
  this.svg_root = null;
1708
1711
  this.teardown = [];
1709
1712
  this.element_index = /* @__PURE__ */ new Map();
1713
+ this.rendered_doc_revision = -1;
1710
1714
  this.last_pointer = {
1711
1715
  x: 0,
1712
1716
  y: 0
@@ -1731,6 +1735,7 @@ var DomSurface = class DomSurface {
1731
1735
  this.container = options.container;
1732
1736
  const container = this.container;
1733
1737
  this.fit_on_attach = options.fit === true;
1738
+ this.clipboard_enabled = options.clipboard !== false;
1734
1739
  this.attention = create_attention_tracker(container);
1735
1740
  this.teardown.push(() => this.attention.dispose());
1736
1741
  if (process.env.NODE_ENV !== "production" && container.children.length > 0) console.warn("@grida/svg-editor: surface container is not empty at attach time. Render chrome (toolbars, layer lists, inspectors) as siblings of the container, not children — otherwise clicks on those children will silently break. See README §Surface.");
@@ -1738,6 +1743,8 @@ var DomSurface = class DomSurface {
1738
1743
  container.style.overflow = "hidden";
1739
1744
  container.style.userSelect = "none";
1740
1745
  container.style.webkitUserSelect = "none";
1746
+ container.tabIndex = -1;
1747
+ container.style.outline = "none";
1741
1748
  const translate_options = () => {
1742
1749
  const style = this.editor.style;
1743
1750
  const zoom = this.camera.zoom || 1;
@@ -1753,7 +1760,9 @@ var DomSurface = class DomSurface {
1753
1760
  open_preview: (label) => this.editor_internal().history.preview(label),
1754
1761
  open_snap: (ids) => this.open_snap_session_for(ids),
1755
1762
  options: translate_options,
1756
- project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d
1763
+ project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d,
1764
+ set_selection: (ids) => this.editor.commands.select(ids),
1765
+ on_clone_commit: (record) => this.editor_internal().seed_duplication(record)
1757
1766
  });
1758
1767
  const resize_options = () => {
1759
1768
  const style = this.editor.style;
@@ -1881,7 +1890,7 @@ var DomSurface = class DomSurface {
1881
1890
  this.current_tool = editor.state.tool;
1882
1891
  this.hud.setVectorSelectionMode(this.current_tool.type === "lasso" ? "lasso" : "marquee");
1883
1892
  this.hud.setVectorBendMode(this.current_tool.type === "bend" ? "always" : "auto");
1884
- this.render();
1893
+ this.flush_dom();
1885
1894
  this.sync_surface_selection();
1886
1895
  this.hud.setPixelGrid({
1887
1896
  enabled: editor.style.pixel_grid,
@@ -1937,12 +1946,14 @@ var DomSurface = class DomSurface {
1937
1946
  this.teardown.push(() => internal.set_content_edit_driver(null));
1938
1947
  internal.set_computed_resolver({
1939
1948
  computed_property: (id, name) => {
1949
+ this.flush_dom();
1940
1950
  const el = this.element_index.get(id);
1941
1951
  if (!el) return null;
1942
1952
  const value = getComputedStyle(el).getPropertyValue(name);
1943
1953
  return value === "" ? null : value;
1944
1954
  },
1945
1955
  computed_paint: (id, channel) => {
1956
+ this.flush_dom();
1946
1957
  const el = this.element_index.get(id);
1947
1958
  if (!el) return null;
1948
1959
  const computed = getComputedStyle(el).getPropertyValue(channel);
@@ -1959,7 +1970,8 @@ var DomSurface = class DomSurface {
1959
1970
  root: () => this.svg_root,
1960
1971
  camera: () => this.camera,
1961
1972
  container: () => this.container,
1962
- pick_at_world: (p, allow_root) => this._pick_node_at_world(p, allow_root)
1973
+ pick_at_world: (p, allow_root) => this._pick_node_at_world(p, allow_root),
1974
+ flush: () => this.flush_dom()
1963
1975
  }), {
1964
1976
  subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
1965
1977
  subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
@@ -2141,6 +2153,25 @@ var DomSurface = class DomSurface {
2141
2153
  detach_gestures() {
2142
2154
  this.gestures._dispose();
2143
2155
  }
2156
+ /**
2157
+ * Bring the live DOM up to date with the doc IR iff it is stale.
2158
+ *
2159
+ * Staleness contract: anything that reads the LIVE DOM as a proxy for
2160
+ * document state — the geometry driver (`getBBox` / `getCTM`), the
2161
+ * computed-style resolver — MUST call this first. Doc listeners (the
2162
+ * geometry channel, editor `subscribe`) fire synchronously inside the
2163
+ * mutation, BEFORE the surface's render listener has projected the new
2164
+ * attrs into the DOM; a read in that window returns the PREVIOUS
2165
+ * geometry, and through `MemoizedGeometryProvider` it would be cached as
2166
+ * if current — every later consumer (align, resize_to, snap) then plans
2167
+ * against one-mutation-stale bounds. Same model as CSS layout: reading
2168
+ * `offsetWidth` flushes pending layout; reading `bounds_of` flushes the
2169
+ * pending render.
2170
+ */
2171
+ flush_dom() {
2172
+ if (this.rendered_doc_revision === this.editor._internal.doc.revision) return;
2173
+ this.render();
2174
+ }
2144
2175
  render() {
2145
2176
  if (this.text_edit) return;
2146
2177
  const owner_doc = this.container.ownerDocument;
@@ -2167,6 +2198,7 @@ var DomSurface = class DomSurface {
2167
2198
  for (let c = el.firstElementChild; c; c = c.nextElementSibling) if (c instanceof SVGElement) tag_walk(c);
2168
2199
  };
2169
2200
  tag_walk(new_svg);
2201
+ this.rendered_doc_revision = doc.revision;
2170
2202
  }
2171
2203
  sync_canvas_size() {
2172
2204
  const cr = this.container.getBoundingClientRect();
@@ -2761,6 +2793,60 @@ var DomSurface = class DomSurface {
2761
2793
  });
2762
2794
  on(win, "blur", () => this.sync_modifiers(null));
2763
2795
  on(this.container, "contextmenu", (e) => e.preventDefault());
2796
+ if (this.clipboard_enabled) {
2797
+ on(owner_doc, "copy", (e) => this.on_copy_or_cut(e, "copy"));
2798
+ on(owner_doc, "cut", (e) => this.on_copy_or_cut(e, "cut"));
2799
+ on(owner_doc, "paste", (e) => this.on_paste(e));
2800
+ }
2801
+ }
2802
+ /**
2803
+ * Gate for claiming a native clipboard gesture. Deliberately STRICTER
2804
+ * than the keyboard attention gate: focus-based only — pointer-over is
2805
+ * a sufficient signal for a keystroke (worst case: a stolen scroll) but
2806
+ * not for clipboard (worst case: destroying what the user believed they
2807
+ * copied, or routing a paste meant for a host text field into the
2808
+ * document). A user with text selected in a sibling panel and the
2809
+ * pointer idly over the canvas must get their text copy.
2810
+ */
2811
+ claims_clipboard(kind) {
2812
+ if (this.text_edit) return false;
2813
+ if (this.editor.state.mode !== "select") return false;
2814
+ if (!this.attention.is_focus_within()) return false;
2815
+ if (require_model.is_text_input_focused()) return false;
2816
+ if (kind !== "paste") {
2817
+ const sel = this.container.ownerDocument.getSelection();
2818
+ if (sel && !sel.isCollapsed) return false;
2819
+ }
2820
+ return true;
2821
+ }
2822
+ /**
2823
+ * Act-then-claim: an empty selection returns without `preventDefault()`,
2824
+ * leaving the browser default (and the OS clipboard) untouched. The
2825
+ * buffer-only `_internal.clipboard` variants are used here — the event's
2826
+ * DataTransfer is this gesture's ONE external channel (the public
2827
+ * commands would additionally write the provider; one gesture, one
2828
+ * external write — FRD §Transport).
2829
+ */
2830
+ on_copy_or_cut(e, kind) {
2831
+ if (!this.claims_clipboard(kind)) return;
2832
+ if (!e.clipboardData) return;
2833
+ const internal = this.editor_internal();
2834
+ const payload = kind === "copy" ? internal.clipboard.copy() : internal.clipboard.cut();
2835
+ if (payload === null) return;
2836
+ e.clipboardData.setData("text/plain", payload);
2837
+ e.preventDefault();
2838
+ }
2839
+ /**
2840
+ * Claim-then-act (mirrors the keydown claim doctrine: swallow when the
2841
+ * gesture is aimed at the editor, not just when a handler consumed):
2842
+ * a refused paste — junk text — still claims; the suppressed default is
2843
+ * a no-op on a div anyway.
2844
+ */
2845
+ on_paste(e) {
2846
+ if (!this.claims_clipboard("paste")) return;
2847
+ e.preventDefault();
2848
+ const text = e.clipboardData?.getData("text/plain");
2849
+ if (text) this.editor.commands.paste(text);
2764
2850
  }
2765
2851
  /**
2766
2852
  * Master signal for modifier-driven UX consumers (measurement, future
@@ -2782,7 +2868,7 @@ var DomSurface = class DomSurface {
2782
2868
  kind: "modifiers",
2783
2869
  mods: next
2784
2870
  });
2785
- if (prev.shift !== next.shift && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
2871
+ if ((prev.shift !== next.shift || prev.alt !== next.alt) && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
2786
2872
  if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
2787
2873
  if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
2788
2874
  this.redraw();
@@ -2803,6 +2889,7 @@ var DomSurface = class DomSurface {
2803
2889
  } else if (kind === "pointer_up") this.text_edit.pointerUp();
2804
2890
  return;
2805
2891
  }
2892
+ if (kind === "pointer_down") this.container.focus({ preventScroll: true });
2806
2893
  const cr = this.container.getBoundingClientRect();
2807
2894
  const x = e.clientX - cr.left;
2808
2895
  const y = e.clientY - cr.top;
@@ -3119,13 +3206,15 @@ var DomSurface = class DomSurface {
3119
3206
  });
3120
3207
  if (intent.phase === "commit") this.request_redraw();
3121
3208
  }
3122
- /** Snapshot of HUD modifier state mapped to pipeline `TranslateModifiers`.
3209
+ /** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
3123
3210
  * Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
3124
3211
  * read live so mid-drag Shift press/release reflects on the next pass. */
3125
3212
  current_translate_modifiers() {
3213
+ const mods = this.hud.modifiers();
3126
3214
  return {
3127
- axis_lock: this.hud.modifiers().shift ? "by_dominance" : "off",
3128
- force_disable_snap: false
3215
+ axis_lock: mods.shift ? "by_dominance" : "off",
3216
+ force_disable_snap: false,
3217
+ clone: mods.alt
3129
3218
  };
3130
3219
  }
3131
3220
  /** Snapshot of HUD modifier state mapped to `ResizeModifiers`. Same
@@ -4464,6 +4553,7 @@ var SvgGeometryDriver = class {
4464
4553
  this.accessors = accessors;
4465
4554
  }
4466
4555
  bounds_of(id) {
4556
+ this.accessors.flush();
4467
4557
  const el = this.accessors.element_for(id);
4468
4558
  if (!el) return null;
4469
4559
  if (el instanceof SVGSVGElement) return svg_viewport_bounds(el);
@@ -4515,6 +4605,7 @@ var SvgGeometryDriver = class {
4515
4605
  return out;
4516
4606
  }
4517
4607
  nodes_in_rect(rect) {
4608
+ this.accessors.flush();
4518
4609
  const root = this.accessors.root();
4519
4610
  if (!root) return [];
4520
4611
  const hits = [];
@@ -4527,6 +4618,7 @@ var SvgGeometryDriver = class {
4527
4618
  return hits;
4528
4619
  }
4529
4620
  node_at_point(p) {
4621
+ this.accessors.flush();
4530
4622
  return this.accessors.pick_at_world(p, true);
4531
4623
  }
4532
4624
  /** World→local delta projection. The frame an element's position is
@@ -4542,6 +4634,7 @@ var SvgGeometryDriver = class {
4542
4634
  * the local delta. Identity (→ delta unchanged) for flat frames,
4543
4635
  * top-level nodes, and any degenerate / unavailable matrix. */
4544
4636
  world_delta_to_local(id, delta) {
4637
+ this.accessors.flush();
4545
4638
  const parent = this.accessors.element_for(id)?.parentNode;
4546
4639
  const root = this.accessors.root();
4547
4640
  if (!(parent instanceof SVGGraphicsElement) || !root) return delta;
@@ -1,4 +1,4 @@
1
- import { a as paint, b as is_text_input_focused, c as hit_shape_svg, d as NudgeDwellWatcher, f as TranslateOrchestrator, h as transform, i as TOOL_CURSOR, l as RotateOrchestrator, m as group, n as insertions, o as ResizeOrchestrator, s as resize_pipeline, t as PathModel, y as array_shallow_equal } from "./model-L3t9ixT_.mjs";
1
+ import { S as is_text_input_focused, a as paint, c as hit_shape_svg, d as NudgeDwellWatcher, f as TranslateOrchestrator, h as transform, i as TOOL_CURSOR, l as RotateOrchestrator, m as group, n as insertions, o as ResizeOrchestrator, s as resize_pipeline, t as PathModel, x as array_shallow_equal } from "./model-DU0GOMwM.mjs";
2
2
  import cmath from "@grida/cmath";
3
3
  import { svg_parse } from "@grida/svg/parse";
4
4
  import { SVGShapes } from "@grida/svg/pathdata";
@@ -1005,15 +1005,18 @@ function create_attention_tracker(container) {
1005
1005
  };
1006
1006
  container.addEventListener("pointerenter", on_enter);
1007
1007
  container.addEventListener("pointerleave", on_leave);
1008
- const is_attended = () => {
1008
+ const is_focus_within = () => {
1009
1009
  const owner = container.ownerDocument;
1010
- if (!owner) return pointer_over;
1010
+ if (!owner) return false;
1011
1011
  const active = owner.activeElement;
1012
- if (active && active !== owner.body && container.contains(active)) return true;
1013
- return pointer_over;
1012
+ return !!active && active !== owner.body && container.contains(active);
1013
+ };
1014
+ const is_attended = () => {
1015
+ return is_focus_within() || pointer_over;
1014
1016
  };
1015
1017
  return {
1016
1018
  is_attended,
1019
+ is_focus_within,
1017
1020
  dispose: () => {
1018
1021
  container.removeEventListener("pointerenter", on_enter);
1019
1022
  container.removeEventListener("pointerleave", on_leave);
@@ -1705,6 +1708,7 @@ var DomSurface = class DomSurface {
1705
1708
  this.svg_root = null;
1706
1709
  this.teardown = [];
1707
1710
  this.element_index = /* @__PURE__ */ new Map();
1711
+ this.rendered_doc_revision = -1;
1708
1712
  this.last_pointer = {
1709
1713
  x: 0,
1710
1714
  y: 0
@@ -1729,6 +1733,7 @@ var DomSurface = class DomSurface {
1729
1733
  this.container = options.container;
1730
1734
  const container = this.container;
1731
1735
  this.fit_on_attach = options.fit === true;
1736
+ this.clipboard_enabled = options.clipboard !== false;
1732
1737
  this.attention = create_attention_tracker(container);
1733
1738
  this.teardown.push(() => this.attention.dispose());
1734
1739
  if (process.env.NODE_ENV !== "production" && container.children.length > 0) console.warn("@grida/svg-editor: surface container is not empty at attach time. Render chrome (toolbars, layer lists, inspectors) as siblings of the container, not children — otherwise clicks on those children will silently break. See README §Surface.");
@@ -1736,6 +1741,8 @@ var DomSurface = class DomSurface {
1736
1741
  container.style.overflow = "hidden";
1737
1742
  container.style.userSelect = "none";
1738
1743
  container.style.webkitUserSelect = "none";
1744
+ container.tabIndex = -1;
1745
+ container.style.outline = "none";
1739
1746
  const translate_options = () => {
1740
1747
  const style = this.editor.style;
1741
1748
  const zoom = this.camera.zoom || 1;
@@ -1751,7 +1758,9 @@ var DomSurface = class DomSurface {
1751
1758
  open_preview: (label) => this.editor_internal().history.preview(label),
1752
1759
  open_snap: (ids) => this.open_snap_session_for(ids),
1753
1760
  options: translate_options,
1754
- project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d
1761
+ project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d,
1762
+ set_selection: (ids) => this.editor.commands.select(ids),
1763
+ on_clone_commit: (record) => this.editor_internal().seed_duplication(record)
1755
1764
  });
1756
1765
  const resize_options = () => {
1757
1766
  const style = this.editor.style;
@@ -1879,7 +1888,7 @@ var DomSurface = class DomSurface {
1879
1888
  this.current_tool = editor.state.tool;
1880
1889
  this.hud.setVectorSelectionMode(this.current_tool.type === "lasso" ? "lasso" : "marquee");
1881
1890
  this.hud.setVectorBendMode(this.current_tool.type === "bend" ? "always" : "auto");
1882
- this.render();
1891
+ this.flush_dom();
1883
1892
  this.sync_surface_selection();
1884
1893
  this.hud.setPixelGrid({
1885
1894
  enabled: editor.style.pixel_grid,
@@ -1935,12 +1944,14 @@ var DomSurface = class DomSurface {
1935
1944
  this.teardown.push(() => internal.set_content_edit_driver(null));
1936
1945
  internal.set_computed_resolver({
1937
1946
  computed_property: (id, name) => {
1947
+ this.flush_dom();
1938
1948
  const el = this.element_index.get(id);
1939
1949
  if (!el) return null;
1940
1950
  const value = getComputedStyle(el).getPropertyValue(name);
1941
1951
  return value === "" ? null : value;
1942
1952
  },
1943
1953
  computed_paint: (id, channel) => {
1954
+ this.flush_dom();
1944
1955
  const el = this.element_index.get(id);
1945
1956
  if (!el) return null;
1946
1957
  const computed = getComputedStyle(el).getPropertyValue(channel);
@@ -1957,7 +1968,8 @@ var DomSurface = class DomSurface {
1957
1968
  root: () => this.svg_root,
1958
1969
  camera: () => this.camera,
1959
1970
  container: () => this.container,
1960
- pick_at_world: (p, allow_root) => this._pick_node_at_world(p, allow_root)
1971
+ pick_at_world: (p, allow_root) => this._pick_node_at_world(p, allow_root),
1972
+ flush: () => this.flush_dom()
1961
1973
  }), {
1962
1974
  subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
1963
1975
  subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
@@ -2139,6 +2151,25 @@ var DomSurface = class DomSurface {
2139
2151
  detach_gestures() {
2140
2152
  this.gestures._dispose();
2141
2153
  }
2154
+ /**
2155
+ * Bring the live DOM up to date with the doc IR iff it is stale.
2156
+ *
2157
+ * Staleness contract: anything that reads the LIVE DOM as a proxy for
2158
+ * document state — the geometry driver (`getBBox` / `getCTM`), the
2159
+ * computed-style resolver — MUST call this first. Doc listeners (the
2160
+ * geometry channel, editor `subscribe`) fire synchronously inside the
2161
+ * mutation, BEFORE the surface's render listener has projected the new
2162
+ * attrs into the DOM; a read in that window returns the PREVIOUS
2163
+ * geometry, and through `MemoizedGeometryProvider` it would be cached as
2164
+ * if current — every later consumer (align, resize_to, snap) then plans
2165
+ * against one-mutation-stale bounds. Same model as CSS layout: reading
2166
+ * `offsetWidth` flushes pending layout; reading `bounds_of` flushes the
2167
+ * pending render.
2168
+ */
2169
+ flush_dom() {
2170
+ if (this.rendered_doc_revision === this.editor._internal.doc.revision) return;
2171
+ this.render();
2172
+ }
2142
2173
  render() {
2143
2174
  if (this.text_edit) return;
2144
2175
  const owner_doc = this.container.ownerDocument;
@@ -2165,6 +2196,7 @@ var DomSurface = class DomSurface {
2165
2196
  for (let c = el.firstElementChild; c; c = c.nextElementSibling) if (c instanceof SVGElement) tag_walk(c);
2166
2197
  };
2167
2198
  tag_walk(new_svg);
2199
+ this.rendered_doc_revision = doc.revision;
2168
2200
  }
2169
2201
  sync_canvas_size() {
2170
2202
  const cr = this.container.getBoundingClientRect();
@@ -2759,6 +2791,60 @@ var DomSurface = class DomSurface {
2759
2791
  });
2760
2792
  on(win, "blur", () => this.sync_modifiers(null));
2761
2793
  on(this.container, "contextmenu", (e) => e.preventDefault());
2794
+ if (this.clipboard_enabled) {
2795
+ on(owner_doc, "copy", (e) => this.on_copy_or_cut(e, "copy"));
2796
+ on(owner_doc, "cut", (e) => this.on_copy_or_cut(e, "cut"));
2797
+ on(owner_doc, "paste", (e) => this.on_paste(e));
2798
+ }
2799
+ }
2800
+ /**
2801
+ * Gate for claiming a native clipboard gesture. Deliberately STRICTER
2802
+ * than the keyboard attention gate: focus-based only — pointer-over is
2803
+ * a sufficient signal for a keystroke (worst case: a stolen scroll) but
2804
+ * not for clipboard (worst case: destroying what the user believed they
2805
+ * copied, or routing a paste meant for a host text field into the
2806
+ * document). A user with text selected in a sibling panel and the
2807
+ * pointer idly over the canvas must get their text copy.
2808
+ */
2809
+ claims_clipboard(kind) {
2810
+ if (this.text_edit) return false;
2811
+ if (this.editor.state.mode !== "select") return false;
2812
+ if (!this.attention.is_focus_within()) return false;
2813
+ if (is_text_input_focused()) return false;
2814
+ if (kind !== "paste") {
2815
+ const sel = this.container.ownerDocument.getSelection();
2816
+ if (sel && !sel.isCollapsed) return false;
2817
+ }
2818
+ return true;
2819
+ }
2820
+ /**
2821
+ * Act-then-claim: an empty selection returns without `preventDefault()`,
2822
+ * leaving the browser default (and the OS clipboard) untouched. The
2823
+ * buffer-only `_internal.clipboard` variants are used here — the event's
2824
+ * DataTransfer is this gesture's ONE external channel (the public
2825
+ * commands would additionally write the provider; one gesture, one
2826
+ * external write — FRD §Transport).
2827
+ */
2828
+ on_copy_or_cut(e, kind) {
2829
+ if (!this.claims_clipboard(kind)) return;
2830
+ if (!e.clipboardData) return;
2831
+ const internal = this.editor_internal();
2832
+ const payload = kind === "copy" ? internal.clipboard.copy() : internal.clipboard.cut();
2833
+ if (payload === null) return;
2834
+ e.clipboardData.setData("text/plain", payload);
2835
+ e.preventDefault();
2836
+ }
2837
+ /**
2838
+ * Claim-then-act (mirrors the keydown claim doctrine: swallow when the
2839
+ * gesture is aimed at the editor, not just when a handler consumed):
2840
+ * a refused paste — junk text — still claims; the suppressed default is
2841
+ * a no-op on a div anyway.
2842
+ */
2843
+ on_paste(e) {
2844
+ if (!this.claims_clipboard("paste")) return;
2845
+ e.preventDefault();
2846
+ const text = e.clipboardData?.getData("text/plain");
2847
+ if (text) this.editor.commands.paste(text);
2762
2848
  }
2763
2849
  /**
2764
2850
  * Master signal for modifier-driven UX consumers (measurement, future
@@ -2780,7 +2866,7 @@ var DomSurface = class DomSurface {
2780
2866
  kind: "modifiers",
2781
2867
  mods: next
2782
2868
  });
2783
- if (prev.shift !== next.shift && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
2869
+ if ((prev.shift !== next.shift || prev.alt !== next.alt) && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
2784
2870
  if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
2785
2871
  if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
2786
2872
  this.redraw();
@@ -2801,6 +2887,7 @@ var DomSurface = class DomSurface {
2801
2887
  } else if (kind === "pointer_up") this.text_edit.pointerUp();
2802
2888
  return;
2803
2889
  }
2890
+ if (kind === "pointer_down") this.container.focus({ preventScroll: true });
2804
2891
  const cr = this.container.getBoundingClientRect();
2805
2892
  const x = e.clientX - cr.left;
2806
2893
  const y = e.clientY - cr.top;
@@ -3117,13 +3204,15 @@ var DomSurface = class DomSurface {
3117
3204
  });
3118
3205
  if (intent.phase === "commit") this.request_redraw();
3119
3206
  }
3120
- /** Snapshot of HUD modifier state mapped to pipeline `TranslateModifiers`.
3207
+ /** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
3121
3208
  * Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
3122
3209
  * read live so mid-drag Shift press/release reflects on the next pass. */
3123
3210
  current_translate_modifiers() {
3211
+ const mods = this.hud.modifiers();
3124
3212
  return {
3125
- axis_lock: this.hud.modifiers().shift ? "by_dominance" : "off",
3126
- force_disable_snap: false
3213
+ axis_lock: mods.shift ? "by_dominance" : "off",
3214
+ force_disable_snap: false,
3215
+ clone: mods.alt
3127
3216
  };
3128
3217
  }
3129
3218
  /** Snapshot of HUD modifier state mapped to `ResizeModifiers`. Same
@@ -4462,6 +4551,7 @@ var SvgGeometryDriver = class {
4462
4551
  this.accessors = accessors;
4463
4552
  }
4464
4553
  bounds_of(id) {
4554
+ this.accessors.flush();
4465
4555
  const el = this.accessors.element_for(id);
4466
4556
  if (!el) return null;
4467
4557
  if (el instanceof SVGSVGElement) return svg_viewport_bounds(el);
@@ -4513,6 +4603,7 @@ var SvgGeometryDriver = class {
4513
4603
  return out;
4514
4604
  }
4515
4605
  nodes_in_rect(rect) {
4606
+ this.accessors.flush();
4516
4607
  const root = this.accessors.root();
4517
4608
  if (!root) return [];
4518
4609
  const hits = [];
@@ -4525,6 +4616,7 @@ var SvgGeometryDriver = class {
4525
4616
  return hits;
4526
4617
  }
4527
4618
  node_at_point(p) {
4619
+ this.accessors.flush();
4528
4620
  return this.accessors.pick_at_world(p, true);
4529
4621
  }
4530
4622
  /** World→local delta projection. The frame an element's position is
@@ -4540,6 +4632,7 @@ var SvgGeometryDriver = class {
4540
4632
  * the local delta. Identity (→ delta unchanged) for flat frames,
4541
4633
  * top-level nodes, and any degenerate / unavailable matrix. */
4542
4634
  world_delta_to_local(id, delta) {
4635
+ this.accessors.flush();
4543
4636
  const parent = this.accessors.element_for(id)?.parentNode;
4544
4637
  const root = this.accessors.root();
4545
4638
  if (!(parent instanceof SVGGraphicsElement) || !root) return delta;
@@ -1,4 +1,4 @@
1
- import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-D2eQe8lB.mjs";
1
+ import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-KqpIW1qm.mjs";
2
2
  import cmath from "@grida/cmath";
3
3
  import { guide } from "@grida/cmath/_snap";
4
4
 
@@ -50,6 +50,18 @@ type DomSurfaceOptions = {
50
50
  * carte via `handle.gestures.bind(...)`.
51
51
  */
52
52
  gestures?: boolean;
53
+ /**
54
+ * Wire native ClipboardEvent transport — `copy` / `cut` / `paste`
55
+ * listeners on the owner document, gated by the clipboard attention
56
+ * discipline. Default `true`. Pass `false` to route ALL clipboard
57
+ * traffic through the `ClipboardProvider` seam instead (the
58
+ * configuration under which a host's paste-time screening governs
59
+ * every path) — see docs/wg/feat-svg-editor/clipboard.md §Transport
60
+ * "Host control over the native path". Focus management (the container
61
+ * focusing on pointerdown) stays either way — it is a general canvas
62
+ * mitigation, not a clipboard feature.
63
+ */
64
+ clipboard?: boolean;
53
65
  /**
54
66
  * Auto-fit the document into the viewport on initial attach. Default
55
67
  * `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
package/dist/dom.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { C as BoundsResolver, E as CameraOptions, T as CameraConstraints, d as GestureContext, f as GestureId, g as MemoizedGeometryProvider, h as GeometrySignals, i as DomComputedResolver, m as GeometryProvider, p as Gestures, r as DomComputedPaint, u as GestureBinding, w as Camera } from "./editor-D2eQe8lB.mjs";
2
- import { a as inverse_project_rect, c as DEFAULT_SNAP_OPTIONS, i as install_font_load_geometry_bump, l as SnapOptions, n as DomSurfaceOptions, o as project_delta_inverse_ctm, r as attach_dom_surface, s as project_point_through_ctm, t as DomSurfaceHandle } from "./dom-98AUOfsP.mjs";
1
+ import { C as BoundsResolver, E as CameraOptions, T as CameraConstraints, d as GestureContext, f as GestureId, g as MemoizedGeometryProvider, h as GeometrySignals, i as DomComputedResolver, m as GeometryProvider, p as Gestures, r as DomComputedPaint, u as GestureBinding, w as Camera } from "./editor-KqpIW1qm.mjs";
2
+ import { a as inverse_project_rect, c as DEFAULT_SNAP_OPTIONS, i as install_font_load_geometry_bump, l as SnapOptions, n as DomSurfaceOptions, o as project_delta_inverse_ctm, r as attach_dom_surface, s as project_point_through_ctm, t as DomSurfaceHandle } from "./dom-TctdgRnn.mjs";
3
3
  export { type BoundsResolver, Camera, type CameraConstraints, type CameraOptions, DEFAULT_SNAP_OPTIONS, type DomComputedPaint, type DomComputedResolver, DomSurfaceHandle, DomSurfaceOptions, type GeometryProvider, type GeometrySignals, type GestureBinding, type GestureContext, type GestureId, Gestures, MemoizedGeometryProvider, type SnapOptions, attach_dom_surface, install_font_load_geometry_bump, inverse_project_rect, project_delta_inverse_ctm, project_point_through_ctm };
package/dist/dom.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { C as BoundsResolver, E as CameraOptions, T as CameraConstraints, d as GestureContext, f as GestureId, g as MemoizedGeometryProvider, h as GeometrySignals, i as DomComputedResolver, m as GeometryProvider, p as Gestures, r as DomComputedPaint, u as GestureBinding, w as Camera } from "./editor-CYoGJ3Hf.js";
2
- import { a as inverse_project_rect, c as DEFAULT_SNAP_OPTIONS, i as install_font_load_geometry_bump, l as SnapOptions, n as DomSurfaceOptions, o as project_delta_inverse_ctm, r as attach_dom_surface, s as project_point_through_ctm, t as DomSurfaceHandle } from "./dom-BO2-E9oK.js";
1
+ import { C as BoundsResolver, E as CameraOptions, T as CameraConstraints, d as GestureContext, f as GestureId, g as MemoizedGeometryProvider, h as GeometrySignals, i as DomComputedResolver, m as GeometryProvider, p as Gestures, r as DomComputedPaint, u as GestureBinding, w as Camera } from "./editor-BSxTUsW_.js";
2
+ import { a as inverse_project_rect, c as DEFAULT_SNAP_OPTIONS, i as install_font_load_geometry_bump, l as SnapOptions, n as DomSurfaceOptions, o as project_delta_inverse_ctm, r as attach_dom_surface, s as project_point_through_ctm, t as DomSurfaceHandle } from "./dom-BMzX1CXZ.js";
3
3
  export { type BoundsResolver, Camera, type CameraConstraints, type CameraOptions, DEFAULT_SNAP_OPTIONS, type DomComputedPaint, type DomComputedResolver, DomSurfaceHandle, DomSurfaceOptions, type GeometryProvider, type GeometrySignals, type GestureBinding, type GestureContext, type GestureId, Gestures, MemoizedGeometryProvider, type SnapOptions, attach_dom_surface, install_font_load_geometry_bump, inverse_project_rect, project_delta_inverse_ctm, project_point_through_ctm };
package/dist/dom.js CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_dom = require("./dom-U6ae5fQF.js");
2
+ const require_dom = require("./dom-DKQ4Vt3z.js");
3
3
  exports.Camera = require_dom.Camera;
4
4
  exports.DEFAULT_SNAP_OPTIONS = require_dom.DEFAULT_SNAP_OPTIONS;
5
5
  exports.Gestures = require_dom.Gestures;
package/dist/dom.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as project_point_through_ctm, c as MemoizedGeometryProvider, i as project_delta_inverse_ctm, l as Camera, n as install_font_load_geometry_bump, o as Gestures, r as inverse_project_rect, s as DEFAULT_SNAP_OPTIONS, t as attach_dom_surface } from "./dom-DOvcMvl4.mjs";
1
+ import { a as project_point_through_ctm, c as MemoizedGeometryProvider, i as project_delta_inverse_ctm, l as Camera, n as install_font_load_geometry_bump, o as Gestures, r as inverse_project_rect, s as DEFAULT_SNAP_OPTIONS, t as attach_dom_surface } from "./dom-OP-kmK8k.mjs";
2
2
  export { Camera, DEFAULT_SNAP_OPTIONS, Gestures, MemoizedGeometryProvider, attach_dom_surface, install_font_load_geometry_bump, inverse_project_rect, project_delta_inverse_ctm, project_point_through_ctm };