@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.
- package/README.md +343 -189
- package/dist/chunk-D7D4PA-g.mjs +13 -0
- package/dist/dom-CQkWJNrK.d.ts +237 -0
- package/dist/dom-CuK0LFUY.js +5276 -0
- package/dist/dom-DHaTIObb.mjs +5221 -0
- package/dist/dom-Dw2SPHgc.d.mts +239 -0
- package/dist/dom.d.mts +3 -16
- package/dist/dom.d.ts +3 -16
- package/dist/dom.js +9 -1
- package/dist/dom.mjs +2 -2
- package/dist/editor-BlByfVyF.js +2936 -0
- package/dist/editor-CJ3ROm0G.mjs +2930 -0
- package/dist/editor-CcW4BVth.d.mts +2359 -0
- package/dist/editor-CxqRhhzP.d.ts +2359 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -2
- package/dist/index.mjs +3 -2
- package/dist/model-C6jCFK_p.mjs +5329 -0
- package/dist/model-DVwjrVYp.js +5512 -0
- package/dist/presets.d.mts +61 -0
- package/dist/presets.d.ts +61 -0
- package/dist/presets.js +60 -0
- package/dist/presets.mjs +54 -0
- package/dist/react.d.mts +133 -12
- package/dist/react.d.ts +133 -12
- package/dist/react.js +214 -19
- package/dist/react.mjs +203 -21
- package/package.json +40 -9
- package/dist/dom-CfP_ZURh.js +0 -963
- package/dist/dom-kA8NDuVh.mjs +0 -929
- package/dist/editor-BryibVvr.d.mts +0 -612
- package/dist/editor-DllAMsDu.js +0 -1835
- package/dist/editor-M6j8XGO5.mjs +0 -1823
- package/dist/editor-klT8wu-x.d.ts +0 -612
- package/dist/paint-DHq_3iwU.js +0 -509
- package/dist/paint-DuCg6Y-K.mjs +0 -461
|
@@ -0,0 +1,2359 @@
|
|
|
1
|
+
import cmath from "@grida/cmath";
|
|
2
|
+
import vn from "@grida/vn";
|
|
3
|
+
import { AnyNode, AttrToken } from "@grida/svg/parser";
|
|
4
|
+
import { SelectMode } from "@grida/hud";
|
|
5
|
+
import { Keybinding, Platform } from "@grida/keybinding";
|
|
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 = {
|
|
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) => 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 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 };
|