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

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
@@ -304,6 +304,25 @@ editor.subscribe_with_selector<T>(
304
304
 
305
305
  `state` is a frozen snapshot. Consumers never destructure into internals; if a view they need isn't here or in the purpose-built views below, that's an API gap.
306
306
 
307
+ ### Observation — pick (tap)
308
+
309
+ A **pick** is a discrete tap on the canvas — a press and release within the drag threshold, no drag. It is observe-only and deliberately **separate from selection**: selection answers "what do commands target," a pick answers "what did the user just click, and where." A primary tap on a node both selects it _and_ emits a pick; a tap on empty canvas emits a pick with `node_id: null` (distinguishable from "nothing is selected," which selection alone cannot express); a secondary (right-button) tap emits a pick and does **not** change selection. This is the seam a click-driven host tool needs — a comment / annotation tool anchors UI at `point` and scopes its action to `node_id`, or to the whole document when `null`.
310
+
311
+ ```ts
312
+ type PickEvent = {
313
+ point: Vec2; // document-space — the pointer-DOWN point the tap resolved against
314
+ node_id: NodeId | null; // topmost node under point; null = empty canvas
315
+ button: "primary" | "secondary"; // middle is pan, never taps
316
+ mods: { shift: boolean; alt: boolean; meta: boolean; ctrl: boolean };
317
+ };
318
+
319
+ editor.subscribe_pick(fn: (e: PickEvent) => void): Unsubscribe;
320
+ ```
321
+
322
+ The point is document-space and always the pointer-**down** point — so it stays correct even for a tap on an already-selected node (whose selection commits on pointer-up). The channel does **not** bump `state.version`. In React, wire it with `useEditorPick(handler)`.
323
+
324
+ > **Status:** `@unstable` — shipped against one consumer; the shape is open until a second click-driven tool exercises it (P6).
325
+
307
326
  ### Observation — properties
308
327
 
309
328
  This section is about **property semantics on a single node**, following the CSS / SVG spec. Multi-selection ("mixed values") is a separate concern; see the [Multi-selection](#multi-selection-mixed-values) section below. The two are kept apart on purpose: property semantics is defined by the spec; mixed semantics is an aggregation layer the editor adds because it supports multi-select.
@@ -608,6 +627,8 @@ editor.commands.{
608
627
  resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
609
628
  rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
610
629
  rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void;
630
+ // `matrix` is SVG `matrix(a b c d e f)` order (the `Matrix2D` tuple).
631
+ transform(matrix: Matrix2D, opts?: { ids?: NodeId[]; pivot?: { x: number; y: number } }): boolean;
611
632
  flatten_transform(): void; // bake `transform=` into native attrs where possible
612
633
 
613
634
  // alignment (operates on selection of ≥2 nodes against their union bbox)
@@ -616,12 +637,57 @@ editor.commands.{
616
637
  // structure
617
638
  reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
618
639
  group(): void; // wrap selection in a new <g>
640
+ ungroup(opts?: { id?: NodeId }): boolean; // dissolve a plain structural <g>
641
+ // (clean-structural subset only; refuses
642
+ // groups with visual state — see TODO §10)
619
643
  remove(): void;
620
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
+
621
678
  // insertion — `tag` is an open string (so paste / RPC can create any element,
622
679
  // e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
623
680
  // draw gesture and default paint.
624
681
  insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
682
+ // markup-shaped sibling of `insert` — one or more sibling elements, or a
683
+ // full `<svg>` doc (the shell is discarded; its children are the content).
684
+ // Subtrees adopted verbatim; ONE history step; returns root ids in
685
+ // document order. Authored ids are NEVER rewritten (dedup is Tidy's job);
686
+ // undeclared `xlink:` / shell-declared prefixes are hoisted onto the root
687
+ // in the same step. Position is authored content: wrap the fragment in
688
+ // `<g transform="translate(x y)">` to land it at a point — same single
689
+ // undo step, no placement opt.
690
+ insert_fragment(svg: string, opts?: { parent?: NodeId; index?: number; select?: boolean }): NodeId[];
625
691
  insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
626
692
 
627
693
  // content
@@ -643,6 +709,8 @@ editor.commands.{
643
709
 
644
710
  All commands operate on `state.selection` unless they take an explicit target. Commands that can't apply (e.g. `set_text` with no text node selected) are no-ops, not errors.
645
711
 
712
+ `transform` composes a general 2×3 affine onto the selection **relative** and **in world space about a pivot** (default: the selection union-bbox center) — `E = T(pivot) · matrix · T(-pivot)` — so a bare `[-1, 0, 0, 1, 0, 0]` is an in-place horizontal flip and `[1, 0, 0, -1, 0, 0]` a vertical one. The editor owns the round-trip: `E` is folded onto each member's transform list as a **single leading `matrix` op** (existing `rotate`/`translate` tokens are preserved after it; repeated applies collapse into one matrix; a net-identity leading matrix is dropped). It refuses (returns `false`, no history) on empty selection, no attached surface, or any member that isn't rotatable (matrix / scale / skew / `<text rotate>` / CSS-property / animated transforms — same gate as `rotate`; Flatten Transform is the recovery path). Flat-doc only: nested transformed ancestors are out of scope.
713
+
646
714
  (Naming convention for the API surface is `snake_case` to match the SVG / CSS property naming the editor already echoes — `set_property("stroke-width", …)` reads cleanly next to `set_paint("fill", …)`. JavaScript identifiers use `snake_case`; user-facing strings that mirror SVG attribute names stay `kebab-case` exactly as the spec writes them.)
647
715
 
648
716
  ### Providers
@@ -880,6 +948,7 @@ What this editor will never be. Each one is a defensive perimeter for the princi
880
948
  - **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
881
949
  - **Not a private IR.** SVG is the source of truth. The editor does not maintain an alternative on-disk format, and the bytes are not projected from any in-memory canonical store. (The internal typed element IR described under [Paradigm § Element IR (internal)](#element-ir-internal) is a typed view over the parsed AST, not a store the file is derived from — the AST and the file are the source of truth, and the IR is rebuilt from them on each load.)
882
950
  - **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
951
+ - **Not an input-interception hook.** The pick/tap observation (`subscribe_pick`) reports a click that already happened; it cannot prevent, delay, or replace the editor's own selection and gesture handling. A host that needs to intercept input owns the container and splices its own layer in (the DOM escape hatch) — it does not get a veto through the observation surface.
883
952
 
884
953
  If a consumer needs any of the above, the right answer is "this is the wrong tool." Saying yes to any one is the path that turned the Grida main editor into a 6,800-line god-class.
885
954
 
@@ -1,4 +1,4 @@
1
- import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-BKoo9SPL.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 = {
@@ -11,6 +11,35 @@ type SnapOptions = {
11
11
  declare const DEFAULT_SNAP_OPTIONS: SnapOptions;
12
12
  //#endregion
13
13
  //#region src/dom.d.ts
14
+ /**
15
+ * Wire a web-font settle source to the editor's geometry channel.
16
+ *
17
+ * The DOM surface re-serializes the `<svg>` on every editor tick, but a
18
+ * `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
19
+ * finishing load AFTER its `font-family` / `font-size` was already written.
20
+ * The IR never sees that reflow, so nothing bumps `geometry_version` and
21
+ * every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
22
+ * the fallback-face metrics until the next real edit.
23
+ *
24
+ * Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
25
+ * `EventTarget` in tests) and calls `bump` once per settle. COARSE on
26
+ * purpose: one bump clears the WHOLE bounds cache, not just text nodes —
27
+ * consistent with the package's pessimistic-invalidation stance, and far
28
+ * cheaper than scoping the bump to the (possibly many) reflowed runs.
29
+ *
30
+ * Also bumps once when `source.ready` resolves (when present): fonts that
31
+ * settled before attach — a cache hit, or `font-display` resolving the same
32
+ * tick the surface mounts — never re-fire `loadingdone`, so a document
33
+ * mounted post-settle still needs one bump to re-read at the real metrics.
34
+ *
35
+ * Returns a teardown that removes the listener and neutralizes the pending
36
+ * `ready` bump (leak guard) — call it on surface detach.
37
+ *
38
+ * Factored out of the surface so it can be unit-tested with a fake
39
+ * `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
40
+ * incomplete); never a real font / network.
41
+ */
42
+ declare function install_font_load_geometry_bump(source: EventTarget | null, bump: () => void): () => void;
14
43
  type DomSurfaceOptions = {
15
44
  /** Mount the SVG inside this container. */container: HTMLElement;
16
45
  /**
@@ -19,6 +48,18 @@ type DomSurfaceOptions = {
19
48
  * carte via `handle.gestures.bind(...)`.
20
49
  */
21
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;
22
63
  /**
23
64
  * Auto-fit the document into the viewport on initial attach. Default
24
65
  * `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
@@ -31,6 +72,19 @@ type DomSurfaceOptions = {
31
72
  * when `fit: true`.
32
73
  */
33
74
  initial_camera?: cmath.Transform;
75
+ /**
76
+ * Font-load settle source — the `EventTarget` whose `loadingdone` event
77
+ * signals "web fonts finished loading, text may have reflowed." Defaults
78
+ * to `container.ownerDocument.fonts` (the live `FontFaceSet`). The
79
+ * surface installs a `loadingdone` listener that advances the editor's
80
+ * geometry channel so text bounds re-read at the settled glyph metrics
81
+ * (see ../docs/geometry.md §Limitations "Text bbox depends on font").
82
+ *
83
+ * Injectable as a DOM seam: jsdom's `FontFaceSet` is incomplete, so
84
+ * tests pass a plain `EventTarget` stub and `dispatchEvent(new Event(
85
+ * "loadingdone"))` to simulate a settle without a real font / network.
86
+ */
87
+ font_load_source?: EventTarget;
34
88
  };
35
89
  /**
36
90
  * Surface handle for the DOM surface. Extends the editor's core
@@ -109,4 +163,4 @@ declare function inverse_project_rect(rect: {
109
163
  f: number;
110
164
  }, offset: readonly [number, number]): cmath.Rectangle | null;
111
165
  //#endregion
112
- export { project_delta_inverse_ctm as a, SnapOptions as c, inverse_project_rect as i, DomSurfaceOptions as n, project_point_through_ctm as o, attach_dom_surface as r, DEFAULT_SNAP_OPTIONS as s, DomSurfaceHandle as t };
166
+ export { inverse_project_rect as a, DEFAULT_SNAP_OPTIONS as c, install_font_load_geometry_bump as i, SnapOptions as l, DomSurfaceOptions as n, project_delta_inverse_ctm as o, attach_dom_surface as r, project_point_through_ctm as s, DomSurfaceHandle as t };
@@ -1,4 +1,4 @@
1
- import { _ as is_text_input_focused, a as paint, c as hit_shape_svg, d as NudgeDwellWatcher, f as TranslateOrchestrator, g as array_shallow_equal, h as group, i as TOOL_CURSOR, l as RotateOrchestrator, m as transform, n as insertions, o as ResizeOrchestrator, s as resize_pipeline, t as PathModel } from "./model-B2UWgViT.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-DMaN5GnH.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);
@@ -1638,6 +1641,48 @@ const IS_MODIFIER_KEY = {
1638
1641
  * live `<text>` element out from under the about-to-mount text surface. */
1639
1642
  const TEXT_EDIT_PENDING = { __pending: true };
1640
1643
  /**
1644
+ * Wire a web-font settle source to the editor's geometry channel.
1645
+ *
1646
+ * The DOM surface re-serializes the `<svg>` on every editor tick, but a
1647
+ * `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
1648
+ * finishing load AFTER its `font-family` / `font-size` was already written.
1649
+ * The IR never sees that reflow, so nothing bumps `geometry_version` and
1650
+ * every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
1651
+ * the fallback-face metrics until the next real edit.
1652
+ *
1653
+ * Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
1654
+ * `EventTarget` in tests) and calls `bump` once per settle. COARSE on
1655
+ * purpose: one bump clears the WHOLE bounds cache, not just text nodes —
1656
+ * consistent with the package's pessimistic-invalidation stance, and far
1657
+ * cheaper than scoping the bump to the (possibly many) reflowed runs.
1658
+ *
1659
+ * Also bumps once when `source.ready` resolves (when present): fonts that
1660
+ * settled before attach — a cache hit, or `font-display` resolving the same
1661
+ * tick the surface mounts — never re-fire `loadingdone`, so a document
1662
+ * mounted post-settle still needs one bump to re-read at the real metrics.
1663
+ *
1664
+ * Returns a teardown that removes the listener and neutralizes the pending
1665
+ * `ready` bump (leak guard) — call it on surface detach.
1666
+ *
1667
+ * Factored out of the surface so it can be unit-tested with a fake
1668
+ * `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
1669
+ * incomplete); never a real font / network.
1670
+ */
1671
+ function install_font_load_geometry_bump(source, bump) {
1672
+ if (!source) return () => {};
1673
+ const on_fonts_settled = () => bump();
1674
+ source.addEventListener("loadingdone", on_fonts_settled);
1675
+ let alive = true;
1676
+ const ready = source.ready;
1677
+ if (ready && typeof ready.then === "function") ready.then(() => {
1678
+ if (alive) bump();
1679
+ });
1680
+ return () => {
1681
+ alive = false;
1682
+ source.removeEventListener("loadingdone", on_fonts_settled);
1683
+ };
1684
+ }
1685
+ /**
1641
1686
  * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
1642
1687
  * whose `detach()` is the inverse — DOM cleared, listeners removed,
1643
1688
  * gestures uninstalled.
@@ -1663,6 +1708,7 @@ var DomSurface = class DomSurface {
1663
1708
  this.svg_root = null;
1664
1709
  this.teardown = [];
1665
1710
  this.element_index = /* @__PURE__ */ new Map();
1711
+ this.rendered_doc_revision = -1;
1666
1712
  this.last_pointer = {
1667
1713
  x: 0,
1668
1714
  y: 0
@@ -1687,6 +1733,7 @@ var DomSurface = class DomSurface {
1687
1733
  this.container = options.container;
1688
1734
  const container = this.container;
1689
1735
  this.fit_on_attach = options.fit === true;
1736
+ this.clipboard_enabled = options.clipboard !== false;
1690
1737
  this.attention = create_attention_tracker(container);
1691
1738
  this.teardown.push(() => this.attention.dispose());
1692
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.");
@@ -1694,6 +1741,8 @@ var DomSurface = class DomSurface {
1694
1741
  container.style.overflow = "hidden";
1695
1742
  container.style.userSelect = "none";
1696
1743
  container.style.webkitUserSelect = "none";
1744
+ container.tabIndex = -1;
1745
+ container.style.outline = "none";
1697
1746
  const translate_options = () => {
1698
1747
  const style = this.editor.style;
1699
1748
  const zoom = this.camera.zoom || 1;
@@ -1709,7 +1758,9 @@ var DomSurface = class DomSurface {
1709
1758
  open_preview: (label) => this.editor_internal().history.preview(label),
1710
1759
  open_snap: (ids) => this.open_snap_session_for(ids),
1711
1760
  options: translate_options,
1712
- 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)
1713
1764
  });
1714
1765
  const resize_options = () => {
1715
1766
  const style = this.editor.style;
@@ -1777,6 +1828,7 @@ var DomSurface = class DomSurface {
1777
1828
  shapeOf: (id) => this.shape_of(id),
1778
1829
  vectorOf: (id) => this.vector_of(id),
1779
1830
  onIntent: (i) => this.commit_intent(i),
1831
+ onTap: (t) => this.handle_tap(t),
1780
1832
  style: {
1781
1833
  chromeColor: editor.style.chrome_color,
1782
1834
  showRotationHandles: true
@@ -1836,7 +1888,7 @@ var DomSurface = class DomSurface {
1836
1888
  this.current_tool = editor.state.tool;
1837
1889
  this.hud.setVectorSelectionMode(this.current_tool.type === "lasso" ? "lasso" : "marquee");
1838
1890
  this.hud.setVectorBendMode(this.current_tool.type === "bend" ? "always" : "auto");
1839
- this.render();
1891
+ this.flush_dom();
1840
1892
  this.sync_surface_selection();
1841
1893
  this.hud.setPixelGrid({
1842
1894
  enabled: editor.style.pixel_grid,
@@ -1883,6 +1935,8 @@ var DomSurface = class DomSurface {
1883
1935
  win.addEventListener("resize", fn);
1884
1936
  this.teardown.push(() => win.removeEventListener("resize", fn));
1885
1937
  }
1938
+ const detach_font_listener = install_font_load_geometry_bump(options.font_load_source ?? container.ownerDocument.fonts ?? null, () => editor._internal.bump_geometry());
1939
+ this.teardown.push(detach_font_listener);
1886
1940
  this.wire_events();
1887
1941
  const internal = editor._internal;
1888
1942
  this.editor_hover_internal = internal;
@@ -1890,12 +1944,14 @@ var DomSurface = class DomSurface {
1890
1944
  this.teardown.push(() => internal.set_content_edit_driver(null));
1891
1945
  internal.set_computed_resolver({
1892
1946
  computed_property: (id, name) => {
1947
+ this.flush_dom();
1893
1948
  const el = this.element_index.get(id);
1894
1949
  if (!el) return null;
1895
1950
  const value = getComputedStyle(el).getPropertyValue(name);
1896
1951
  return value === "" ? null : value;
1897
1952
  },
1898
1953
  computed_paint: (id, channel) => {
1954
+ this.flush_dom();
1899
1955
  const el = this.element_index.get(id);
1900
1956
  if (!el) return null;
1901
1957
  const computed = getComputedStyle(el).getPropertyValue(channel);
@@ -1912,7 +1968,8 @@ var DomSurface = class DomSurface {
1912
1968
  root: () => this.svg_root,
1913
1969
  camera: () => this.camera,
1914
1970
  container: () => this.container,
1915
- 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()
1916
1973
  }), {
1917
1974
  subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
1918
1975
  subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
@@ -2094,6 +2151,25 @@ var DomSurface = class DomSurface {
2094
2151
  detach_gestures() {
2095
2152
  this.gestures._dispose();
2096
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
+ }
2097
2173
  render() {
2098
2174
  if (this.text_edit) return;
2099
2175
  const owner_doc = this.container.ownerDocument;
@@ -2120,6 +2196,7 @@ var DomSurface = class DomSurface {
2120
2196
  for (let c = el.firstElementChild; c; c = c.nextElementSibling) if (c instanceof SVGElement) tag_walk(c);
2121
2197
  };
2122
2198
  tag_walk(new_svg);
2199
+ this.rendered_doc_revision = doc.revision;
2123
2200
  }
2124
2201
  sync_canvas_size() {
2125
2202
  const cr = this.container.getBoundingClientRect();
@@ -2714,6 +2791,60 @@ var DomSurface = class DomSurface {
2714
2791
  });
2715
2792
  on(win, "blur", () => this.sync_modifiers(null));
2716
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);
2717
2848
  }
2718
2849
  /**
2719
2850
  * Master signal for modifier-driven UX consumers (measurement, future
@@ -2735,7 +2866,7 @@ var DomSurface = class DomSurface {
2735
2866
  kind: "modifiers",
2736
2867
  mods: next
2737
2868
  });
2738
- 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());
2739
2870
  if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
2740
2871
  if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
2741
2872
  this.redraw();
@@ -2756,6 +2887,7 @@ var DomSurface = class DomSurface {
2756
2887
  } else if (kind === "pointer_up") this.text_edit.pointerUp();
2757
2888
  return;
2758
2889
  }
2890
+ if (kind === "pointer_down") this.container.focus({ preventScroll: true });
2759
2891
  const cr = this.container.getBoundingClientRect();
2760
2892
  const x = e.clientX - cr.left;
2761
2893
  const y = e.clientY - cr.top;
@@ -2966,6 +3098,26 @@ var DomSurface = class DomSurface {
2966
3098
  if (this.editor.keymap.claims(e)) e.preventDefault();
2967
3099
  this.editor.keymap.dispatch(e);
2968
3100
  }
3101
+ /**
3102
+ * Re-express a HUD tap as an editor {@link PickEvent} and fan it out on the
3103
+ * editor's pick channel. The HUD already resolved everything that matters —
3104
+ * the pointer-down point, the hit node, and click-vs-drag — so this is a
3105
+ * pure translation (HUD `[x, y]` tuple → editor `{ x, y }` doc-space point)
3106
+ * with NO re-hit-testing. Taking the hit from the HUD (not a fresh
3107
+ * `node_at_point`) guarantees the pick and the selection it accompanies can
3108
+ * never disagree. Observe-only: this mutates no editor state.
3109
+ */
3110
+ handle_tap(tap) {
3111
+ this.editor._internal.push_pick({
3112
+ point: {
3113
+ x: tap.point[0],
3114
+ y: tap.point[1]
3115
+ },
3116
+ node_id: tap.hit,
3117
+ button: tap.button,
3118
+ mods: tap.mods
3119
+ });
3120
+ }
2969
3121
  commit_intent(intent) {
2970
3122
  switch (intent.kind) {
2971
3123
  case "select":
@@ -3052,13 +3204,15 @@ var DomSurface = class DomSurface {
3052
3204
  });
3053
3205
  if (intent.phase === "commit") this.request_redraw();
3054
3206
  }
3055
- /** Snapshot of HUD modifier state mapped to pipeline `TranslateModifiers`.
3207
+ /** Snapshot of HUD modifier state mapped to the orchestrator's `GestureModifiers`.
3056
3208
  * Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
3057
3209
  * read live so mid-drag Shift press/release reflects on the next pass. */
3058
3210
  current_translate_modifiers() {
3211
+ const mods = this.hud.modifiers();
3059
3212
  return {
3060
- axis_lock: this.hud.modifiers().shift ? "by_dominance" : "off",
3061
- force_disable_snap: false
3213
+ axis_lock: mods.shift ? "by_dominance" : "off",
3214
+ force_disable_snap: false,
3215
+ clone: mods.alt
3062
3216
  };
3063
3217
  }
3064
3218
  /** Snapshot of HUD modifier state mapped to `ResizeModifiers`. Same
@@ -4397,6 +4551,7 @@ var SvgGeometryDriver = class {
4397
4551
  this.accessors = accessors;
4398
4552
  }
4399
4553
  bounds_of(id) {
4554
+ this.accessors.flush();
4400
4555
  const el = this.accessors.element_for(id);
4401
4556
  if (!el) return null;
4402
4557
  if (el instanceof SVGSVGElement) return svg_viewport_bounds(el);
@@ -4448,6 +4603,7 @@ var SvgGeometryDriver = class {
4448
4603
  return out;
4449
4604
  }
4450
4605
  nodes_in_rect(rect) {
4606
+ this.accessors.flush();
4451
4607
  const root = this.accessors.root();
4452
4608
  if (!root) return [];
4453
4609
  const hits = [];
@@ -4460,6 +4616,7 @@ var SvgGeometryDriver = class {
4460
4616
  return hits;
4461
4617
  }
4462
4618
  node_at_point(p) {
4619
+ this.accessors.flush();
4463
4620
  return this.accessors.pick_at_world(p, true);
4464
4621
  }
4465
4622
  /** World→local delta projection. The frame an element's position is
@@ -4475,6 +4632,7 @@ var SvgGeometryDriver = class {
4475
4632
  * the local delta. Identity (→ delta unchanged) for flat frames,
4476
4633
  * top-level nodes, and any degenerate / unavailable matrix. */
4477
4634
  world_delta_to_local(id, delta) {
4635
+ this.accessors.flush();
4478
4636
  const parent = this.accessors.element_for(id)?.parentNode;
4479
4637
  const root = this.accessors.root();
4480
4638
  if (!(parent instanceof SVGGraphicsElement) || !root) return delta;
@@ -4515,4 +4673,4 @@ var SvgHitShapeDriver = class {
4515
4673
  }
4516
4674
  };
4517
4675
  //#endregion
4518
- export { Gestures as a, Camera as c, project_point_through_ctm as i, inverse_project_rect as n, DEFAULT_SNAP_OPTIONS as o, project_delta_inverse_ctm as r, MemoizedGeometryProvider as s, attach_dom_surface as t };
4676
+ export { project_point_through_ctm as a, MemoizedGeometryProvider as c, project_delta_inverse_ctm as i, Camera as l, install_font_load_geometry_bump as n, Gestures as o, inverse_project_rect as r, DEFAULT_SNAP_OPTIONS as s, attach_dom_surface as t };