@grida/svg-editor 1.0.0-alpha.3 → 1.0.0-alpha.5

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
@@ -142,7 +142,10 @@ export function FreeFormCanvas({ svg }: { svg: string }) {
142
142
  return (
143
143
  <SvgEditorProvider svg={svg}>
144
144
  <div style={{ position: "relative", width: "100%", height: "100vh" }}>
145
- <SvgEditorCanvas onAttach={setHandle} style={{ width: "100%", height: "100%" }} />
145
+ <SvgEditorCanvas
146
+ onAttach={setHandle}
147
+ style={{ width: "100%", height: "100%" }}
148
+ />
146
149
  <div style={{ position: "absolute", bottom: 12, right: 12 }}>
147
150
  <button onClick={() => handle?.camera.fit("<root>")}>Fit</button>
148
151
  <button onClick={() => handle?.camera.reset()}>100%</button>
@@ -158,91 +161,18 @@ export function FreeFormCanvas({ svg }: { svg: string }) {
158
161
 
159
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.
160
163
 
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.
164
+ This bundle ships as a one-import preset at the **`@grida/svg-editor/presets`** subpath. The preset composes the public primitives:
162
165
 
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:
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.
164
169
 
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.
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.
239
171
 
240
172
  ```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";
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";
246
176
 
247
177
  export function KeynoteCanvas({ svg }: { svg: string }) {
248
178
  return (
@@ -254,24 +184,46 @@ export function KeynoteCanvas({ svg }: { svg: string }) {
254
184
 
255
185
  function KeynoteHost() {
256
186
  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;
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
+ };
261
202
  }, [editor]);
262
- useEffect(() => () => policy_ref.current?.dispose(), []);
203
+
263
204
  return (
264
205
  <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
- }}/>
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. */}
269
217
  </div>
270
218
  );
271
219
  }
272
220
  ```
273
221
 
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.
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.
275
227
 
276
228
  ## Install
277
229
 
@@ -316,8 +268,8 @@ import { attach_dom_surface } from "@grida/svg-editor/dom";
316
268
 
317
269
  const handle = attach_dom_surface(editor, {
318
270
  container,
319
- gestures: true, // default — install the bundled gesture set (see "Camera")
320
- fit: false, // default — start with identity camera (no auto-fit)
271
+ gestures: true, // default — install the bundled gesture set (see "Camera")
272
+ fit: false, // default — start with identity camera (no auto-fit)
321
273
  });
322
274
 
323
275
  handle.camera; // surface-scoped pan/zoom (see "Camera")
@@ -353,28 +305,50 @@ handle.camera.reset(); // identity
353
305
  handle.camera.subscribe(cb): Unsubscribe; // transient channel — no state.version bump
354
306
  ```
355
307
 
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.
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:
357
311
 
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).
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.
359
333
 
360
334
  #### Gestures
361
335
 
362
336
  `handle.gestures` is sibling to `editor.keymap` — a layer of installable bindings. The default set is bundled on attach:
363
337
 
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 |
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 |
372
346
 
373
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`.
374
348
 
375
349
  #### `svg-boundary ≠ editor boundary`
376
350
 
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)).
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.
378
352
 
379
353
  ### Lifecycle
380
354
 
@@ -407,6 +381,8 @@ editor.state: {
407
381
  readonly version: number; // bumps on every emit (selection, mutation, history)
408
382
  readonly structure_version: number; // bumps only on tree-shape / display-label changes;
409
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
410
386
  };
411
387
 
412
388
  editor.subscribe(fn: (state: EditorState) => void): Unsubscribe;
@@ -997,6 +973,28 @@ The `@grida/keybinding` package provides the `Keybinding` type, `kb()` / `seq()`
997
973
 
998
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.
999
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
+
1000
998
  ## Deferred for v1
1001
999
 
1002
1000
  What v1 explicitly doesn't ship. Each is documented elsewhere with the v1 workaround.
@@ -1010,9 +1008,9 @@ What v1 explicitly doesn't ship. Each is documented elsewhere with the v1 workar
1010
1008
  - **Non-DOM surfaces** (worker, React Native, headless test harness).
1011
1009
  - **Paint-server validity warnings** (dangling `url(#id)` references).
1012
1010
  - **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
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.
1015
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.
1016
1014
 
1017
1015
  ## Anti-goals
1018
1016
 
@@ -1030,7 +1028,7 @@ If a consumer needs any of the above, the right answer is "this is the wrong too
1030
1028
 
1031
1029
  ## Status
1032
1030
 
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).
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.
1034
1032
 
1035
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.
1036
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 };
@@ -0,0 +1,48 @@
1
+ import { _ as Camera, f as Gestures, o as SurfaceHandle, s as SvgEditor } from "./editor-KRAmUodY.mjs";
2
+ import cmath from "@grida/cmath";
3
+
4
+ //#region src/dom.d.ts
5
+ type DomSurfaceOptions = {
6
+ /** Mount the SVG inside this container. */container: HTMLElement;
7
+ /**
8
+ * Install the default gesture set (wheel-pan/zoom, space-drag, middle-mouse,
9
+ * keyboard zoom). Default `true`. Pass `false` to start blank and bind à la
10
+ * carte via `handle.gestures.bind(...)`.
11
+ */
12
+ gestures?: boolean;
13
+ /**
14
+ * Auto-fit the document into the viewport on initial attach. Default
15
+ * `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
16
+ * Subsequent `editor.load()` calls do NOT re-fit — call
17
+ * `handle.camera.fit("<root>")` yourself if you want that behavior.
18
+ */
19
+ fit?: boolean;
20
+ /**
21
+ * Initial camera transform. Default `cmath.transform.identity`. Ignored
22
+ * when `fit: true`.
23
+ */
24
+ initial_camera?: cmath.Transform;
25
+ };
26
+ /**
27
+ * Surface handle for the DOM surface. Extends the editor's core
28
+ * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`)
29
+ * and pointer/wheel/keyboard gesture bindings (`gestures`).
30
+ *
31
+ * Camera + gestures are **surface-scoped**: detaching the surface drops
32
+ * both. They never appear on the headless `SvgEditor`.
33
+ */
34
+ type DomSurfaceHandle = SurfaceHandle & {
35
+ camera: Camera;
36
+ gestures: Gestures;
37
+ };
38
+ /**
39
+ * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
40
+ * whose `detach()` is the inverse — DOM cleared, listeners removed,
41
+ * gestures uninstalled.
42
+ *
43
+ * Usage is one-shot per container: the surface owns the container's children
44
+ * for its lifetime, and `detach()` restores it to empty.
45
+ */
46
+ declare function attach_dom_surface(editor: SvgEditor, options: DomSurfaceOptions): DomSurfaceHandle;
47
+ //#endregion
48
+ export { DomSurfaceOptions as n, attach_dom_surface as r, DomSurfaceHandle as t };
@@ -5,6 +5,15 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __exportAll = (all, no_symbols) => {
9
+ let target = {};
10
+ for (var name in all) __defProp(target, name, {
11
+ get: all[name],
12
+ enumerable: true
13
+ });
14
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
15
+ return target;
16
+ };
8
17
  var __copyProps = (to, from, except, desc) => {
9
18
  if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
19
  key = keys[i];
@@ -20,7 +29,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
20
29
  enumerable: true
21
30
  }) : target, mod));
22
31
  //#endregion
23
- const require_paint = require("./paint-CVLZazOa.js");
32
+ const require_paint = require("./paint-cTjePy5e.js");
24
33
  let _grida_text_editor_dom = require("@grida/text-editor/dom");
25
34
  let _grida_hud = require("@grida/hud");
26
35
  let _grida_cmath__measurement = require("@grida/cmath/_measurement");
@@ -39,9 +48,26 @@ var Camera = class {
39
48
  this.viewport_w = 0;
40
49
  this.viewport_h = 0;
41
50
  this.listeners = /* @__PURE__ */ new Set();
51
+ this._constraints = null;
42
52
  this._transform = opts.initial ?? _grida_cmath.default.transform.identity;
43
53
  this.resolve_bounds = opts.resolve_bounds;
44
54
  }
55
+ /**
56
+ * Current viewport constraint, or `null` for free pan/zoom. Set with
57
+ * `camera.constraints = { type: 'cover', bounds: '<root>', padding: 80 }`
58
+ * to clamp zoom + pan; assign `null` to clear.
59
+ *
60
+ * Constraints are applied synchronously inside `set_transform` (and
61
+ * `_set_viewport_size`), so every public mutation respects them
62
+ * automatically — the host never needs to subscribe-and-clamp itself.
63
+ */
64
+ get constraints() {
65
+ return this._constraints;
66
+ }
67
+ set constraints(c) {
68
+ this._constraints = c;
69
+ if (c) this.reenforce();
70
+ }
45
71
  /** Underlying 2D affine. World→screen. */
46
72
  get transform() {
47
73
  return this._transform;
@@ -140,10 +166,15 @@ var Camera = class {
140
166
  * makes external constraint loops (e.g. "subscribe → compute clamped →
141
167
  * set_transform") terminate: the clamp re-emits the same transform on
142
168
  * the second pass, set_transform short-circuits, no recursion.
169
+ *
170
+ * When `camera.constraints` is non-null, the input transform is clamped
171
+ * synchronously before being stored — every public mutation respects the
172
+ * constraint automatically.
143
173
  */
144
174
  set_transform(t) {
145
- if (transform_equal(this._transform, t)) return;
146
- this._transform = t;
175
+ const next = this.apply_constraints(t);
176
+ if (transform_equal(this._transform, next)) return;
177
+ this._transform = next;
147
178
  this.notify();
148
179
  }
149
180
  /** Viewport size in screen pixels. Read by host code computing constraints. */
@@ -197,6 +228,10 @@ var Camera = class {
197
228
  if (w === this.viewport_w && h === this.viewport_h) return;
198
229
  this.viewport_w = w;
199
230
  this.viewport_h = h;
231
+ if (this._constraints) {
232
+ const next = this.apply_constraints(this._transform);
233
+ if (!transform_equal(this._transform, next)) this._transform = next;
234
+ }
200
235
  this.notify();
201
236
  }
202
237
  /** Convert a screen-space point to world-space. */
@@ -216,10 +251,71 @@ var Camera = class {
216
251
  y: sy
217
252
  };
218
253
  }
254
+ /**
255
+ * Apply the current constraint (if any) to a candidate transform.
256
+ * Pure: returns the clamped result, never mutates state. Returns the
257
+ * input unchanged when constraints are null / bounds are unresolvable /
258
+ * viewport is 0.
259
+ */
260
+ apply_constraints(t) {
261
+ if (!this._constraints) return t;
262
+ if (this.viewport_w <= 0 || this.viewport_h <= 0) return t;
263
+ switch (this._constraints.type) {
264
+ case "cover": return clamp_cover(t, this._constraints, this.viewport_w, this.viewport_h, this.resolve_bounds);
265
+ }
266
+ }
267
+ /**
268
+ * Re-clamp the stored transform against the current constraint. Called
269
+ * from the `constraints` setter; `_set_viewport_size` has its own
270
+ * notify-inclusive path.
271
+ */
272
+ reenforce() {
273
+ if (!this._constraints) return;
274
+ const next = this.apply_constraints(this._transform);
275
+ if (transform_equal(this._transform, next)) return;
276
+ this._transform = next;
277
+ this.notify();
278
+ }
219
279
  notify() {
220
280
  for (const cb of this.listeners) cb();
221
281
  }
222
282
  };
283
+ /**
284
+ * Clamp a transform under a `'cover'` constraint:
285
+ * - Zoom lower-bounded at fit-with-padding (the slide always fills the
286
+ * viewport edge-to-edge).
287
+ * - When at min-zoom the slide is locked centered (bounds smaller than
288
+ * viewport on the constrained axis is impossible above min_zoom; below
289
+ * is impossible because zoom is clamped up).
290
+ * - When zoomed in, pan is clamped so the slide always covers the viewport
291
+ * (no black bars).
292
+ *
293
+ * Returns the input transform unchanged when bounds can't be resolved or
294
+ * are degenerate.
295
+ */
296
+ function clamp_cover(t, c, vp_w, vp_h, resolve) {
297
+ const bounds = typeof c.bounds === "string" ? resolve(c.bounds) : c.bounds;
298
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return t;
299
+ const padding = c.padding ?? 0;
300
+ const eff_w = vp_w - 2 * padding;
301
+ const eff_h = vp_h - 2 * padding;
302
+ if (eff_w <= 0 || eff_h <= 0) return t;
303
+ const min_zoom = Math.min(eff_w / bounds.width, eff_h / bounds.height);
304
+ const s = Math.max(t[0][0], min_zoom);
305
+ const sw = s * bounds.width;
306
+ const sh = s * bounds.height;
307
+ const tx = sw > vp_w ? _grida_cmath.default.clamp(t[0][2], vp_w - s * (bounds.x + bounds.width), -s * bounds.x) : (vp_w - sw) / 2 - s * bounds.x;
308
+ const ty = sh > vp_h ? _grida_cmath.default.clamp(t[1][2], vp_h - s * (bounds.y + bounds.height), -s * bounds.y) : (vp_h - sh) / 2 - s * bounds.y;
309
+ return [[
310
+ s,
311
+ 0,
312
+ tx
313
+ ], [
314
+ 0,
315
+ s,
316
+ ty
317
+ ]];
318
+ }
223
319
  function transform_equal(a, b) {
224
320
  return a[0][0] === b[0][0] && a[0][1] === b[0][1] && a[0][2] === b[0][2] && a[1][0] === b[1][0] && a[1][1] === b[1][1] && a[1][2] === b[1][2];
225
321
  }
@@ -468,7 +564,9 @@ var SvgTextSurface = class {
468
564
  this.last_sel_end = -1;
469
565
  this.textEl = textEl;
470
566
  const ownerDoc = textEl.ownerDocument;
471
- const parent = textEl.parentNode;
567
+ let mountAnchor = textEl;
568
+ while (mountAnchor.parentElement instanceof SVGElement && (mountAnchor.localName === "tspan" || mountAnchor.localName === "textPath")) mountAnchor = mountAnchor.parentElement;
569
+ const parent = mountAnchor.parentNode;
472
570
  if (!parent) throw new Error("text element has no parent");
473
571
  const computedWhitespace = ownerDoc.defaultView?.getComputedStyle(textEl).whiteSpace;
474
572
  if (!(computedWhitespace === "pre" || computedWhitespace === "pre-wrap" || computedWhitespace === "break-spaces")) {
@@ -483,14 +581,14 @@ var SvgTextSurface = class {
483
581
  selection.setAttribute("pointer-events", "none");
484
582
  selection.setAttribute("data-svg-text-edit-selection", "");
485
583
  selection.style.display = "none";
486
- parent.insertBefore(selection, textEl);
584
+ parent.insertBefore(selection, mountAnchor);
487
585
  this.selectionRect = selection;
488
586
  const caret = ownerDoc.createElementNS(SVG_NS, "rect");
489
587
  caret.setAttribute("fill", "#2563eb");
490
588
  caret.setAttribute("pointer-events", "none");
491
589
  caret.setAttribute("data-svg-text-edit-caret", "");
492
590
  caret.style.display = "none";
493
- parent.insertBefore(caret, textEl.nextSibling);
591
+ parent.insertBefore(caret, mountAnchor.nextSibling);
494
592
  this.caretRect = caret;
495
593
  }
496
594
  setText(text) {
@@ -1233,7 +1331,7 @@ var DomSurface = class {
1233
1331
  commit_intent(intent) {
1234
1332
  switch (intent.kind) {
1235
1333
  case "select":
1236
- this.editor.commands.select(intent.ids, { additive: intent.mode !== "replace" });
1334
+ this.editor.commands.select(intent.ids, { mode: intent.mode });
1237
1335
  return;
1238
1336
  case "deselect_all":
1239
1337
  this.editor.commands.deselect();
@@ -1460,13 +1558,15 @@ var DomSurface = class {
1460
1558
  if (!intent.additive) this.editor.commands.deselect();
1461
1559
  return;
1462
1560
  }
1463
- this.editor.commands.select(ids, { additive: intent.additive });
1561
+ this.editor.commands.select(ids, { mode: intent.additive ? "add" : "replace" });
1464
1562
  }
1465
1563
  enter_content_edit(id) {
1466
1564
  if (this.text_edit) return false;
1467
1565
  const el = this.element_index.get(id);
1468
- if (!(el instanceof SVGTextElement)) return false;
1566
+ if (!(el instanceof SVGElement)) return false;
1469
1567
  const doc = this.editor._internal;
1568
+ if (!doc.doc.is_text_edit_target(id)) return false;
1569
+ if (!(el instanceof SVGTextContentElement)) return false;
1470
1570
  this.text_edit_target = id;
1471
1571
  this.text_edit_original = doc.doc.text_of(id);
1472
1572
  this.text_edit = TEXT_EDIT_PENDING;
@@ -1556,6 +1656,12 @@ function rect_intersects(a, b) {
1556
1656
  return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
1557
1657
  }
1558
1658
  //#endregion
1659
+ Object.defineProperty(exports, "__exportAll", {
1660
+ enumerable: true,
1661
+ get: function() {
1662
+ return __exportAll;
1663
+ }
1664
+ });
1559
1665
  Object.defineProperty(exports, "__toESM", {
1560
1666
  enumerable: true,
1561
1667
  get: function() {
@@ -0,0 +1,48 @@
1
+ import { _ as Camera, f as Gestures, o as SurfaceHandle, s as SvgEditor } from "./editor-DdgqLDC9.js";
2
+ import cmath from "@grida/cmath";
3
+
4
+ //#region src/dom.d.ts
5
+ type DomSurfaceOptions = {
6
+ /** Mount the SVG inside this container. */container: HTMLElement;
7
+ /**
8
+ * Install the default gesture set (wheel-pan/zoom, space-drag, middle-mouse,
9
+ * keyboard zoom). Default `true`. Pass `false` to start blank and bind à la
10
+ * carte via `handle.gestures.bind(...)`.
11
+ */
12
+ gestures?: boolean;
13
+ /**
14
+ * Auto-fit the document into the viewport on initial attach. Default
15
+ * `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
16
+ * Subsequent `editor.load()` calls do NOT re-fit — call
17
+ * `handle.camera.fit("<root>")` yourself if you want that behavior.
18
+ */
19
+ fit?: boolean;
20
+ /**
21
+ * Initial camera transform. Default `cmath.transform.identity`. Ignored
22
+ * when `fit: true`.
23
+ */
24
+ initial_camera?: cmath.Transform;
25
+ };
26
+ /**
27
+ * Surface handle for the DOM surface. Extends the editor's core
28
+ * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`)
29
+ * and pointer/wheel/keyboard gesture bindings (`gestures`).
30
+ *
31
+ * Camera + gestures are **surface-scoped**: detaching the surface drops
32
+ * both. They never appear on the headless `SvgEditor`.
33
+ */
34
+ type DomSurfaceHandle = SurfaceHandle & {
35
+ camera: Camera;
36
+ gestures: Gestures;
37
+ };
38
+ /**
39
+ * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
40
+ * whose `detach()` is the inverse — DOM cleared, listeners removed,
41
+ * gestures uninstalled.
42
+ *
43
+ * Usage is one-shot per container: the surface owns the container's children
44
+ * for its lifetime, and `detach()` restores it to empty.
45
+ */
46
+ declare function attach_dom_surface(editor: SvgEditor, options: DomSurfaceOptions): DomSurfaceHandle;
47
+ //#endregion
48
+ export { DomSurfaceOptions as n, attach_dom_surface as r, DomSurfaceHandle as t };