@grida/svg-editor 1.0.0-alpha.13 → 1.0.0-alpha.15

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
@@ -51,7 +51,15 @@ Internally, the editor wraps the parsed SVG in a **typed element IR**: a per-nod
51
51
 
52
52
  The IR is a **typed view, not alternative storage**. The parsed AST remains the in-memory store; file bytes remain the source of truth; the parse-side source-position trivia store carries whitespace, attribute order, and unknown-namespace content. The IR is rebuilt from the AST on every load and discarded on `dispose`. P1 round-trip stands.
53
53
 
54
- This is consistent with the "Not a private IR" anti-goal below — that anti-goal rejects alternative on-disk format and bytes-projected-from-IR storage, neither of which this is. Design: `docs/wg/feat-svg-editor/element-ir.md`. Migration sketch: `docs/wg/feat-svg-editor/element-ir-migration.md`.
54
+ This is consistent with the "Not a private IR" anti-goal below — that anti-goal rejects alternative on-disk format and bytes-projected-from-IR storage, neither of which this is. Design: [`docs/wg/feat-svg-editor/element-ir.md`](https://grida.co/docs/wg/feat-svg-editor/element-ir). The phased migration sketch lands with the implementation slice.
55
+
56
+ ### Defined terms
57
+
58
+ The editor's design docs use capitalised terms with precise meanings. The one most often referenced in everyday review:
59
+
60
+ - **[Policy Class](https://grida.co/docs/wg/feat-svg-editor/glossary/policy-class)** — the minimal partition of editable elements such that every editing intent admits the same set of legal solutions within a class. `<circle>` and `<ellipse>` are different Policy Classes because their resize solution spaces fork differently, even though both are conics. The unit at which a host's policy decision (refuse / native / promote / via-transform) maps onto. When a design discussion asks "should X and Y be treated the same?" — apply the Policy Class fork test, not authoring intuition.
61
+
62
+ Full glossary: [`docs/wg/feat-svg-editor/glossary/`](https://github.com/gridaco/grida/tree/main/docs/wg/feat-svg-editor/glossary/).
55
63
 
56
64
  ## Principles
57
65
 
@@ -163,13 +171,34 @@ const editor = createSvgEditor({
163
171
  });
164
172
  ```
165
173
 
166
- `createSvgEditor` is the only constructor. The returned `SvgEditor` is the only object consumers ever hold.
174
+ `createSvgEditor` is the only **editor** constructor. The returned `SvgEditor` is the only editor instance consumers ever hold. A small set of Layer-A geometry primitives (see [Geometry primitives](#geometry-primitives) below) is also exported for callers that need canonical SVG geometry without mounting an editor — those are not editor instances and have no lifecycle.
167
175
 
168
176
  The editor core is **headless**. It parses the SVG, owns the document IR, accepts commands, and emits state — but it does not import, reference, or call into `window`, `document`, `HTMLElement`, or any DOM type. To render or take input, the host attaches a `Surface` (next section).
169
177
 
178
+ ### Geometry primitives
179
+
180
+ A small set of Layer-A primitives is exported for callers that want canonical SVG geometry without mounting an editor. These are not part of the editor lifecycle, do not subscribe, and do not produce diffs against an `SvgDocument` — they are pure value classes over the bytes you hand them.
181
+
182
+ #### `PathModel`
183
+
184
+ Models a single SVG path's vector network for callers that want path geometry without an editor. Construct from a `d` string, observe vertex/segment shape, compute a bbox, serialize back to `d`. No editor, no document, no DOM.
185
+
186
+ ```ts
187
+ import { PathModel } from "@grida/svg-editor";
188
+
189
+ const m = PathModel.fromSvgPathD("M 10 10 L 100 10 L 100 100 Z");
190
+ m.vertexCount(); // 3
191
+ m.segmentCount(); // 3
192
+ m.snapshot(); // { vertices, segments } — POJO
193
+ m.bbox(); // { x, y, width, height }
194
+ m.toSvgPathD(); // canonical d
195
+ ```
196
+
197
+ `@experimental` — the externally-stable contract for v0 is construction (`fromSvgPathD`) plus `snapshot()` / `bbox()` / `vertexCount()` / `segmentCount()` / `toSvgPathD()`. Mutation methods on the class exist for the editor's internal use and are not part of the documented public surface.
198
+
170
199
  ### Surface
171
200
 
172
- A `Surface` is the host-provided rendering and input boundary. The editor pushes paint instructions and HUD descriptors to the surface; the surface pushes normalized input events back. Non-DOM hosts (React Native, worker-side renderer, headless test harness) implement the `Surface` interface themselves. The shipped `domSurface` is the reference implementation used by the React layer.
201
+ A `Surface` is the host-provided rendering and input boundary. The shipped `domSurface` is the reference implementation used by the React layer; non-DOM hosts (React Native, worker-side renderer, headless test harness) would implement the same interface though only one implementation exists today (P6: public only after dogfooding).
173
202
 
174
203
  ```ts
175
204
  import { attach_dom_surface } from "@grida/svg-editor/dom";
@@ -179,29 +208,32 @@ const handle = attach_dom_surface(editor, { container });
179
208
  handle.detach();
180
209
  ```
181
210
 
182
- The contract (full surface contract documented separately — out of scope for this README):
211
+ The v0 contract is pure lifecycle:
183
212
 
184
213
  ```ts
185
214
  interface Surface {
186
- // editor surface: paint the document + HUD overlay
187
- paint(snapshot: SurfacePaintSnapshot): void;
188
-
189
- // surface → editor: hit-test on screen pixel
190
- hit_test(x: number, y: number): NodeId | null;
191
-
192
- // surface → editor: subscribe to normalized input
193
- on_input(listener: (event: SurfaceInputEvent) => void): Unsubscribe;
194
-
215
+ /** Teardown: detach listeners, drop retained refs. Called from
216
+ * `editor.detach()` and `editor.dispose()`. */
195
217
  dispose(): void;
196
218
  }
197
219
  ```
198
220
 
221
+ What's deliberately **not** part of the contract yet:
222
+
223
+ - **Paint push.** There is no `paint(snapshot)` channel. The surface re-serializes the document by subscribing to the editor and writing to its own rendering target.
224
+ - **Normalized input events.** Input routing is surface-private — the DOM surface attaches pointer/keyboard listeners on its own container and reaches the editor through the in-package `_internal` channel.
225
+ - **Hit-testing.** Picking is surface-private: the DOM surface owns its own pointer → node-id resolver against its rendered scene. World-space geometry queries (`bounds_of`, `node_at_point` for non-pointer callers) route through `editor.geometry` instead. A cross-surface `hit_test` contract is deferred until a second surface needs one — its shape (screen vs. world units, z-order tie-breaks, hit-record vs. id) isn't pinned.
226
+
227
+ Each will become a public seam when a second surface implementation arrives and pins its shape. Until then, exporting `paint(snapshot: unknown)` / `on_input(event: unknown)` / `hit_test(x, y)` would be contracts a foreign implementer cannot honestly satisfy (P6 — public only after dogfooding).
228
+
199
229
  Geometry (world-space bboxes, screen ↔ local projection) is exposed via `editor.geometry`, not the `Surface` itself — the DOM surface registers a `MemoizedGeometryProvider` with the editor on attach so headless callers can query bounds without going through the surface.
200
230
 
201
231
  `@grida/svg-editor/dom` exports `attach_dom_surface(editor, { container, ... })` as the default DOM implementation, plus the surface-scoped types (`Camera`, `Gestures`, `SnapOptions`, `MemoizedGeometryProvider`, `DomComputedResolver`) that callers writing alternative surfaces or advanced integrations may need. It mounts the SVG into the container, wires pointer / keyboard listeners scoped to that container, and uses native `getBBox` / `getScreenCTM` for geometry. It is the only place in this package that imports DOM types.
202
232
 
203
233
  The container is **exclusively owned** by the surface. Render toolbars, layer lists, inspectors, and any other interactive chrome as **siblings** of the container, not children. Children of the container interfere with pointer routing (capture redirects, hit-test ordering) and produce silent click breakage. The shipped `SvgEditorCanvas` React component enforces this by creating its own internal div; hosts using `domSurface` / `keynote.attach` directly are responsible for the same discipline. In development, the surface emits a `console.warn` at attach time when the container is non-empty.
204
234
 
235
+ **Attention gate.** The DOM surface installs document- and window-level keydown listeners (so a user with focus on a side-panel button can still hit editor shortcuts while the pointer is on the canvas). Those listeners are gated by an internal attention predicate: a key is claimed (and `preventDefault()`-ed) only when **focus is inside the container's subtree OR the pointer is over the container**. Body-focus alone — the natural state when the surface is embedded as a block in a longer document — is not attended, so the editor stays out of the way of page-level shortcuts (Space / arrows to scroll, Cmd+= to zoom, etc.). Passive observation listeners (modifier mirrors, blur resets) are not gated — they don't call `preventDefault()` and need to stay live across focus boundaries.
236
+
205
237
  ### Lifecycle
206
238
 
207
239
  ```ts
@@ -217,9 +249,32 @@ editor.dispose(): void; // permanent teardown
217
249
  ```ts
218
250
  editor.load(svg: string): void; // replace the document (e.g. file-on-disk changed)
219
251
  editor.serialize(): string; // emit clean SVG — guaranteed round-trip per P1
252
+ editor.serialize_node(id: NodeId): string; // emit ONE element's subtree — a fragment, see below
220
253
  editor.reset(): void; // back to last load() input, clears history
221
254
  ```
222
255
 
256
+ `serialize_node(id)` exports the markup of a single element — the bridge from
257
+ "what the user selected" (a `NodeId`) to "the SVG for that element," e.g. to
258
+ hand a downstream consumer (an AI agent) the selected subtree without
259
+ re-serializing the whole document. It reuses `serialize()`'s trivia-preserving
260
+ rules (attribute order, quotes, whitespace, comments — emitted as authored).
261
+
262
+ It is deliberately **weaker** than `serialize()`, and the two must not be
263
+ conflated: `serialize()` emits the whole document and carries the P1
264
+ round-trip guarantee; `serialize_node()` emits a **fragment** and does not.
265
+ Namespace declarations that live on an ancestor (`xmlns:xlink` and friends,
266
+ normally on the root `<svg>`) are **not** inlined into the fragment — a node
267
+ using `xlink:href` serializes without `xmlns:xlink`. The fragment is the
268
+ element's markup as authored, not a standalone parseable document. Throws on
269
+ an unknown id or a non-element node (selections are always elements).
270
+
271
+ > A stable reference to a node that survives a `load()` — and survives an
272
+ > external rewrite of the file — is a separate, unsolved problem (`NodeId`
273
+ > regenerates on each parse). Positional child-index paths address only the
274
+ > deterministic-re-parse case, not structural edits; durable node identity is
275
+ > under design — see
276
+ > [durable node identity](https://grida.co/docs/wg/feat-svg-editor/durable-node-identity).
277
+
223
278
  ### Observation — state
224
279
 
225
280
  ```ts
@@ -498,18 +553,23 @@ editor.tree(): {
498
553
 
499
554
  Returns a shallow snapshot. Cheap to call after a `version` bump.
500
555
 
501
- ### Modes
556
+ ### Modes and tools
557
+
558
+ "What does a click do" is governed by **two orthogonal axes**, both editor-internal — consumers observe them and flip them via commands, but cannot define new values for either.
502
559
 
503
- Modes are the editor's internal state machine for "what does a click do." Consumers observe `state.mode`, flip it via commands, but cannot define new modes.
560
+ - **`Mode`** — what the editor is _doing_. Two values: `select` (normal interaction pick / marquee / drag) and `edit-content` (inline text edit, or vector content edit on a path).
561
+ - **`Tool`** — what pointer-down _means_ within the current mode. `cursor` (the default — select / marquee / drag), `insert` (a tag — pointer-down draws a new element of that tag, drag-to-size), `insert-text` (click-only — places a single-line `<text>` and enters content-edit immediately; `<text>` has no intrinsic size so it doesn't drag-to-size), and the content-edit-only `lasso` / `bend` (valid only while `mode === "edit-content"` on a path).
504
562
 
505
563
  ```ts
506
- editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction
507
- // e.g. ["select", "insert-rect", "insert-ellipse", "insert-line", "insert-text", "edit-content"]
564
+ editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction — ["select", "edit-content"]
565
+ editor.state.mode: Mode;
566
+ editor.state.tool: Tool;
508
567
 
509
568
  editor.commands.set_mode(mode: Mode): void;
569
+ editor.set_tool(tool: Tool): void; // also dispatchable as the `tool.set` command (keymap V/R/O/L/T)
510
570
  ```
511
571
 
512
- When a mode-driven gesture completes (rect drawn, text inserted), the editor returns to `select` automatically. Modifier keys can override this (Shift to stay in insert mode); that behavior is bundled, not customizable.
572
+ When a tool-driven gesture completes (a shape is drawn, a text element placed), the tool reverts to `cursor` automatically. Modifier keys can override this (e.g. hold to stay in the insert tool); that behavior is bundled, not customizable.
513
573
 
514
574
  ### Commands
515
575
 
@@ -558,9 +618,11 @@ editor.commands.{
558
618
  group(): void; // wrap selection in a new <g>
559
619
  remove(): void;
560
620
 
561
- // insertion
562
- insert(tag: InsertableTag, attrs?: Readonly<Record<string, string>>): NodeId;
563
- insert_preview(tag: InsertableTag, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
621
+ // insertion — `tag` is an open string (so paste / RPC can create any element,
622
+ // e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
623
+ // draw gesture and default paint.
624
+ insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
625
+ insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
564
626
 
565
627
  // content
566
628
  set_text(value: string): void;
@@ -654,25 +716,25 @@ These are not internals to be replaced — they're documented sugar over `useEdi
654
716
  ```tsx
655
717
  import {
656
718
  // state slices (one-line wrappers over useEditorState)
657
- useSelection, // → readonly NodeId[]
658
- useTool, // → Tool
659
- useMode, // → Mode
660
- useCanUndo, // → boolean
661
- useCanRedo, // → boolean
719
+ useSelection, // → readonly NodeId[]
720
+ useTool, // → Tool
721
+ useMode, // → Mode
722
+ useCanUndo, // → boolean
723
+ useCanRedo, // → boolean
662
724
 
663
725
  // lifecycle-aware preview sessions — unmount = discard (never commit)
664
- usePaintPreview, // (channel) → PaintPreviewSession
665
- usePropertyPreview, // (name) → PreviewSession
726
+ usePaintPreview, // (channel) → PaintPreviewSession
727
+ usePropertyPreview, // (name) → PreviewSession
666
728
 
667
729
  // bound imperative actions, stable identity across renders
668
- useEditorLoad, // → (svg: string) => void
669
- useEditorSerialize, // → () => string
730
+ useEditorLoad, // → (svg: string) => void
731
+ useEditorSerialize, // → () => string
670
732
 
671
733
  // RAII hover override (clears on unmount if this hook set the override)
672
- useHoverOverride, // → (id: NodeId | null) => void
734
+ useHoverOverride, // → (id: NodeId | null) => void
673
735
 
674
736
  // camera bridge (subscribe to a slice of handle.camera without bumping state.version)
675
- useCameraSnapshot, // (handle, selector, fallback) → T
737
+ useCameraSnapshot, // (handle, selector, fallback) → T
676
738
  } from "@grida/svg-editor/react";
677
739
  ```
678
740
 
@@ -701,25 +763,27 @@ Everything else is consumer-built against the editor's API. The two patterns:
701
763
 
702
764
  ```tsx
703
765
  function Toolbar() {
704
- const mode = useEditorState((s) => s.mode);
705
- const cmd = useCommands();
766
+ // Insertion is the `Tool` axis, not `Mode` — `Mode` is only
767
+ // "select" / "edit-content". Flip tools via `editor.set_tool(...)`.
768
+ const tool = useEditorState((s) => s.tool);
769
+ const editor = useSvgEditor();
706
770
  return (
707
771
  <>
708
772
  <ToolButton
709
- active={mode === "select"}
710
- onClick={() => cmd.set_mode("select")}
773
+ active={tool.type === "cursor"}
774
+ onClick={() => editor.set_tool({ type: "cursor" })}
711
775
  >
712
776
 
713
777
  </ToolButton>
714
778
  <ToolButton
715
- active={mode === "insert-rect"}
716
- onClick={() => cmd.set_mode("insert-rect")}
779
+ active={tool.type === "insert" && tool.tag === "rect"}
780
+ onClick={() => editor.set_tool({ type: "insert", tag: "rect" })}
717
781
  >
718
782
 
719
783
  </ToolButton>
720
784
  <ToolButton
721
- active={mode === "insert-text"}
722
- onClick={() => cmd.set_mode("insert-text")}
785
+ active={tool.type === "insert-text"}
786
+ onClick={() => editor.set_tool({ type: "insert-text" })}
723
787
  >
724
788
  T
725
789
  </ToolButton>
@@ -821,9 +885,14 @@ If a consumer needs any of the above, the right answer is "this is the wrong too
821
885
 
822
886
  ## Status
823
887
 
824
- - `v0.0.0` — selection only, no mutation.
888
+ - `v0.x` — selection, transform, insert (rect / ellipse / line), inline text
889
+ edit, and the click-to-place text tool. Experimental.
890
+
891
+ The shape of the API, the mental model, the file-format guarantees, and the scope are all unsettled. Nothing here is stable — public types still in flux include the `Tool` union (a planned axis split, see `TODO.md` F2). Do not depend on it from production code.
892
+
893
+ ## Contributing
825
894
 
826
- The shape of the API, the mental model, the file-format guarantees, and the scope are all unsettled. Nothing here is stable. Do not depend on it from production code.
895
+ - [`TODO.md`](./TODO.md) open questions and deferred work, grouped by area.
827
896
 
828
897
  ## License
829
898
 
@@ -0,0 +1,114 @@
1
+ import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-Dl7c0q5A.mjs";
2
+ import cmath from "@grida/cmath";
3
+ import { guide } from "@grida/cmath/_snap";
4
+
5
+ //#region src/core/snap/options.d.ts
6
+ type SnapOptions = {
7
+ /** When false, snap behavior and snap-guide rendering are both off. */enabled: boolean;
8
+ /** Snap threshold in HUD canvas pixels (container CSS px in svg-editor;
9
+ * whatever space the consumer feeds in). Constant across zoom because
10
+ * the input rects are already in screen-equivalent units. */
11
+ threshold_px: number;
12
+ };
13
+ declare const DEFAULT_SNAP_OPTIONS: SnapOptions;
14
+ //#endregion
15
+ //#region src/dom.d.ts
16
+ type DomSurfaceOptions = {
17
+ /** Mount the SVG inside this container. */container: HTMLElement;
18
+ /**
19
+ * Install the default gesture set (wheel-pan/zoom, space-drag, middle-mouse,
20
+ * keyboard zoom). Default `true`. Pass `false` to start blank and bind à la
21
+ * carte via `handle.gestures.bind(...)`.
22
+ */
23
+ gestures?: boolean;
24
+ /**
25
+ * Auto-fit the document into the viewport on initial attach. Default
26
+ * `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
27
+ * Subsequent `editor.load()` calls do NOT re-fit — call
28
+ * `handle.camera.fit("<root>")` yourself if you want that behavior.
29
+ */
30
+ fit?: boolean;
31
+ /**
32
+ * Initial camera transform. Default `cmath.transform.identity`. Ignored
33
+ * when `fit: true`.
34
+ */
35
+ initial_camera?: cmath.Transform;
36
+ };
37
+ /**
38
+ * Surface handle for the DOM surface. Extends the editor's core
39
+ * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`)
40
+ * and pointer/wheel/keyboard gesture bindings (`gestures`).
41
+ *
42
+ * Camera + gestures are **surface-scoped**: detaching the surface drops
43
+ * both. They never appear on the headless `SvgEditor`.
44
+ */
45
+ type DomSurfaceHandle = SurfaceHandle & {
46
+ camera: Camera;
47
+ gestures: Gestures;
48
+ };
49
+ /**
50
+ * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
51
+ * whose `detach()` is the inverse — DOM cleared, listeners removed,
52
+ * gestures uninstalled.
53
+ *
54
+ * Usage is one-shot per container: the surface owns the container's children
55
+ * for its lifetime, and `detach()` restores it to empty.
56
+ */
57
+ declare function attach_dom_surface(editor: SvgEditor, options: DomSurfaceOptions): DomSurfaceHandle;
58
+ /**
59
+ * Affine projection of a point through a 2×3 CTM, then offset by a
60
+ * container origin (in page CSS-px). Mirrors how `line_endpoints_in_container`
61
+ * and `shape_of` (transformed branch) bridge from local SVG coords to the
62
+ * HUD's container-CSS-px space (HUD keeps its own transform at identity;
63
+ * the SVG carries the camera as a CSS transform, which getScreenCTM
64
+ * folds in).
65
+ *
66
+ * Exported for headless test coverage — pure function, no DOM types.
67
+ */
68
+ declare function project_point_through_ctm(px: number, py: number, ctm: {
69
+ a: number;
70
+ b: number;
71
+ c: number;
72
+ d: number;
73
+ e: number;
74
+ f: number;
75
+ }, container_offset: readonly [number, number]): [number, number];
76
+ /**
77
+ * Inverse of the CTM's linear part applied to a delta vector. Drops
78
+ * translation. Used to convert a HUD-reported container-space drag delta
79
+ * back to the path's local coord space for `PathModel.translateVertices`.
80
+ *
81
+ * Throws on a degenerate (det = 0) matrix — the caller is expected to
82
+ * have a non-singular CTM for any visible element.
83
+ */
84
+ declare function project_delta_inverse_ctm(dx: number, dy: number, ctm: {
85
+ a: number;
86
+ b: number;
87
+ c: number;
88
+ d: number;
89
+ }): [number, number];
90
+ /**
91
+ * Inverse-project a doc-space rect through a CTM + container offset back
92
+ * into the element's local frame. The output is the AABB of the four
93
+ * inverse-projected corners — when the CTM has a rotation component the
94
+ * AABB is an approximation, but it matches what the user visually
95
+ * expects from a screen-aligned marquee drag.
96
+ *
97
+ * Returns `null` when the CTM's linear part is singular (degenerate
98
+ * camera) — the caller should skip any test that needs the local rect.
99
+ */
100
+ declare function inverse_project_rect(rect: {
101
+ x: number;
102
+ y: number;
103
+ width: number;
104
+ height: number;
105
+ }, ctm: {
106
+ a: number;
107
+ b: number;
108
+ c: number;
109
+ d: number;
110
+ e: number;
111
+ f: number;
112
+ }, offset: readonly [number, number]): cmath.Rectangle | null;
113
+ //#endregion
114
+ export { project_delta_inverse_ctm as a, SnapOptions as c, inverse_project_rect as i, DomSurfaceOptions as n, project_point_through_ctm as o, attach_dom_surface as r, DEFAULT_SNAP_OPTIONS as s, DomSurfaceHandle as t };
@@ -0,0 +1,112 @@
1
+ import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-BKoo9SPL.js";
2
+ import cmath from "@grida/cmath";
3
+ //#region src/core/snap/options.d.ts
4
+ type SnapOptions = {
5
+ /** When false, snap behavior and snap-guide rendering are both off. */enabled: boolean;
6
+ /** Snap threshold in HUD canvas pixels (container CSS px in svg-editor;
7
+ * whatever space the consumer feeds in). Constant across zoom because
8
+ * the input rects are already in screen-equivalent units. */
9
+ threshold_px: number;
10
+ };
11
+ declare const DEFAULT_SNAP_OPTIONS: SnapOptions;
12
+ //#endregion
13
+ //#region src/dom.d.ts
14
+ type DomSurfaceOptions = {
15
+ /** Mount the SVG inside this container. */container: HTMLElement;
16
+ /**
17
+ * Install the default gesture set (wheel-pan/zoom, space-drag, middle-mouse,
18
+ * keyboard zoom). Default `true`. Pass `false` to start blank and bind à la
19
+ * carte via `handle.gestures.bind(...)`.
20
+ */
21
+ gestures?: boolean;
22
+ /**
23
+ * Auto-fit the document into the viewport on initial attach. Default
24
+ * `false`. Mirrors Excalidraw's `initialData.scrollToContent`.
25
+ * Subsequent `editor.load()` calls do NOT re-fit — call
26
+ * `handle.camera.fit("<root>")` yourself if you want that behavior.
27
+ */
28
+ fit?: boolean;
29
+ /**
30
+ * Initial camera transform. Default `cmath.transform.identity`. Ignored
31
+ * when `fit: true`.
32
+ */
33
+ initial_camera?: cmath.Transform;
34
+ };
35
+ /**
36
+ * Surface handle for the DOM surface. Extends the editor's core
37
+ * `SurfaceHandle` with the viewport-scoped concerns: pan/zoom (`camera`)
38
+ * and pointer/wheel/keyboard gesture bindings (`gestures`).
39
+ *
40
+ * Camera + gestures are **surface-scoped**: detaching the surface drops
41
+ * both. They never appear on the headless `SvgEditor`.
42
+ */
43
+ type DomSurfaceHandle = SurfaceHandle & {
44
+ camera: Camera;
45
+ gestures: Gestures;
46
+ };
47
+ /**
48
+ * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
49
+ * whose `detach()` is the inverse — DOM cleared, listeners removed,
50
+ * gestures uninstalled.
51
+ *
52
+ * Usage is one-shot per container: the surface owns the container's children
53
+ * for its lifetime, and `detach()` restores it to empty.
54
+ */
55
+ declare function attach_dom_surface(editor: SvgEditor, options: DomSurfaceOptions): DomSurfaceHandle;
56
+ /**
57
+ * Affine projection of a point through a 2×3 CTM, then offset by a
58
+ * container origin (in page CSS-px). Mirrors how `line_endpoints_in_container`
59
+ * and `shape_of` (transformed branch) bridge from local SVG coords to the
60
+ * HUD's container-CSS-px space (HUD keeps its own transform at identity;
61
+ * the SVG carries the camera as a CSS transform, which getScreenCTM
62
+ * folds in).
63
+ *
64
+ * Exported for headless test coverage — pure function, no DOM types.
65
+ */
66
+ declare function project_point_through_ctm(px: number, py: number, ctm: {
67
+ a: number;
68
+ b: number;
69
+ c: number;
70
+ d: number;
71
+ e: number;
72
+ f: number;
73
+ }, container_offset: readonly [number, number]): [number, number];
74
+ /**
75
+ * Inverse of the CTM's linear part applied to a delta vector. Drops
76
+ * translation. Used to convert a HUD-reported container-space drag delta
77
+ * back to the path's local coord space for `PathModel.translateVertices`.
78
+ *
79
+ * Throws on a degenerate (det = 0) matrix — the caller is expected to
80
+ * have a non-singular CTM for any visible element.
81
+ */
82
+ declare function project_delta_inverse_ctm(dx: number, dy: number, ctm: {
83
+ a: number;
84
+ b: number;
85
+ c: number;
86
+ d: number;
87
+ }): [number, number];
88
+ /**
89
+ * Inverse-project a doc-space rect through a CTM + container offset back
90
+ * into the element's local frame. The output is the AABB of the four
91
+ * inverse-projected corners — when the CTM has a rotation component the
92
+ * AABB is an approximation, but it matches what the user visually
93
+ * expects from a screen-aligned marquee drag.
94
+ *
95
+ * Returns `null` when the CTM's linear part is singular (degenerate
96
+ * camera) — the caller should skip any test that needs the local rect.
97
+ */
98
+ declare function inverse_project_rect(rect: {
99
+ x: number;
100
+ y: number;
101
+ width: number;
102
+ height: number;
103
+ }, ctm: {
104
+ a: number;
105
+ b: number;
106
+ c: number;
107
+ d: number;
108
+ e: number;
109
+ f: number;
110
+ }, offset: readonly [number, number]): cmath.Rectangle | null;
111
+ //#endregion
112
+ export { project_delta_inverse_ctm as a, SnapOptions as c, inverse_project_rect as i, DomSurfaceOptions as n, project_point_through_ctm as o, attach_dom_surface as r, DEFAULT_SNAP_OPTIONS as s, DomSurfaceHandle as t };