@grida/svg-editor 1.0.0-alpha.12 → 1.0.0-alpha.14

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
@@ -51,7 +51,15 @@ Internally, the editor wraps the parsed SVG in a **typed element IR**: a per-nod
51
51
 
52
52
  The IR is a **typed view, not alternative storage**. The parsed AST remains the in-memory store; file bytes remain the source of truth; the parse-side source-position trivia store carries whitespace, attribute order, and unknown-namespace content. The IR is rebuilt from the AST on every load and discarded on `dispose`. P1 round-trip stands.
53
53
 
54
- This is consistent with the "Not a private IR" anti-goal below — that anti-goal rejects alternative on-disk format and bytes-projected-from-IR storage, neither of which this is. Design: `docs/wg/feat-svg-editor/element-ir.md`. Migration sketch: `docs/wg/feat-svg-editor/element-ir-migration.md`.
54
+ This is consistent with the "Not a private IR" anti-goal below — that anti-goal rejects alternative on-disk format and bytes-projected-from-IR storage, neither of which this is. Design: [`docs/wg/feat-svg-editor/element-ir.md`](https://grida.co/docs/wg/feat-svg-editor/element-ir). The phased migration sketch lands with the implementation slice.
55
+
56
+ ### Defined terms
57
+
58
+ The editor's design docs use capitalised terms with precise meanings. The one most often referenced in everyday review:
59
+
60
+ - **[Policy Class](https://grida.co/docs/wg/feat-svg-editor/glossary/policy-class)** — the minimal partition of editable elements such that every editing intent admits the same set of legal solutions within a class. `<circle>` and `<ellipse>` are different Policy Classes because their resize solution spaces fork differently, even though both are conics. The unit at which a host's policy decision (refuse / native / promote / via-transform) maps onto. When a design discussion asks "should X and Y be treated the same?" — apply the Policy Class fork test, not authoring intuition.
61
+
62
+ Full glossary: [`docs/wg/feat-svg-editor/glossary/`](https://github.com/gridaco/grida/tree/main/docs/wg/feat-svg-editor/glossary/).
55
63
 
56
64
  ## Principles
57
65
 
@@ -163,13 +171,34 @@ const editor = createSvgEditor({
163
171
  });
164
172
  ```
165
173
 
166
- `createSvgEditor` is the only constructor. The returned `SvgEditor` is the only object consumers ever hold.
174
+ `createSvgEditor` is the only **editor** constructor. The returned `SvgEditor` is the only editor instance consumers ever hold. A small set of Layer-A geometry primitives (see [Geometry primitives](#geometry-primitives) below) is also exported for callers that need canonical SVG geometry without mounting an editor — those are not editor instances and have no lifecycle.
167
175
 
168
176
  The editor core is **headless**. It parses the SVG, owns the document IR, accepts commands, and emits state — but it does not import, reference, or call into `window`, `document`, `HTMLElement`, or any DOM type. To render or take input, the host attaches a `Surface` (next section).
169
177
 
178
+ ### Geometry primitives
179
+
180
+ A small set of Layer-A primitives is exported for callers that want canonical SVG geometry without mounting an editor. These are not part of the editor lifecycle, do not subscribe, and do not produce diffs against an `SvgDocument` — they are pure value classes over the bytes you hand them.
181
+
182
+ #### `PathModel`
183
+
184
+ Models a single SVG path's vector network for callers that want path geometry without an editor. Construct from a `d` string, observe vertex/segment shape, compute a bbox, serialize back to `d`. No editor, no document, no DOM.
185
+
186
+ ```ts
187
+ import { PathModel } from "@grida/svg-editor";
188
+
189
+ const m = PathModel.fromSvgPathD("M 10 10 L 100 10 L 100 100 Z");
190
+ m.vertexCount(); // 3
191
+ m.segmentCount(); // 3
192
+ m.snapshot(); // { vertices, segments } — POJO
193
+ m.bbox(); // { x, y, width, height }
194
+ m.toSvgPathD(); // canonical d
195
+ ```
196
+
197
+ `@experimental` — the externally-stable contract for v0 is construction (`fromSvgPathD`) plus `snapshot()` / `bbox()` / `vertexCount()` / `segmentCount()` / `toSvgPathD()`. Mutation methods on the class exist for the editor's internal use and are not part of the documented public surface.
198
+
170
199
  ### Surface
171
200
 
172
- A `Surface` is the host-provided rendering and input boundary. The editor pushes paint instructions and HUD descriptors to the surface; the surface pushes normalized input events back. Non-DOM hosts (React Native, worker-side renderer, headless test harness) implement the `Surface` interface themselves. The shipped `domSurface` is the reference implementation used by the React layer.
201
+ A `Surface` is the host-provided rendering and input boundary. The shipped `domSurface` is the reference implementation used by the React layer; non-DOM hosts (React Native, worker-side renderer, headless test harness) would implement the same interface though only one implementation exists today (P6: public only after dogfooding).
173
202
 
174
203
  ```ts
175
204
  import { attach_dom_surface } from "@grida/svg-editor/dom";
@@ -179,29 +208,32 @@ const handle = attach_dom_surface(editor, { container });
179
208
  handle.detach();
180
209
  ```
181
210
 
182
- The contract (full surface contract documented separately — out of scope for this README):
211
+ The v0 contract is pure lifecycle:
183
212
 
184
213
  ```ts
185
214
  interface Surface {
186
- // editor surface: paint the document + HUD overlay
187
- paint(snapshot: SurfacePaintSnapshot): void;
188
-
189
- // surface → editor: hit-test on screen pixel
190
- hit_test(x: number, y: number): NodeId | null;
191
-
192
- // surface → editor: subscribe to normalized input
193
- on_input(listener: (event: SurfaceInputEvent) => void): Unsubscribe;
194
-
215
+ /** Teardown: detach listeners, drop retained refs. Called from
216
+ * `editor.detach()` and `editor.dispose()`. */
195
217
  dispose(): void;
196
218
  }
197
219
  ```
198
220
 
221
+ What's deliberately **not** part of the contract yet:
222
+
223
+ - **Paint push.** There is no `paint(snapshot)` channel. The surface re-serializes the document by subscribing to the editor and writing to its own rendering target.
224
+ - **Normalized input events.** Input routing is surface-private — the DOM surface attaches pointer/keyboard listeners on its own container and reaches the editor through the in-package `_internal` channel.
225
+ - **Hit-testing.** Picking is surface-private: the DOM surface owns its own pointer → node-id resolver against its rendered scene. World-space geometry queries (`bounds_of`, `node_at_point` for non-pointer callers) route through `editor.geometry` instead. A cross-surface `hit_test` contract is deferred until a second surface needs one — its shape (screen vs. world units, z-order tie-breaks, hit-record vs. id) isn't pinned.
226
+
227
+ Each will become a public seam when a second surface implementation arrives and pins its shape. Until then, exporting `paint(snapshot: unknown)` / `on_input(event: unknown)` / `hit_test(x, y)` would be contracts a foreign implementer cannot honestly satisfy (P6 — public only after dogfooding).
228
+
199
229
  Geometry (world-space bboxes, screen ↔ local projection) is exposed via `editor.geometry`, not the `Surface` itself — the DOM surface registers a `MemoizedGeometryProvider` with the editor on attach so headless callers can query bounds without going through the surface.
200
230
 
201
231
  `@grida/svg-editor/dom` exports `attach_dom_surface(editor, { container, ... })` as the default DOM implementation, plus the surface-scoped types (`Camera`, `Gestures`, `SnapOptions`, `MemoizedGeometryProvider`, `DomComputedResolver`) that callers writing alternative surfaces or advanced integrations may need. It mounts the SVG into the container, wires pointer / keyboard listeners scoped to that container, and uses native `getBBox` / `getScreenCTM` for geometry. It is the only place in this package that imports DOM types.
202
232
 
203
233
  The container is **exclusively owned** by the surface. Render toolbars, layer lists, inspectors, and any other interactive chrome as **siblings** of the container, not children. Children of the container interfere with pointer routing (capture redirects, hit-test ordering) and produce silent click breakage. The shipped `SvgEditorCanvas` React component enforces this by creating its own internal div; hosts using `domSurface` / `keynote.attach` directly are responsible for the same discipline. In development, the surface emits a `console.warn` at attach time when the container is non-empty.
204
234
 
235
+ **Attention gate.** The DOM surface installs document- and window-level keydown listeners (so a user with focus on a side-panel button can still hit editor shortcuts while the pointer is on the canvas). Those listeners are gated by an internal attention predicate: a key is claimed (and `preventDefault()`-ed) only when **focus is inside the container's subtree OR the pointer is over the container**. Body-focus alone — the natural state when the surface is embedded as a block in a longer document — is not attended, so the editor stays out of the way of page-level shortcuts (Space / arrows to scroll, Cmd+= to zoom, etc.). Passive observation listeners (modifier mirrors, blur resets) are not gated — they don't call `preventDefault()` and need to stay live across focus boundaries.
236
+
205
237
  ### Lifecycle
206
238
 
207
239
  ```ts
@@ -498,18 +530,23 @@ editor.tree(): {
498
530
 
499
531
  Returns a shallow snapshot. Cheap to call after a `version` bump.
500
532
 
501
- ### Modes
533
+ ### Modes and tools
534
+
535
+ "What does a click do" is governed by **two orthogonal axes**, both editor-internal — consumers observe them and flip them via commands, but cannot define new values for either.
502
536
 
503
- Modes are the editor's internal state machine for "what does a click do." Consumers observe `state.mode`, flip it via commands, but cannot define new modes.
537
+ - **`Mode`** — what the editor is _doing_. Two values: `select` (normal interaction pick / marquee / drag) and `edit-content` (inline text edit, or vector content edit on a path).
538
+ - **`Tool`** — what pointer-down _means_ within the current mode. `cursor` (the default — select / marquee / drag), `insert` (a tag — pointer-down draws a new element of that tag, drag-to-size), `insert-text` (click-only — places a single-line `<text>` and enters content-edit immediately; `<text>` has no intrinsic size so it doesn't drag-to-size), and the content-edit-only `lasso` / `bend` (valid only while `mode === "edit-content"` on a path).
504
539
 
505
540
  ```ts
506
- editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction
507
- // e.g. ["select", "insert-rect", "insert-ellipse", "insert-line", "insert-text", "edit-content"]
541
+ editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction — ["select", "edit-content"]
542
+ editor.state.mode: Mode;
543
+ editor.state.tool: Tool;
508
544
 
509
545
  editor.commands.set_mode(mode: Mode): void;
546
+ editor.set_tool(tool: Tool): void; // also dispatchable as the `tool.set` command (keymap V/R/O/L/T)
510
547
  ```
511
548
 
512
- When a mode-driven gesture completes (rect drawn, text inserted), the editor returns to `select` automatically. Modifier keys can override this (Shift to stay in insert mode); that behavior is bundled, not customizable.
549
+ When a tool-driven gesture completes (a shape is drawn, a text element placed), the tool reverts to `cursor` automatically. Modifier keys can override this (e.g. hold to stay in the insert tool); that behavior is bundled, not customizable.
513
550
 
514
551
  ### Commands
515
552
 
@@ -558,9 +595,11 @@ editor.commands.{
558
595
  group(): void; // wrap selection in a new <g>
559
596
  remove(): void;
560
597
 
561
- // insertion
562
- insert(tag: InsertableTag, attrs?: Readonly<Record<string, string>>): NodeId;
563
- insert_preview(tag: InsertableTag, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
598
+ // insertion — `tag` is an open string (so paste / RPC can create any element,
599
+ // e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
600
+ // draw gesture and default paint.
601
+ insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
602
+ insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
564
603
 
565
604
  // content
566
605
  set_text(value: string): void;
@@ -654,25 +693,25 @@ These are not internals to be replaced — they're documented sugar over `useEdi
654
693
  ```tsx
655
694
  import {
656
695
  // state slices (one-line wrappers over useEditorState)
657
- useSelection, // → readonly NodeId[]
658
- useTool, // → Tool
659
- useMode, // → Mode
660
- useCanUndo, // → boolean
661
- useCanRedo, // → boolean
696
+ useSelection, // → readonly NodeId[]
697
+ useTool, // → Tool
698
+ useMode, // → Mode
699
+ useCanUndo, // → boolean
700
+ useCanRedo, // → boolean
662
701
 
663
702
  // lifecycle-aware preview sessions — unmount = discard (never commit)
664
- usePaintPreview, // (channel) → PaintPreviewSession
665
- usePropertyPreview, // (name) → PreviewSession
703
+ usePaintPreview, // (channel) → PaintPreviewSession
704
+ usePropertyPreview, // (name) → PreviewSession
666
705
 
667
706
  // bound imperative actions, stable identity across renders
668
- useEditorLoad, // → (svg: string) => void
669
- useEditorSerialize, // → () => string
707
+ useEditorLoad, // → (svg: string) => void
708
+ useEditorSerialize, // → () => string
670
709
 
671
710
  // RAII hover override (clears on unmount if this hook set the override)
672
- useHoverOverride, // → (id: NodeId | null) => void
711
+ useHoverOverride, // → (id: NodeId | null) => void
673
712
 
674
713
  // camera bridge (subscribe to a slice of handle.camera without bumping state.version)
675
- useCameraSnapshot, // (handle, selector, fallback) → T
714
+ useCameraSnapshot, // (handle, selector, fallback) → T
676
715
  } from "@grida/svg-editor/react";
677
716
  ```
678
717
 
@@ -701,25 +740,27 @@ Everything else is consumer-built against the editor's API. The two patterns:
701
740
 
702
741
  ```tsx
703
742
  function Toolbar() {
704
- const mode = useEditorState((s) => s.mode);
705
- const cmd = useCommands();
743
+ // Insertion is the `Tool` axis, not `Mode` — `Mode` is only
744
+ // "select" / "edit-content". Flip tools via `editor.set_tool(...)`.
745
+ const tool = useEditorState((s) => s.tool);
746
+ const editor = useSvgEditor();
706
747
  return (
707
748
  <>
708
749
  <ToolButton
709
- active={mode === "select"}
710
- onClick={() => cmd.set_mode("select")}
750
+ active={tool.type === "cursor"}
751
+ onClick={() => editor.set_tool({ type: "cursor" })}
711
752
  >
712
753
 
713
754
  </ToolButton>
714
755
  <ToolButton
715
- active={mode === "insert-rect"}
716
- onClick={() => cmd.set_mode("insert-rect")}
756
+ active={tool.type === "insert" && tool.tag === "rect"}
757
+ onClick={() => editor.set_tool({ type: "insert", tag: "rect" })}
717
758
  >
718
759
 
719
760
  </ToolButton>
720
761
  <ToolButton
721
- active={mode === "insert-text"}
722
- onClick={() => cmd.set_mode("insert-text")}
762
+ active={tool.type === "insert-text"}
763
+ onClick={() => editor.set_tool({ type: "insert-text" })}
723
764
  >
724
765
  T
725
766
  </ToolButton>
@@ -821,9 +862,14 @@ If a consumer needs any of the above, the right answer is "this is the wrong too
821
862
 
822
863
  ## Status
823
864
 
824
- - `v0.0.0` — selection only, no mutation.
865
+ - `v0.x` — selection, transform, insert (rect / ellipse / line), inline text
866
+ edit, and the click-to-place text tool. Experimental.
867
+
868
+ The shape of the API, the mental model, the file-format guarantees, and the scope are all unsettled. Nothing here is stable — public types still in flux include the `Tool` union (a planned axis split, see `TODO.md` F2). Do not depend on it from production code.
869
+
870
+ ## Contributing
825
871
 
826
- The shape of the API, the mental model, the file-format guarantees, and the scope are all unsettled. Nothing here is stable. Do not depend on it from production code.
872
+ - [`TODO.md`](./TODO.md) open questions and deferred work, grouped by area.
827
873
 
828
874
  ## License
829
875