@grida/svg-editor 1.0.0-alpha.1 → 1.0.0-alpha.3
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 +215 -5
- package/dist/{dom-CfP_ZURh.js → dom-BlJZWpR_.js} +614 -7
- package/dist/{dom-kA8NDuVh.mjs → dom-D-5D_3o0.mjs} +614 -7
- package/dist/dom.d.mts +37 -5
- package/dist/dom.d.ts +37 -5
- package/dist/dom.js +1 -1
- package/dist/dom.mjs +1 -1
- package/dist/{editor-B5z-gTML.mjs → editor-DP36h-SE.mjs} +3 -10
- package/dist/{editor-JY7AQrR1.d.mts → editor-DSADZszj.d.mts} +263 -108
- package/dist/{editor-CTtU2gu4.d.ts → editor-Da446SPO.d.ts} +263 -108
- package/dist/{editor-DQWUWrVZ.js → editor-Eon0043Z.js} +5 -12
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{paint-DuCg6Y-K.mjs → paint-BTKvRItP.mjs} +17 -1
- package/dist/{paint-DHq_3iwU.js → paint-CVLZazOa.js} +23 -1
- package/dist/react.d.mts +49 -6
- package/dist/react.d.ts +49 -6
- package/dist/react.js +49 -9
- package/dist/react.mjs +49 -10
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -123,6 +123,156 @@ A few scenarios this is designed to handle well.
|
|
|
123
123
|
>
|
|
124
124
|
> The editor freezes the animation at `t=0` for editing, applies the position change to the static `cx` attribute, and preserves the `<animate>` block verbatim. The inspector shows a clear "animated property" badge so the user understands what they're editing.
|
|
125
125
|
|
|
126
|
+
### Free-form canvas (Figma-style infinite canvas)
|
|
127
|
+
|
|
128
|
+
Identity camera, default gestures, no auto-fit. Pan with the wheel, Cmd+wheel to zoom at cursor, space-drag for hand-tool, `Shift+1` to fit, `Shift+0` to reset.
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import {
|
|
132
|
+
SvgEditorCanvas,
|
|
133
|
+
SvgEditorProvider,
|
|
134
|
+
useCameraSnapshot,
|
|
135
|
+
} from "@grida/svg-editor/react";
|
|
136
|
+
import type { DomSurfaceHandle } from "@grida/svg-editor/dom";
|
|
137
|
+
import { useState } from "react";
|
|
138
|
+
|
|
139
|
+
export function FreeFormCanvas({ svg }: { svg: string }) {
|
|
140
|
+
const [handle, setHandle] = useState<DomSurfaceHandle | null>(null);
|
|
141
|
+
const zoom = useCameraSnapshot(handle, (c) => c.zoom, 1);
|
|
142
|
+
return (
|
|
143
|
+
<SvgEditorProvider svg={svg}>
|
|
144
|
+
<div style={{ position: "relative", width: "100%", height: "100vh" }}>
|
|
145
|
+
<SvgEditorCanvas onAttach={setHandle} style={{ width: "100%", height: "100%" }} />
|
|
146
|
+
<div style={{ position: "absolute", bottom: 12, right: 12 }}>
|
|
147
|
+
<button onClick={() => handle?.camera.fit("<root>")}>Fit</button>
|
|
148
|
+
<button onClick={() => handle?.camera.reset()}>100%</button>
|
|
149
|
+
<span>{Math.round(zoom * 100)}%</span>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</SvgEditorProvider>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Keynote-like canvas (fixed-viewBox slide with bounded camera)
|
|
158
|
+
|
|
159
|
+
The keynote case wants more than "fit on load" — it wants **"the slide is the world"**: no zooming out past fit, no panning when the slide already fills the viewport, and clamped panning when zoomed in so the slide always covers the viewport edge-to-edge.
|
|
160
|
+
|
|
161
|
+
The package doesn't ship `camera.constraints` at v1 (see [Deferred for v1](#deferred-for-v1)). The recipe is **pure imperative JS** — no React. Camera lifecycle is owned by the DOM surface, not by React render cycles, so the policy lives next to the surface and React is just orchestration glue.
|
|
162
|
+
|
|
163
|
+
The reusable primitive — `create_slide_constraint(camera)` — installs a `camera.subscribe(...)` that clamps zoom (≥ fit-zoom) and pan (slide always covers the viewport) on every change. `Camera.set_transform` is idempotent, so the subscribe→clamp→set loop converges in one round. Compose it with `editor.subscribe_with_selector(s => s.structure_version, ...)` for refit-on-load:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
// Pure-JS policy. No React. Construct in onAttach, dispose on detach.
|
|
167
|
+
import cmath from "@grida/cmath";
|
|
168
|
+
import type { Camera, Rect, SvgEditor, Unsubscribe } from "@grida/svg-editor";
|
|
169
|
+
import type { DomSurfaceHandle } from "@grida/svg-editor/dom";
|
|
170
|
+
|
|
171
|
+
function create_slide_constraint(camera: Camera) {
|
|
172
|
+
let slide: Rect | null = null;
|
|
173
|
+
let margin = 0;
|
|
174
|
+
const enforce = () => {
|
|
175
|
+
if (!slide) return;
|
|
176
|
+
const t = camera.transform;
|
|
177
|
+
const vp = camera.viewport_size;
|
|
178
|
+
if (vp.width <= 0 || vp.height <= 0) return;
|
|
179
|
+
const min_zoom = Math.min(
|
|
180
|
+
(vp.width - 2 * margin) / slide.width,
|
|
181
|
+
(vp.height - 2 * margin) / slide.height
|
|
182
|
+
);
|
|
183
|
+
const s = Math.max(t[0][0], min_zoom);
|
|
184
|
+
const sw = s * slide.width, sh = s * slide.height;
|
|
185
|
+
const tx = sw > vp.width
|
|
186
|
+
? cmath.clamp(t[0][2], vp.width - s * (slide.x + slide.width), -s * slide.x)
|
|
187
|
+
: (vp.width - sw) / 2 - s * slide.x;
|
|
188
|
+
const ty = sh > vp.height
|
|
189
|
+
? cmath.clamp(t[1][2], vp.height - s * (slide.y + slide.height), -s * slide.y)
|
|
190
|
+
: (vp.height - sh) / 2 - s * slide.y;
|
|
191
|
+
camera.set_transform([[s, 0, tx], [0, s, ty]]);
|
|
192
|
+
};
|
|
193
|
+
const unsubscribe = camera.subscribe(enforce);
|
|
194
|
+
return {
|
|
195
|
+
set_slide(next: Rect | null) { slide = next; enforce(); },
|
|
196
|
+
set_margin(next: number) { margin = next; enforce(); },
|
|
197
|
+
dispose() { unsubscribe(); },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
class KeynotePolicy {
|
|
202
|
+
private constraint = create_slide_constraint(this.handle.camera);
|
|
203
|
+
private unsub: Unsubscribe;
|
|
204
|
+
private margin: number;
|
|
205
|
+
|
|
206
|
+
constructor(
|
|
207
|
+
private editor: SvgEditor,
|
|
208
|
+
private handle: DomSurfaceHandle,
|
|
209
|
+
opts: { margin: number }
|
|
210
|
+
) {
|
|
211
|
+
this.margin = opts.margin;
|
|
212
|
+
this.constraint.set_margin(this.margin);
|
|
213
|
+
this.refresh();
|
|
214
|
+
this.unsub = editor.subscribe_with_selector(
|
|
215
|
+
(s) => s.structure_version,
|
|
216
|
+
() => this.refresh()
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
set_margin(m: number) {
|
|
220
|
+
this.margin = m;
|
|
221
|
+
this.constraint.set_margin(m);
|
|
222
|
+
this.handle.camera.fit("<root>", { margin: m });
|
|
223
|
+
}
|
|
224
|
+
private refresh() {
|
|
225
|
+
const root = this.editor.tree().root;
|
|
226
|
+
const vb = this.editor.document.get_attr(root, "viewBox")?.split(/[\s,]+/).map(Number);
|
|
227
|
+
this.constraint.set_slide(
|
|
228
|
+
vb?.length === 4 && vb.every(Number.isFinite)
|
|
229
|
+
? { x: vb[0], y: vb[1], width: vb[2], height: vb[3] }
|
|
230
|
+
: null
|
|
231
|
+
);
|
|
232
|
+
this.handle.camera.fit("<root>", { margin: this.margin });
|
|
233
|
+
}
|
|
234
|
+
dispose() { this.unsub(); this.constraint.dispose(); }
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
React's role drops to: instantiate the policy in `onAttach`, hold it in a ref, dispose on unmount.
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
242
|
+
import {
|
|
243
|
+
SvgEditorCanvas, SvgEditorProvider, useSvgEditor,
|
|
244
|
+
} from "@grida/svg-editor/react";
|
|
245
|
+
import type { DomSurfaceHandle } from "@grida/svg-editor/dom";
|
|
246
|
+
|
|
247
|
+
export function KeynoteCanvas({ svg }: { svg: string }) {
|
|
248
|
+
return (
|
|
249
|
+
<SvgEditorProvider svg={svg}>
|
|
250
|
+
<KeynoteHost />
|
|
251
|
+
</SvgEditorProvider>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function KeynoteHost() {
|
|
256
|
+
const editor = useSvgEditor();
|
|
257
|
+
const policy_ref = useRef<KeynotePolicy | null>(null);
|
|
258
|
+
const onAttach = useCallback((h: DomSurfaceHandle | null) => {
|
|
259
|
+
policy_ref.current?.dispose();
|
|
260
|
+
policy_ref.current = h ? new KeynotePolicy(editor, h, { margin: 80 }) : null;
|
|
261
|
+
}, [editor]);
|
|
262
|
+
useEffect(() => () => policy_ref.current?.dispose(), []);
|
|
263
|
+
return (
|
|
264
|
+
<div style={{ padding: 24, background: "#1f2937", height: "100vh" }}>
|
|
265
|
+
<SvgEditorCanvas fit onAttach={onAttach} style={{
|
|
266
|
+
width: "100%", height: "100%",
|
|
267
|
+
background: "#fff", boxShadow: "0 10px 40px rgba(0,0,0,0.3)",
|
|
268
|
+
}}/>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Both examples ship as live demos at `/canvas/svg` (free-form) and `/canvas/svg/keynote` (constrained) in the dogfood app. Their diff is the spec for what the SDK owes its users: same editor, same camera, same gestures — different host policies layered on top, and the policy itself is plain imperative JS that any framework could reuse.
|
|
275
|
+
|
|
126
276
|
## Install
|
|
127
277
|
|
|
128
278
|
```sh
|
|
@@ -164,13 +314,68 @@ A surface is the editor's attachment seam — it mounts the SVG into a host envi
|
|
|
164
314
|
```ts
|
|
165
315
|
import { attach_dom_surface } from "@grida/svg-editor/dom";
|
|
166
316
|
|
|
167
|
-
const handle = attach_dom_surface(editor, {
|
|
168
|
-
|
|
317
|
+
const handle = attach_dom_surface(editor, {
|
|
318
|
+
container,
|
|
319
|
+
gestures: true, // default — install the bundled gesture set (see "Camera")
|
|
320
|
+
fit: false, // default — start with identity camera (no auto-fit)
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
handle.camera; // surface-scoped pan/zoom (see "Camera")
|
|
324
|
+
handle.gestures; // surface-scoped wheel/pointer/keyboard layer
|
|
169
325
|
handle.detach();
|
|
170
326
|
```
|
|
171
327
|
|
|
172
328
|
`@grida/svg-editor/dom` is the only place in this package that imports DOM types. The internal `Surface` type exists for tests and future work; it is not a documented extension point at v1.
|
|
173
329
|
|
|
330
|
+
### Camera
|
|
331
|
+
|
|
332
|
+
The camera is a **surface-scoped** pan/zoom: it lives on `handle.camera`, never on `editor.state`. View state and document state are disjoint — pan/zoom doesn't bump `state.version`, doesn't serialize, and isn't undoable. A second `attach_dom_surface(...)` gets its own camera.
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
handle.camera.center; // world-space point at viewport center (Vec2)
|
|
336
|
+
handle.camera.zoom; // uniform scale; 1 = 100 %
|
|
337
|
+
handle.camera.bounds; // world Rect visible in the viewport
|
|
338
|
+
handle.camera.viewport_size; // { width, height } in screen px
|
|
339
|
+
handle.camera.transform; // underlying cmath.Transform (advanced read)
|
|
340
|
+
|
|
341
|
+
handle.camera.pan({ x, y }); // translate by a screen-space delta
|
|
342
|
+
handle.camera.zoom_at(factor, origin_screen); // pinch / wheel-at-cursor
|
|
343
|
+
handle.camera.set_center({ x, y });
|
|
344
|
+
handle.camera.set_zoom(z, pivot_screen?);
|
|
345
|
+
handle.camera.set_transform(t); // idempotent — no notify when t === current
|
|
346
|
+
|
|
347
|
+
handle.camera.fit("<root>"); // fit the document into the viewport
|
|
348
|
+
handle.camera.fit("<selection>"); // fit current selection
|
|
349
|
+
handle.camera.fit(nodeId);
|
|
350
|
+
handle.camera.fit(rect, { margin: 64 });
|
|
351
|
+
handle.camera.reset(); // identity
|
|
352
|
+
|
|
353
|
+
handle.camera.subscribe(cb): Unsubscribe; // transient channel — no state.version bump
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
`set_transform` is **idempotent by contract**: when the new transform is element-wise equal to the current, the call is a no-op (no notification fires). This is what makes host-side "subscribe → derive → set" loops (constraints, snap, sync) terminate in finite steps — the [keynote example](#keynote-like-canvas-fixed-viewbox-slide-with-bounded-camera) relies on it.
|
|
357
|
+
|
|
358
|
+
Default behavior: **identity on attach**. Pass `fit: true` to `attach_dom_surface(...)` and the camera fits `"<root>"` on the first frame — Excalidraw's `initialData.scrollToContent: true`, but on the surface. Subsequent `editor.load()` calls do **not** re-fit; that's a host concern (`editor.subscribe_with_selector(s => s.structure_version, () => handle.camera.fit("<root>"))` is the recipe).
|
|
359
|
+
|
|
360
|
+
#### Gestures
|
|
361
|
+
|
|
362
|
+
`handle.gestures` is sibling to `editor.keymap` — a layer of installable bindings. The default set is bundled on attach:
|
|
363
|
+
|
|
364
|
+
| `id` | trigger | effect |
|
|
365
|
+
| ------------------ | ------------------------------------ | ---------------------------- |
|
|
366
|
+
| `wheel-pan-zoom` | plain wheel | `camera.pan(delta)` |
|
|
367
|
+
| `wheel-pan-zoom` | Ctrl/Cmd + wheel, native pinch | `camera.zoom_at(...)` at cursor |
|
|
368
|
+
| `space-drag-pan` | Space-down + drag | hand-tool pan |
|
|
369
|
+
| `middle-mouse-pan` | middle-button drag | pan |
|
|
370
|
+
| `keyboard-zoom` | `Shift+0` / `Shift+1` / `Shift+2` | reset / fit root / fit selection |
|
|
371
|
+
| `keyboard-zoom` | `Cmd/Ctrl + =` / `Cmd/Ctrl + -` | zoom in / out |
|
|
372
|
+
|
|
373
|
+
Swap as a unit (`attach_dom_surface(editor, { container, gestures: false })`) then call `handle.gestures.bind({ id, install: ctx => uninstall })` à la carte. **No per-gesture options bag** — if you disagree with a default, unbind it and bind your own. Same discipline as `editor.keymap`.
|
|
374
|
+
|
|
375
|
+
#### `svg-boundary ≠ editor boundary`
|
|
376
|
+
|
|
377
|
+
The SVG's intrinsic `width` / `height` / `viewBox` are *content*, not viewport. The camera frames space; the SVG sits in that space. A 1920×1080 attachment is fully serviced by `camera.fit("<root>")` + container CSS — there's no first-class "stage" concept (see [Deferred for v1](#deferred-for-v1)).
|
|
378
|
+
|
|
174
379
|
### Lifecycle
|
|
175
380
|
|
|
176
381
|
```ts
|
|
@@ -597,16 +802,18 @@ import {
|
|
|
597
802
|
useSvgEditor,
|
|
598
803
|
useEditorState,
|
|
599
804
|
useCommands,
|
|
805
|
+
useCameraSnapshot,
|
|
600
806
|
} from "@grida/svg-editor/react";
|
|
601
807
|
```
|
|
602
808
|
|
|
603
809
|
That's the whole public surface.
|
|
604
810
|
|
|
605
811
|
- `SvgEditorProvider` — owns the headless editor, puts it in context.
|
|
606
|
-
- `SvgEditorCanvas` — the only UI component we ship; internally calls `attach_dom_surface(editor, { container: div })` on mount and `detach()` on unmount.
|
|
812
|
+
- `SvgEditorCanvas` — the only UI component we ship; internally calls `attach_dom_surface(editor, { container: div, gestures, fit, initial_camera })` on mount and `detach()` on unmount. Pass `onAttach={setHandle}` to receive the `DomSurfaceHandle` (for `handle.camera` / `handle.gestures`).
|
|
607
813
|
- `useSvgEditor()` — returns the editor instance from context.
|
|
608
|
-
- `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change.
|
|
814
|
+
- `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change.
|
|
609
815
|
- `useCommands()` — sugar for `useSvgEditor().commands`.
|
|
816
|
+
- `useCameraSnapshot(handle, selector, fallback)` — sibling to `useEditorState`, for the camera channel. Returns the selected value and re-renders on camera mutation; uses `fallback` while `handle` is null. The recipe lives in the package because both demos wrote it identically (see `/canvas/svg` and `/canvas/svg/keynote`).
|
|
610
817
|
|
|
611
818
|
Top-level wiring:
|
|
612
819
|
|
|
@@ -803,6 +1010,9 @@ What v1 explicitly doesn't ship. Each is documented elsewhere with the v1 workar
|
|
|
803
1010
|
- **Non-DOM surfaces** (worker, React Native, headless test harness).
|
|
804
1011
|
- **Paint-server validity warnings** (dangling `url(#id)` references).
|
|
805
1012
|
- **Rotation gesture in the HUD.**
|
|
1013
|
+
- **Stage / bounded working area + `camera.constraints`.** *svg-boundary ≠ editor boundary*: the editor models an infinite canvas; the SVG's intrinsic `width` / `height` / `viewBox` are content, not viewport. Host code that wants Keynote-style bounded camera (lower-bounded zoom at fit; pan clamped so the slide always covers the viewport) writes ~25 lines of `subscribe + clamp + set_transform` against the public API — the recipe lives in the [keynote example above](#keynote-like-canvas-fixed-viewbox-slide-with-bounded-camera) and as a hook in the live demo at `/canvas/svg/keynote`. `Camera.set_transform` is intentionally idempotent so this loop terminates. A first-class `handle.camera.constraints = { bounds, padding, behavior }` (peer-validated via tldraw's `TLCameraConstraints`) is reserved for v2 — once we see a second use case, the recipe graduates.
|
|
1014
|
+
- **Persisted camera state.** Camera is surface-scoped and never enters `editor.state` / `editor.serialize()` / history. Hosts that want view bookmarks persist `handle.camera.transform` themselves.
|
|
1015
|
+
- **Gesture momentum / inertia / animated `fit`.** The `animate` shape isn't reserved in the type — it'll be added when delivered, not before.
|
|
806
1016
|
|
|
807
1017
|
## Anti-goals
|
|
808
1018
|
|
|
@@ -820,7 +1030,7 @@ If a consumer needs any of the above, the right answer is "this is the wrong too
|
|
|
820
1030
|
|
|
821
1031
|
## Status
|
|
822
1032
|
|
|
823
|
-
`v1.0.0-alpha.
|
|
1033
|
+
`v1.0.0-alpha.3` — selection, translate, paint editing (solid + gradient), gradient defs, history, reorder, remove, set_text, keyboard shortcuts, **surface-scoped camera** (pan / zoom / fit with idempotent `set_transform`), **gestures layer** sibling to `editor.keymap` (wheel-pan-zoom, space-drag, middle-mouse-drag, keyboard zoom).
|
|
824
1034
|
|
|
825
1035
|
Round-trip fidelity (P1) is the design invariant; the v1 surface is deliberately minimal and the [Low-level API](#low-level-api) is the escape hatch when the high-level API doesn't yet cover what you need.
|
|
826
1036
|
|