@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 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, { container });
168
- // later:
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. The subscription primitive.
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.1` — selection, translate, paint editing (solid + gradient), gradient defs, history, reorder, remove, set_text, keyboard shortcuts.
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