@grida/svg-editor 1.0.0-alpha.2 → 1.0.0-alpha.4

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,108 @@ 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
146
+ onAttach={setHandle}
147
+ style={{ width: "100%", height: "100%" }}
148
+ />
149
+ <div style={{ position: "absolute", bottom: 12, right: 12 }}>
150
+ <button onClick={() => handle?.camera.fit("<root>")}>Fit</button>
151
+ <button onClick={() => handle?.camera.reset()}>100%</button>
152
+ <span>{Math.round(zoom * 100)}%</span>
153
+ </div>
154
+ </div>
155
+ </SvgEditorProvider>
156
+ );
157
+ }
158
+ ```
159
+
160
+ ### Keynote-like canvas (fixed-viewBox slide with bounded camera)
161
+
162
+ 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.
163
+
164
+ This bundle ships as a one-import preset at the **`@grida/svg-editor/presets`** subpath. The preset composes the public primitives:
165
+
166
+ 1. `attach_dom_surface` with `fit: true` — slide visible on first frame.
167
+ 2. `camera.constraints = { type: 'cover', bounds: '<root>', padding }` — typed [camera constraint](#camera-constraints) that clamps zoom + pan.
168
+ 3. `editor.subscribe_with_selector(s => s.load_version, () => camera.fit('<root>'))` — refit only on fresh document loads, not on edits.
169
+
170
+ The preset is opt-in by import: the main `@grida/svg-editor` entry never touches `presets/`, so hosts that don't want the bundle aren't paying for it.
171
+
172
+ ```tsx
173
+ import { useEffect, useRef, useState } from "react";
174
+ import { SvgEditorProvider, useSvgEditor } from "@grida/svg-editor/react";
175
+ import { keynote, type KeynoteSurfaceHandle } from "@grida/svg-editor/presets";
176
+
177
+ export function KeynoteCanvas({ svg }: { svg: string }) {
178
+ return (
179
+ <SvgEditorProvider svg={svg}>
180
+ <KeynoteHost />
181
+ </SvgEditorProvider>
182
+ );
183
+ }
184
+
185
+ function KeynoteHost() {
186
+ const editor = useSvgEditor();
187
+ const container_ref = useRef<HTMLDivElement | null>(null);
188
+ const [handle, setHandle] = useState<KeynoteSurfaceHandle | null>(null);
189
+
190
+ // Mount the preset on first render; dispose on unmount. The preset
191
+ // owns fit-on-attach, the cover constraint, and the load-version refit
192
+ // subscription — the host writes ~10 lines of React lifecycle, no math.
193
+ useEffect(() => {
194
+ const container = container_ref.current;
195
+ if (!container) return;
196
+ const h = keynote.attach(editor, { container, padding: 80 });
197
+ setHandle(h);
198
+ return () => {
199
+ setHandle(null);
200
+ h.detach();
201
+ };
202
+ }, [editor]);
203
+
204
+ return (
205
+ <div style={{ padding: 24, background: "#1f2937", height: "100vh" }}>
206
+ <div
207
+ ref={container_ref}
208
+ style={{
209
+ width: "100%",
210
+ height: "100%",
211
+ background: "#fff",
212
+ boxShadow: "0 10px 40px rgba(0,0,0,0.3)",
213
+ }}
214
+ />
215
+ {/* Use `handle` for chrome — `useCameraSnapshot(handle, c => c.zoom, 1)`
216
+ for a zoom badge, `handle.set_padding(0)` for a Present-mode toggle, etc. */}
217
+ </div>
218
+ );
219
+ }
220
+ ```
221
+
222
+ The handle is `KeynoteSurfaceHandle = DomSurfaceHandle & { set_padding(p: number): void }`. `set_padding` is preset-specific sugar for "I want a present-mode toggle that varies padding at runtime"; it mutates the live constraint and refits in one call.
223
+
224
+ Both examples ship as live demos at `/canvas/svg` (free-form, no preset) and `/canvas/svg/keynote` (preset) in the dogfood app. Their diff is the spec for what the SDK owes its users: same editor, same camera, same gestures — the keynote demo opts into the preset; the free-form demo doesn't.
225
+
226
+ > **Going deeper.** If you need to compose the building blocks differently — different constraint shape, conditional refit, host-managed bounds — read [`src/presets/keynote.ts`](https://github.com/gridaco/grida/blob/main/packages/grida-svg-editor/src/presets/keynote.ts) (≤ 70 lines of pure composition over public API) as the canonical reference.
227
+
126
228
  ## Install
127
229
 
128
230
  ```sh
@@ -164,13 +266,90 @@ A surface is the editor's attachment seam — it mounts the SVG into a host envi
164
266
  ```ts
165
267
  import { attach_dom_surface } from "@grida/svg-editor/dom";
166
268
 
167
- const handle = attach_dom_surface(editor, { container });
168
- // later:
269
+ const handle = attach_dom_surface(editor, {
270
+ container,
271
+ gestures: true, // default — install the bundled gesture set (see "Camera")
272
+ fit: false, // default — start with identity camera (no auto-fit)
273
+ });
274
+
275
+ handle.camera; // surface-scoped pan/zoom (see "Camera")
276
+ handle.gestures; // surface-scoped wheel/pointer/keyboard layer
169
277
  handle.detach();
170
278
  ```
171
279
 
172
280
  `@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
281
 
282
+ ### Camera
283
+
284
+ 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.
285
+
286
+ ```ts
287
+ handle.camera.center; // world-space point at viewport center (Vec2)
288
+ handle.camera.zoom; // uniform scale; 1 = 100 %
289
+ handle.camera.bounds; // world Rect visible in the viewport
290
+ handle.camera.viewport_size; // { width, height } in screen px
291
+ handle.camera.transform; // underlying cmath.Transform (advanced read)
292
+
293
+ handle.camera.pan({ x, y }); // translate by a screen-space delta
294
+ handle.camera.zoom_at(factor, origin_screen); // pinch / wheel-at-cursor
295
+ handle.camera.set_center({ x, y });
296
+ handle.camera.set_zoom(z, pivot_screen?);
297
+ handle.camera.set_transform(t); // idempotent — no notify when t === current
298
+
299
+ handle.camera.fit("<root>"); // fit the document into the viewport
300
+ handle.camera.fit("<selection>"); // fit current selection
301
+ handle.camera.fit(nodeId);
302
+ handle.camera.fit(rect, { margin: 64 });
303
+ handle.camera.reset(); // identity
304
+
305
+ handle.camera.subscribe(cb): Unsubscribe; // transient channel — no state.version bump
306
+ ```
307
+
308
+ `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 constraints terminate in finite steps and what lets host-side "subscribe → derive → set" loops converge without recursion.
309
+
310
+ 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. Use `editor.state.load_version` (the "fresh document" signal, [see State](#observation--state)) as the trigger — it bumps once per `editor.load()` and is stable across edits:
311
+
312
+ ```ts
313
+ editor.subscribe_with_selector(
314
+ (s) => s.load_version,
315
+ () => handle.camera.fit("<root>")
316
+ );
317
+ ```
318
+
319
+ #### Constraints
320
+
321
+ ```ts
322
+ handle.camera.constraints = {
323
+ type: "cover", // bounds cover viewport (keynote / slide UX)
324
+ bounds: "<root>", // or an explicit Rect
325
+ padding: 80, // screen-pixel breathing room
326
+ };
327
+ handle.camera.constraints = null; // clear
328
+ ```
329
+
330
+ A typed viewport clamp, evaluated synchronously inside every `set_transform` call. v1.1 ships one variant, `type: 'cover'`: bounds cover the viewport edge-to-edge — zoom lower-bounded at fit-with-padding, pan clamped so the user can't see past the bounds. CSS analogy: `object-fit: cover` applied to the bounds rect.
331
+
332
+ The discriminator (`type`) is a tagged union for forward compatibility — future variants (`'contain'`, `'pan-region'`) will be addable without breaking existing call sites. The [keynote preset](#keynote-like-canvas-fixed-viewbox-slide-with-bounded-camera) is built on this primitive.
333
+
334
+ #### Gestures
335
+
336
+ `handle.gestures` is sibling to `editor.keymap` — a layer of installable bindings. The default set is bundled on attach:
337
+
338
+ | `id` | trigger | effect |
339
+ | ------------------ | --------------------------------- | -------------------------------- |
340
+ | `wheel-pan-zoom` | plain wheel | `camera.pan(delta)` |
341
+ | `wheel-pan-zoom` | Ctrl/Cmd + wheel, native pinch | `camera.zoom_at(...)` at cursor |
342
+ | `space-drag-pan` | Space-down + drag | hand-tool pan |
343
+ | `middle-mouse-pan` | middle-button drag | pan |
344
+ | `keyboard-zoom` | `Shift+0` / `Shift+1` / `Shift+2` | reset / fit root / fit selection |
345
+ | `keyboard-zoom` | `Cmd/Ctrl + =` / `Cmd/Ctrl + -` | zoom in / out |
346
+
347
+ 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`.
348
+
349
+ #### `svg-boundary ≠ editor boundary`
350
+
351
+ 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 for the unbounded case, or by `camera.constraints = { type: 'cover', bounds: '<root>', padding }` for the slide case. There is no first-class "stage" concept; the constraint is the layer where bounded-canvas UX is expressed.
352
+
174
353
  ### Lifecycle
175
354
 
176
355
  ```ts
@@ -202,6 +381,8 @@ editor.state: {
202
381
  readonly version: number; // bumps on every emit (selection, mutation, history)
203
382
  readonly structure_version: number; // bumps only on tree-shape / display-label changes;
204
383
  // stable across pure attribute writes
384
+ readonly load_version: number; // bumps once per editor.load(svg) call; starts at 0;
385
+ // the right "fresh document" signal — stable across edits
205
386
  };
206
387
 
207
388
  editor.subscribe(fn: (state: EditorState) => void): Unsubscribe;
@@ -597,16 +778,18 @@ import {
597
778
  useSvgEditor,
598
779
  useEditorState,
599
780
  useCommands,
781
+ useCameraSnapshot,
600
782
  } from "@grida/svg-editor/react";
601
783
  ```
602
784
 
603
785
  That's the whole public surface.
604
786
 
605
787
  - `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.
788
+ - `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
789
  - `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.
790
+ - `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change.
609
791
  - `useCommands()` — sugar for `useSvgEditor().commands`.
792
+ - `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
793
 
611
794
  Top-level wiring:
612
795
 
@@ -790,6 +973,28 @@ The `@grida/keybinding` package provides the `Keybinding` type, `kb()` / `seq()`
790
973
 
791
974
  Anything that earns enough use to be a stable primitive graduates out of this section into the high-level API. Anything we add but later regret stays here under a deprecation marker until it can be removed.
792
975
 
976
+ ## Presets
977
+
978
+ ```ts
979
+ import { keynote } from "@grida/svg-editor/presets";
980
+
981
+ const handle = keynote.attach(editor, { container, padding: 80 });
982
+ // later: handle.set_padding(0); // present-mode toggle
983
+ // handle.detach();
984
+ ```
985
+
986
+ Presets are **opinionated bundles** that compose the public primitives (`attach_dom_surface`, `camera.constraints`, `load_version`, etc.) into one-import setups for common UX shapes. They live at the **`@grida/svg-editor/presets`** subpath — an explicit opt-in. The main `@grida/svg-editor` entry never references this subpath, so hosts that don't want a preset don't pay for it.
987
+
988
+ **v1.1 ships one preset:**
989
+
990
+ - **`keynote`** — slide-shaped canvas. Attaches the DOM surface with `fit: true`, installs `camera.constraints = { type: 'cover', bounds: '<root>', padding }`, subscribes to `editor.state.load_version` for refit-on-load. Returns a `KeynoteSurfaceHandle = DomSurfaceHandle & { set_padding(p): void }` — the `set_padding` method swaps the constraint padding at runtime (use for "Present mode" toggles).
991
+
992
+ The preset's source ([`src/presets/keynote.ts`](https://github.com/gridaco/grida/blob/main/packages/grida-svg-editor/src/presets/keynote.ts)) is ≤ 70 lines of pure composition over the public package surface — no `_internal` reach, no private coupling. If you need a different shape, copy the file and modify.
993
+
994
+ **Discipline.** Code in `src/presets/` is allowed to import only from `@grida/svg-editor` / `@grida/svg-editor/dom` / `@grida/svg-editor/react` and third-party packages — never from `src/core/`, `src/commands/`, `src/keymap/`, or `src/gestures/`. This means every preset is composable against what an external consumer has access to: if a preset works, the same composition works for any host.
995
+
996
+ A second preset graduates when a second use case asks for one (`slidedeck`, `sticker`, `canvas`, …) — discovery-loop discipline.
997
+
793
998
  ## Deferred for v1
794
999
 
795
1000
  What v1 explicitly doesn't ship. Each is documented elsewhere with the v1 workaround.
@@ -803,6 +1008,9 @@ What v1 explicitly doesn't ship. Each is documented elsewhere with the v1 workar
803
1008
  - **Non-DOM surfaces** (worker, React Native, headless test harness).
804
1009
  - **Paint-server validity warnings** (dangling `url(#id)` references).
805
1010
  - **Rotation gesture in the HUD.**
1011
+ - **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.
1012
+ - **Gesture momentum / inertia / animated `fit`.** The `animate` shape isn't reserved in the type — it'll be added when delivered, not before.
1013
+ - **`CameraConstraints` variants beyond `'cover'`.** v1.1 ships `type: 'cover'` only (bounds cover viewport — slide / keynote UX). The discriminator is a tagged union so future variants land as additive arms: `type: 'contain'` (bounds always fully visible inside viewport — bounded artwork) and `type: 'pan-region'` (d3-zoom `translateExtent` style — pan clamped to a region with free zoom) are reserved names but not yet implemented. Peer-validated direction: tldraw's `TLCameraConstraints.behavior: 'inside' | 'outside'`, mapped to CSS-style `'cover'` / `'contain'` naming.
806
1014
 
807
1015
  ## Anti-goals
808
1016
 
@@ -820,7 +1028,7 @@ If a consumer needs any of the above, the right answer is "this is the wrong too
820
1028
 
821
1029
  ## Status
822
1030
 
823
- `v1.0.0-alpha.1` — selection, translate, paint editing (solid + gradient), gradient defs, history, reorder, remove, set_text, keyboard shortcuts.
1031
+ `v1.0.0-alpha.4` — 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), **typed `camera.constraints`** (`'cover'` variant, peer-validated tagged union), **`editor.state.load_version`** (fresh-document signal distinct from `structure_version`), and the **`@grida/svg-editor/presets`** subpath shipping `keynote.attach` for slide-shaped SVGs.
824
1032
 
825
1033
  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
1034
 
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll as t };