@grida/svg-editor 1.0.0-alpha.13 → 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 +88 -42
- package/dist/{dom-Cvm9Towu.js → dom-BuD8TKmL.js} +1592 -624
- package/dist/dom-D4dy6kq5.d.ts +112 -0
- package/dist/{dom-BlMk07oX.mjs → dom-DSjfCllZ.mjs} +1566 -617
- package/dist/dom-Dz_V6q0Y.d.mts +114 -0
- package/dist/dom.d.mts +3 -3
- package/dist/dom.d.ts +3 -3
- package/dist/dom.js +4 -1
- package/dist/dom.mjs +2 -2
- package/dist/{editor-DtuRIs-Q.mjs → editor-B6pchGYk.mjs} +519 -321
- package/dist/{editor-CdyC3uAe.js → editor-BHHU_Nvz.js} +527 -329
- package/dist/{editor-Bd4-VCEJ.d.ts → editor-CJ2KuRh5.d.ts} +472 -24
- package/dist/{editor-BH03X8cX.d.mts → editor-YQwdWHBb.d.mts} +472 -24
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -4
- package/dist/index.mjs +3 -3
- package/dist/model-DIzZmeyf.mjs +3677 -0
- package/dist/model-DqGqV1H4.js +3823 -0
- package/dist/presets.d.mts +2 -2
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +3 -3
- package/dist/presets.mjs +2 -2
- package/dist/react.d.mts +18 -6
- package/dist/react.d.ts +18 -6
- package/dist/react.js +24 -3
- package/dist/react.mjs +24 -3
- package/package.json +9 -8
- package/dist/dom-DCX-a8Kr.d.ts +0 -57
- package/dist/dom-DgB4f-TE.d.mts +0 -59
- package/dist/insertions-BJ-6o6o5.js +0 -2399
- package/dist/insertions-Okcuo-Ck.mjs +0 -2176
- /package/dist/{chunk-CfYAbeIz.mjs → chunk-D7D4PA-g.mjs} +0 -0
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
|
|
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
|
|
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
|
|
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
|
|
211
|
+
The v0 contract is pure lifecycle:
|
|
183
212
|
|
|
184
213
|
```ts
|
|
185
214
|
interface Surface {
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
563
|
-
|
|
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,
|
|
658
|
-
useTool,
|
|
659
|
-
useMode,
|
|
660
|
-
useCanUndo,
|
|
661
|
-
useCanRedo,
|
|
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,
|
|
665
|
-
usePropertyPreview,
|
|
703
|
+
usePaintPreview, // (channel) → PaintPreviewSession
|
|
704
|
+
usePropertyPreview, // (name) → PreviewSession
|
|
666
705
|
|
|
667
706
|
// bound imperative actions, stable identity across renders
|
|
668
|
-
useEditorLoad,
|
|
669
|
-
useEditorSerialize,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
705
|
-
|
|
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={
|
|
710
|
-
onClick={() =>
|
|
750
|
+
active={tool.type === "cursor"}
|
|
751
|
+
onClick={() => editor.set_tool({ type: "cursor" })}
|
|
711
752
|
>
|
|
712
753
|
↖
|
|
713
754
|
</ToolButton>
|
|
714
755
|
<ToolButton
|
|
715
|
-
active={
|
|
716
|
-
onClick={() =>
|
|
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={
|
|
722
|
-
onClick={() =>
|
|
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.
|
|
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
|
-
|
|
872
|
+
- [`TODO.md`](./TODO.md) — open questions and deferred work, grouped by area.
|
|
827
873
|
|
|
828
874
|
## License
|
|
829
875
|
|