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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,24 @@ 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
 
621
645
  // insertion — `tag` is an open string (so paste / RPC can create any element,
622
646
  // e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
623
647
  // draw gesture and default paint.
624
648
  insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
649
+ // markup-shaped sibling of `insert` — one or more sibling elements, or a
650
+ // full `<svg>` doc (the shell is discarded; its children are the content).
651
+ // Subtrees adopted verbatim; ONE history step; returns root ids in
652
+ // document order. Authored ids are NEVER rewritten (dedup is Tidy's job);
653
+ // undeclared `xlink:` / shell-declared prefixes are hoisted onto the root
654
+ // in the same step. Position is authored content: wrap the fragment in
655
+ // `<g transform="translate(x y)">` to land it at a point — same single
656
+ // undo step, no placement opt.
657
+ insert_fragment(svg: string, opts?: { parent?: NodeId; index?: number; select?: boolean }): NodeId[];
625
658
  insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
626
659
 
627
660
  // content
@@ -643,6 +676,8 @@ editor.commands.{
643
676
 
644
677
  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
678
 
679
+ `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.
680
+
646
681
  (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
682
 
648
683
  ### Providers
@@ -880,6 +915,7 @@ What this editor will never be. Each one is a defensive perimeter for the princi
880
915
  - **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
881
916
  - **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
917
  - **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
918
+ - **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
919
 
884
920
  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
921
 
@@ -1,4 +1,4 @@
1
- import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-Dl7c0q5A.mjs";
1
+ import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-D2eQe8lB.mjs";
2
2
  import cmath from "@grida/cmath";
3
3
  import { guide } from "@grida/cmath/_snap";
4
4
 
@@ -13,6 +13,35 @@ type SnapOptions = {
13
13
  declare const DEFAULT_SNAP_OPTIONS: SnapOptions;
14
14
  //#endregion
15
15
  //#region src/dom.d.ts
16
+ /**
17
+ * Wire a web-font settle source to the editor's geometry channel.
18
+ *
19
+ * The DOM surface re-serializes the `<svg>` on every editor tick, but a
20
+ * `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
21
+ * finishing load AFTER its `font-family` / `font-size` was already written.
22
+ * The IR never sees that reflow, so nothing bumps `geometry_version` and
23
+ * every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
24
+ * the fallback-face metrics until the next real edit.
25
+ *
26
+ * Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
27
+ * `EventTarget` in tests) and calls `bump` once per settle. COARSE on
28
+ * purpose: one bump clears the WHOLE bounds cache, not just text nodes —
29
+ * consistent with the package's pessimistic-invalidation stance, and far
30
+ * cheaper than scoping the bump to the (possibly many) reflowed runs.
31
+ *
32
+ * Also bumps once when `source.ready` resolves (when present): fonts that
33
+ * settled before attach — a cache hit, or `font-display` resolving the same
34
+ * tick the surface mounts — never re-fire `loadingdone`, so a document
35
+ * mounted post-settle still needs one bump to re-read at the real metrics.
36
+ *
37
+ * Returns a teardown that removes the listener and neutralizes the pending
38
+ * `ready` bump (leak guard) — call it on surface detach.
39
+ *
40
+ * Factored out of the surface so it can be unit-tested with a fake
41
+ * `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
42
+ * incomplete); never a real font / network.
43
+ */
44
+ declare function install_font_load_geometry_bump(source: EventTarget | null, bump: () => void): () => void;
16
45
  type DomSurfaceOptions = {
17
46
  /** Mount the SVG inside this container. */container: HTMLElement;
18
47
  /**
@@ -33,6 +62,19 @@ type DomSurfaceOptions = {
33
62
  * when `fit: true`.
34
63
  */
35
64
  initial_camera?: cmath.Transform;
65
+ /**
66
+ * Font-load settle source — the `EventTarget` whose `loadingdone` event
67
+ * signals "web fonts finished loading, text may have reflowed." Defaults
68
+ * to `container.ownerDocument.fonts` (the live `FontFaceSet`). The
69
+ * surface installs a `loadingdone` listener that advances the editor's
70
+ * geometry channel so text bounds re-read at the settled glyph metrics
71
+ * (see ../docs/geometry.md §Limitations "Text bbox depends on font").
72
+ *
73
+ * Injectable as a DOM seam: jsdom's `FontFaceSet` is incomplete, so
74
+ * tests pass a plain `EventTarget` stub and `dispatchEvent(new Event(
75
+ * "loadingdone"))` to simulate a settle without a real font / network.
76
+ */
77
+ font_load_source?: EventTarget;
36
78
  };
37
79
  /**
38
80
  * Surface handle for the DOM surface. Extends the editor's core
@@ -111,4 +153,4 @@ declare function inverse_project_rect(rect: {
111
153
  f: number;
112
154
  }, offset: readonly [number, number]): cmath.Rectangle | null;
113
155
  //#endregion
114
- 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 };
156
+ 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 { 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-CYoGJ3Hf.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
  /**
@@ -31,6 +60,19 @@ type DomSurfaceOptions = {
31
60
  * when `fit: true`.
32
61
  */
33
62
  initial_camera?: cmath.Transform;
63
+ /**
64
+ * Font-load settle source — the `EventTarget` whose `loadingdone` event
65
+ * signals "web fonts finished loading, text may have reflowed." Defaults
66
+ * to `container.ownerDocument.fonts` (the live `FontFaceSet`). The
67
+ * surface installs a `loadingdone` listener that advances the editor's
68
+ * geometry channel so text bounds re-read at the settled glyph metrics
69
+ * (see ../docs/geometry.md §Limitations "Text bbox depends on font").
70
+ *
71
+ * Injectable as a DOM seam: jsdom's `FontFaceSet` is incomplete, so
72
+ * tests pass a plain `EventTarget` stub and `dispatchEvent(new Event(
73
+ * "loadingdone"))` to simulate a settle without a real font / network.
74
+ */
75
+ font_load_source?: EventTarget;
34
76
  };
35
77
  /**
36
78
  * Surface handle for the DOM surface. Extends the editor's core
@@ -109,4 +151,4 @@ declare function inverse_project_rect(rect: {
109
151
  f: number;
110
152
  }, offset: readonly [number, number]): cmath.Rectangle | null;
111
153
  //#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 };
154
+ 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 { 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";
2
2
  import cmath from "@grida/cmath";
3
3
  import { svg_parse } from "@grida/svg/parse";
4
4
  import { SVGShapes } from "@grida/svg/pathdata";
@@ -1638,6 +1638,48 @@ const IS_MODIFIER_KEY = {
1638
1638
  * live `<text>` element out from under the about-to-mount text surface. */
1639
1639
  const TEXT_EDIT_PENDING = { __pending: true };
1640
1640
  /**
1641
+ * Wire a web-font settle source to the editor's geometry channel.
1642
+ *
1643
+ * The DOM surface re-serializes the `<svg>` on every editor tick, but a
1644
+ * `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
1645
+ * finishing load AFTER its `font-family` / `font-size` was already written.
1646
+ * The IR never sees that reflow, so nothing bumps `geometry_version` and
1647
+ * every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
1648
+ * the fallback-face metrics until the next real edit.
1649
+ *
1650
+ * Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
1651
+ * `EventTarget` in tests) and calls `bump` once per settle. COARSE on
1652
+ * purpose: one bump clears the WHOLE bounds cache, not just text nodes —
1653
+ * consistent with the package's pessimistic-invalidation stance, and far
1654
+ * cheaper than scoping the bump to the (possibly many) reflowed runs.
1655
+ *
1656
+ * Also bumps once when `source.ready` resolves (when present): fonts that
1657
+ * settled before attach — a cache hit, or `font-display` resolving the same
1658
+ * tick the surface mounts — never re-fire `loadingdone`, so a document
1659
+ * mounted post-settle still needs one bump to re-read at the real metrics.
1660
+ *
1661
+ * Returns a teardown that removes the listener and neutralizes the pending
1662
+ * `ready` bump (leak guard) — call it on surface detach.
1663
+ *
1664
+ * Factored out of the surface so it can be unit-tested with a fake
1665
+ * `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
1666
+ * incomplete); never a real font / network.
1667
+ */
1668
+ function install_font_load_geometry_bump(source, bump) {
1669
+ if (!source) return () => {};
1670
+ const on_fonts_settled = () => bump();
1671
+ source.addEventListener("loadingdone", on_fonts_settled);
1672
+ let alive = true;
1673
+ const ready = source.ready;
1674
+ if (ready && typeof ready.then === "function") ready.then(() => {
1675
+ if (alive) bump();
1676
+ });
1677
+ return () => {
1678
+ alive = false;
1679
+ source.removeEventListener("loadingdone", on_fonts_settled);
1680
+ };
1681
+ }
1682
+ /**
1641
1683
  * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
1642
1684
  * whose `detach()` is the inverse — DOM cleared, listeners removed,
1643
1685
  * gestures uninstalled.
@@ -1777,6 +1819,7 @@ var DomSurface = class DomSurface {
1777
1819
  shapeOf: (id) => this.shape_of(id),
1778
1820
  vectorOf: (id) => this.vector_of(id),
1779
1821
  onIntent: (i) => this.commit_intent(i),
1822
+ onTap: (t) => this.handle_tap(t),
1780
1823
  style: {
1781
1824
  chromeColor: editor.style.chrome_color,
1782
1825
  showRotationHandles: true
@@ -1883,6 +1926,8 @@ var DomSurface = class DomSurface {
1883
1926
  win.addEventListener("resize", fn);
1884
1927
  this.teardown.push(() => win.removeEventListener("resize", fn));
1885
1928
  }
1929
+ const detach_font_listener = install_font_load_geometry_bump(options.font_load_source ?? container.ownerDocument.fonts ?? null, () => editor._internal.bump_geometry());
1930
+ this.teardown.push(detach_font_listener);
1886
1931
  this.wire_events();
1887
1932
  const internal = editor._internal;
1888
1933
  this.editor_hover_internal = internal;
@@ -2966,6 +3011,26 @@ var DomSurface = class DomSurface {
2966
3011
  if (this.editor.keymap.claims(e)) e.preventDefault();
2967
3012
  this.editor.keymap.dispatch(e);
2968
3013
  }
3014
+ /**
3015
+ * Re-express a HUD tap as an editor {@link PickEvent} and fan it out on the
3016
+ * editor's pick channel. The HUD already resolved everything that matters —
3017
+ * the pointer-down point, the hit node, and click-vs-drag — so this is a
3018
+ * pure translation (HUD `[x, y]` tuple → editor `{ x, y }` doc-space point)
3019
+ * with NO re-hit-testing. Taking the hit from the HUD (not a fresh
3020
+ * `node_at_point`) guarantees the pick and the selection it accompanies can
3021
+ * never disagree. Observe-only: this mutates no editor state.
3022
+ */
3023
+ handle_tap(tap) {
3024
+ this.editor._internal.push_pick({
3025
+ point: {
3026
+ x: tap.point[0],
3027
+ y: tap.point[1]
3028
+ },
3029
+ node_id: tap.hit,
3030
+ button: tap.button,
3031
+ mods: tap.mods
3032
+ });
3033
+ }
2969
3034
  commit_intent(intent) {
2970
3035
  switch (intent.kind) {
2971
3036
  case "select":
@@ -4515,4 +4580,4 @@ var SvgHitShapeDriver = class {
4515
4580
  }
4516
4581
  };
4517
4582
  //#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 };
4583
+ 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 };
@@ -1,4 +1,4 @@
1
- const require_model = require("./model-CJ1Ctq14.js");
1
+ const require_model = require("./model-D0nU_EkL.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");
@@ -1640,6 +1640,48 @@ const IS_MODIFIER_KEY = {
1640
1640
  * live `<text>` element out from under the about-to-mount text surface. */
1641
1641
  const TEXT_EDIT_PENDING = { __pending: true };
1642
1642
  /**
1643
+ * Wire a web-font settle source to the editor's geometry channel.
1644
+ *
1645
+ * The DOM surface re-serializes the `<svg>` on every editor tick, but a
1646
+ * `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
1647
+ * finishing load AFTER its `font-family` / `font-size` was already written.
1648
+ * The IR never sees that reflow, so nothing bumps `geometry_version` and
1649
+ * every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
1650
+ * the fallback-face metrics until the next real edit.
1651
+ *
1652
+ * Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
1653
+ * `EventTarget` in tests) and calls `bump` once per settle. COARSE on
1654
+ * purpose: one bump clears the WHOLE bounds cache, not just text nodes —
1655
+ * consistent with the package's pessimistic-invalidation stance, and far
1656
+ * cheaper than scoping the bump to the (possibly many) reflowed runs.
1657
+ *
1658
+ * Also bumps once when `source.ready` resolves (when present): fonts that
1659
+ * settled before attach — a cache hit, or `font-display` resolving the same
1660
+ * tick the surface mounts — never re-fire `loadingdone`, so a document
1661
+ * mounted post-settle still needs one bump to re-read at the real metrics.
1662
+ *
1663
+ * Returns a teardown that removes the listener and neutralizes the pending
1664
+ * `ready` bump (leak guard) — call it on surface detach.
1665
+ *
1666
+ * Factored out of the surface so it can be unit-tested with a fake
1667
+ * `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
1668
+ * incomplete); never a real font / network.
1669
+ */
1670
+ function install_font_load_geometry_bump(source, bump) {
1671
+ if (!source) return () => {};
1672
+ const on_fonts_settled = () => bump();
1673
+ source.addEventListener("loadingdone", on_fonts_settled);
1674
+ let alive = true;
1675
+ const ready = source.ready;
1676
+ if (ready && typeof ready.then === "function") ready.then(() => {
1677
+ if (alive) bump();
1678
+ });
1679
+ return () => {
1680
+ alive = false;
1681
+ source.removeEventListener("loadingdone", on_fonts_settled);
1682
+ };
1683
+ }
1684
+ /**
1643
1685
  * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
1644
1686
  * whose `detach()` is the inverse — DOM cleared, listeners removed,
1645
1687
  * gestures uninstalled.
@@ -1779,6 +1821,7 @@ var DomSurface = class DomSurface {
1779
1821
  shapeOf: (id) => this.shape_of(id),
1780
1822
  vectorOf: (id) => this.vector_of(id),
1781
1823
  onIntent: (i) => this.commit_intent(i),
1824
+ onTap: (t) => this.handle_tap(t),
1782
1825
  style: {
1783
1826
  chromeColor: editor.style.chrome_color,
1784
1827
  showRotationHandles: true
@@ -1885,6 +1928,8 @@ var DomSurface = class DomSurface {
1885
1928
  win.addEventListener("resize", fn);
1886
1929
  this.teardown.push(() => win.removeEventListener("resize", fn));
1887
1930
  }
1931
+ const detach_font_listener = install_font_load_geometry_bump(options.font_load_source ?? container.ownerDocument.fonts ?? null, () => editor._internal.bump_geometry());
1932
+ this.teardown.push(detach_font_listener);
1888
1933
  this.wire_events();
1889
1934
  const internal = editor._internal;
1890
1935
  this.editor_hover_internal = internal;
@@ -2968,6 +3013,26 @@ var DomSurface = class DomSurface {
2968
3013
  if (this.editor.keymap.claims(e)) e.preventDefault();
2969
3014
  this.editor.keymap.dispatch(e);
2970
3015
  }
3016
+ /**
3017
+ * Re-express a HUD tap as an editor {@link PickEvent} and fan it out on the
3018
+ * editor's pick channel. The HUD already resolved everything that matters —
3019
+ * the pointer-down point, the hit node, and click-vs-drag — so this is a
3020
+ * pure translation (HUD `[x, y]` tuple → editor `{ x, y }` doc-space point)
3021
+ * with NO re-hit-testing. Taking the hit from the HUD (not a fresh
3022
+ * `node_at_point`) guarantees the pick and the selection it accompanies can
3023
+ * never disagree. Observe-only: this mutates no editor state.
3024
+ */
3025
+ handle_tap(tap) {
3026
+ this.editor._internal.push_pick({
3027
+ point: {
3028
+ x: tap.point[0],
3029
+ y: tap.point[1]
3030
+ },
3031
+ node_id: tap.hit,
3032
+ button: tap.button,
3033
+ mods: tap.mods
3034
+ });
3035
+ }
2971
3036
  commit_intent(intent) {
2972
3037
  switch (intent.kind) {
2973
3038
  case "select":
@@ -4547,6 +4612,12 @@ Object.defineProperty(exports, "attach_dom_surface", {
4547
4612
  return attach_dom_surface;
4548
4613
  }
4549
4614
  });
4615
+ Object.defineProperty(exports, "install_font_load_geometry_bump", {
4616
+ enumerable: true,
4617
+ get: function() {
4618
+ return install_font_load_geometry_bump;
4619
+ }
4620
+ });
4550
4621
  Object.defineProperty(exports, "inverse_project_rect", {
4551
4622
  enumerable: true,
4552
4623
  get: function() {
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-Dl7c0q5A.mjs";
2
- import { a as project_delta_inverse_ctm, c as SnapOptions, i as inverse_project_rect, n as DomSurfaceOptions, o as project_point_through_ctm, r as attach_dom_surface, s as DEFAULT_SNAP_OPTIONS, t as DomSurfaceHandle } from "./dom-CK6GlgFF.mjs";
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, inverse_project_rect, project_delta_inverse_ctm, project_point_through_ctm };
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";
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-BKoo9SPL.js";
2
- import { a as project_delta_inverse_ctm, c as SnapOptions, i as inverse_project_rect, n as DomSurfaceOptions, o as project_point_through_ctm, r as attach_dom_surface, s as DEFAULT_SNAP_OPTIONS, t as DomSurfaceHandle } from "./dom-CsKXTaNw.js";
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, inverse_project_rect, project_delta_inverse_ctm, project_point_through_ctm };
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";
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,10 +1,11 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_dom = require("./dom-Dee6FtgZ.js");
2
+ const require_dom = require("./dom-U6ae5fQF.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;
6
6
  exports.MemoizedGeometryProvider = require_dom.MemoizedGeometryProvider;
7
7
  exports.attach_dom_surface = require_dom.attach_dom_surface;
8
+ exports.install_font_load_geometry_bump = require_dom.install_font_load_geometry_bump;
8
9
  exports.inverse_project_rect = require_dom.inverse_project_rect;
9
10
  exports.project_delta_inverse_ctm = require_dom.project_delta_inverse_ctm;
10
11
  exports.project_point_through_ctm = require_dom.project_point_through_ctm;
package/dist/dom.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as Gestures, c as Camera, i as project_point_through_ctm, n as inverse_project_rect, o as DEFAULT_SNAP_OPTIONS, r as project_delta_inverse_ctm, s as MemoizedGeometryProvider, t as attach_dom_surface } from "./dom-DILY80j7.mjs";
2
- export { Camera, DEFAULT_SNAP_OPTIONS, Gestures, MemoizedGeometryProvider, attach_dom_surface, inverse_project_rect, project_delta_inverse_ctm, project_point_through_ctm };
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";
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 };