@grida/svg-editor 1.0.0-alpha.3 → 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 +105 -107
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/{dom-D-5D_3o0.mjs → dom-CmOu0HvI.mjs} +90 -3
- package/dist/dom-Cn-RtjRL.d.ts +48 -0
- package/dist/{dom-BlJZWpR_.js → dom-CoVZzFqy.js} +105 -3
- package/dist/dom-DJnZhtOd.d.mts +48 -0
- package/dist/dom.d.mts +1 -47
- package/dist/dom.d.ts +1 -47
- package/dist/dom.js +1 -1
- package/dist/dom.mjs +1 -1
- package/dist/{editor-DP36h-SE.mjs → editor-CjK56cgb.mjs} +11 -2
- package/dist/{editor-Da446SPO.d.ts → editor-D2l_CDr0.d.ts} +57 -1
- package/dist/{editor-Eon0043Z.js → editor-D2zZAyny.js} +12 -3
- package/dist/{editor-DSADZszj.d.mts → editor-Uu6dZX4y.d.mts} +57 -1
- package/dist/index-CHiXYO9-.d.ts +1 -0
- package/dist/index-ThDLM4Am.d.mts +1 -0
- 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-CVLZazOa.js → paint-dDV-Trt9.js} +1 -1
- package/dist/presets.d.mts +46 -0
- package/dist/presets.d.ts +46 -0
- package/dist/presets.js +55 -0
- package/dist/presets.mjs +50 -0
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/react.mjs +2 -2
- package/package.json +9 -4
- /package/dist/{paint-BTKvRItP.mjs → paint-Cfiw4g_J.mjs} +0 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
242
|
-
import {
|
|
243
|
-
|
|
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
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
203
|
+
|
|
263
204
|
return (
|
|
264
205
|
<div style={{ padding: 24, background: "#1f2937", height: "100vh" }}>
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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,
|
|
320
|
-
fit: false,
|
|
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
|
|
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
|
-
|
|
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
|
|
365
|
-
| ------------------ |
|
|
366
|
-
| `wheel-pan-zoom` | plain wheel
|
|
367
|
-
| `wheel-pan-zoom` | Ctrl/Cmd + wheel, native pinch
|
|
368
|
-
| `space-drag-pan` | Space-down + drag
|
|
369
|
-
| `middle-mouse-pan` | middle-button drag
|
|
370
|
-
| `keyboard-zoom` | `Shift+0` / `Shift+1` / `Shift+2`
|
|
371
|
-
| `keyboard-zoom` | `Cmd/Ctrl + =` / `Cmd/Ctrl + -`
|
|
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
|
|
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.
|
|
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 };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as capture_resize_baseline, c as is_resizable, i as apply_translate, l as is_text_input_focused, o as capture_translate_baseline, r as apply_resize, s as compute_resize_factors, t as parse_paint } from "./paint-
|
|
1
|
+
import { a as capture_resize_baseline, c as is_resizable, i as apply_translate, l as is_text_input_focused, o as capture_translate_baseline, r as apply_resize, s as compute_resize_factors, t as parse_paint } from "./paint-Cfiw4g_J.mjs";
|
|
2
2
|
import { createTextEditor } from "@grida/text-editor/dom";
|
|
3
3
|
import { NO_MODS, Surface, measurementToHUDDraw } from "@grida/hud";
|
|
4
4
|
import { measure } from "@grida/cmath/_measurement";
|
|
@@ -16,9 +16,26 @@ var Camera = class {
|
|
|
16
16
|
this.viewport_w = 0;
|
|
17
17
|
this.viewport_h = 0;
|
|
18
18
|
this.listeners = /* @__PURE__ */ new Set();
|
|
19
|
+
this._constraints = null;
|
|
19
20
|
this._transform = opts.initial ?? cmath.transform.identity;
|
|
20
21
|
this.resolve_bounds = opts.resolve_bounds;
|
|
21
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Current viewport constraint, or `null` for free pan/zoom. Set with
|
|
25
|
+
* `camera.constraints = { type: 'cover', bounds: '<root>', padding: 80 }`
|
|
26
|
+
* to clamp zoom + pan; assign `null` to clear.
|
|
27
|
+
*
|
|
28
|
+
* Constraints are applied synchronously inside `set_transform` (and
|
|
29
|
+
* `_set_viewport_size`), so every public mutation respects them
|
|
30
|
+
* automatically — the host never needs to subscribe-and-clamp itself.
|
|
31
|
+
*/
|
|
32
|
+
get constraints() {
|
|
33
|
+
return this._constraints;
|
|
34
|
+
}
|
|
35
|
+
set constraints(c) {
|
|
36
|
+
this._constraints = c;
|
|
37
|
+
if (c) this.reenforce();
|
|
38
|
+
}
|
|
22
39
|
/** Underlying 2D affine. World→screen. */
|
|
23
40
|
get transform() {
|
|
24
41
|
return this._transform;
|
|
@@ -117,10 +134,15 @@ var Camera = class {
|
|
|
117
134
|
* makes external constraint loops (e.g. "subscribe → compute clamped →
|
|
118
135
|
* set_transform") terminate: the clamp re-emits the same transform on
|
|
119
136
|
* the second pass, set_transform short-circuits, no recursion.
|
|
137
|
+
*
|
|
138
|
+
* When `camera.constraints` is non-null, the input transform is clamped
|
|
139
|
+
* synchronously before being stored — every public mutation respects the
|
|
140
|
+
* constraint automatically.
|
|
120
141
|
*/
|
|
121
142
|
set_transform(t) {
|
|
122
|
-
|
|
123
|
-
this._transform
|
|
143
|
+
const next = this.apply_constraints(t);
|
|
144
|
+
if (transform_equal(this._transform, next)) return;
|
|
145
|
+
this._transform = next;
|
|
124
146
|
this.notify();
|
|
125
147
|
}
|
|
126
148
|
/** Viewport size in screen pixels. Read by host code computing constraints. */
|
|
@@ -174,6 +196,10 @@ var Camera = class {
|
|
|
174
196
|
if (w === this.viewport_w && h === this.viewport_h) return;
|
|
175
197
|
this.viewport_w = w;
|
|
176
198
|
this.viewport_h = h;
|
|
199
|
+
if (this._constraints) {
|
|
200
|
+
const next = this.apply_constraints(this._transform);
|
|
201
|
+
if (!transform_equal(this._transform, next)) this._transform = next;
|
|
202
|
+
}
|
|
177
203
|
this.notify();
|
|
178
204
|
}
|
|
179
205
|
/** Convert a screen-space point to world-space. */
|
|
@@ -193,10 +219,71 @@ var Camera = class {
|
|
|
193
219
|
y: sy
|
|
194
220
|
};
|
|
195
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Apply the current constraint (if any) to a candidate transform.
|
|
224
|
+
* Pure: returns the clamped result, never mutates state. Returns the
|
|
225
|
+
* input unchanged when constraints are null / bounds are unresolvable /
|
|
226
|
+
* viewport is 0.
|
|
227
|
+
*/
|
|
228
|
+
apply_constraints(t) {
|
|
229
|
+
if (!this._constraints) return t;
|
|
230
|
+
if (this.viewport_w <= 0 || this.viewport_h <= 0) return t;
|
|
231
|
+
switch (this._constraints.type) {
|
|
232
|
+
case "cover": return clamp_cover(t, this._constraints, this.viewport_w, this.viewport_h, this.resolve_bounds);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Re-clamp the stored transform against the current constraint. Called
|
|
237
|
+
* from the `constraints` setter; `_set_viewport_size` has its own
|
|
238
|
+
* notify-inclusive path.
|
|
239
|
+
*/
|
|
240
|
+
reenforce() {
|
|
241
|
+
if (!this._constraints) return;
|
|
242
|
+
const next = this.apply_constraints(this._transform);
|
|
243
|
+
if (transform_equal(this._transform, next)) return;
|
|
244
|
+
this._transform = next;
|
|
245
|
+
this.notify();
|
|
246
|
+
}
|
|
196
247
|
notify() {
|
|
197
248
|
for (const cb of this.listeners) cb();
|
|
198
249
|
}
|
|
199
250
|
};
|
|
251
|
+
/**
|
|
252
|
+
* Clamp a transform under a `'cover'` constraint:
|
|
253
|
+
* - Zoom lower-bounded at fit-with-padding (the slide always fills the
|
|
254
|
+
* viewport edge-to-edge).
|
|
255
|
+
* - When at min-zoom the slide is locked centered (bounds smaller than
|
|
256
|
+
* viewport on the constrained axis is impossible above min_zoom; below
|
|
257
|
+
* is impossible because zoom is clamped up).
|
|
258
|
+
* - When zoomed in, pan is clamped so the slide always covers the viewport
|
|
259
|
+
* (no black bars).
|
|
260
|
+
*
|
|
261
|
+
* Returns the input transform unchanged when bounds can't be resolved or
|
|
262
|
+
* are degenerate.
|
|
263
|
+
*/
|
|
264
|
+
function clamp_cover(t, c, vp_w, vp_h, resolve) {
|
|
265
|
+
const bounds = typeof c.bounds === "string" ? resolve(c.bounds) : c.bounds;
|
|
266
|
+
if (!bounds || bounds.width <= 0 || bounds.height <= 0) return t;
|
|
267
|
+
const padding = c.padding ?? 0;
|
|
268
|
+
const eff_w = vp_w - 2 * padding;
|
|
269
|
+
const eff_h = vp_h - 2 * padding;
|
|
270
|
+
if (eff_w <= 0 || eff_h <= 0) return t;
|
|
271
|
+
const min_zoom = Math.min(eff_w / bounds.width, eff_h / bounds.height);
|
|
272
|
+
const s = Math.max(t[0][0], min_zoom);
|
|
273
|
+
const sw = s * bounds.width;
|
|
274
|
+
const sh = s * bounds.height;
|
|
275
|
+
const tx = sw > vp_w ? cmath.clamp(t[0][2], vp_w - s * (bounds.x + bounds.width), -s * bounds.x) : (vp_w - sw) / 2 - s * bounds.x;
|
|
276
|
+
const ty = sh > vp_h ? cmath.clamp(t[1][2], vp_h - s * (bounds.y + bounds.height), -s * bounds.y) : (vp_h - sh) / 2 - s * bounds.y;
|
|
277
|
+
return [[
|
|
278
|
+
s,
|
|
279
|
+
0,
|
|
280
|
+
tx
|
|
281
|
+
], [
|
|
282
|
+
0,
|
|
283
|
+
s,
|
|
284
|
+
ty
|
|
285
|
+
]];
|
|
286
|
+
}
|
|
200
287
|
function transform_equal(a, b) {
|
|
201
288
|
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];
|
|
202
289
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { a as SurfaceHandle, d as Gestures, g as Camera, o as SvgEditor } from "./editor-D2l_CDr0.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 };
|
|
@@ -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-
|
|
32
|
+
const require_paint = require("./paint-dDV-Trt9.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
|
-
|
|
146
|
-
this._transform
|
|
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
|
}
|
|
@@ -1556,6 +1652,12 @@ function rect_intersects(a, b) {
|
|
|
1556
1652
|
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
1653
|
}
|
|
1558
1654
|
//#endregion
|
|
1655
|
+
Object.defineProperty(exports, "__exportAll", {
|
|
1656
|
+
enumerable: true,
|
|
1657
|
+
get: function() {
|
|
1658
|
+
return __exportAll;
|
|
1659
|
+
}
|
|
1660
|
+
});
|
|
1559
1661
|
Object.defineProperty(exports, "__toESM", {
|
|
1560
1662
|
enumerable: true,
|
|
1561
1663
|
get: function() {
|