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

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.
@@ -0,0 +1,2359 @@
1
+ import { Keybinding, Platform } from "@grida/keybinding";
2
+ import cmath from "@grida/cmath";
3
+ import { AnyNode, AttrToken } from "@grida/svg/parser";
4
+ import vn from "@grida/vn";
5
+ import { SelectMode } from "@grida/hud";
6
+
7
+ //#region src/types.d.ts
8
+ /**
9
+ * Stable identifier for a node in the editor's document model.
10
+ *
11
+ * Independent of any backing representation. Generated when the document is
12
+ * parsed.
13
+ */
14
+ type NodeId = string;
15
+ type Vec2 = {
16
+ x: number;
17
+ y: number;
18
+ };
19
+ type Rect = {
20
+ x: number;
21
+ y: number;
22
+ width: number;
23
+ height: number;
24
+ };
25
+ /**
26
+ * A 2×3 affine transform in SVG `matrix(a b c d e f)` order — the same
27
+ * six-number tuple the SVG `transform="matrix(...)"` function takes.
28
+ *
29
+ * Applied to a point `(x, y)`:
30
+ * x' = a·x + c·y + e
31
+ * y' = b·x + d·y + f
32
+ *
33
+ * This is the wire shape `commands.transform` accepts. Examples:
34
+ * - `[-1, 0, 0, 1, 0, 0]` — horizontal flip (mirror x about the origin)
35
+ * - `[1, 0, 0, -1, 0, 0]` — vertical flip (mirror y about the origin)
36
+ * - `[1, 0, 0, 1, 0, 0]` — identity (no-op)
37
+ *
38
+ * `commands.transform` re-centers this about a pivot, so the bare flip
39
+ * tuples become in-place flips about the selection center.
40
+ */
41
+ type Matrix2D = readonly [a: number, b: number, c: number, d: number, e: number, f: number];
42
+ /**
43
+ * Observe-only outcome of a discrete pointer **tap** on the canvas: the user
44
+ * pressed and released within the drag threshold, without dragging. Delivered
45
+ * through {@link SvgEditor.subscribe_pick} — a transient event, never part of
46
+ * `EditorState` (it would be stale on the next snapshot).
47
+ *
48
+ * A pick is deliberately **separate from selection**. Selection answers "what
49
+ * do commands target"; a pick answers "what did the user just click, and
50
+ * where". A primary tap on a node both selects it and emits a pick; a tap on
51
+ * empty canvas emits a pick with `node_id: null` (distinguishable from "nothing
52
+ * is selected"); a secondary (right-button) tap emits a pick and does NOT
53
+ * change selection. This is what a click-driven host tool (annotation, context
54
+ * menu, custom selection) needs and selection alone cannot express.
55
+ *
56
+ * Observe-only: a pick reports a click that already happened. It cannot
57
+ * prevent or replace the editor's own selection handling.
58
+ *
59
+ * @unstable Shape is provisional until ≥2 consumers exercise it. Fields may
60
+ * change without a semver bump until then.
61
+ */
62
+ type PickEvent = {
63
+ /** Document-space point the tap resolved against (the pointer-DOWN point). */point: Vec2; /** Topmost node under `point`, or `null` for empty canvas / background. */
64
+ node_id: NodeId | null; /** Which button produced the tap. `"middle"` is pan and never taps. */
65
+ button: "primary" | "secondary"; /** Modifier snapshot at press time. */
66
+ mods: {
67
+ shift: boolean;
68
+ alt: boolean;
69
+ meta: boolean;
70
+ ctrl: boolean;
71
+ };
72
+ };
73
+ type Mode = "select" | "edit-content";
74
+ /**
75
+ * SVG element tags inserted by the **drag-to-size** subsystem. Closed set;
76
+ * adding a new insertable tag requires a PR.
77
+ *
78
+ * `text` is intentionally NOT here. It is creatable (the `insert-text`
79
+ * tool — see {@link Tool}), but via a **click-only** gesture, not
80
+ * drag-to-size: `<text>` has no intrinsic size, so there is nothing for a
81
+ * drag to set. Its creation path mounts the inline content-editor
82
+ * immediately. Design: `docs/wg/feat-svg-editor/text-tool.md`.
83
+ */
84
+ type InsertableTag = "rect" | "ellipse" | "line";
85
+ /**
86
+ * Active tool — orthogonal to `Mode`. `Mode` is what the editor is doing
87
+ * (interacting normally vs. inline text edit); `Tool` is what pointer-down
88
+ * does while in `select` mode.
89
+ *
90
+ * - `cursor` (default): pointer-down selects / starts marquee / drags
91
+ * selection (existing HUD-driven behavior).
92
+ * - `insert`: pointer-down opens an insertion-preview gesture for the
93
+ * given tag. Click-no-drag inserts a default-sized node; drag sizes
94
+ * the new node. Tool reverts to `cursor` after commit.
95
+ */
96
+ type Tool = {
97
+ type: "cursor";
98
+ } | {
99
+ type: "insert";
100
+ tag: InsertableTag;
101
+ }
102
+ /**
103
+ * Text creation tool. A select-mode tool like `insert`, but **click-only**
104
+ * rather than drag-to-size: pointer-down creates a single-line `<text>` at
105
+ * the click point with default appearance and immediately enters
106
+ * content-edit (caret active). `<text>` has no intrinsic size, so the
107
+ * drag-to-size model doesn't apply; a drag box would mean SVG 2 wrapped
108
+ * text, which is a separate (out-of-scope) model. Reverts to `cursor`
109
+ * after the node is placed. Design:
110
+ * `docs/wg/feat-svg-editor/text-tool.md`.
111
+ */
112
+ | {
113
+ type: "insert-text";
114
+ }
115
+ /**
116
+ * Vector content-edit lasso (Q). Empty-space drag draws a freeform
117
+ * polygon that picks vertices + tangents inside it (segments are NOT
118
+ * tested — matches the main editor decision; see `@grida/hud`
119
+ * `VectorSelectionMode`). Valid only while `state.mode === "edit-content"`
120
+ * on a path; tool reverts to cursor on path-content-edit exit.
121
+ */
122
+ | {
123
+ type: "lasso";
124
+ }
125
+ /**
126
+ * Vector content-edit bend. Sticky version of holding Meta — every
127
+ * segment-body drag bends instead of translating, regardless of
128
+ * Meta state. See `@grida/hud` `VectorBendMode`. Valid only while
129
+ * `state.mode === "edit-content"` on a path; tool reverts to cursor
130
+ * on path-content-edit exit.
131
+ */
132
+ | {
133
+ type: "bend";
134
+ };
135
+ declare const TOOL_CURSOR: Tool;
136
+ type Provenance = {
137
+ /** CSS cascade origin (css-cascade-5 §6.2). */origin: "author" | "user_agent"; /** Editor metadata — where in the file the winning declaration lives. */
138
+ carrier: "presentation_attribute" | "inline_style" | "stylesheet" | "inherited" | "defaulted";
139
+ };
140
+ type InvalidComputedValue = {
141
+ error: "invalid_at_computed_value_time";
142
+ reason: string;
143
+ };
144
+ type PropertyValue<T = string | number> = {
145
+ declared: string | null;
146
+ computed: T | InvalidComputedValue | null;
147
+ provenance: Provenance;
148
+ };
149
+ /**
150
+ * Computed-time color. `current_color` stays a keyword (CSS Color 4 §4.4)
151
+ * — its rgb resolution happens at *used* value, which needs the surface's
152
+ * painting context.
153
+ *
154
+ * For `rgb`, `value` is canonical lowercase hex — `#rrggbb`, or
155
+ * `#rrggbbaa` when alpha < 1 — whenever the literal is resolvable without
156
+ * a rendering context (named colors, hex, `rgb()`, `hsl()`, `hwb()`).
157
+ * Literals the editor does not resolve (`lab()` / `oklch()` / `color()` —
158
+ * gamut mapping out of scope) pass through as authored. The authored
159
+ * string is always available on the `declared` channel.
160
+ */
161
+ type Color = {
162
+ kind: "rgb";
163
+ value: string;
164
+ } | {
165
+ kind: "current_color";
166
+ };
167
+ type PaintFallback = {
168
+ kind: "none";
169
+ } | {
170
+ kind: "color";
171
+ value: Color;
172
+ };
173
+ type Paint = {
174
+ kind: "none";
175
+ } | {
176
+ kind: "color";
177
+ value: Color;
178
+ } | {
179
+ kind: "ref";
180
+ id: string;
181
+ fallback?: PaintFallback;
182
+ } | {
183
+ kind: "context_fill";
184
+ } | {
185
+ kind: "context_stroke";
186
+ };
187
+ type PaintValue = {
188
+ declared: string | null;
189
+ computed: Paint | InvalidComputedValue | null;
190
+ provenance: Provenance;
191
+ };
192
+ type GradientStop = {
193
+ offset: number;
194
+ color: string;
195
+ opacity?: number;
196
+ };
197
+ type LinearGradientDefinition = {
198
+ kind: "linear";
199
+ stops: GradientStop[];
200
+ x1?: number;
201
+ y1?: number;
202
+ x2?: number;
203
+ y2?: number;
204
+ gradient_units?: "user_space_on_use" | "object_bounding_box";
205
+ spread_method?: "pad" | "reflect" | "repeat";
206
+ };
207
+ type RadialGradientDefinition = {
208
+ kind: "radial";
209
+ stops: GradientStop[];
210
+ cx?: number;
211
+ cy?: number;
212
+ r?: number;
213
+ fx?: number;
214
+ fy?: number;
215
+ gradient_units?: "user_space_on_use" | "object_bounding_box";
216
+ spread_method?: "pad" | "reflect" | "repeat";
217
+ };
218
+ type GradientDefinition = LinearGradientDefinition | RadialGradientDefinition;
219
+ type GradientEntry = {
220
+ id: string;
221
+ definition: GradientDefinition;
222
+ ref_count: number;
223
+ };
224
+ type EditorStyle = {
225
+ chrome_color: string;
226
+ handle_size: number;
227
+ handle_fill: string;
228
+ handle_stroke: string;
229
+ endpoint_dot_radius: number;
230
+ selection_outline_width: number;
231
+ /**
232
+ * Color for measurement guides (distance lines + numeric pills). Distinct
233
+ * from `chrome_color` so the user can tell at a glance whether something
234
+ * is selection chrome or a measurement readout.
235
+ */
236
+ measurement_color: string; /** `W × H` pill under each selected node, in `chrome_color`. */
237
+ show_size_meter: boolean;
238
+ /** Snap to neighbor edges, centers, and equidistant spacing during
239
+ * translate. Both behavior and guides are off when `false`. */
240
+ snap_enabled: boolean;
241
+ /** Snap activation distance in HUD container pixels. Touch UIs may
242
+ * prefer larger (~10–12); precision UIs smaller (~3–4). */
243
+ snap_threshold_px: number;
244
+ /** Hit-test tolerance in screen CSS pixels. The picker selects a node
245
+ * whose rendered geometry is within this many pixels of the pointer,
246
+ * making thin elements (1-px lines, hairline strokes) selectable
247
+ * without pixel-perfect aiming. Tolerance is screen-space, not
248
+ * world-space — the band stays the same width on screen regardless
249
+ * of zoom. `0` disables fat-hit and falls back to elementFromPoint
250
+ * exact-pixel selection. */
251
+ hit_tolerance_px: number;
252
+ /** Snap-to-pixel-grid quantization for translate. When `true`, every
253
+ * translate (drag, nudge, RPC) snaps the agent-union origin to integer
254
+ * multiples of `pixel_grid_size` as the final pipeline stage — composes
255
+ * on top of snap-to-geometry, which still emits guides as usual. Default
256
+ * `false` for SVG-fidelity: SVG paths are unitless and frequently
257
+ * fractional; forcing integers would corrupt authored geometry.
258
+ *
259
+ * Naming: this is the *action* flag (snap to the pixel grid). The
260
+ * "pixel grid" itself — a visual integer-pixel overlay — is a separate
261
+ * feature (see `@grida/canvas-pixelgrid`) and is unrelated. */
262
+ snap_to_pixel_grid: boolean;
263
+ /** Quantum size in HUD container pixels (`1` = integer grid). Ignored
264
+ * when `snap_to_pixel_grid` is false. */
265
+ pixel_grid_size: number;
266
+ /** Show the visual pixel-grid overlay on the HUD. Independent of
267
+ * `snap_to_pixel_grid` — this is the *display* flag, that one is the
268
+ * *action* flag. The grid is zoom-gated; it only paints at high zoom
269
+ * (see `@grida/hud` `setPixelGrid` for the threshold). Default `true`. */
270
+ pixel_grid: boolean;
271
+ /** Shift-drag snap step for rotation, in radians. Default π/12 (15°).
272
+ * When `null` or `<= 0`, shift-rotate is free (no quantization). */
273
+ angle_snap_step_radians: number | null;
274
+ };
275
+ declare const DEFAULT_STYLE: EditorStyle;
276
+ type ClipboardProvider = {
277
+ read(): Promise<string | null>;
278
+ write(text: string): Promise<void>;
279
+ };
280
+ type FontResolver = {
281
+ resolve(family: string): Promise<{
282
+ available: boolean;
283
+ metrics?: {
284
+ ascent: number;
285
+ descent: number;
286
+ unitsPerEm: number;
287
+ };
288
+ }>;
289
+ };
290
+ type FileIOProvider = {
291
+ openSvg(): Promise<string | null>;
292
+ saveSvg(svg: string, suggestedName?: string): Promise<void>;
293
+ };
294
+ type Providers = {
295
+ clipboard?: ClipboardProvider;
296
+ fonts?: FontResolver;
297
+ file_io?: FileIOProvider;
298
+ };
299
+ type EditorState = {
300
+ readonly selection: ReadonlyArray<NodeId>;
301
+ readonly scope: NodeId | null;
302
+ readonly mode: Mode;
303
+ /**
304
+ * Active tool — orthogonal to `mode`. Default `{ type: "cursor" }`. While
305
+ * in `select` mode + `insert` tool, pointer-down on the surface starts
306
+ * an insertion gesture for the configured tag instead of selection.
307
+ * Switched via `editor.set_tool(...)` or the bundled `tool.set` keymap
308
+ * (V/R/O/L). Bumps `state.version` only.
309
+ */
310
+ readonly tool: Tool;
311
+ readonly dirty: boolean;
312
+ readonly can_undo: boolean;
313
+ readonly can_redo: boolean;
314
+ /**
315
+ * Bumps on every editor emission. Use this when you need to react to
316
+ * any change — selection, history, mutation. NOT a good cache key for
317
+ * tree-shape views because it fires on attribute writes too (e.g. x/y
318
+ * during a drag).
319
+ *
320
+ * NOT a content fingerprint either: this bumps on UI-state emissions
321
+ * (selection, scope, mode, tool) that leave the serialized SVG
322
+ * unchanged. For "did the document content change?" use
323
+ * {@link content_version}.
324
+ */
325
+ readonly version: number;
326
+ /**
327
+ * Bumps on every document mutation — insert, remove, reorder, attribute
328
+ * write, style write, undo, redo, load. Stable across pure UI-state
329
+ * emissions (selection, scope, mode, tool).
330
+ *
331
+ * The honest fingerprint for serialized content: if `content_version`
332
+ * is unchanged, `editor.serialize()` returns the same bytes. Use this
333
+ * — not `version` — as the freshness token when persisting, diffing,
334
+ * or hashing the document.
335
+ */
336
+ readonly content_version: number;
337
+ /**
338
+ * Bumps only when the document's tree shape or display-label-affecting
339
+ * data changes — node added/removed/reordered, text content, or the
340
+ * `id` attribute. Stable across pure presentation-attribute writes.
341
+ *
342
+ * The right cache key for hierarchy / layers panels: snapshot once per
343
+ * `structure_version` so a drag doesn't invalidate the tree view.
344
+ */
345
+ readonly structure_version: number;
346
+ /**
347
+ * Bumps when any change occurs that could shift a node's world-space
348
+ * bounds — geometry-affecting attribute writes (x, y, d, transform,
349
+ * font-size, …), text content, or structure (insert/remove). Stable
350
+ * across pure presentation writes (fill, stroke-color, opacity).
351
+ *
352
+ * Cache key for `GeometryProvider` — bounds caches snapshot on this
353
+ * so a fill-color change doesn't invalidate them.
354
+ */
355
+ readonly geometry_version: number;
356
+ /**
357
+ * Bumps once per `editor.load(svg)` call. Distinct from
358
+ * `structure_version` (which bumps on edits too). Starts at 0; the
359
+ * constructor's initial SVG does NOT count as a load. Use this when
360
+ * you want to react to "a new document was loaded" — e.g. refit
361
+ * camera to the new root, reset host-side UI state, clear per-file
362
+ * scratch — without firing on text edits, reorders, or deletes.
363
+ *
364
+ * Monotonic, never resets.
365
+ */
366
+ readonly load_version: number;
367
+ };
368
+ type Unsubscribe = () => void;
369
+ type ReorderDirection = "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back";
370
+ /**
371
+ * Continuous-gesture write session returned by
372
+ * `commands.preview_property(name)`: many `update()` calls during a drag,
373
+ * one `commit()` (→ single history step) or `discard()` (→ no step).
374
+ *
375
+ * Lifecycle invariant: **the session ends as soon as its result can no
376
+ * longer become the document's next state** — and after it ends, for any
377
+ * reason, every method is a no-op (never a throw) and `live` is `false`.
378
+ * The ending events:
379
+ *
380
+ * - `commit()` / `discard()` — the host closes the gesture;
381
+ * - a discrete write to the same property (`set_property(name, …)`, and
382
+ * for paint channels `set_paint` / `set_paint_from_gradient`, which
383
+ * write through it) — the discrete write is the user's final intent
384
+ * and a later `commit()` must not replay the stale previewed value;
385
+ * - opening a second session on the same name (same supersession rule);
386
+ * - an operation that detaches the session's target nodes (`remove` /
387
+ * `cut`, `ungroup`) — sessions on EVERY name end, since the deltas
388
+ * would target detached nodes;
389
+ * - a document swap (`load` / `reset`) — every NodeId dies wholesale;
390
+ * - `undo` / `redo` — history discards all in-flight previews.
391
+ *
392
+ * A defensive `discard()` before a discrete write is valid but not
393
+ * required. Sessions on OTHER property names are untouched by discrete
394
+ * writes. Hosts that cache a session across renders should consult
395
+ * `live` and lazily reopen — the bundled React hooks do this.
396
+ *
397
+ * Known exclusion: the discrete GEOMETRY commands (`translate` / `nudge`,
398
+ * `resize*`, `rotate*`, `align`, `transform`, `flatten_transform`) write
399
+ * `x` / `y` / `width` / `height` / `transform` through their own
400
+ * pipelines and do NOT yet supersede a same-name property session —
401
+ * avoid mixing `preview_property` on geometry attributes with those
402
+ * commands until that lands.
403
+ */
404
+ type PreviewSession = {
405
+ /** `true` while the session can still affect the document; `false`
406
+ * once it has ended for any of the reasons above. */
407
+ readonly live: boolean;
408
+ update(value: string): void;
409
+ commit(): void;
410
+ discard(): void;
411
+ };
412
+ /** `PreviewSession` over typed `Paint` values, from
413
+ * `commands.preview_paint(channel)`. Same lifecycle and supersession
414
+ * contract — the channel ("fill" / "stroke") is the property name. */
415
+ type PaintPreviewSession = {
416
+ readonly live: boolean;
417
+ update(paint: Paint): void;
418
+ commit(): void;
419
+ discard(): void;
420
+ };
421
+ /**
422
+ * Preview-bracketed insertion gesture. Returned by
423
+ * `editor.commands.insert_preview(...)`. The pending node is created and
424
+ * inserted immediately (so the HUD selection chrome renders); per-frame
425
+ * geometry writes call `update(attrs)`; `commit()` collapses the gesture
426
+ * into a single undo step; `discard()` rolls back as if the gesture never
427
+ * happened.
428
+ */
429
+ type InsertPreviewSession = {
430
+ /** The live node, addressable during drag. */readonly id: NodeId;
431
+ update(attrs: Readonly<Record<string, string>>): void;
432
+ commit(): void;
433
+ discard(): void;
434
+ };
435
+ //#endregion
436
+ //#region src/core/camera.d.ts
437
+ /**
438
+ * Returns world-space bounds for the given target, or `null` when
439
+ * unresolvable (e.g. empty selection, unknown node id). Implemented by the
440
+ * surface — the camera itself has no view into the document.
441
+ *
442
+ * Only string targets are passed to the resolver — `Rect` targets are
443
+ * handled by the camera as identity (the rect IS its own bounds).
444
+ */
445
+ type BoundsResolver = (target: "<root>" | "<selection>" | NodeId) => Rect | null;
446
+ type CameraOptions = {
447
+ resolve_bounds: BoundsResolver;
448
+ initial?: cmath.Transform;
449
+ };
450
+ /**
451
+ * Camera viewport constraint. Discriminated union with `type` so future
452
+ * variants (`'contain'`, `'pan-region'`) can be added without breaking
453
+ * existing call sites — each future variant has its own payload shape.
454
+ *
455
+ * v1.1 ships only `'cover'`. CSS analogy: `object-fit: cover` — the
456
+ * bounds rect covers the viewport edge-to-edge. Zoom is lower-bounded
457
+ * at fit-with-padding; pan is clamped so the bounds always covers the
458
+ * viewport. Use for slide / page / kiosk UX where the user should
459
+ * never see past the artwork.
460
+ */
461
+ type CameraConstraints = {
462
+ /** Bounds cover viewport (viewport ⊆ bounds). Keynote / slide UX. */type: "cover"; /** World-space rect, or `"<root>"` to resolve via BoundsResolver. */
463
+ bounds: Rect | "<root>"; /** Screen-pixel breathing room between bounds and viewport edge. */
464
+ padding?: number;
465
+ /**
466
+ * Screen-pixel scroll slack past the bounds edge when zoomed in past fit.
467
+ * Applies only on axes where the bounds strictly exceed the viewport
468
+ * (`sw > vp_w` / `sh > vp_h`); the centered branch is unchanged, so a
469
+ * fitted axis stays locked at center. Hard clamp — no elasticity, no
470
+ * bounce-back. Default 0 (strict cover behavior). Negative values are
471
+ * clamped to 0. Values approaching or exceeding the bounds extent are
472
+ * permitted but produce visually degenerate behavior; the constraint
473
+ * makes no attempt to cap.
474
+ */
475
+ pan_overshoot?: number;
476
+ };
477
+ /**
478
+ * Surface-scoped pan/zoom state.
479
+ *
480
+ * The public shape leads with the peer convention (`center` / `zoom` /
481
+ * `bounds`) and keeps the matrix as an advanced read. Methods mirror
482
+ * Figma/Penpot where they overlap.
483
+ */
484
+ declare class Camera {
485
+ private _transform;
486
+ private viewport_w;
487
+ private viewport_h;
488
+ private listeners;
489
+ private resolve_bounds;
490
+ private _constraints;
491
+ constructor(opts: CameraOptions);
492
+ /**
493
+ * Current viewport constraint, or `null` for free pan/zoom. Set with
494
+ * `camera.constraints = { type: 'cover', bounds: '<root>', padding: 80 }`
495
+ * to clamp zoom + pan; assign `null` to clear.
496
+ *
497
+ * Constraints are applied synchronously inside `set_transform` (and
498
+ * `_set_viewport_size`), so every public mutation respects them
499
+ * automatically — the host never needs to subscribe-and-clamp itself.
500
+ */
501
+ get constraints(): CameraConstraints | null;
502
+ set constraints(c: CameraConstraints | null);
503
+ /** Underlying 2D affine. World→screen. */
504
+ get transform(): cmath.Transform;
505
+ /** Uniform scale factor. 1 = 100 %. */
506
+ get zoom(): number;
507
+ /** World-space point currently at viewport center. */
508
+ get center(): Vec2;
509
+ /** World-space rectangle visible in the viewport. */
510
+ get bounds(): Rect;
511
+ /** Translate the camera by a screen-space delta. */
512
+ pan(delta_screen: Vec2): void;
513
+ /**
514
+ * Multiply zoom by `factor` keeping `origin_screen` fixed in world space.
515
+ * Used by wheel-zoom-at-cursor and pinch-zoom.
516
+ */
517
+ zoom_at(factor: number, origin_screen: Vec2): void;
518
+ /** Pan so `c` lands at the viewport center. Zoom unchanged. */
519
+ set_center(c: Vec2): void;
520
+ /** Set zoom directly; pivot defaults to viewport center. */
521
+ set_zoom(z: number, pivot_screen?: Vec2): void;
522
+ /**
523
+ * Replace the entire transform.
524
+ *
525
+ * Idempotent: when the new transform is element-wise equal to the current
526
+ * one, this is a no-op (no notification fires). This is the seam that
527
+ * makes external constraint loops (e.g. "subscribe → compute clamped →
528
+ * set_transform") terminate: the clamp re-emits the same transform on
529
+ * the second pass, set_transform short-circuits, no recursion.
530
+ *
531
+ * When `camera.constraints` is non-null, the input transform is clamped
532
+ * synchronously before being stored — every public mutation respects the
533
+ * constraint automatically.
534
+ */
535
+ set_transform(t: cmath.Transform): void;
536
+ /** Viewport size in screen pixels. Read by host code computing constraints. */
537
+ get viewport_size(): {
538
+ width: number;
539
+ height: number;
540
+ };
541
+ /**
542
+ * Fit a target into the viewport.
543
+ *
544
+ * - `"<root>"` — the document root's content bounds (host-resolved).
545
+ * - `"<selection>"` — current editor.state.selection's union bounds.
546
+ * - `NodeId` — that node's content bounds.
547
+ * - `Rect` — an explicit world-space rectangle.
548
+ *
549
+ * No-ops if the target resolves to `null` (e.g. empty selection) or if
550
+ * the viewport size is 0 (no container).
551
+ */
552
+ fit(target: "<root>" | "<selection>" | NodeId | Rect, opts?: {
553
+ margin?: number;
554
+ }): void;
555
+ /** Snap back to identity. */
556
+ reset(): void;
557
+ /**
558
+ * Subscribe to camera changes. Fires on every mutation. Cheap channel —
559
+ * does NOT bump `editor.state.version`. Same pattern as
560
+ * `editor.subscribe_surface_hover`.
561
+ */
562
+ subscribe(cb: () => void): Unsubscribe;
563
+ /** @internal Surface drives this on container resize. */
564
+ _set_viewport_size(w: number, h: number): void;
565
+ /** Convert a screen-space point to world-space. */
566
+ screen_to_world(p: Vec2): Vec2;
567
+ /** Convert a world-space point to screen-space. */
568
+ world_to_screen(p: Vec2): Vec2;
569
+ /**
570
+ * Apply the current constraint (if any) to a candidate transform.
571
+ * Pure: returns the clamped result, never mutates state. Returns the
572
+ * input unchanged when constraints are null / bounds are unresolvable /
573
+ * viewport is 0.
574
+ */
575
+ private apply_constraints;
576
+ /**
577
+ * Re-clamp the stored transform against the current constraint. Called
578
+ * from the `constraints` setter; `_set_viewport_size` has its own
579
+ * notify-inclusive path.
580
+ */
581
+ private reenforce;
582
+ private notify;
583
+ }
584
+ //#endregion
585
+ //#region src/core/align.d.ts
586
+ type AlignDirection = "left" | "right" | "top" | "bottom" | "horizontal_centers" | "vertical_centers";
587
+ //#endregion
588
+ //#region src/core/document.d.ts
589
+ /**
590
+ * What `is_vector_edit_target` returns when a node is eligible for
591
+ * vector (vertex) editing — a tag-discriminated snapshot of the authored
592
+ * geometry attributes at enter time.
593
+ *
594
+ * Consumed by `VectorEditSession` (which holds it as `source_attrs`) and by
595
+ * `PathModel` (whose `toNativeAttrs(source_tag)` decides on each commit
596
+ * whether the post-edit form is still expressible in the source tag, or
597
+ * whether the element must promote to `<path d="…">`).
598
+ *
599
+ * Geometry conventions:
600
+ * - All coordinates are in the element's own local space, exactly as
601
+ * authored. No `transform=` resolution, no parent CTM, no viewport
602
+ * remap.
603
+ * - `line` carries its two endpoints; `polyline` / `polygon` points are
604
+ * `[x, y]` tuples so the consumer can hand them straight to
605
+ * `vn.fromPolyline` / `vn.fromPolygon`.
606
+ * - `rect` / `circle` / `ellipse` carry their native geometry numbers.
607
+ * These geometry primitives have no addressable interior vertices in
608
+ * their native form, so editing one as vector geometry re-types the
609
+ * element to `<path>` (see `retype_to_path`). The document holds the
610
+ * native tag until that re-type is committed. Design:
611
+ * `docs/wg/feat-svg-editor/promote-to-path.md`.
612
+ *
613
+ * Re-type vs. native writeback is decided per edit, not per tag: an edit
614
+ * that the source tag can still express (a straight vertex move on
615
+ * `line` / `polyline` / `polygon`) writes back natively; one it cannot (a
616
+ * curve, or a topology change that leaves the tag's canonical form)
617
+ * re-types the element to `<path>`.
618
+ */
619
+ type VectorEditSource = {
620
+ kind: "path";
621
+ d: string;
622
+ } | {
623
+ kind: "line";
624
+ x1: number;
625
+ y1: number;
626
+ x2: number;
627
+ y2: number;
628
+ } | {
629
+ kind: "polyline";
630
+ points: ReadonlyArray<readonly [number, number]>;
631
+ } | {
632
+ kind: "polygon";
633
+ points: ReadonlyArray<readonly [number, number]>;
634
+ } | {
635
+ kind: "rect";
636
+ x: number;
637
+ y: number;
638
+ width: number;
639
+ height: number; /** Corner radii; `0` when the rect has square corners. */
640
+ rx: number;
641
+ ry: number;
642
+ } | {
643
+ kind: "circle";
644
+ cx: number;
645
+ cy: number;
646
+ r: number;
647
+ } | {
648
+ kind: "ellipse";
649
+ cx: number;
650
+ cy: number;
651
+ rx: number;
652
+ ry: number;
653
+ };
654
+ /**
655
+ * Opaque reversal token returned by `retype_to_path`. Callers hold it and
656
+ * hand it back to `revert_retype` to restore the original primitive
657
+ * byte-for-byte; they do not inspect it. All trivia / attribute-token
658
+ * knowledge stays inside `SvgDocument`.
659
+ */
660
+ type RetypeRecord = {
661
+ readonly prev_local: string;
662
+ readonly prev_raw_tag: string;
663
+ /** Geometry attribute tokens removed on re-type, with their original
664
+ * index in the element's `attrs` array. Ascending by index so they can
665
+ * be spliced back in order. Typed as the document's internal attr token. */
666
+ readonly removed: ReadonlyArray<{
667
+ index: number;
668
+ token: AttrToken;
669
+ }>;
670
+ /** True iff the re-type added a synthetic `fill="none"` (the `<line>`
671
+ * fidelity guard — see `retype_to_path`). `revert_retype` removes it. */
672
+ readonly added_fill_none?: boolean;
673
+ };
674
+ interface DocumentEvents {
675
+ /** Fires after any structural mutation. */
676
+ on_change(fn: () => void): () => void;
677
+ }
678
+ declare class SvgDocument implements DocumentEvents {
679
+ private nodes;
680
+ private prolog;
681
+ private epilog;
682
+ /** Snapshot of the input string, used for `reset()`. */
683
+ private source;
684
+ /** Original parse result, for `reset()`. */
685
+ private original;
686
+ readonly root: NodeId;
687
+ private listeners;
688
+ /**
689
+ * Counter that bumps ONLY when something the hierarchy view cares about
690
+ * changes — tree topology (`insert`/`remove`), text-node content
691
+ * (`set_text`), or the `id` attribute (which feeds display labels). Pure
692
+ * presentation-attribute writes (x, y, fill, …) do NOT bump it.
693
+ *
694
+ * Why a separate counter: consumers like the layers panel cache snapshots
695
+ * keyed on this. During a drag, x/y writes fire `emit()` repeatedly but
696
+ * `structure_version` stays stable, so the panel's snapshot reference
697
+ * stays the same and React skips the re-render of the whole tree.
698
+ */
699
+ private _structure_version;
700
+ /** Total listener-visible mutation count. See the `revision` getter. */
701
+ private _revision;
702
+ /** Bumps on writes that can shift world-space bounds (`GEOMETRY_ATTRS`,
703
+ * `set_text`, `insert`, `remove`). Cache key for `GeometryProvider`;
704
+ * see ../../docs/geometry.md. */
705
+ private _geometry_version;
706
+ constructor(svg: string);
707
+ static parse(svg: string): SvgDocument;
708
+ /** Reload from the original parse, discarding all edits. */
709
+ reset_to_original(): void;
710
+ /** Replace document with new svg source (clears edits + history-owned state). */
711
+ load(svg: string): void;
712
+ on_change(fn: () => void): () => void;
713
+ /** See `_structure_version` for what this counter signals. */
714
+ get structure_version(): number;
715
+ /**
716
+ * Total mutation counter — advances on EVERY listener-visible mutation
717
+ * (attribute, style, text, topology, load/reset), unlike the selective
718
+ * `structure_version` / `geometry_version` channels. The single
719
+ * edit-version source: anything derived from this document — the
720
+ * editor's `content_version` / `dirty`, memoized reads, a rendered
721
+ * projection — answers "am I current?" by comparing values, with no
722
+ * event-ordering dependence. Advances BEFORE listeners fire, so a
723
+ * read issued from inside a change listener already sees the new
724
+ * value.
725
+ */
726
+ get revision(): number;
727
+ /** See `_geometry_version` for what this counter signals. */
728
+ get geometry_version(): number;
729
+ /**
730
+ * Advance `_geometry_version` by exactly 1 WITHOUT touching the tree,
731
+ * any attribute, `structure_version`, or the `on_change` listeners.
732
+ *
733
+ * The one geometry mutation with no attribute write: a `<text>` /
734
+ * `<tspan>` reflow the IR cannot see — a web font finishing load AFTER
735
+ * the `font-family` / `font-size` write was already serialized. The DOM
736
+ * surface observes the reflow (`document.fonts` `loadingdone`) and asks
737
+ * the geometry channel to advance so the bounds cache re-reads the
738
+ * settled glyph metrics. See ../../docs/geometry.md §Limitations.
739
+ *
740
+ * Deliberately does NOT call `emit()`: this is not a document edit, so
741
+ * `revision` must not advance — no dirty flag, no undo, no render
742
+ * flush. The editor's `_internal.bump_geometry` advances
743
+ * `geometry_version` here and fans out the geometry listeners itself.
744
+ */
745
+ bump_geometry(): void;
746
+ private emit;
747
+ /** Notify subscribers — for callers that mutate directly via setAttr/etc. */
748
+ notify(): void;
749
+ get(id: NodeId): AnyNode | null;
750
+ is_element(id: NodeId): boolean;
751
+ parent_of(id: NodeId): NodeId | null;
752
+ children_of(id: NodeId): readonly NodeId[];
753
+ /** Element children only — text/comment/cdata filtered out. */
754
+ element_children_of(id: NodeId): readonly NodeId[];
755
+ next_sibling_of(id: NodeId): NodeId | null;
756
+ next_element_sibling_of(id: NodeId): NodeId | null;
757
+ tag_of(id: NodeId): string;
758
+ contains(ancestor: NodeId, descendant: NodeId): boolean;
759
+ /**
760
+ * Filter a selection down to its **subtree roots** — drop any id whose
761
+ * ancestor is also in the input set.
762
+ *
763
+ * Mirrors `pruneNestedNodes` in the main canvas editor's query module
764
+ * ([editor/grida-canvas/query/index.ts:138](../../../../editor/grida-canvas/query/index.ts)) and shares its UX motivation:
765
+ * when a parent and a descendant are both selected, only the parent
766
+ * should drive multi-node mutations — otherwise the descendant
767
+ * accumulates the transform twice (once via the parent's `transform`,
768
+ * once via its own attribute write). Required for `commands.remove`
769
+ * (avoids re-attaching detached descendants on undo) and any multi-
770
+ * member translate path (avoids 2× drift for the Bar-chart marquee
771
+ * case).
772
+ *
773
+ * Order: preserves the input order for retained ids. Duplicates in
774
+ * the input are not deduplicated — callers are responsible (the
775
+ * editor's `commands.select` already dedupes).
776
+ *
777
+ * Performance: `O(n × depth)`. Builds a `Set` over the input once,
778
+ * then walks each id's ancestor chain at most once. The main editor's
779
+ * version is `O(n² × depth)` (per-pair `isAncestor`) — fine at typical
780
+ * selection sizes (a few dozen), worth winning here for free since
781
+ * `parent_of` is `O(1)` on our parent-map.
782
+ */
783
+ prune_nested_nodes(ids: ReadonlyArray<NodeId>): NodeId[];
784
+ all_nodes(): readonly NodeId[];
785
+ all_elements(): readonly NodeId[];
786
+ find_by_tag(ancestor: NodeId, tag: string): readonly NodeId[];
787
+ /** Read attribute by local name, optionally namespace-filtered. */
788
+ get_attr(id: NodeId, name: string, ns?: string | null): string | null;
789
+ /**
790
+ * Set / remove an attribute. If the attribute exists, it is mutated in place
791
+ * (preserving source position). If it doesn't, it's appended.
792
+ */
793
+ set_attr(id: NodeId, name: string, value: string | null, ns?: string | null): void;
794
+ attributes_of(id: NodeId): {
795
+ name: string;
796
+ ns: string | null;
797
+ value: string;
798
+ }[];
799
+ get_style(id: NodeId, property: string): string | null;
800
+ set_style(id: NodeId, property: string, value: string | null): void;
801
+ get_all_styles(id: NodeId): Array<{
802
+ property: string;
803
+ value: string;
804
+ }>;
805
+ /**
806
+ * Whether `id` can be opened in the flat-string text editor.
807
+ *
808
+ * v1 contract: the editor only operates on a *single flat text run*. That
809
+ * means the target must be a `<text>` or `<tspan>` whose direct children
810
+ * are all text nodes (or it has no children). A `<text>` containing a
811
+ * `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
812
+ * content from the editor's view, and a flat-text write would leave the
813
+ * tspan dangling. Tspan-as-target is fine and well-defined when it's a
814
+ * leaf; only the host decides whether to route double-click to a tspan
815
+ * or its parent text.
816
+ */
817
+ is_text_edit_target(id: NodeId): boolean;
818
+ /**
819
+ * Returns a tag-discriminated snapshot of the authored geometry attrs
820
+ * if this node is eligible for vector (vertex) editing — else `null`.
821
+ *
822
+ * Eligibility:
823
+ * - `<path>` — requires non-empty `d`.
824
+ * - `<line>` — requires two distinct finite user-unit endpoints.
825
+ * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
826
+ * - `<polygon>` — same as polyline.
827
+ * - `<rect>` — requires finite user-unit `width`/`height` > 0.
828
+ * - `<circle>` — requires finite user-unit `r` > 0.
829
+ * - `<ellipse>` — requires finite user-unit `rx`/`ry` > 0.
830
+ *
831
+ * The vertex tags (`line` / `polyline` / `polygon`) write edits back to
832
+ * their native attributes while the geometry stays expressible there; an
833
+ * edit that escapes the native form (a curve, or a topology change that
834
+ * leaves the canonical chain) re-types the element to `<path>`. The
835
+ * geometry primitives (`rect` / `circle` / `ellipse`) have no native
836
+ * vector form, so any vector edit re-types them. In all cases the native
837
+ * tag is preserved byte-for-byte until the first re-typing edit commits
838
+ * (see `retype_to_path`). Design:
839
+ * `docs/wg/feat-svg-editor/promote-to-path.md`.
840
+ *
841
+ * Geometry that is not a plain user-unit number (`%`, `px`, `em`, …) is
842
+ * an out-of-scope gap, so such an element returns `null` rather than
843
+ * advertising an edit the editor cannot perform faithfully.
844
+ *
845
+ * Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
846
+ * editable outline).
847
+ */
848
+ /**
849
+ * Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
850
+ * endpoints). An **absent** attribute takes the SVG default (`0`); a
851
+ * **present** attribute that is not a plain user-unit number (`%`, `px`,
852
+ * `em`, …) is out of scope and yields `null` so the caller refuses the
853
+ * element — the same gate required attrs (width / radius) already apply.
854
+ *
855
+ * The absent-vs-present distinction is the point: a bare `?? 0` would
856
+ * silently coerce an authored `x1="5px"` to `0`, then the first native
857
+ * writeback would overwrite that authored value. Refusing keeps the
858
+ * editor from misrepresenting geometry it cannot read faithfully.
859
+ */
860
+ private optional_user_unit_coord;
861
+ is_vector_edit_target(id: NodeId): VectorEditSource | null;
862
+ /**
863
+ * Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
864
+ * `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
865
+ * its native geometry attributes and setting `d`. A structural mutation:
866
+ * this layer executes the re-type; it does not decide when one is
867
+ * warranted.
868
+ *
869
+ * Idempotent: returns `null` if `id` is not currently one of those tags
870
+ * (so it is safe to call repeatedly — once re-typed, e.g. already a
871
+ * `<path>`, further calls are no-ops). Otherwise mutates the node and
872
+ * returns an opaque {@link RetypeRecord} reversal token.
873
+ *
874
+ * Identity, children, `self_closing`, non-geometry attributes, and all
875
+ * source trivia are preserved unchanged — only the tag and the geometry
876
+ * attributes move. Pass the token to {@link revert_retype} to restore
877
+ * the original primitive byte-for-byte.
878
+ *
879
+ * (see test/svg-editor-vector-promote-to-path.md)
880
+ */
881
+ retype_to_path(id: NodeId, d: string): RetypeRecord | null;
882
+ /**
883
+ * Reverse a {@link retype_to_path}: restore the original tag, remove the
884
+ * `d` attribute the promotion added, and splice the captured geometry
885
+ * attribute tokens back at their original positions (preserving their
886
+ * trivia, so a later `serialize()` is byte-equal to the pre-promotion
887
+ * source).
888
+ */
889
+ revert_retype(id: NodeId, token: RetypeRecord): void;
890
+ /**
891
+ * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
892
+ * per-glyph attribute (which conflicts with element-level rotation).
893
+ */
894
+ has_glyph_rotate(id: NodeId): boolean;
895
+ /**
896
+ * True iff this element's inline `style=""` declares a `transform:`
897
+ * CSS property (which would shadow the editor's `transform=` writes).
898
+ */
899
+ has_inline_css_transform(id: NodeId): boolean;
900
+ /**
901
+ * True iff this element has a direct `<animateTransform>` child
902
+ * (which produces a time-varying transform invisible to attribute writes).
903
+ * Only direct children are checked — nested cases attach to the nearer ancestor.
904
+ */
905
+ has_animate_transform_child(id: NodeId): boolean;
906
+ text_of(id: NodeId): string;
907
+ /** Replace all direct text children with a single text node carrying `value`. */
908
+ set_text(id: NodeId, value: string): void;
909
+ insert(id: NodeId, parent: NodeId, before: NodeId | null): void;
910
+ remove(id: NodeId): void;
911
+ /** Create a new element node and register it (not yet inserted). */
912
+ create_element(local: string, opts?: {
913
+ prefix?: string | null;
914
+ ns?: string | null;
915
+ }): NodeId;
916
+ /** Fresh internal NodeId, guaranteed unique within this document's node
917
+ * map. Shared by `create_element` and fragment adoption — collisions
918
+ * matter for the latter because the parser assigns sequential per-parse
919
+ * ids that a second parse would repeat. */
920
+ private fresh_node_id;
921
+ /**
922
+ * Parse an SVG **fragment** string and adopt its element subtrees into
923
+ * this document's node store — registered like {@link create_element}
924
+ * but NOT inserted into the tree (no version bump, no emit). Callers
925
+ * attach the returned roots via {@link insert}; the editor's
926
+ * `commands.insert_fragment` is the history-bracketed consumer.
927
+ *
928
+ * Input shapes:
929
+ * - A **bare fragment** — one or more sibling elements
930
+ * (`<path …/><path …/>`, or a single `<g>…</g>`). The top-level
931
+ * elements become the returned roots, in source order.
932
+ * - A **full SVG document** — when the input's only top-level element
933
+ * is an `<svg>`, that element is treated as a document SHELL, not
934
+ * content: its element children become the roots and the shell
935
+ * itself (viewBox, width/height, prolog, doctype) is discarded. Its
936
+ * `xmlns:*` prefix declarations are harvested into `xmlns` so the
937
+ * caller can re-declare prefixes the adopted content still uses.
938
+ * An `<svg>` that appears as one of SEVERAL top-level elements (or
939
+ * anywhere below the top level) is content, adopted as-is.
940
+ *
941
+ * Top-level non-element nodes (whitespace between roots, comments, PIs,
942
+ * doctype) are dropped — adoption takes elements, and the host
943
+ * document's own trivia stays untouched. WITHIN each adopted subtree
944
+ * every byte of source trivia survives verbatim (attribute order, quote
945
+ * styles, whitespace, comments), so the inserted markup serializes back
946
+ * exactly as authored — same rules as the initial parse.
947
+ *
948
+ * Authored `id=""` attributes are adopted verbatim — never rewritten,
949
+ * even when they collide with ids already in the document. Silent id
950
+ * renaming is exactly the proprietary noise this editor refuses (README
951
+ * "What clean means" §3); deduplication belongs to the explicit Tidy
952
+ * command. Internal NodeIds ARE freshly assigned (see
953
+ * {@link fresh_node_id}) so adopted nodes never collide in the id map.
954
+ *
955
+ * Throws `TypeError` on a non-string input and `Error` on markup the
956
+ * parser rejects (unclosed / mismatched tags, malformed attributes). An
957
+ * input with no top-level elements (empty string, whitespace, comments
958
+ * only) returns `{ roots: [], xmlns: [] }`.
959
+ */
960
+ create_fragment(markup: string): {
961
+ roots: NodeId[];
962
+ xmlns: ReadonlyArray<{
963
+ prefix: string;
964
+ uri: string;
965
+ }>;
966
+ };
967
+ /**
968
+ * Register `node` and its whole subtree (from a foreign parse) into this
969
+ * document's node map under fresh NodeIds. The parser assigns sequential
970
+ * per-parse ids (`n0`, `n1`, …), so adopting without a remap would
971
+ * collide with this document's own nodes. Children links are rewritten;
972
+ * the subtree root arrives detached (`parent: null`), like
973
+ * `create_element`. Mutates the parsed nodes in place — a parse result
974
+ * is single-use.
975
+ */
976
+ private adopt_parsed_subtree;
977
+ /**
978
+ * Namespace prefixes USED within `id`'s subtree (element tags and
979
+ * attribute names) that are not DECLARED within the subtree itself —
980
+ * i.e. prefixes the subtree borrows from ancestor scope. `xml` and
981
+ * `xmlns` are excluded (bound by the XML spec, never declared).
982
+ * Declaration scoping is honored per use-site: a prefix declared on the
983
+ * using element or any of its ancestors up to (and including) the
984
+ * subtree root counts as declared.
985
+ *
986
+ * Structural fact only — the caller decides what an unbound prefix
987
+ * means (e.g. `commands.insert_fragment` hoists a resolvable
988
+ * declaration onto the document root).
989
+ */
990
+ undeclared_ns_prefixes(id: NodeId): ReadonlySet<string>;
991
+ /**
992
+ * Declare a namespace prefix on the ROOT element: appends
993
+ * `xmlns:<prefix>="<uri>"` when the root doesn't already declare that
994
+ * prefix. An authored declaration always wins — this never rebinds.
995
+ * Policy wrapper over {@link set_attr} in the `XMLNS_NS` space; removal
996
+ * works through `set_attr(root, prefix, null, XMLNS_NS)` as usual.
997
+ */
998
+ declare_xmlns(prefix: string, uri: string): void;
999
+ serialize(): string;
1000
+ /**
1001
+ * Serialize a single element's subtree as an SVG **fragment**, using the
1002
+ * same trivia-preserving rules as {@link serialize} (attribute order,
1003
+ * quote style, whitespace, comments — emitted exactly as authored).
1004
+ *
1005
+ * This is NOT {@link serialize} scoped to a node — it is a deliberately
1006
+ * weaker output (sdk-design D3, asymmetric outputs stay separate):
1007
+ *
1008
+ * - `serialize()` emits the whole document and carries the P1
1009
+ * whole-document round-trip guarantee.
1010
+ * - `serialize_node()` emits a fragment and does NOT. Namespace
1011
+ * declarations that live on an ancestor (`xmlns:xlink` and friends,
1012
+ * normally on the root `<svg>`) are NOT inlined — a node using
1013
+ * `xlink:href` serializes without `xmlns:xlink`. The fragment is the
1014
+ * element's markup as authored, not a standalone parseable document.
1015
+ *
1016
+ * Throws on an unknown id, a non-element node, or a node detached from
1017
+ * the live tree: the contract is "the markup for a selected element,"
1018
+ * selections are always live elements, and a string return of `""` for a
1019
+ * bad id would hide consumer bugs. The detached case matters because
1020
+ * `remove()` keeps the node in the id map for undo — a stale id from a
1021
+ * removed node would otherwise serialize content no longer in the
1022
+ * document, silently feeding a consumer deleted markup.
1023
+ */
1024
+ serialize_node(id: NodeId): string;
1025
+ private emit_node;
1026
+ private emit_attr;
1027
+ }
1028
+ //#endregion
1029
+ //#region src/core/vector-edit/model.d.ts
1030
+ type Verb = "M" | "L" | "H" | "V" | "C" | "S" | "Q" | "T" | "A" | "Z";
1031
+ type VertexId = number;
1032
+ type SegmentId = number;
1033
+ /** `[vertex_idx, 0]` = ta on segment whose `a === vertex_idx`; `[vertex_idx, 1]` = tb where `b === vertex_idx`. */
1034
+ type TangentRef = readonly [VertexId, 0 | 1];
1035
+ /**
1036
+ * Tangent mirroring policy applied around a vertex when one tangent moves.
1037
+ * Mirrors `vn.TangentMirroringMode`.
1038
+ *
1039
+ * - `auto` — infer from current state (smooth join → mirror angle+length;
1040
+ * broken / asymmetric → don't mirror).
1041
+ * - `none` — only move the chosen tangent. Other tangents at this vertex
1042
+ * stay put.
1043
+ * - `angle` — keep opposite tangent collinear (mirror angle), preserve its
1044
+ * length. Used when the user wants a sharp-vs-smooth-but-asymmetric
1045
+ * handle (Figma's "Mirror angle" mode).
1046
+ * - `all` — mirror both angle and length. Standard "smooth" handle pair.
1047
+ */
1048
+ type TangentMirrorMode = "auto" | "none" | "angle" | "all";
1049
+ type SegmentView = {
1050
+ a: VertexId;
1051
+ b: VertexId;
1052
+ ta: readonly [number, number];
1053
+ tb: readonly [number, number];
1054
+ source_verb?: Verb;
1055
+ };
1056
+ type PathSnapshot = {
1057
+ vertices: ReadonlyArray<readonly [number, number]>;
1058
+ segments: ReadonlyArray<SegmentView>;
1059
+ };
1060
+ type SubSelection = {
1061
+ vertices: ReadonlyArray<VertexId>;
1062
+ segments: ReadonlyArray<SegmentId>;
1063
+ tangents: ReadonlyArray<TangentRef>;
1064
+ };
1065
+ /**
1066
+ * Per-segment metadata maintained alongside vn's segment array.
1067
+ * `meta[i]` corresponds to `network.segments[i]`.
1068
+ */
1069
+ type SegmentMeta = {
1070
+ /** The SVG verb that originally produced this segment, if known. */source_verb?: Verb; /** Arc-specific metadata for segments born from an `A` command. */
1071
+ arc?: ArcMeta; /** True iff this segment was emitted by a `Z` command (closing the subpath). */
1072
+ is_close_segment?: boolean;
1073
+ };
1074
+ /**
1075
+ * When an `A` command is parsed, it decomposes to N cubic segments.
1076
+ * All segments in the same arc share the same `group_id` and the same
1077
+ * arc parameters, plus each carries a snapshot of its original tangents
1078
+ * (used at emit time to detect "still an arc" vs "user has edited").
1079
+ */
1080
+ type ArcMeta = {
1081
+ group_id: number;
1082
+ rx: number;
1083
+ ry: number;
1084
+ x_rot: number;
1085
+ large_arc_flag: 0 | 1;
1086
+ sweep_flag: 0 | 1; /** Snapshot of this segment's ta at parse time (relative). */
1087
+ baseline_ta: cmath.Vector2; /** Snapshot of this segment's tb at parse time (relative). */
1088
+ baseline_tb: cmath.Vector2; /** Snapshot of this segment's end-vertex absolute position at parse time. */
1089
+ baseline_b_abs: cmath.Vector2; /** Sequence index within the arc group (0..N-1). */
1090
+ seq: number; /** Total segments in the arc group. */
1091
+ count: number;
1092
+ /** Original SVG arc command's `(x, y)` endpoint. Only populated on the
1093
+ * LAST segment of the arc group (seq === count - 1). Used by the emitter
1094
+ * to write back the exact coordinate the author wrote, avoiding floating-
1095
+ * point drift from arc-to-cubic decomposition. */
1096
+ original_end?: cmath.Vector2;
1097
+ };
1098
+ /**
1099
+ * Canonical vector-network model for a single SVG path's `d` string.
1100
+ *
1101
+ * `PathModel` is a self-contained geometry primitive — it parses an SVG
1102
+ * path `d` into a vertex/segment graph (with verb hints preserved for
1103
+ * round-trip honesty), exposes POJO observers, and serializes back to
1104
+ * `d`. It does not hold or reference an `SvgDocument`, an editor
1105
+ * instance, the DOM, or any host. It is safe to construct in any
1106
+ * environment that can run the package.
1107
+ *
1108
+ * Public re-exported as a top-level Layer-A primitive from
1109
+ * `@grida/svg-editor` for callers that want canonical path geometry
1110
+ * without mounting an editor. The full mutation surface (translate /
1111
+ * bend / set-tangent / split, etc.) is package-internal and may shift;
1112
+ * the publicly-stable contract for external callers is the construction
1113
+ * + serialization + observation methods documented at the entry point.
1114
+ *
1115
+ * @experimental Surface shape is v0; signatures may change before the
1116
+ * package reaches semver stability.
1117
+ */
1118
+ declare class PathModel {
1119
+ private readonly _network;
1120
+ private readonly _meta;
1121
+ private constructor();
1122
+ static fromSvgPathD(d: string): PathModel;
1123
+ /** Construct from a vn network with no verb info (every segment defaults to undefined verb). */
1124
+ static fromVectorNetwork(network: vn.VectorNetwork): PathModel;
1125
+ toSvgPathD(): string;
1126
+ snapshot(): PathSnapshot;
1127
+ bbox(): cmath.Rectangle;
1128
+ vertexCount(): number;
1129
+ segmentCount(): number;
1130
+ /**
1131
+ * If the model's current geometry is still expressible in the source
1132
+ * SVG tag's native attribute form, return the equivalent
1133
+ * `VectorEditSource` (which is also the writeable shape) — else `null`.
1134
+ *
1135
+ * This is the decider that gates per-gesture native-attrs writeback in
1136
+ * `VectorEditSession.apply_d`. `null` means "the user's edit cannot be
1137
+ * faithfully written back to the source tag" — in v1 with no
1138
+ * promotion, the gesture is refused; in v1.1+ with promotion, the
1139
+ * element is rewritten to `<path d="…">`.
1140
+ *
1141
+ * v1 expressibility (all source kinds require every segment's `ta` and
1142
+ * `tb` to be exactly zero — any tangent edit forces promotion):
1143
+ *
1144
+ * - **path** — always `null` (no native fallback; the canonical form
1145
+ * IS `<path d>`, so callers should just write `d` directly).
1146
+ * - **line** — exactly two vertices joined by one straight segment
1147
+ * `0→1`. (Topology after a 2-point `vn.fromPolyline` and any sequence
1148
+ * of endpoint translates.)
1149
+ * - **polyline** — segments form the canonical open chain
1150
+ * `0→1, 1→2, …, (n-2)→(n-1)`. (Topology after `vn.fromPolyline` and
1151
+ * any sequence of vertex translates.)
1152
+ * - **polygon** — segments form the canonical closed chain
1153
+ * `0→1, 1→2, …, (n-1)→0`. (Topology after `vn.fromPolygon` and any
1154
+ * sequence of vertex translates.)
1155
+ * - **rect / circle / ellipse** — always `null`. These geometry
1156
+ * primitives have no native writeback target; any vector gesture on
1157
+ * them re-types the element to `<path>` (see `vector_apply` /
1158
+ * `SvgDocument.retype_to_path`), so they never round-trip through here.
1159
+ *
1160
+ * Anything that changes segment topology (insert-vertex, delete-vertex,
1161
+ * close/open shape) or introduces a curve leaves the canonical chain and
1162
+ * returns `null` here; the caller re-types the element to `<path>`.
1163
+ */
1164
+ toNativeAttrs(source_tag: VectorEditSource["kind"]): Extract<VectorEditSource, {
1165
+ kind: "line" | "polyline" | "polygon";
1166
+ }> | null;
1167
+ /** Translate one vertex by `delta`. Connected segments follow because
1168
+ * tangents are stored relative to vertices. Verb metadata is preserved
1169
+ * as-is; emit-time honesty handles cases where the shape no longer
1170
+ * matches the recorded verb (e.g. an H whose endpoint y-coord drifts). */
1171
+ translateVertex(v: VertexId, delta: readonly [number, number]): PathModel;
1172
+ /** Bulk-translate a set of vertices by the same delta. Atomic — either
1173
+ * every move succeeds or none (input is validated up-front). */
1174
+ translateVertices(indices: ReadonlyArray<VertexId>, delta: readonly [number, number]): PathModel;
1175
+ /** Translate one segment by `delta` — moves both endpoints, dragging
1176
+ * their tangents along (tangents are stored relative to vertices, so
1177
+ * this is automatic). Other segments connected to the moved endpoints
1178
+ * also follow at the shared vertex. */
1179
+ translateSegment(seg: SegmentId, delta: readonly [number, number]): PathModel;
1180
+ /**
1181
+ * Bend a curve segment by dragging a point at parameter `ca` to `cb`
1182
+ * (cb is in absolute doc-space). Delegates to vn's `bendSegment` —
1183
+ * which solves for the new ta/tb that put `B(ca) === cb`, holding the
1184
+ * endpoints fixed.
1185
+ *
1186
+ * The "frozen" snapshot of the segment at gesture start is the caller's
1187
+ * responsibility. Convention: call this from a preview session where
1188
+ * each frame replays from the baseline (same pattern as translate).
1189
+ */
1190
+ bendSegment(seg: SegmentId, ca: number, cb: readonly [number, number], frozen: {
1191
+ a: readonly [number, number];
1192
+ b: readonly [number, number];
1193
+ ta: readonly [number, number];
1194
+ tb: readonly [number, number];
1195
+ }): PathModel;
1196
+ /**
1197
+ * Move one tangent control point to a new absolute position. Mirror
1198
+ * policy follows vn's `updateTangent`. The other tangent at the same
1199
+ * vertex is updated according to the policy.
1200
+ *
1201
+ * Returns a new PathModel; verb metadata is preserved verbatim.
1202
+ * `toSvgPathD` will demote (e.g. L → C) if the new tangents make the
1203
+ * recorded verb no longer match the geometry.
1204
+ */
1205
+ setTangent(t: TangentRef, abs_pos: readonly [number, number], mirror?: TangentMirrorMode): PathModel;
1206
+ /**
1207
+ * Split segment `seg` at parametric position `t ∈ [0,1]`, inserting a
1208
+ * new vertex. Returns the new model and the **canonical (path-order)**
1209
+ * index of the inserted vertex.
1210
+ *
1211
+ * Verb metadata for the split: the original segment's verb propagates
1212
+ * to BOTH halves if it was a curve type (`C`/`S`/`Q`/`T`/`A`); for
1213
+ * straight verbs (`L`/`H`/`V`), the split halves stay straight (their
1214
+ * tangents are zero from vn's `preserveZero` path when both originals
1215
+ * were zero). Arc-group identity is dropped from the halves — the
1216
+ * arc is broken once split (the emitter will fall back to `C`/`L`).
1217
+ *
1218
+ * **Index space contract.** `VectorNetworkEditor.splitSegment` APPENDS
1219
+ * the new vertex at the end of the network's vertices array — its
1220
+ * index is the in-memory insertion order. But `toSvgPathD` / `fromSvgPathD`
1221
+ * canonicalize vertices in path order, so the same vertex gets a
1222
+ * DIFFERENT index in the d-derived model that consumers re-parse each
1223
+ * frame (e.g., the host's `handle_translate_vertices`). Returning the
1224
+ * insertion-order index causes the classic split-and-drag bug: the
1225
+ * surface holds index N (insertion-order) but the live model has
1226
+ * index M (path-order) at that position — drag moves the wrong vertex
1227
+ * and the user sees "split happened but the new vertex doesn't move".
1228
+ *
1229
+ * To prevent that, we round-trip the post-split model through
1230
+ * `toSvgPathD` → `fromSvgPathD` and return the canonical (path-order)
1231
+ * index of the new vertex. The returned `model` is the canonical
1232
+ * one, so any subsequent op on it uses the same index space the d
1233
+ * roundtrip exposes. See `__tests__/README.md` §"index identity
1234
+ * across the `d` round-trip" for the test pattern that pins this.
1235
+ */
1236
+ splitSegment(seg: SegmentId, t: number): {
1237
+ model: PathModel;
1238
+ new_vertex: VertexId;
1239
+ };
1240
+ /**
1241
+ * Doc-space position of a tangent control point. `t` references a
1242
+ * segment and which end (`a` or `b`) the tangent belongs to; the
1243
+ * result is `vertex + tangent_value + origin`. Returns null if no
1244
+ * segment has this tangent (e.g. the vertex is isolated).
1245
+ */
1246
+ tangentAbsolute(t: TangentRef, origin: readonly [number, number]): [number, number] | null;
1247
+ /**
1248
+ * Vertices "neighbouring" the current selection — these are the
1249
+ * vertices whose tangent handles should render in chrome.
1250
+ *
1251
+ * Two-phase, mirrors `editor/grida-canvas/reducers/methods/vector.ts`
1252
+ * `getUXNeighbouringVertices`:
1253
+ *
1254
+ * 1. Collect "active" vertices:
1255
+ * - every selected vertex
1256
+ * - every tangent-owning vertex
1257
+ * - both endpoints of every selected segment
1258
+ * 2. Expand uniformly to 1-hop neighbours (vertices sharing a segment
1259
+ * with any active vertex).
1260
+ *
1261
+ * Without phase 2 for tangent / segment selections, selecting only a
1262
+ * tangent would hide neighbouring-vertex tangents — the user loses
1263
+ * spatial context. Phase 2 makes the affordance symmetric: whatever
1264
+ * triggered selection, the 1-hop ring of tangent handles is visible.
1265
+ *
1266
+ * Sorted ascending; deduped.
1267
+ */
1268
+ neighbouringVertices(sel: SubSelection): VertexId[];
1269
+ /**
1270
+ * True iff segment `seg`'s curve is entirely contained in the rect.
1271
+ * Delegates to `cmath.bezier.containedByRect`.
1272
+ */
1273
+ segmentContainedByRect(seg: SegmentId, rect: cmath.Rectangle, origin?: readonly [number, number]): boolean;
1274
+ /** @internal */
1275
+ _rawNetwork(): vn.VectorNetwork;
1276
+ /** @internal */
1277
+ _rawMeta(): ReadonlyArray<SegmentMeta>;
1278
+ /**
1279
+ * Map a `TangentRef` to a concrete `(segment_index, control)` pair.
1280
+ *
1281
+ * `[v, 0]` → first segment whose `a === v` (its `ta`).
1282
+ * `[v, 1]` → first segment whose `b === v` (its `tb`).
1283
+ *
1284
+ * Y-junctions (multi-outgoing or multi-incoming) are uncommon for SVG
1285
+ * `<path>` content; v1 picks the first match. If we ever support those
1286
+ * cleanly, extend `TangentRef` to carry the segment id explicitly.
1287
+ */
1288
+ private _locateTangent;
1289
+ }
1290
+ //#endregion
1291
+ //#region src/core/defs.d.ts
1292
+ interface GradientsApi {
1293
+ list(): ReadonlyArray<GradientEntry>;
1294
+ get(id: string): GradientEntry | null;
1295
+ upsert(definition: GradientDefinition, opts?: {
1296
+ id?: string;
1297
+ }): string;
1298
+ remove(id: string): void;
1299
+ subscribe(fn: (entries: ReadonlyArray<GradientEntry>) => void): Unsubscribe;
1300
+ }
1301
+ type Defs = {
1302
+ gradients: GradientsApi;
1303
+ };
1304
+ //#endregion
1305
+ //#region src/commands/registry.d.ts
1306
+ /**
1307
+ * Command registry.
1308
+ *
1309
+ * A passive id-keyed registry of handlers. Built so that:
1310
+ *
1311
+ * - keybindings (in `src/keymap`) can address commands by stable id;
1312
+ * - new commands can be added in ONE place (`src/commands/defaults.ts`)
1313
+ * without growing the public surface of the editor;
1314
+ * - "one key, many meanings" can be expressed via chain semantics: a
1315
+ * handler returns `true` if it consumed, `false`/`void` otherwise,
1316
+ * and the dispatcher tries the next candidate in the chain.
1317
+ *
1318
+ * Handlers are plain closures — they capture whatever editor reference
1319
+ * they need. The registry itself stays unaware of the editor's type,
1320
+ * which avoids a circular type dependency between editor and registry.
1321
+ */
1322
+ /** Stable, dotted id for a command, e.g. `"history.undo"`. */
1323
+ type CommandId = string;
1324
+ /**
1325
+ * A command handler.
1326
+ *
1327
+ * Return `true` if the handler consumed the invocation. Return `false`
1328
+ * or `undefined` to signal "did not apply" — the dispatcher will try
1329
+ * the next candidate registered for the same key.
1330
+ *
1331
+ * Handlers are closures: they capture their editor reference. No
1332
+ * editor parameter is passed — keep handlers self-contained.
1333
+ */
1334
+ type CommandHandler = (args?: unknown) => boolean | void;
1335
+ declare class CommandRegistry {
1336
+ private readonly map;
1337
+ /**
1338
+ * Register a command. Returns an unregister function. Re-registering
1339
+ * the same id replaces the previous handler (last writer wins).
1340
+ */
1341
+ register(id: CommandId, handler: CommandHandler): () => void;
1342
+ /**
1343
+ * Invoke a command by id. Returns `true` if the handler consumed,
1344
+ * `false` otherwise (including unknown ids and handlers that returned
1345
+ * `false`/`undefined`).
1346
+ */
1347
+ invoke(id: CommandId, args?: unknown): boolean;
1348
+ has(id: CommandId): boolean;
1349
+ /** All registered ids, for debugging / introspection. */
1350
+ ids(): readonly CommandId[];
1351
+ }
1352
+ //#endregion
1353
+ //#region src/keymap/keymap.d.ts
1354
+ type KeymapBinding = {
1355
+ /** Declarative key combination. Build with `kb()` / `c()` / `seq()`. */keybinding: Keybinding; /** Command id to invoke on match. */
1356
+ command: CommandId; /** Forwarded as the `args` parameter to the command handler. */
1357
+ args?: unknown; /** Higher priorities run first in the chain. Default 0. */
1358
+ priority?: number;
1359
+ /**
1360
+ * Bypass the form-element focus guard. When `true`, this binding fires
1361
+ * even if a text input is focused (`<input>`, `<textarea>`, or any
1362
+ * `contentEditable` element).
1363
+ *
1364
+ * Default `false`. The platform's native text-editing shortcuts —
1365
+ * Cmd+A (select all), Cmd+Z/Y (input undo/redo), Cmd+C/V/X
1366
+ * (clipboard), arrow keys, Backspace, Tab, Enter — must win over
1367
+ * editor shortcuts while the user is typing.
1368
+ *
1369
+ * Opt in sparingly. Reasonable candidates: truly global app shortcuts
1370
+ * like Cmd+S (save). Unreasonable candidates: anything that has a
1371
+ * native text-editing meaning.
1372
+ */
1373
+ allowInFormElement?: boolean;
1374
+ /**
1375
+ * Reserved for V2; not honored by the V1 dispatcher. When added, this
1376
+ * will be evaluated before the handler runs; if false, the binding is
1377
+ * skipped without invoking the handler.
1378
+ */
1379
+ when?: (ctx: unknown) => boolean;
1380
+ };
1381
+ declare class Keymap {
1382
+ private readonly commands;
1383
+ private readonly platformGetter;
1384
+ /**
1385
+ * Bindings bucketed by canonical chunk-key hash, computed per
1386
+ * `@grida/keybinding`'s `chunkKey`. Each list is the chain for that
1387
+ * key, sorted in dispatch order (priority desc, then registration
1388
+ * order).
1389
+ */
1390
+ private readonly buckets;
1391
+ /** Insert order, so ties on priority are deterministic. */
1392
+ private seq;
1393
+ constructor(commands: CommandRegistry, platformGetter?: () => Platform);
1394
+ /**
1395
+ * Bind a key combination to a command. Returns an unbind function.
1396
+ * The same `Keybinding` can be bound to multiple commands — they will
1397
+ * all be tried in chain order on dispatch.
1398
+ */
1399
+ bind(binding: KeymapBinding): () => void;
1400
+ /**
1401
+ * Remove bindings matching the spec. If both filters are passed, only
1402
+ * bindings that match BOTH are removed.
1403
+ */
1404
+ unbind(spec: {
1405
+ keybinding?: Keybinding;
1406
+ command?: CommandId;
1407
+ }): void;
1408
+ /** All registered bindings, for introspection. Order is not guaranteed. */
1409
+ bindings(): readonly KeymapBinding[];
1410
+ /**
1411
+ * Does the keymap have a binding that matches this event's chord —
1412
+ * regardless of whether any handler would consume it? Hosts use this
1413
+ * to decide whether to swallow the platform's default action (e.g.
1414
+ * `event.preventDefault()` in the browser), so that an advertised
1415
+ * shortcut like `Cmd+G` doesn't fall through to the browser's find
1416
+ * bar even when the binding's handler rejects.
1417
+ *
1418
+ * Pure read; runs no handlers, no side effects. Honors the same
1419
+ * form-element focus guard `dispatch` uses, so a typing user's
1420
+ * keystroke isn't "claimed" — and the browser's native text-editing
1421
+ * default (Cmd+A select all, Cmd+Z undo, etc.) wins.
1422
+ */
1423
+ claims(event: KeyboardEvent): boolean;
1424
+ /**
1425
+ * Match the event against bound chunks, then run candidates in chain
1426
+ * order. Returns `true` on the first handler that consumes; returns
1427
+ * `false` if nothing matched or all matches fell through.
1428
+ *
1429
+ * **Form-element focus guard.** When a text input is focused
1430
+ * (`<input>`, `<textarea>`, contentEditable), bindings are suppressed
1431
+ * by default so the platform's native shortcuts (Cmd+A, Cmd+Z, Cmd+C,
1432
+ * arrow nav, …) are preserved. A binding can opt out of this guard
1433
+ * with `allowInFormElement: true` — see `KeymapBinding`.
1434
+ *
1435
+ * `dispatch` is browser-agnostic: it does NOT call `preventDefault()`
1436
+ * or touch the event in any way. The host decides what to do with the
1437
+ * platform default — typically `if (keymap.claims(e)) e.preventDefault()`,
1438
+ * which prevents the platform default for advertised shortcuts even
1439
+ * when the chain rejects. See README → `editor.keymap`.
1440
+ */
1441
+ dispatch(event: KeyboardEvent): boolean;
1442
+ /**
1443
+ * Compute the set of canonical hashes a `Keybinding` lights up. A
1444
+ * binding with aliases (multiple sequences) contributes one hash per
1445
+ * single-chunk alias; multi-chunk sequences (chords) are skipped in
1446
+ * V1.
1447
+ */
1448
+ private chunkKeysFor;
1449
+ }
1450
+ //#endregion
1451
+ //#region src/core/subtree.d.ts
1452
+ declare namespace subtree {
1453
+ /**
1454
+ * Document-order comparator over node ids. Builds the index once per
1455
+ * call (one full tree walk) — create one comparator per operation and
1456
+ * reuse it, as `clipboard.extract_payload` does.
1457
+ */
1458
+ function by_document_order(doc: SvgDocument): (a: NodeId, b: NodeId) => number;
1459
+ /**
1460
+ * Selection normalization — the half of extraction that payload
1461
+ * extraction (copy) and subtree clone share, per the FRD:
1462
+ * dedupe → live elements only → ancestor subtrees subsume selected
1463
+ * descendants (`prune_nested_nodes`) → DOCUMENT order regardless of
1464
+ * selection order (sibling order is paint order, and paint order is
1465
+ * meaning). Stale / non-element / detached ids are skipped, never
1466
+ * thrown — normalization is a filter, not a validator.
1467
+ */
1468
+ function normalize_roots(doc: SvgDocument, selection: ReadonlyArray<NodeId>, order?: (a: NodeId, b: NodeId) => number): NodeId[];
1469
+ /** One origin → clone pairing with the clone's placement, captured at
1470
+ * plan time. `before` is the origin's next sibling NODE (element or
1471
+ * trivia) so the clone lands immediately after its origin. */
1472
+ type SubtreeClonePlanEntry = {
1473
+ origin: NodeId;
1474
+ /** Registered in the document's node map, DETACHED (`create_fragment`
1475
+ * style) — the consumer inserts it inside its own history closure. */
1476
+ clone: NodeId; /** `parent_of(origin)` at plan time. */
1477
+ parent: NodeId; /** `next_sibling_of(origin)` at plan time; `null` = append. */
1478
+ before: NodeId | null;
1479
+ };
1480
+ type SubtreeClonePlan = ReadonlyArray<SubtreeClonePlanEntry>;
1481
+ /**
1482
+ * Build a clone plan for the selection: for each normalized origin,
1483
+ * serialize its subtree verbatim and re-adopt it under fresh runtime
1484
+ * NodeIds via `create_fragment` — the markup round-trip rides the same
1485
+ * trivia-preserving emit and never-rewrite-ids adoption the package
1486
+ * already guarantees, so `serialize_node(clone) === serialize_node(origin)`
1487
+ * byte-for-byte once inserted.
1488
+ *
1489
+ * Clones are returned DETACHED (plan, don't insert): consumers own
1490
+ * insertion inside their history closures so redo can re-insert the
1491
+ * same NodeIds (`remove` keeps nodes in the id map).
1492
+ *
1493
+ * Skipped origins (refusals, normalized away — not errors):
1494
+ * - the document root and any other parentless node (no sibling slot);
1495
+ * - nested `<svg>` elements — `create_fragment` deliberately treats a
1496
+ * lone `<svg>` root as a full-document shell and discards it (the
1497
+ * FRD's paste rule), which would silently unwrap the clone; refusing
1498
+ * beats mishandling.
1499
+ *
1500
+ * An empty selection (or one that normalizes to nothing) yields an
1501
+ * empty plan — the caller's no-op.
1502
+ */
1503
+ function clone_plan(doc: SvgDocument, selection: ReadonlyArray<NodeId>): SubtreeClonePlan;
1504
+ /**
1505
+ * Attach every plan clone at its captured anchor. Anchors predate the
1506
+ * plan (captured before any insertion), so no entry anchors on another
1507
+ * entry's clone — each insert is independent and the interleaved
1508
+ * `A, A′, B, B′` order falls out of the per-origin anchors.
1509
+ *
1510
+ * Idempotent: `doc.insert` detaches-then-splices, so re-attaching an
1511
+ * already-live clone repositions it to the same slot. History redo
1512
+ * closures rely on this.
1513
+ */
1514
+ function insert_plan(doc: SvgDocument, plan: SubtreeClonePlan): void;
1515
+ /**
1516
+ * Detach every plan clone. Order-independent for the same reason
1517
+ * {@link insert_plan} is: anchors are never plan clones. Removed nodes
1518
+ * stay in the document's id map (standard removed-node policy), so a
1519
+ * later {@link insert_plan} over the same plan restores them.
1520
+ */
1521
+ function remove_plan(doc: SvgDocument, plan: SubtreeClonePlan): void;
1522
+ /**
1523
+ * The last committed duplication — the memory the repeating-offset
1524
+ * behavior reads (gridaco/grida#825; spec:
1525
+ * [docs/wg/feat-svg-editor/subtree-clone.md](../../../../docs/wg/feat-svg-editor/subtree-clone.md)
1526
+ * §Repeating offset). Armed by `commands.duplicate` and by a cloned
1527
+ * translate commit (Alt-drag); consumed and re-armed by the next
1528
+ * `duplicate()`. Editor-session state — never observable, never in
1529
+ * history. Staleness is caught at use by {@link repeat_delta}, not by
1530
+ * per-mutation bookkeeping.
1531
+ *
1532
+ * The arrays are INDEX-PAIRED: `clones[i]` is the clone of
1533
+ * `origins[i]` (both producers derive them from the same
1534
+ * {@link SubtreeClonePlan}). {@link repeat_delta}'s per-member
1535
+ * rigidity check depends on that pairing.
1536
+ */
1537
+ type DuplicationRecord = {
1538
+ origins: ReadonlyArray<NodeId>;
1539
+ clones: ReadonlyArray<NodeId>;
1540
+ };
1541
+ /**
1542
+ * The repeating-offset delta (gridaco/grida#825): given the previous
1543
+ * duplication's record and the CURRENT duplicate's normalized origins,
1544
+ * return the world-space offset the fresh clones should repeat, or
1545
+ * `null` for "no repeat — duplicate in place".
1546
+ *
1547
+ * The repeat fires only when the record still witnesses
1548
+ * "duplicate, then rigidly translate the copies":
1549
+ * - `targets` is exactly `record.clones`, in the same (document)
1550
+ * order — the user is duplicating the previous duplication's
1551
+ * copies and nothing else;
1552
+ * - `bounds_of` answers for EVERY member of both sets (a detached
1553
+ * member, a measureless tag, or a missing geometry provider all
1554
+ * refuse);
1555
+ * - EVERY clone is rigid against its own origin (the record's
1556
+ * arrays are index-paired): same size, displaced by the same
1557
+ * delta as the union, within {@link REPEAT_RIGID_EPSILON}. The
1558
+ * check is per member, not per envelope — a rearranged or
1559
+ * resized inner copy is no longer a translate even when the
1560
+ * envelope-defining copies keep the union bbox intact;
1561
+ * - the union top-left delta exceeds the same tolerance (a copy
1562
+ * that never moved — or drifted by float noise only — repeats
1563
+ * nothing; the in-place duplicate stays byte-equal instead of
1564
+ * inheriting noise-sized attribute writes).
1565
+ *
1566
+ * Pure and gesture-grade: reads only through `bounds_of`, never
1567
+ * throws — every failed precondition degrades to `null` (the main
1568
+ * editor's `active_duplication` assert-on-mismatch is deliberately
1569
+ * NOT copied; ⌘D must never crash on a stale record).
1570
+ */
1571
+ function repeat_delta(record: DuplicationRecord | null, targets: ReadonlyArray<NodeId>, bounds_of: (id: NodeId) => Rect | null): Vec2 | null;
1572
+ }
1573
+ //#endregion
1574
+ //#region src/core/geometry.d.ts
1575
+ /**
1576
+ * Read-only access to world-space node bounds and hit-tests.
1577
+ *
1578
+ * Implementations should be cheap to call: snap (the main consumer)
1579
+ * will query dozens of nodes per pointermove. The driver wraps SVG
1580
+ * `getBBox` + `getCTM`; the memoizer caches per-`NodeId` to survive
1581
+ * the surface re-rendering the SVG tree every editor tick.
1582
+ *
1583
+ * **Freshness contract.** Every read MUST reflect the CURRENT document
1584
+ * — including when issued synchronously from inside a doc-change
1585
+ * listener (`subscribe_geometry` fires mid-mutation, before any
1586
+ * render). An implementation backed by a lazily-synced projection
1587
+ * (e.g. a rendered DOM tree) must flush that projection before
1588
+ * reading; compare `SvgDocument.revision` against the projection's
1589
+ * last-rendered revision. Returning the previous document's geometry
1590
+ * is not a transient glitch: the `MemoizedGeometryProvider` wrapper
1591
+ * caches whatever the driver returns, so one stale read poisons every
1592
+ * later consumer until the next invalidation (align/resize then plan
1593
+ * against one-mutation-old bounds and oscillate — see
1594
+ * `__tests__/geometry-stale-read.browser.test.ts`).
1595
+ */
1596
+ interface GeometryProvider {
1597
+ /**
1598
+ * World-space bounding rect for a single node, or `null` when the
1599
+ * node has no rendered geometry (orphan, detached, unsupported tag).
1600
+ */
1601
+ bounds_of(id: NodeId): Rect | null;
1602
+ /**
1603
+ * Bulk read. Missing nodes are simply absent from the result map.
1604
+ * Drivers should be free to batch / parallelize internally.
1605
+ */
1606
+ bounds_of_many(ids: ReadonlyArray<NodeId>): Map<NodeId, Rect>;
1607
+ /**
1608
+ * Ids of nodes whose world-space bounds intersect `rect`. Order is
1609
+ * implementation-defined.
1610
+ */
1611
+ nodes_in_rect(rect: Rect): NodeId[];
1612
+ /**
1613
+ * Topmost node id at world-space point `p`, or `null` when no node
1614
+ * is hit. "Topmost" is defined by the renderer's z-order.
1615
+ */
1616
+ node_at_point(p: Vec2): NodeId | null;
1617
+ /**
1618
+ * Re-express a **world-space** delta vector in the frame a node's
1619
+ * position attributes are written in — its parent user-space. For a
1620
+ * node under a scaled/rotated `<g>` ancestor, or inside a nested
1621
+ * `<svg>` viewport that scales its user space, the local frame differs
1622
+ * from world by that linear transform; a translate must be written in
1623
+ * the local frame so the on-screen motion matches the world delta
1624
+ * (otherwise it moves `scale ×` too far).
1625
+ *
1626
+ * Optional: only DOM-backed providers (with a real layout engine) can
1627
+ * derive the frame. Providers that omit it imply the flat-doc identity
1628
+ * (world ≡ local), and callers fall back to the raw delta.
1629
+ */
1630
+ world_delta_to_local?(id: NodeId, delta: Vec2): Vec2;
1631
+ }
1632
+ type GeometrySignals = {
1633
+ /** Fires when tree shape changes (insert/remove/reorder). */subscribe_structure: (cb: () => void) => Unsubscribe; /** Fires when any bounds-affecting change occurs. */
1634
+ subscribe_geometry: (cb: () => void) => Unsubscribe;
1635
+ };
1636
+ /**
1637
+ * Caches `bounds_of` results keyed on `NodeId`; full-clears on either
1638
+ * `structure_version` or `geometry_version` bump. See ../../docs/geometry.md for
1639
+ * why the cache is load-bearing under the surface's per-tick re-render.
1640
+ */
1641
+ declare class MemoizedGeometryProvider implements GeometryProvider {
1642
+ private readonly driver;
1643
+ private readonly unsubscribers;
1644
+ private cache;
1645
+ constructor(driver: GeometryProvider, signals: GeometrySignals);
1646
+ bounds_of(id: NodeId): Rect | null;
1647
+ bounds_of_many(ids: ReadonlyArray<NodeId>): Map<NodeId, Rect>;
1648
+ /**
1649
+ * Pass-through. These are less hot than `bounds_of` (called once per
1650
+ * gesture frame at most) and their result is sensitive to current
1651
+ * viewport state, so caching them would be a footgun.
1652
+ */
1653
+ nodes_in_rect(rect: Rect): NodeId[];
1654
+ node_at_point(p: Vec2): NodeId | null;
1655
+ /** Pass-through. Frame projection depends on live layout, not on the
1656
+ * bounds cache, so there is nothing to memoize. Falls back to the raw
1657
+ * delta when the driver can't resolve a frame. */
1658
+ world_delta_to_local(id: NodeId, delta: Vec2): Vec2;
1659
+ /** Unsubscribe from both signals. Call on surface detach. */
1660
+ dispose(): void;
1661
+ }
1662
+ //#endregion
1663
+ //#region src/gestures/gestures.d.ts
1664
+ /** Stable identifier for a gesture binding. Used by `unbind({ id })`. */
1665
+ type GestureId = string;
1666
+ /**
1667
+ * Context passed to every installer. Exposes the seams a gesture needs:
1668
+ * the container element to listen on, the camera to mutate, and the
1669
+ * editor for keymap dispatch / state reads.
1670
+ *
1671
+ * Surface authors construct this once at attach; bindings receive it on
1672
+ * every `install(...)` call.
1673
+ */
1674
+ type GestureContext = {
1675
+ /** Container element listeners attach to. */container: HTMLElement; /** SVG element being framed by the camera. Useful for hit-testing. */
1676
+ svg_root: () => SVGSVGElement | null; /** HUD canvas overlay; sits on top of the SVG. */
1677
+ hud_canvas: HTMLCanvasElement; /** Camera the binding mutates. */
1678
+ camera: Camera; /** Editor for keymap dispatch / state reads. */
1679
+ editor: SvgEditor; /** Handle for advanced bindings (e.g. wanting `camera.fit("<selection>")`). */
1680
+ handle: SurfaceHandle;
1681
+ /**
1682
+ * Predicate returning `true` iff the surface is currently "attended" —
1683
+ * focus inside the container subtree OR pointer over the container.
1684
+ * Gesture bindings whose keydown handlers call `preventDefault()` MUST
1685
+ * consult this before claiming, so the surface doesn't steal page-level
1686
+ * shortcuts when embedded in a larger document. See `util/attention.ts`.
1687
+ */
1688
+ is_attended: () => boolean;
1689
+ };
1690
+ type GestureBinding = {
1691
+ /** Stable id used by `unbind` / `bindings()`. */id: GestureId;
1692
+ /**
1693
+ * Wire DOM listeners (or any side-effect) needed for this gesture.
1694
+ * Returns the uninstaller — called on `unbind` or surface detach.
1695
+ */
1696
+ install(ctx: GestureContext): () => void;
1697
+ };
1698
+ /**
1699
+ * Sibling to `Keymap`. Owns a list of installed gesture bindings; each
1700
+ * binding's `install(ctx)` is called eagerly when bound and uninstalled
1701
+ * on `unbind` or surface detach.
1702
+ */
1703
+ declare class Gestures {
1704
+ private readonly ctx;
1705
+ private entries;
1706
+ constructor(ctx: GestureContext);
1707
+ /**
1708
+ * Install a gesture binding. Returns an unbind function.
1709
+ * Re-binding the same `id` does NOT replace — both will be active.
1710
+ * Use `unbind({ id })` first if you want a clean swap.
1711
+ */
1712
+ bind(binding: GestureBinding): () => void;
1713
+ /**
1714
+ * Remove bindings matching the spec. With `{ id }`, all bindings with
1715
+ * that id are uninstalled. With no spec, this is a no-op (use
1716
+ * `dispose()` to nuke everything).
1717
+ */
1718
+ unbind(spec: {
1719
+ id?: GestureId;
1720
+ }): void;
1721
+ /** All currently installed bindings. Order is registration order. */
1722
+ bindings(): readonly GestureBinding[];
1723
+ /** @internal Uninstall every binding. Surface calls on detach. */
1724
+ _dispose(): void;
1725
+ }
1726
+ //#endregion
1727
+ //#region src/core/editor.d.ts
1728
+ /** Resolved paint from the DOM-attached cascade. `resolved_paint` mirrors the
1729
+ * shape of `PaintValue.computed` so consumers can treat it uniformly with
1730
+ * the headless cascade. */
1731
+ type DomComputedPaint = {
1732
+ computed: string;
1733
+ resolved_paint: Paint | InvalidComputedValue | null;
1734
+ };
1735
+ /** Contract the DOM surface implements to delegate cascade resolution to
1736
+ * `getComputedStyle()`. Registered via `editor._internal.set_computed_resolver`
1737
+ * on attach. */
1738
+ type DomComputedResolver = {
1739
+ computed_property(id: NodeId, name: string): string | null;
1740
+ computed_paint(id: NodeId, channel: "fill" | "stroke"): DomComputedPaint | null;
1741
+ };
1742
+ type CreateSvgEditorOptions = {
1743
+ svg: string;
1744
+ providers?: Providers;
1745
+ style?: Partial<EditorStyle>;
1746
+ };
1747
+ /**
1748
+ * Internal-only members the package's own surfaces reach for. NOT part of
1749
+ * the published API — `_internal` is the surface↔editor bridge, `keymap`
1750
+ * is the keymap dispatcher the DOM surface forwards keyboard events to.
1751
+ * Lives on the runtime object for in-package callers; stripped from the
1752
+ * public `SvgEditor` type so the published `.d.ts` doesn't advertise them.
1753
+ */
1754
+ type SvgEditorInternalMembers = "_internal" | "keymap";
1755
+ /** Internal handle. Use only inside `@grida/svg-editor`. */
1756
+ type SvgEditorInternal = ReturnType<typeof _create_svg_editor_internal>;
1757
+ /**
1758
+ * The published editor type. `Omit`s the in-package-only surfaces (see
1759
+ * `SvgEditorInternalMembers`) so consumers don't see them in IntelliSense
1760
+ * or the dist `.d.ts`. They still exist on the runtime object — casting
1761
+ * to `SvgEditorInternal` reaches them, but the contract is clear: stay
1762
+ * inside the package.
1763
+ */
1764
+ type SvgEditor = Omit<SvgEditorInternal, SvgEditorInternalMembers>;
1765
+ /**
1766
+ * Host-provided rendering and input boundary. v0 contract is pure
1767
+ * lifecycle: the editor calls `dispose()` on `editor.detach()` /
1768
+ * `editor.dispose()`; the surface owns its own teardown there (event
1769
+ * listeners, DOM nodes, retained refs).
1770
+ *
1771
+ * **Why so narrow?** The cross-surface vocabulary — paint push, input
1772
+ * routing, hit-testing — isn't pinned yet. The DOM surface re-serializes
1773
+ * the document via `editor.subscribe`, attaches its own listeners, and
1774
+ * owns its own pick. The editor reaches into the DOM surface through
1775
+ * the in-package `_internal` channel (`SurfaceBridge` in
1776
+ * `core/surface-bridge.ts`), not through the public `Surface` type. A
1777
+ * cross-surface paint / input / hit-test contract is deferred until a
1778
+ * second surface implementation arrives and pins each shape (P6 —
1779
+ * public only after dogfooding).
1780
+ */
1781
+ type Surface$1 = {
1782
+ dispose(): void;
1783
+ };
1784
+ type SurfaceHandle = {
1785
+ detach(): void;
1786
+ };
1787
+ type Commands = {
1788
+ select(target: NodeId | ReadonlyArray<NodeId>, opts?: {
1789
+ mode?: SelectMode;
1790
+ }): void;
1791
+ deselect(): void;
1792
+ /**
1793
+ * Replace the selection with every element-child of the current scope
1794
+ * (or `doc.root` when no scope is entered). Returns `false` when the
1795
+ * scope has no element children — letting the keymap chain fall through
1796
+ * unchanged.
1797
+ */
1798
+ select_all(): boolean;
1799
+ /**
1800
+ * Rotate the selection to the next or previous sibling within the
1801
+ * current single-selection's parent. Wraps at both ends. With an empty
1802
+ * or multi-selection, falls back to selecting the first / last child of
1803
+ * the current scope (so Tab from "nothing selected" picks something).
1804
+ * Returns `false` when no candidate exists (empty scope).
1805
+ */
1806
+ select_sibling(direction: "next" | "prev"): boolean;
1807
+ enter_scope(group: NodeId): void;
1808
+ exit_scope(): void;
1809
+ set_mode(mode: Mode): void;
1810
+ set_property(name: string, value: string | null): void;
1811
+ preview_property(name: string): PreviewSession;
1812
+ set_paint(channel: "fill" | "stroke", paint: Paint): void;
1813
+ preview_paint(channel: "fill" | "stroke"): PaintPreviewSession;
1814
+ set_paint_from_gradient(channel: "fill" | "stroke", definition: GradientDefinition, opts?: {
1815
+ reuse_existing?: boolean;
1816
+ }): {
1817
+ gradient_id: string;
1818
+ };
1819
+ translate(delta: {
1820
+ dx: number;
1821
+ dy: number;
1822
+ }): void;
1823
+ nudge(delta: {
1824
+ dx: number;
1825
+ dy: number;
1826
+ }): void;
1827
+ /**
1828
+ * Map the selection's current union-bbox to `target` as a single atomic
1829
+ * step. Maps width/height/x/y simultaneously — each member scales
1830
+ * around the union NW anchor, then the result is translated so the
1831
+ * union NW lands at `target.{x, y}`. Per-tag constraints (circle
1832
+ * uniform, text edge no-op) execute inside `apply_resize` for each
1833
+ * member.
1834
+ *
1835
+ * The default selection is `state.selection`. Pass `opts.ids` to
1836
+ * override. Members that are not resizable are skipped silently: this
1837
+ * means both an unresizable tag (e.g. `<g>`) AND a resizable tag carrying
1838
+ * a non-trivial transform (rotate-without-pivot, matrix, scale, skew),
1839
+ * which can't be resized in local space without breaking round-trip — the
1840
+ * same `is_resizable_node` gate the resize HUD applies. The gesture is a
1841
+ * no-op when no resizable member remains. Returns `true` when a history
1842
+ * step was pushed. `opts.label` overrides the atomic history label
1843
+ * (default `"resize-to"`).
1844
+ */
1845
+ resize_to(target: {
1846
+ x: number;
1847
+ y: number;
1848
+ width: number;
1849
+ height: number;
1850
+ }, opts?: {
1851
+ ids?: ReadonlyArray<NodeId>;
1852
+ label?: string;
1853
+ }): boolean;
1854
+ /**
1855
+ * Resize the selection by a delta — PER-ELEMENT: each selected member
1856
+ * grows/shrinks around its OWN NW corner, so members keep their positions
1857
+ * relative to one another (NOT a union/group resize — contrast
1858
+ * {@link resize_to}, which scales the whole selection around the shared
1859
+ * union origin and so translates off-origin members). `delta.dw` /
1860
+ * `delta.dh` are applied additively to each member (clamped to >= 0). The
1861
+ * core verb behind keyboard nudge-resize.
1862
+ *
1863
+ * ALL-OR-NOTHING gate: refuses (returns `false`, no history step) unless
1864
+ * EVERY member passes `is_resizable_node` — the same tag + transform-class
1865
+ * check the resize HUD uses, applied wholesale (a mixed selection is
1866
+ * refused, not partially resized — matches a HUD handle-drag, which is
1867
+ * rejected when any member is unsafe). Also refuses on empty selection or
1868
+ * when no geometry provider (DOM surface) is attached.
1869
+ *
1870
+ * Per-tag constraints (circle uniform, text edge no-op) apply per member.
1871
+ * The default selection is `state.selection`; pass `opts.ids` to override.
1872
+ */
1873
+ resize_by(delta: {
1874
+ dw: number;
1875
+ dh: number;
1876
+ }, opts?: {
1877
+ ids?: ReadonlyArray<NodeId>;
1878
+ }): boolean;
1879
+ /**
1880
+ * Rotate the selection by `angle` radians around the union-bbox center
1881
+ * (or `opts.pivot` if provided). One atomic history step. Returns
1882
+ * `false` and a no-op when any member's transform isn't rotatable (see
1883
+ * `is_rotatable` — refuses non-trivial transforms, `<text rotate>`,
1884
+ * CSS-property transforms, animated transforms).
1885
+ *
1886
+ * Pivot defaults to bbox-center of the live selection via the attached
1887
+ * surface's `geometry_provider`. With no surface attached, the function
1888
+ * uses local-attribute bbox approximations (less precise for transformed
1889
+ * ancestors, but correct for flat docs).
1890
+ */
1891
+ rotate(angle: number, opts?: {
1892
+ ids?: ReadonlyArray<NodeId>;
1893
+ pivot?: {
1894
+ x: number;
1895
+ y: number;
1896
+ };
1897
+ }): boolean;
1898
+ /**
1899
+ * Set the absolute rotation of each member to `angle` radians. Computes
1900
+ * the per-member delta from each baseline's current rotation. Pivot
1901
+ * defaults to union-bbox center. Same refusal semantics as `rotate`.
1902
+ * One atomic history step.
1903
+ */
1904
+ rotate_to(angle: number, opts?: {
1905
+ ids?: ReadonlyArray<NodeId>;
1906
+ pivot?: {
1907
+ x: number;
1908
+ y: number;
1909
+ };
1910
+ }): boolean;
1911
+ /**
1912
+ * Compose an arbitrary 2×3 affine onto the selection, **relative** and
1913
+ * applied in **world space about a pivot**. `matrix` is in SVG
1914
+ * `matrix(a b c d e f)` order (see {@link Matrix2D}).
1915
+ *
1916
+ * Semantics: the effective affine written to each member is
1917
+ * `E = T(pivot) · matrix · T(-pivot)`, so the bare flip tuples become
1918
+ * in-place flips about the pivot. Pivot defaults to the selection
1919
+ * union-bbox center (via the attached surface's `geometry_provider`);
1920
+ * pass `opts.pivot` to override.
1921
+ *
1922
+ * Round-trip: `E` is folded onto each member's transform list as a
1923
+ * single LEADING `matrix` op — existing `rotate`/`translate` tokens are
1924
+ * preserved after it, repeated applies collapse into one matrix, and a
1925
+ * net-identity leading matrix is dropped (so flip-then-flip restores
1926
+ * the original). One atomic history step labelled `"transform"`.
1927
+ *
1928
+ * Refusal (returns `false`, no-op, no history): empty selection, no
1929
+ * `geometry_provider`, or any member failing `is_rotatable` (the same
1930
+ * non-trivial-transform / `<text rotate>` / CSS-property / animated
1931
+ * gate `rotate` uses). All-or-nothing — no partial writes.
1932
+ *
1933
+ * Flat-doc limitation: only each element's OWN transform is folded;
1934
+ * the pivot is treated as world ≡ parent space. Nested transformed
1935
+ * ancestors (`<g transform=…>`) are out of scope.
1936
+ */
1937
+ transform(matrix: Matrix2D, opts?: {
1938
+ ids?: ReadonlyArray<NodeId>;
1939
+ pivot?: {
1940
+ x: number;
1941
+ y: number;
1942
+ };
1943
+ }): boolean;
1944
+ /**
1945
+ * Collapse each selected member's `transform=` to a single `matrix(...)`
1946
+ * token, baking accumulated translates / rotates / scales / skews into
1947
+ * the equivalent affine. After flatten, the element's transform list
1948
+ * classifies as `mixed` from the parser's view — but `rotate` will then
1949
+ * refuse it. The point isn't to enable further rotation; it's to give
1950
+ * the user an explicit pre-rotation reset path so accumulated trig
1951
+ * drift has a recovery option.
1952
+ *
1953
+ * Returns `false` when nothing was changed (selection empty or every
1954
+ * member already has no transform / a single matrix).
1955
+ */
1956
+ flatten_transform(opts?: {
1957
+ ids?: ReadonlyArray<NodeId>;
1958
+ }): boolean;
1959
+ /**
1960
+ * Translate each member so its bbox aligns with the requested edge or
1961
+ * center of the selection's union bbox. Single atomic history step.
1962
+ * Refuses (returns `false`) when fewer than two members have a world-
1963
+ * bbox available — alignment with a single reference is undefined here
1964
+ * (target-to-canvas / target-to-parent semantics are not yet designed).
1965
+ */
1966
+ align(direction: AlignDirection, opts?: {
1967
+ ids?: ReadonlyArray<NodeId>;
1968
+ }): boolean;
1969
+ reorder(direction: ReorderDirection): void;
1970
+ remove(): void;
1971
+ /**
1972
+ * Copy the selection as a **standalone SVG document** (the payload is
1973
+ * the file format — no private envelope). The payload carries the
1974
+ * outbound `url(#…)` / `href` reference closure in one `<defs>` block
1975
+ * and declares every namespace prefix the fragment borrows from
1976
+ * ancestor scope; ancestor transforms, inherited presentation, and the
1977
+ * viewport are deliberately NOT carried (verbatim policy — see the FRD).
1978
+ *
1979
+ * Pure read: no document mutation, no history entry. The payload is
1980
+ * always written to the editor's internal clipboard buffer (the
1981
+ * transport floor — cannot fail) and, when a `ClipboardProvider` is
1982
+ * configured, delivered to it best-effort (a failed provider write is
1983
+ * dev-warned, never a copy failure).
1984
+ *
1985
+ * Returns the payload string, or `null` on empty / non-live selection
1986
+ * (a no-op, not an error — copy has no refusal path).
1987
+ */
1988
+ copy(): string | null;
1989
+ /**
1990
+ * Copy, then delete the selection — ONE history step labeled `"cut"`
1991
+ * with {@link remove}'s exact capture/revert semantics. The payload is
1992
+ * secured in the internal buffer BEFORE the deletion commits, so a
1993
+ * failed external write never strands the user with deleted content
1994
+ * and no copy. The clipboard write is not part of the history step:
1995
+ * undo restores the document and leaves the buffer holding the payload
1996
+ * (cut → undo → paste works as a move idiom).
1997
+ *
1998
+ * Returns the payload string, or `null` on empty selection (no
1999
+ * mutation, no history).
2000
+ */
2001
+ cut(): string | null;
2002
+ /**
2003
+ * Paste SVG markup — `text` when given, else the internal clipboard
2004
+ * buffer. Synchronous over delivered text: acquisition from a native
2005
+ * clipboard event or an async provider read is the invoking channel's
2006
+ * job and completes before this command runs.
2007
+ *
2008
+ * Accepts anything {@link insert_fragment} parses (bare fragment or
2009
+ * full document — the editor's own payloads are an ordinary case, not
2010
+ * a privileged one) and inserts it with the same atomic semantics:
2011
+ * one history step, subtrees adopted verbatim, ids never rewritten,
2012
+ * namespace declarations hoisted, appended at the document top level,
2013
+ * inserted roots selected.
2014
+ *
2015
+ * **Gesture-grade refusal table** (deliberately weaker than
2016
+ * `insert_fragment`'s): paste's input is environment-supplied — prose,
2017
+ * URLs, and JSON are what clipboards hold most of the day — so
2018
+ * non-parseable input is a **no-op refusal** (`[]`, no mutation, no
2019
+ * history), never a thrown error. A non-string argument still throws
2020
+ * `TypeError` (caller bug — no acquisition channel produces one).
2021
+ * Empty selection→buffer misses (`undefined` text, empty buffer) also
2022
+ * return `[]`.
2023
+ */
2024
+ paste(text?: string): NodeId[];
2025
+ /**
2026
+ * Duplicate the selection in place — the **subtree-clone** operation
2027
+ * (the clipboard FRD's second extraction operation; design note:
2028
+ * `docs/wg/feat-svg-editor/subtree-clone.md`). Each normalized
2029
+ * selection root is cloned verbatim (byte-equal subtree markup — and
2030
+ * therefore NO defs closure, NO namespace shell: the destination is
2031
+ * the source document) and inserted as its origin's next sibling, so
2032
+ * the clone paints directly above its origin. Selection moves to the
2033
+ * clones. ONE history step; a single `undo()` removes the clones and
2034
+ * restores the prior selection.
2035
+ *
2036
+ * Authored `id=""` attributes are cloned verbatim, NEVER rewritten —
2037
+ * the document gains colliding ids that resolve first-in-document-order
2038
+ * (so a clone's internal self-reference resolves to the ORIGINAL);
2039
+ * dedup is the explicit Tidy command's job.
2040
+ *
2041
+ * **Repeating offset** (gridaco/grida#825, spec §Repeating offset):
2042
+ * duplicate, move the copy, duplicate again — the next copy lands at
2043
+ * the same relative offset from the previous one (Figma's repeating
2044
+ * duplicate; an Alt-drag clone commit arms the same memory, so ⌘D
2045
+ * after a clone-drag repeats the drag offset). Still ONE history
2046
+ * step: a single `undo()` removes copy + offset together. Requires an
2047
+ * attached geometry provider; when the repeat's preconditions don't
2048
+ * hold (selection isn't the previous clones, a copy was resized,
2049
+ * nothing moved, no geometry) the command degrades to the plain
2050
+ * in-place duplicate above — never an error.
2051
+ *
2052
+ * Refusal (no mutation, no history): an empty selection, or one that
2053
+ * normalizes to nothing cloneable (document root, nested `<svg>`,
2054
+ * stale / non-element ids) → `[]`. Returns the clone ids in document
2055
+ * order otherwise.
2056
+ */
2057
+ duplicate(): NodeId[];
2058
+ /**
2059
+ * Wrap the current selection in a new plain `<g>`. Returns `true` if
2060
+ * the wrap was performed (a history step was pushed and the new group
2061
+ * is the active selection); `false` if the policy in `GROUPING.md`
2062
+ * rejected the call.
2063
+ */
2064
+ group(): boolean;
2065
+ /**
2066
+ * Dissolve the selected `<g>` (or `opts.id`), hoisting its children
2067
+ * into the group's parent at the group's z-position. Returns `true`
2068
+ * when a history step was pushed (children hoisted, group removed, the
2069
+ * former children selected); `false` when the call was refused.
2070
+ *
2071
+ * Only the **safe clean-structural subset** is accepted (see
2072
+ * `core/group.ts:plan_ungroup` and `../docs/grouping.md` §Ungrouping).
2073
+ * Refused — with NO mutation and NO history entry — when: the target
2074
+ * is not a single `<g>`; the group is inside `<defs>`; the group has
2075
+ * no element children; the group carries any own attribute beyond
2076
+ * `{ transform, id, data-grida-id }` (i.e. any visual / cascade state
2077
+ * such as `opacity` / `class` / `style` / `filter` / `clip-path` /
2078
+ * `mask` / `fill`); the group's `id` is referenced by a `<use>`; a
2079
+ * direct child is an SMIL animation element; or — when the group has a
2080
+ * `transform` — any child's own transform is unparseable.
2081
+ *
2082
+ * When the group has a `transform`, it is BAKED into each child by
2083
+ * prepending the group's parsed ops to the child's (clean token
2084
+ * compose, not a matrix collapse), so paint output round-trips.
2085
+ */
2086
+ ungroup(opts?: {
2087
+ id?: NodeId;
2088
+ }): boolean;
2089
+ /**
2090
+ * Atomic one-shot insertion. Creates a new element of the given SVG
2091
+ * tag with the supplied attributes (merged on top of the package's
2092
+ * default paint attrs for `rect` / `ellipse` / `line`), inserts it at
2093
+ * the given parent (default: root), and selects it. One undo step.
2094
+ * Returns the new node id.
2095
+ *
2096
+ * Use this for paste, programmatic creation, and any non-pointer
2097
+ * insertion path. The DOM surface's drag-to-size gesture uses
2098
+ * `insert_preview` instead so it can bracket per-frame attr writes.
2099
+ */
2100
+ insert(tag: string, attrs: Readonly<Record<string, string>>, opts?: {
2101
+ parent?: NodeId;
2102
+ index?: number;
2103
+ select?: boolean;
2104
+ }): NodeId;
2105
+ /**
2106
+ * Atomic insertion of a pre-authored SVG **fragment** — one or more
2107
+ * sibling elements as markup (`"<g …><path …/></g>"`), or a full
2108
+ * `<svg>` document whose element children are taken as the content
2109
+ * (the `<svg>` shell — viewBox, width/height, prolog, doctype — is
2110
+ * discarded; an `<svg>` that is one of several top-level elements is
2111
+ * content and inserted as-is). The element subtrees are adopted
2112
+ * verbatim — every byte of trivia inside each element survives
2113
+ * (attribute order, quote styles, whitespace, comments) — inserted
2114
+ * contiguously in source order at `opts.parent` / `opts.index`, and
2115
+ * selected. ONE history step regardless of fragment size; a single
2116
+ * `undo()` restores the exact pre-insert serialization. Returns the
2117
+ * inserted top-level ids in document order.
2118
+ *
2119
+ * This is the markup-shaped sibling of {@link insert} — the primitive
2120
+ * paste and asset-stamping flows compose. Use `insert` for a tag +
2121
+ * attrs; use `insert_fragment` for markup.
2122
+ *
2123
+ * **Position is authored content.** There is deliberately no placement
2124
+ * opt: to land a fragment at a document-space point, author the
2125
+ * position into the markup before inserting — wrap it in
2126
+ * `<g transform="translate(x y)">…</g>` or set the elements' own
2127
+ * geometry attrs. Placement then round-trips as ordinary markup and
2128
+ * the whole drop is the same single undo step.
2129
+ *
2130
+ * **`id` collisions:** authored `id=""` attributes are inserted
2131
+ * verbatim, NEVER rewritten — silent id renaming is proprietary noise
2132
+ * (P1; README "What clean means" §3). When a fragment id collides
2133
+ * with an existing one, reference resolution (`url(#…)`, `href`)
2134
+ * follows the document-order rules of the host renderer; resolving
2135
+ * the duplication is the explicit Tidy command's job, not insertion's.
2136
+ *
2137
+ * **Namespaces:** when the fragment uses a prefix the document root
2138
+ * doesn't declare, the declaration is hoisted onto the root as part
2139
+ * of the same history step — `xlink` (well-known URI) and any prefix
2140
+ * the discarded `<svg>` shell declared. A prefix whose URI is not
2141
+ * discoverable is left as authored (the input was equally unbound as
2142
+ * a standalone document). An authored root declaration always wins —
2143
+ * never rebound.
2144
+ *
2145
+ * **Refusals:** an input with no top-level elements (empty /
2146
+ * whitespace / comments-only) returns `[]` with NO history step.
2147
+ * Throws on malformed markup (parser errors propagate), on a
2148
+ * non-string input, and on an `opts.parent` that isn't a live element
2149
+ * of the current document — a silent no-op there would hide consumer
2150
+ * bugs (same stance as `serialize_node`).
2151
+ *
2152
+ * `opts.parent` defaults to root; `opts.index` (position in the
2153
+ * parent's element-children list; the whole fragment lands
2154
+ * contiguously at it) defaults to append; `opts.select` defaults to
2155
+ * `true`.
2156
+ */
2157
+ insert_fragment(svg: string, opts?: {
2158
+ parent?: NodeId;
2159
+ index?: number;
2160
+ select?: boolean;
2161
+ }): NodeId[];
2162
+ /**
2163
+ * Preview-bracketed insertion for drag-to-size gestures. Creates and
2164
+ * inserts the node immediately (so HUD selection chrome renders);
2165
+ * per-frame `update(attrs)` writes geometry; `commit()` collapses the
2166
+ * gesture into one undo step; `discard()` rolls back as if the gesture
2167
+ * never happened.
2168
+ */
2169
+ insert_preview(tag: string, initial: Readonly<Record<string, string>>, opts?: {
2170
+ parent?: NodeId;
2171
+ index?: number;
2172
+ }): InsertPreviewSession;
2173
+ set_text(value: string): void;
2174
+ load_svg(svg: string): void;
2175
+ serialize_svg(): string;
2176
+ undo(): void;
2177
+ redo(): void;
2178
+ /**
2179
+ * Register a command handler under a stable id. Returns an unregister
2180
+ * function. Re-registering the same id replaces the previous handler.
2181
+ *
2182
+ * Handlers return `true` if they consumed the invocation; `false` or
2183
+ * `undefined` signal "did not apply" — the keymap dispatcher will try
2184
+ * the next candidate in the chain.
2185
+ */
2186
+ register(id: CommandId, handler: CommandHandler): () => void;
2187
+ /**
2188
+ * Invoke a registered command by id. Returns `true` if a handler
2189
+ * consumed the invocation, `false` otherwise (including unknown ids).
2190
+ */
2191
+ invoke(id: CommandId, args?: unknown): boolean; /** Whether an id has a registered handler. */
2192
+ has(id: CommandId): boolean;
2193
+ };
2194
+ /**
2195
+ * Wide internal factory — returns the full object including the
2196
+ * `_internal` / `keymap` surfaces in its inferred type. Stays private.
2197
+ * The public `createSvgEditor` below wraps this and narrows the return
2198
+ * to `SvgEditor` so the published `.d.ts` doesn't advertise internals.
2199
+ */
2200
+ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
2201
+ /**
2202
+ * Low-level IR handle. Mutating directly bypasses history; prefer
2203
+ * `editor.commands` for app code.
2204
+ */
2205
+ document: SvgDocument;
2206
+ readonly state: EditorState;
2207
+ subscribe: (fn: (state: EditorState) => void) => Unsubscribe;
2208
+ subscribe_with_selector: <T>(selector: (s: EditorState) => T, fn: (value: T, prev: T) => void, equals?: (a: T, b: T) => boolean) => Unsubscribe;
2209
+ node_properties: (id: NodeId, names: ReadonlyArray<string>) => {
2210
+ readonly [name: string]: PropertyValue;
2211
+ };
2212
+ node_paint: (id: NodeId, channel: "fill" | "stroke") => PaintValue;
2213
+ dom_computed_property: (id: NodeId, name: string) => string | null;
2214
+ dom_computed_paint: (id: NodeId, channel: "fill" | "stroke") => DomComputedPaint | null;
2215
+ /**
2216
+ * Enter content-edit mode on a `<text>` node. Returns `false` (no-op)
2217
+ * when no DOM surface is attached.
2218
+ */
2219
+ enter_content_edit: (target?: NodeId) => boolean;
2220
+ defs: Defs;
2221
+ commands: Commands;
2222
+ /**
2223
+ * Human-readable label for hierarchy panels. SVG has no native "name";
2224
+ * this is the package's single source of truth so panels don't reinvent
2225
+ * the rule.
2226
+ *
2227
+ * Rule:
2228
+ * - `<text>` → text content, whitespace-collapsed and truncated at
2229
+ * ~40 chars (falls back to `"text"` for empty content).
2230
+ * - Otherwise → tag name, suffixed with `#id` when the `id` attribute
2231
+ * is present (e.g. `"rect #sun"`).
2232
+ *
2233
+ * `opts.tagLabel` lets callers substitute a friendlier or localized
2234
+ * term for the raw tag (e.g. `"rect"` → `"Rectangle"`). Only invoked
2235
+ * on the non-text branch.
2236
+ */
2237
+ display_label(id: NodeId, opts?: {
2238
+ tagLabel?: (tag: string) => string;
2239
+ }): string;
2240
+ tree(): {
2241
+ root: NodeId;
2242
+ nodes: ReadonlyMap<NodeId, {
2243
+ id: NodeId;
2244
+ tag: string;
2245
+ name?: string;
2246
+ parent: NodeId | null;
2247
+ children: ReadonlyArray<NodeId>;
2248
+ }>;
2249
+ };
2250
+ /**
2251
+ * The effective hover from the attached HUD surface — what's under the
2252
+ * pointer, OR whatever `set_surface_hover_override` last pushed. Used
2253
+ * by out-of-canvas UI (layers panel, breadcrumbs) to mirror the canvas
2254
+ * highlight. Returns `null` when nothing is hovered.
2255
+ */
2256
+ surface_hover(): NodeId | null;
2257
+ /**
2258
+ * Push a hover override into the HUD surface — e.g. when the user
2259
+ * hovers a row in a layers panel. The HUD will render the override's
2260
+ * outline and (when applicable) drive measurement to that node.
2261
+ * Pass `null` to clear and let the pointer pick take over again.
2262
+ */
2263
+ set_surface_hover_override(id: NodeId | null): void;
2264
+ /**
2265
+ * Subscribe to changes in the effective surface hover. Fires when the
2266
+ * HUD reports a new pointer pick AND when an override is set/cleared.
2267
+ * Cheap channel — does NOT bump `state.version`.
2268
+ */
2269
+ subscribe_surface_hover(cb: () => void): () => void;
2270
+ /**
2271
+ * Subscribe to pick (tap) outcomes — a discrete click on the canvas,
2272
+ * reporting the document-space point and the node under it (`null` for
2273
+ * empty canvas), plus the button and modifier snapshot. Fires once per
2274
+ * tap, after the editor's own selection handling. Observe-only: a pick
2275
+ * cannot alter selection, and the channel does NOT bump `state.version`.
2276
+ * See {@link PickEvent}.
2277
+ *
2278
+ * @unstable
2279
+ */
2280
+ subscribe_pick(cb: (e: PickEvent) => void): Unsubscribe;
2281
+ /**
2282
+ * Subscribe to bounds-affecting changes. Fires when any document
2283
+ * mutation advances `state.geometry_version` — drag, resize, text
2284
+ * edit, structural insert/remove. Skips presentation-only writes
2285
+ * (fill, opacity, stroke-color).
2286
+ */
2287
+ subscribe_geometry(cb: () => void): () => void;
2288
+ /**
2289
+ * World-space geometry queries. Non-null when a DOM surface is
2290
+ * attached; null otherwise (queries need a renderer to read bbox
2291
+ * from). Read-only — never mutates document state.
2292
+ */
2293
+ readonly geometry: GeometryProvider | null;
2294
+ modes: readonly Mode[]; /** Switch the active tool. No history entry; bumps `state.version`. */
2295
+ set_tool: (next: Tool) => void;
2296
+ readonly style: Readonly<EditorStyle>;
2297
+ set_style: (partial: Partial<EditorStyle>) => void;
2298
+ load: (svg: string) => void;
2299
+ serialize: () => string;
2300
+ /**
2301
+ * Serialize a single element's subtree as an SVG **fragment**, using the
2302
+ * same trivia-preserving rules as {@link serialize} — for handing "the
2303
+ * markup of the element the user selected" to a downstream consumer
2304
+ * (e.g. an AI agent) without re-serializing the whole document.
2305
+ *
2306
+ * Fragment, not document (see `SvgDocument.serialize_node`): it does NOT
2307
+ * carry `serialize()`'s whole-document round-trip guarantee. Namespace
2308
+ * declarations on an ancestor (`xmlns:xlink`, normally on the root
2309
+ * `<svg>`) are NOT inlined — a node using `xlink:href` serializes without
2310
+ * `xmlns:xlink`. Throws on an unknown id or a non-element node.
2311
+ */
2312
+ serialize_node(id: NodeId): string;
2313
+ reset: () => void;
2314
+ attach: (surface: Surface$1) => SurfaceHandle;
2315
+ detach: () => void;
2316
+ dispose: () => void;
2317
+ providers: Providers;
2318
+ _internal: {
2319
+ doc: SvgDocument;
2320
+ history: {
2321
+ preview: (label: string) => import("@grida/history").Preview;
2322
+ undo_label: () => string | null;
2323
+ };
2324
+ clipboard: {
2325
+ copy: () => string | null;
2326
+ cut: () => string | null;
2327
+ };
2328
+ insert_text_preview: (initial: Readonly<Record<string, string>>, opts?: {
2329
+ parent?: NodeId;
2330
+ }) => {
2331
+ id: NodeId;
2332
+ commit(): void;
2333
+ discard(): void;
2334
+ };
2335
+ emit: () => void;
2336
+ subscribe_translate_commit(cb: () => void): () => void;
2337
+ notify_translate_commit: () => void;
2338
+ seed_duplication(record: subtree.DuplicationRecord): void;
2339
+ set_content_edit_driver(fn: ((target: NodeId) => boolean) | null): void;
2340
+ set_surface_hover_override_driver(fn: ((id: NodeId | null) => void) | null): void;
2341
+ push_surface_hover(id: NodeId | null): void;
2342
+ push_pick(e: PickEvent): void;
2343
+ set_computed_resolver(fn: DomComputedResolver | null): void;
2344
+ set_geometry(p: GeometryProvider | null): void;
2345
+ register_command(id: string, handler: CommandHandler): () => void;
2346
+ bump_geometry(): void;
2347
+ };
2348
+ keymap: Keymap;
2349
+ };
2350
+ /**
2351
+ * Construct a headless SVG editor. The returned object is the public
2352
+ * editor surface — observation (`state`, `subscribe`), commands
2353
+ * (`commands.*`), lifecycle (`attach` / `dispose`), and the typed-read
2354
+ * caches (`node_paint`, `node_properties`). Surfaces (DOM, headless)
2355
+ * attach later via `editor.attach(surface)`.
2356
+ */
2357
+ declare function createSvgEditor(opts: CreateSvgEditorOptions): SvgEditor;
2358
+ //#endregion
2359
+ export { RadialGradientDefinition as $, EditorState as A, LinearGradientDefinition as B, BoundsResolver as C, ClipboardProvider as D, CameraOptions as E, GradientEntry as F, PaintFallback as G, Mode as H, GradientStop as I, PickEvent as J, PaintPreviewSession as K, InsertPreviewSession as L, FileIOProvider as M, FontResolver as N, Color as O, GradientDefinition as P, Providers as Q, InsertableTag as R, AlignDirection as S, CameraConstraints as T, NodeId as U, Matrix2D as V, Paint as W, PropertyValue as X, PreviewSession as Y, Provenance as Z, PathModel as _, SelectMode as a, Vec2 as at, Verb as b, SvgEditor as c, GestureContext as d, Rect as et, GestureId as f, MemoizedGeometryProvider as g, GeometrySignals as h, DomComputedResolver as i, Unsubscribe as it, EditorStyle as j, DEFAULT_STYLE as k, createSvgEditor as l, GeometryProvider as m, CreateSvgEditorOptions as n, TOOL_CURSOR as nt, Surface$1 as o, Gestures as p, PaintValue as q, DomComputedPaint as r, Tool as rt, SurfaceHandle as s, Commands as t, ReorderDirection as tt, GestureBinding as u, PathSnapshot as v, Camera as w, VertexId as x, SegmentId as y, InvalidComputedValue as z };