@grida/svg-editor 1.0.0-alpha.14 → 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
@@ -249,9 +249,32 @@ editor.dispose(): void; // permanent teardown
249
249
  ```ts
250
250
  editor.load(svg: string): void; // replace the document (e.g. file-on-disk changed)
251
251
  editor.serialize(): string; // emit clean SVG — guaranteed round-trip per P1
252
+ editor.serialize_node(id: NodeId): string; // emit ONE element's subtree — a fragment, see below
252
253
  editor.reset(): void; // back to last load() input, clears history
253
254
  ```
254
255
 
256
+ `serialize_node(id)` exports the markup of a single element — the bridge from
257
+ "what the user selected" (a `NodeId`) to "the SVG for that element," e.g. to
258
+ hand a downstream consumer (an AI agent) the selected subtree without
259
+ re-serializing the whole document. It reuses `serialize()`'s trivia-preserving
260
+ rules (attribute order, quotes, whitespace, comments — emitted as authored).
261
+
262
+ It is deliberately **weaker** than `serialize()`, and the two must not be
263
+ conflated: `serialize()` emits the whole document and carries the P1
264
+ round-trip guarantee; `serialize_node()` emits a **fragment** and does not.
265
+ Namespace declarations that live on an ancestor (`xmlns:xlink` and friends,
266
+ normally on the root `<svg>`) are **not** inlined into the fragment — a node
267
+ using `xlink:href` serializes without `xmlns:xlink`. The fragment is the
268
+ element's markup as authored, not a standalone parseable document. Throws on
269
+ an unknown id or a non-element node (selections are always elements).
270
+
271
+ > A stable reference to a node that survives a `load()` — and survives an
272
+ > external rewrite of the file — is a separate, unsolved problem (`NodeId`
273
+ > regenerates on each parse). Positional child-index paths address only the
274
+ > deterministic-re-parse case, not structural edits; durable node identity is
275
+ > under design — see
276
+ > [durable node identity](https://grida.co/docs/wg/feat-svg-editor/durable-node-identity).
277
+
255
278
  ### Observation — state
256
279
 
257
280
  ```ts
@@ -281,6 +304,25 @@ editor.subscribe_with_selector<T>(
281
304
 
282
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.
283
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
+
284
326
  ### Observation — properties
285
327
 
286
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.
@@ -585,6 +627,8 @@ editor.commands.{
585
627
  resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
586
628
  rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
587
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;
588
632
  flatten_transform(): void; // bake `transform=` into native attrs where possible
589
633
 
590
634
  // alignment (operates on selection of ≥2 nodes against their union bbox)
@@ -593,12 +637,24 @@ editor.commands.{
593
637
  // structure
594
638
  reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
595
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)
596
643
  remove(): void;
597
644
 
598
645
  // insertion — `tag` is an open string (so paste / RPC can create any element,
599
646
  // e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
600
647
  // draw gesture and default paint.
601
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[];
602
658
  insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
603
659
 
604
660
  // content
@@ -620,6 +676,8 @@ editor.commands.{
620
676
 
621
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.
622
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
+
623
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.)
624
682
 
625
683
  ### Providers
@@ -857,6 +915,7 @@ What this editor will never be. Each one is a defensive perimeter for the princi
857
915
  - **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
858
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.)
859
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.
860
919
 
861
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.
862
921
 
@@ -1,4 +1,4 @@
1
- import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-YQwdWHBb.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-CJ2KuRh5.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 };