@grida/svg-editor 1.0.0-alpha.1 → 1.0.0-alpha.11
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 +184 -185
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/dom-BlMk07oX.mjs +3515 -0
- package/dist/dom-Cvm9Towu.js +3545 -0
- package/dist/dom-DCX-a8Kr.d.ts +57 -0
- package/dist/dom-DgB4f-TE.d.mts +59 -0
- package/dist/dom.d.mts +3 -16
- package/dist/dom.d.ts +3 -16
- package/dist/dom.js +5 -1
- package/dist/dom.mjs +2 -2
- package/dist/editor-BH03X8cX.d.mts +1139 -0
- package/dist/editor-Bd4-VCEJ.d.ts +1139 -0
- package/dist/{editor-DQWUWrVZ.js → editor-CdyC3uAe.js} +1205 -388
- package/dist/{editor-B5z-gTML.mjs → editor-DtuRIs-Q.mjs} +1195 -372
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -2
- package/dist/index.mjs +3 -2
- package/dist/insertions-BJ-6o6o5.js +2399 -0
- package/dist/insertions-Okcuo-Ck.mjs +2176 -0
- package/dist/presets.d.mts +61 -0
- package/dist/presets.d.ts +61 -0
- package/dist/presets.js +61 -0
- package/dist/presets.mjs +55 -0
- package/dist/react.d.mts +94 -9
- package/dist/react.d.ts +94 -9
- package/dist/react.js +157 -19
- package/dist/react.mjs +147 -21
- package/package.json +11 -6
- package/dist/dom-CfP_ZURh.js +0 -963
- package/dist/dom-kA8NDuVh.mjs +0 -929
- package/dist/editor-CTtU2gu4.d.ts +0 -607
- package/dist/editor-JY7AQrR1.d.mts +0 -607
- package/dist/paint-DHq_3iwU.js +0 -509
- package/dist/paint-DuCg6Y-K.mjs +0 -461
package/README.md
CHANGED
|
@@ -45,6 +45,14 @@ The defaults are inverted from how most editor SDKs grow. Default is **core, not
|
|
|
45
45
|
|
|
46
46
|
The consumer is expected to bring their own UI for everything outside the canvas: toolbar, property panel, layer list, inspector, contextual menus, modals. The editor's job is to be a legible source of state and a legible sink for commands — not to render those surfaces.
|
|
47
47
|
|
|
48
|
+
### Element IR (internal)
|
|
49
|
+
|
|
50
|
+
Internally, the editor wraps the parsed SVG in a **typed element IR**: a per-node typed view with element-typed capabilities (`is_resizable`, `is_rotatable`, `accepts_paint`, …), typed geometry mutators, and an explicit `RefusalReason` enum for unsupported operations. Commands dispatch on capability, not on element tag. Round-trip invariants the bytes alone cannot enforce — for example, "an editor-authored `rotate(θ cx cy)` recomposes its pivot when the local box changes" — are IR invariants enforced inside the mutator methods.
|
|
51
|
+
|
|
52
|
+
The IR is a **typed view, not alternative storage**. The parsed AST remains the in-memory store; file bytes remain the source of truth; the parse-side source-position trivia store carries whitespace, attribute order, and unknown-namespace content. The IR is rebuilt from the AST on every load and discarded on `dispose`. P1 round-trip stands.
|
|
53
|
+
|
|
54
|
+
This is consistent with the "Not a private IR" anti-goal below — that anti-goal rejects alternative on-disk format and bytes-projected-from-IR storage, neither of which this is. Design: `docs/wg/feat-svg-editor/element-ir.md`. Migration sketch: `docs/wg/feat-svg-editor/element-ir-migration.md`.
|
|
55
|
+
|
|
48
56
|
## Principles
|
|
49
57
|
|
|
50
58
|
These are decision rules, not aspirations. Each one points to a verdict when "is this core, customizable, or its own layer?" comes up in review.
|
|
@@ -63,11 +71,11 @@ A small, named set of concerns belongs to the embedding product: clipboard, file
|
|
|
63
71
|
|
|
64
72
|
### P4. Subscribe to outcomes, not events.
|
|
65
73
|
|
|
66
|
-
The public observation surface is **designed**, not raw. It exposes purpose-built views — `selection`, `
|
|
74
|
+
The public observation surface is **designed**, not raw. It exposes purpose-built views — `selection`, `properties(names)`, `mode`, `tree`, `dirty`, `version` — each of which handles multi-selection, capability variance, and history bookkeeping internally. Consumers never receive raw pointer events, reducer actions, or gesture frames. If a needed view doesn't exist, that's an API gap to close, not an internals hatch to open.
|
|
67
75
|
|
|
68
76
|
### P5. A separate layer earns its separateness by reuse or isolated testability.
|
|
69
77
|
|
|
70
|
-
Code becomes its own package or layer when it has ≥2 callers, or can be meaningfully tested without mounting the editor. `@grida/history`, `@grida/cmath`, `@grida/text-editor`, `@grida/
|
|
78
|
+
Code becomes its own package or layer when it has ≥2 callers, or can be meaningfully tested without mounting the editor. `@grida/history`, `@grida/cmath`, `@grida/text-editor`, `@grida/mixed-properties` pass. A hypothetical `@grida/svg-selection-model` (one caller, untestable without an editor) doesn't.
|
|
71
79
|
|
|
72
80
|
### P6. Public only after dogfooding.
|
|
73
81
|
|
|
@@ -89,11 +97,13 @@ When a new design decision lands, walk these in order. The first match wins.
|
|
|
89
97
|
|
|
90
98
|
These are the design principles guiding the implementation.
|
|
91
99
|
|
|
92
|
-
- Built as an SDK, not an app. Headless, backend-agnostic — no DOM or window assumptions in the core.
|
|
100
|
+
- Built as an SDK, not an app. Headless, backend-agnostic — no DOM or window assumptions in the core. Plug into any rendering surface.
|
|
93
101
|
- The IR carries source-position trivia, so the serializer can rewrite only the bytes that actually changed.
|
|
94
102
|
- Per-element-type capability modules (rect, circle, path, text, group, use, ...) contribute intent handlers, inspector controls, and direct-manipulation overlays into a shared editor shell — internally. The shared shell is what's public.
|
|
95
103
|
- Edit intents are dispatched per `(element type, gesture, mode)`, so each mutation chooses the cleanest in-place representation: rewrite native attributes when the gesture allows it, fall back to `transform=` otherwise.
|
|
96
|
-
- A separate, explicit **Tidy** command
|
|
104
|
+
- A separate, explicit **Tidy** command performs structural cleanup — deduplicate defs, strip dead resources, normalize generated class and id names, recognize geometric patterns. Never silent, never automatic.
|
|
105
|
+
|
|
106
|
+
The per-element-module and `(element type, gesture, mode)` bullets above describe today's code. The proposed model groups by edit-shape and dispatches on capability — see [Paradigm § Element IR (internal)](#element-ir-internal) and `docs/wg/feat-svg-editor/element-ir.md`. These bullets will be revised when that model lands.
|
|
97
107
|
|
|
98
108
|
## Examples
|
|
99
109
|
|
|
@@ -107,13 +117,13 @@ A few scenarios this is designed to handle well.
|
|
|
107
117
|
>
|
|
108
118
|
> The AI's structural changes round-trip cleanly through the editor. The two color tweaks are the only added lines in the diff. The next AI pass reads a file that still looks like its own work.
|
|
109
119
|
|
|
110
|
-
> _"I
|
|
120
|
+
> _"I rotated a rect by 12 degrees."_
|
|
111
121
|
>
|
|
112
|
-
> The editor
|
|
122
|
+
> The editor writes `transform="rotate(12 cx cy)"` on the rect itself. It does not wrap it in a `<g>`, does not collapse to a matrix, and does not touch the rect's `x` / `y` / `width` / `height`.
|
|
113
123
|
|
|
114
124
|
> _"This SVG has a `<style>` block with `.brand { fill: var(--brand) }`. I want to change the fill of one element to red."_
|
|
115
125
|
>
|
|
116
|
-
> The editor adds `style="fill: red"` inline on that element, which wins the cascade against the class rule. The stylesheet is untouched. Other `.brand` elements are untouched.
|
|
126
|
+
> The editor adds `style="fill: red"` inline on that element, which wins the cascade against the class rule. The stylesheet is untouched. Other `.brand` elements are untouched.
|
|
117
127
|
|
|
118
128
|
> _"This file has an Inkscape `<sodipodi:namedview>` block and `inkscape:label` attributes."_
|
|
119
129
|
>
|
|
@@ -131,7 +141,7 @@ npm install @grida/svg-editor
|
|
|
131
141
|
|
|
132
142
|
## API
|
|
133
143
|
|
|
134
|
-
> **
|
|
144
|
+
> **Status of this section.** Names and shapes are a v0 proposal — subject to P6 dogfooding before semver stability. The headings (`commands`, `state`, `properties`, `paint`, `defs`, `tree`, `modes`, `subscribe`, providers, style, surface) are committed; their member names are not. The **multi-selection ("mixed values")** section is explicitly deferred — its shape is sketched as a placeholder, not designed. Signatures shown as TypeScript-ish pseudo-code; some types are simplified for readability.
|
|
135
145
|
|
|
136
146
|
### Construction
|
|
137
147
|
|
|
@@ -155,11 +165,11 @@ const editor = createSvgEditor({
|
|
|
155
165
|
|
|
156
166
|
`createSvgEditor` is the only constructor. The returned `SvgEditor` is the only object consumers ever hold.
|
|
157
167
|
|
|
158
|
-
The editor core is **headless**. It parses the SVG, owns the document IR, accepts commands, and emits state — but it does not import, reference, or call into `window`, `document`, `HTMLElement`, or any DOM type. To render or take input, the host attaches a
|
|
168
|
+
The editor core is **headless**. It parses the SVG, owns the document IR, accepts commands, and emits state — but it does not import, reference, or call into `window`, `document`, `HTMLElement`, or any DOM type. To render or take input, the host attaches a `Surface` (next section).
|
|
159
169
|
|
|
160
170
|
### Surface
|
|
161
171
|
|
|
162
|
-
A
|
|
172
|
+
A `Surface` is the host-provided rendering and input boundary. The editor pushes paint instructions and HUD descriptors to the surface; the surface pushes normalized input events back. Non-DOM hosts (React Native, worker-side renderer, headless test harness) implement the `Surface` interface themselves. The shipped `domSurface` is the reference implementation used by the React layer.
|
|
163
173
|
|
|
164
174
|
```ts
|
|
165
175
|
import { attach_dom_surface } from "@grida/svg-editor/dom";
|
|
@@ -169,7 +179,28 @@ const handle = attach_dom_surface(editor, { container });
|
|
|
169
179
|
handle.detach();
|
|
170
180
|
```
|
|
171
181
|
|
|
172
|
-
|
|
182
|
+
The contract (full surface contract documented separately — out of scope for this README):
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
interface Surface {
|
|
186
|
+
// editor → surface: paint the document + HUD overlay
|
|
187
|
+
paint(snapshot: SurfacePaintSnapshot): void;
|
|
188
|
+
|
|
189
|
+
// surface → editor: hit-test on screen pixel
|
|
190
|
+
hit_test(x: number, y: number): NodeId | null;
|
|
191
|
+
|
|
192
|
+
// surface → editor: subscribe to normalized input
|
|
193
|
+
on_input(listener: (event: SurfaceInputEvent) => void): Unsubscribe;
|
|
194
|
+
|
|
195
|
+
dispose(): void;
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Geometry (world-space bboxes, screen ↔ local projection) is exposed via `editor.geometry`, not the `Surface` itself — the DOM surface registers a `MemoizedGeometryProvider` with the editor on attach so headless callers can query bounds without going through the surface.
|
|
200
|
+
|
|
201
|
+
`@grida/svg-editor/dom` exports `attach_dom_surface(editor, { container, ... })` as the default DOM implementation, plus the surface-scoped types (`Camera`, `Gestures`, `SnapOptions`, `MemoizedGeometryProvider`, `DomComputedResolver`) that callers writing alternative surfaces or advanced integrations may need. It mounts the SVG into the container, wires pointer / keyboard listeners scoped to that container, and uses native `getBBox` / `getScreenCTM` for geometry. It is the only place in this package that imports DOM types.
|
|
202
|
+
|
|
203
|
+
The container is **exclusively owned** by the surface. Render toolbars, layer lists, inspectors, and any other interactive chrome as **siblings** of the container, not children. Children of the container interfere with pointer routing (capture redirects, hit-test ordering) and produce silent click breakage. The shipped `SvgEditorCanvas` React component enforces this by creating its own internal div; hosts using `domSurface` / `keynote.attach` directly are responsible for the same discipline. In development, the surface emits a `console.warn` at attach time when the container is non-empty.
|
|
173
204
|
|
|
174
205
|
### Lifecycle
|
|
175
206
|
|
|
@@ -179,7 +210,7 @@ editor.detach(): void; // detach current surface, keep editor state
|
|
|
179
210
|
editor.dispose(): void; // permanent teardown
|
|
180
211
|
```
|
|
181
212
|
|
|
182
|
-
`load()`, `serialize()`, `reset()`, commands, and subscriptions all work on the headless editor regardless of whether a surface is attached.
|
|
213
|
+
`load()`, `serialize()`, `reset()`, commands, and subscriptions all work on the headless editor regardless of whether a surface is attached.
|
|
183
214
|
|
|
184
215
|
### External control
|
|
185
216
|
|
|
@@ -196,12 +227,14 @@ editor.state: {
|
|
|
196
227
|
readonly selection: ReadonlyArray<NodeId>;
|
|
197
228
|
readonly scope: NodeId | null; // active isolation (group entered via dblclick)
|
|
198
229
|
readonly mode: Mode; // "select" | "edit-content"
|
|
230
|
+
readonly tool: Tool; // { type: "cursor" } | { type: "insert", tag } — orthogonal to mode
|
|
199
231
|
readonly dirty: boolean; // unsaved changes since load() / serialize()
|
|
200
232
|
readonly can_undo: boolean;
|
|
201
233
|
readonly can_redo: boolean;
|
|
202
|
-
readonly version: number; // bumps on
|
|
203
|
-
readonly structure_version: number; // bumps only
|
|
204
|
-
|
|
234
|
+
readonly version: number; // bumps on any emission — drag, history, mutation
|
|
235
|
+
readonly structure_version: number; // bumps only when tree shape or display-label inputs change
|
|
236
|
+
readonly geometry_version: number; // bumps only when something that could shift world bounds changes
|
|
237
|
+
readonly load_version: number; // bumps once per `editor.load()` call (constructor doesn't count)
|
|
205
238
|
};
|
|
206
239
|
|
|
207
240
|
editor.subscribe(fn: (state: EditorState) => void): Unsubscribe;
|
|
@@ -212,13 +245,13 @@ editor.subscribe_with_selector<T>(
|
|
|
212
245
|
): Unsubscribe;
|
|
213
246
|
```
|
|
214
247
|
|
|
215
|
-
`
|
|
248
|
+
`version` fires on every emission and is the right key for "anything could have changed" reads. Use the narrower companions (`structure_version`, `geometry_version`, `load_version`) as cache keys when the data only depends on the corresponding slice — e.g. a hierarchy panel snapshots once per `structure_version` so a drag doesn't invalidate the tree view.
|
|
216
249
|
|
|
217
|
-
|
|
250
|
+
`state` is a frozen snapshot. Consumers never destructure into internals; if a view they need isn't here or in the purpose-built views below, that's an API gap.
|
|
218
251
|
|
|
219
252
|
### Observation — properties
|
|
220
253
|
|
|
221
|
-
This section is about **property semantics on a single node**, following the CSS / SVG spec. Multi-selection
|
|
254
|
+
This section is about **property semantics on a single node**, following the CSS / SVG spec. Multi-selection ("mixed values") is a separate concern; see the [Multi-selection](#multi-selection-mixed-values) section below. The two are kept apart on purpose: property semantics is defined by the spec; mixed semantics is an aggregation layer the editor adds because it supports multi-select.
|
|
222
255
|
|
|
223
256
|
The CSS Cascading and Inheritance spec defines a value pipeline of six stages: **declared → cascaded → specified → computed → used → actual** ([css-cascade-5 §4](https://www.w3.org/TR/css-cascade-5/#value-stages)). For an SVG property panel, two stages are useful:
|
|
224
257
|
|
|
@@ -227,9 +260,9 @@ The CSS Cascading and Inheritance spec defines a value pipeline of six stages: *
|
|
|
227
260
|
|
|
228
261
|
Plus the editor's own metadata, marked as such:
|
|
229
262
|
|
|
230
|
-
- **`provenance`** — _editor metadata, not a CSS spec term_. Reports which document carrier won the cascade for this node (presentation attribute, inline style, inherited from parent, defaulted). The cascade itself collapses these into the "author" origin; we surface them because we parsed them.
|
|
263
|
+
- **`provenance`** — _editor metadata, not a CSS spec term_. Reports which document carrier won the cascade for this node (presentation attribute, inline style, stylesheet rule, inherited from parent, defaulted). The cascade itself collapses these into the "author" origin; we surface them because we parsed them.
|
|
231
264
|
|
|
232
|
-
Intermediate stages (`specified`) and downstream stages (`used`, `actual`) are not exposed.
|
|
265
|
+
Intermediate stages (`specified`) and downstream stages (`used`, `actual`) are not exposed. `specified` differs from `declared` only when CSS-wide keywords are present and is rarely useful to a panel UI. `used` and `actual` are surface-bound and out of scope for the headless editor.
|
|
233
266
|
|
|
234
267
|
```ts
|
|
235
268
|
type Provenance = {
|
|
@@ -247,30 +280,13 @@ type InvalidComputedValue = {
|
|
|
247
280
|
reason: string; // e.g. "var(--brand) is not defined and has no fallback"
|
|
248
281
|
};
|
|
249
282
|
|
|
250
|
-
type PropertyValue<T = string
|
|
283
|
+
type PropertyValue<T = string> = {
|
|
251
284
|
declared: string | null;
|
|
252
285
|
computed: T | InvalidComputedValue | null;
|
|
253
286
|
provenance: Provenance;
|
|
254
287
|
};
|
|
255
288
|
```
|
|
256
289
|
|
|
257
|
-
#### What v1's cascade engine covers
|
|
258
|
-
|
|
259
|
-
The headless cascade engine resolves values synchronously without a surface, covering:
|
|
260
|
-
|
|
261
|
-
- Presentation attributes (`<rect fill="red">`)
|
|
262
|
-
- Inline `style="..."`
|
|
263
|
-
- Inheritance from parent elements
|
|
264
|
-
- The property's initial value
|
|
265
|
-
|
|
266
|
-
#### What v1's cascade engine does **not** cover
|
|
267
|
-
|
|
268
|
-
- `<style>` block matching
|
|
269
|
-
- `var()` (custom-property) substitution
|
|
270
|
-
- `currentColor` resolution
|
|
271
|
-
|
|
272
|
-
For these three, attach a DOM surface and use `editor.dom_computed_property(id, name)` or `editor.dom_computed_paint(id, channel)` (see [Low-level API](#low-level-api)). Those methods delegate to `getComputedStyle()` against the mounted SVG, which the browser implements end-to-end — `<style>` block matching, `var()`, inheritance, the full pipeline. Treat them as the v1 path for "what does the browser actually paint?".
|
|
273
|
-
|
|
274
290
|
Read — per node:
|
|
275
291
|
|
|
276
292
|
```ts
|
|
@@ -282,7 +298,9 @@ editor.node_properties(
|
|
|
282
298
|
|
|
283
299
|
Property names mirror SVG attribute / CSS property names exactly. No invented schema. Names the editor knows return type-parsed `computed` values (e.g. `opacity` → `number`); unknown names return generic `string`.
|
|
284
300
|
|
|
285
|
-
|
|
301
|
+
Resolving `computed` requires a cascade engine. The editor implements only the subset needed for clean editor DX: [presentation attributes](https://www.w3.org/TR/SVG2/styling.html#PresentationAttributes), inline `style=""`, and rules from `<style>` blocks within the document. External stylesheets are out of scope; declarations from them would fall through to `defaulted` / `inherited` with the inspector surfacing that honestly.
|
|
302
|
+
|
|
303
|
+
Write — selection-scoped. Writing the same value to every selected node has no mixed-value ambiguity, so the write API is selection-scoped without engaging the multi-selection layer:
|
|
286
304
|
|
|
287
305
|
```ts
|
|
288
306
|
editor.commands.set_property(name: string, value: string | null): void;
|
|
@@ -300,12 +318,16 @@ The editor decides whether to write a presentation attribute vs. inline style fo
|
|
|
300
318
|
|
|
301
319
|
`fill` and `stroke` are common enough — and shape-different enough from a plain string — that they get a dedicated typed API. A solid color, a paint-server reference, and `currentColor` are not interchangeable strings; pretending they are is what produces editors that round-trip badly.
|
|
302
320
|
|
|
321
|
+
This section, like Properties above, is **per-node and spec-aligned**. Multi-selection aggregation is in the [Multi-selection](#multi-selection-mixed-values) section.
|
|
322
|
+
|
|
303
323
|
The `Paint` type follows the [SVG 2 `<paint>` production](https://www.w3.org/TR/SVG2/painting.html#SpecifyingPaint) literally:
|
|
304
324
|
|
|
305
325
|
```
|
|
306
326
|
<paint> = none | <color> | <url> [none | <color>]? | context-fill | context-stroke
|
|
307
327
|
```
|
|
308
328
|
|
|
329
|
+
`<color>` includes `currentColor` per [CSS Color 4 §4](https://www.w3.org/TR/css-color-4/#typedef-color). `inherit` and `var()` are _not_ paint values — they are defaulting / substitution mechanisms that are resolved before the computed value exists (see the property stages above). They appear in `declared` strings but never in a parsed `Paint`.
|
|
330
|
+
|
|
309
331
|
```ts
|
|
310
332
|
type Paint =
|
|
311
333
|
| { kind: "none" } // fill="none"
|
|
@@ -316,13 +338,15 @@ type Paint =
|
|
|
316
338
|
|
|
317
339
|
type PaintFallback = { kind: "none" } | { kind: "color"; value: Color };
|
|
318
340
|
|
|
341
|
+
// Color preserves currentColor as a keyword at computed time (CSS Color 4 §4.4); the
|
|
342
|
+
// rgb resolution happens at *used* value, which requires the surface's painting context.
|
|
319
343
|
type Color =
|
|
320
344
|
| { kind: "rgb"; value: string } // any resolvable CSS color, normalized to rgb-ish
|
|
321
|
-
| { kind: "current_color" }; // unresolved keyword;
|
|
345
|
+
| { kind: "current_color" }; // unresolved keyword; surface dereferences at paint time
|
|
322
346
|
|
|
323
347
|
type PaintValue = {
|
|
324
348
|
declared: string | null; // raw, e.g. "var(--brand, currentColor)" or "url(#g1) red"
|
|
325
|
-
computed: Paint | InvalidComputedValue | null; // post-defaulting
|
|
349
|
+
computed: Paint | InvalidComputedValue | null; // post-defaulting, post-var
|
|
326
350
|
provenance: Provenance;
|
|
327
351
|
};
|
|
328
352
|
```
|
|
@@ -333,11 +357,11 @@ Read — per node:
|
|
|
333
357
|
editor.node_paint(id: NodeId, channel: "fill" | "stroke"): PaintValue;
|
|
334
358
|
```
|
|
335
359
|
|
|
336
|
-
Notes per spec:
|
|
360
|
+
Notes on the `<url>` reference, per spec:
|
|
337
361
|
|
|
338
|
-
-
|
|
339
|
-
-
|
|
340
|
-
-
|
|
362
|
+
- The fallback (`<url> <color>` or `<url> none`) kicks in only when the URL resolves to a missing or invalid paint server. A valid-but-empty gradient is still valid; the fallback does not apply ([SVG 2 §13.2.1](https://www.w3.org/TR/SVG2/painting.html#FillStrokePaintServer)).
|
|
363
|
+
- A reference to a non-existent id with no fallback paints nothing for that layer (silently skipped, not an error). The editor surfaces this via a warning in the `defs` registry's `subscribe()`.
|
|
364
|
+
- `context-fill` / `context-stroke` are only meaningful inside `<marker>` content or a `<use>` shadow tree ([SVG 2 §13.2.2](https://www.w3.org/TR/SVG2/painting.html#TermContextElement)). Outside those contexts, the editor treats them as no-paint and surfaces a warning.
|
|
341
365
|
|
|
342
366
|
Write — selection-scoped (same reasoning as for generic properties):
|
|
343
367
|
|
|
@@ -351,15 +375,43 @@ editor.commands.preview_paint(channel: "fill" | "stroke"): {
|
|
|
351
375
|
};
|
|
352
376
|
```
|
|
353
377
|
|
|
354
|
-
Assigning a gradient as fill is a two-step operation by design — the gradient lives in `<defs>` (per SVG), the paint references it. The editor does not auto-inline. Sugar for the common "create new gradient and set as fill in one undo step" case is provided below.
|
|
378
|
+
Assigning a gradient as fill is a two-step operation by design — the gradient lives in `<defs>` (per SVG), the paint references it. The editor does not auto-inline. Sugar for the common "create new gradient and set as fill in one undo step" case is provided by the resource API below.
|
|
379
|
+
|
|
380
|
+
### Multi-selection (mixed values)
|
|
381
|
+
|
|
382
|
+
When more than one node is selected, **reading** a property no longer has a single answer — values may agree across the selection, or they may differ ("mixed"). This is its own concept, layered on top of the per-node property and paint APIs above. It is the typical reading mode for a property panel.
|
|
383
|
+
|
|
384
|
+
This layer is **not deeply designed yet**. The shape will likely look something like:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
// Provisional — names, contract, and ergonomics subject to design before v0.
|
|
388
|
+
type MixedView<V> =
|
|
389
|
+
| { status: "single"; value: V } // every selected node agrees
|
|
390
|
+
| { status: "mixed"; per_node: ReadonlyMap<NodeId, V> } // values differ
|
|
391
|
+
| { status: "unsupported" } // no selected node has this property
|
|
392
|
+
| { status: "empty" }; // no selection
|
|
393
|
+
|
|
394
|
+
editor.selection_properties(names): { readonly [name: string]: MixedView<PropertyValue> };
|
|
395
|
+
editor.selection_paint(channel): MixedView<PaintValue>;
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
`@grida/mixed-properties` already exists in the monorepo and is the likely starting point, but whether it covers SVG paint and the spec-aligned `PropertyValue` shape cleanly is an open question. Writes do not engage this layer — `set_property` and `set_paint` apply the same value to every selected node and are well-defined as-is.
|
|
399
|
+
|
|
400
|
+
For v0, the per-node APIs (`node_properties`, `node_paint`) are the stable primitives. Consumers who need to render a property panel today can iterate over `state.selection` and aggregate themselves; the goal of the mixed layer is to give them an ergonomic alternative once its shape is settled.
|
|
355
401
|
|
|
356
402
|
### Observation — defs (resources)
|
|
357
403
|
|
|
358
|
-
SVG forces gradients, patterns, symbols, markers, clip-paths, masks, and filters to live as named entries in `<defs>` and be referenced by `url(#id)`.
|
|
404
|
+
SVG forces gradients, patterns, symbols, markers, clip-paths, masks, and filters to live as named entries in `<defs>` and be referenced by `url(#id)`. The editor exposes a typed registry per resource kind. Consumers reading `editor.paint("fill").computed` may encounter a `{ kind: "ref", id }`; they look up the actual gradient via `editor.defs.gradients.get(id)`.
|
|
359
405
|
|
|
360
406
|
```ts
|
|
361
407
|
editor.defs: {
|
|
362
408
|
gradients: GradientsApi;
|
|
409
|
+
patterns: PatternsApi;
|
|
410
|
+
symbols: SymbolsApi;
|
|
411
|
+
markers: MarkersApi;
|
|
412
|
+
clip_paths: ClipPathsApi;
|
|
413
|
+
masks: MasksApi;
|
|
414
|
+
filters: FiltersApi;
|
|
363
415
|
};
|
|
364
416
|
|
|
365
417
|
interface GradientsApi {
|
|
@@ -422,6 +474,10 @@ editor.commands.set_paint_from_gradient(
|
|
|
422
474
|
|
|
423
475
|
This is one undo step.
|
|
424
476
|
|
|
477
|
+
The same shape (`list / get / upsert / remove / subscribe`) repeats for `patterns`, `symbols`, `markers`, `clip_paths`, `masks`, `filters`. Each carries its own `*Definition` type that mirrors the SVG spec. None of them are renamed or renormalized — `<linearGradient>` stays `<linearGradient>`, `<marker>` stays `<marker>`.
|
|
478
|
+
|
|
479
|
+
Markers are referenced via `marker-start` / `marker-mid` / `marker-end` (and the shorthand `marker`), not via `fill`/`stroke`. They appear in the property API the same way any presentation attribute does — read `editor.node_properties(id, ["marker-end"])`, dereference any `{ kind: "ref", id }` via `editor.defs.markers.get(id)`.
|
|
480
|
+
|
|
425
481
|
### Observation — tree
|
|
426
482
|
|
|
427
483
|
```ts
|
|
@@ -440,45 +496,24 @@ editor.tree(): {
|
|
|
440
496
|
};
|
|
441
497
|
```
|
|
442
498
|
|
|
443
|
-
Returns a shallow snapshot. Cheap to call after a `
|
|
444
|
-
|
|
445
|
-
```ts
|
|
446
|
-
editor.display_label(
|
|
447
|
-
id: NodeId,
|
|
448
|
-
opts?: { tagLabel?: (tag: string) => string }
|
|
449
|
-
): string;
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
Single source of truth for "what to call this node in a panel." For `<text>` nodes the label is the (collapsed, truncated) text content; for everything else it's `"tag #id"` when the `id` attribute is present and just `"tag"` otherwise. The optional `tagLabel` resolver lets you substitute friendlier or localized terms ("rect" → "Rectangle") without losing the structural rule. Consumers should call this rather than synthesize their own.
|
|
453
|
-
|
|
454
|
-
### Observation — surface hover
|
|
455
|
-
|
|
456
|
-
Out-of-canvas UI (layers panel, breadcrumbs) often wants to mirror the canvas's pointer hover. This is a transient channel — it does **not** bump `state.version`, so listeners don't get full snapshot re-renders.
|
|
457
|
-
|
|
458
|
-
```ts
|
|
459
|
-
editor.surface_hover(): NodeId | null; // current effective hover (pointer or override)
|
|
460
|
-
editor.set_surface_hover_override(id: NodeId | null): void; // push hover from out-of-canvas UI
|
|
461
|
-
editor.subscribe_surface_hover(cb: () => void): Unsubscribe;
|
|
462
|
-
```
|
|
463
|
-
|
|
464
|
-
When a layers-panel row is hovered, call `set_surface_hover_override(id)` to push the highlight into the HUD (drives measurement guides, outline, etc.). Pass `null` to release and let the pointer pick take over again. The subscribe channel fires for both pointer-driven and override-driven changes.
|
|
499
|
+
Returns a shallow snapshot. Cheap to call after a `version` bump.
|
|
465
500
|
|
|
466
501
|
### Modes
|
|
467
502
|
|
|
468
503
|
Modes are the editor's internal state machine for "what does a click do." Consumers observe `state.mode`, flip it via commands, but cannot define new modes.
|
|
469
504
|
|
|
470
505
|
```ts
|
|
471
|
-
editor.modes: ReadonlyArray<Mode>; // frozen after construction
|
|
472
|
-
//
|
|
506
|
+
editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction
|
|
507
|
+
// e.g. ["select", "insert-rect", "insert-ellipse", "insert-line", "insert-text", "edit-content"]
|
|
473
508
|
|
|
474
509
|
editor.commands.set_mode(mode: Mode): void;
|
|
475
510
|
```
|
|
476
511
|
|
|
477
|
-
|
|
512
|
+
When a mode-driven gesture completes (rect drawn, text inserted), the editor returns to `select` automatically. Modifier keys can override this (Shift to stay in insert mode); that behavior is bundled, not customizable.
|
|
478
513
|
|
|
479
514
|
### Commands
|
|
480
515
|
|
|
481
|
-
The full closed set
|
|
516
|
+
The full closed set. Adding a command requires a PR to this package.
|
|
482
517
|
|
|
483
518
|
```ts
|
|
484
519
|
editor.commands.{
|
|
@@ -488,8 +523,10 @@ editor.commands.{
|
|
|
488
523
|
enter_scope(group: NodeId): void;
|
|
489
524
|
exit_scope(): void;
|
|
490
525
|
|
|
491
|
-
// mode
|
|
526
|
+
// mode + tool
|
|
492
527
|
set_mode(mode: Mode): void;
|
|
528
|
+
// `set_tool` is also accessible as `editor.set_tool(...)`; the command form
|
|
529
|
+
// is provided so keymap bindings (V/R/O/L) can dispatch via the registry.
|
|
493
530
|
|
|
494
531
|
// generic property (any SVG/CSS attribute)
|
|
495
532
|
set_property(name: string, value: string | null): void;
|
|
@@ -504,20 +541,38 @@ editor.commands.{
|
|
|
504
541
|
opts?: { reuse_existing?: boolean },
|
|
505
542
|
): { gradient_id: string };
|
|
506
543
|
|
|
507
|
-
// transforms
|
|
544
|
+
// transforms (atomic — the bundled HUD drives drag-resize-rotate internally)
|
|
508
545
|
translate(delta: { dx: number; dy: number }): void;
|
|
546
|
+
nudge(direction: "left" | "right" | "up" | "down", step?: number): void;
|
|
547
|
+
resize(target: { width?: number; height?: number; anchor?: ResizeAnchor }): void;
|
|
548
|
+
resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
|
|
549
|
+
rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
550
|
+
rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
551
|
+
flatten_transform(): void; // bake `transform=` into native attrs where possible
|
|
552
|
+
|
|
553
|
+
// alignment (operates on selection of ≥2 nodes against their union bbox)
|
|
554
|
+
align(direction: AlignDirection): void;
|
|
509
555
|
|
|
510
556
|
// structure
|
|
511
557
|
reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
|
|
558
|
+
group(): void; // wrap selection in a new <g>
|
|
512
559
|
remove(): void;
|
|
513
560
|
|
|
561
|
+
// insertion
|
|
562
|
+
insert(tag: InsertableTag, attrs?: Readonly<Record<string, string>>): NodeId;
|
|
563
|
+
insert_preview(tag: InsertableTag, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
|
|
564
|
+
|
|
514
565
|
// content
|
|
515
566
|
set_text(value: string): void;
|
|
567
|
+
enter_content_edit(target?: NodeId): boolean;
|
|
516
568
|
|
|
517
569
|
// file
|
|
518
570
|
load_svg(svg: string): void;
|
|
519
571
|
serialize_svg(): string;
|
|
520
572
|
|
|
573
|
+
// cleanup — never silent, never automatic
|
|
574
|
+
tidy(opts?: TidyOptions): void;
|
|
575
|
+
|
|
521
576
|
// history
|
|
522
577
|
undo(): void;
|
|
523
578
|
redo(): void;
|
|
@@ -526,19 +581,7 @@ editor.commands.{
|
|
|
526
581
|
|
|
527
582
|
All commands operate on `state.selection` unless they take an explicit target. Commands that can't apply (e.g. `set_text` with no text node selected) are no-ops, not errors.
|
|
528
583
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
#### Content edit
|
|
532
|
-
|
|
533
|
-
```ts
|
|
534
|
-
editor.enter_content_edit(target?: NodeId): boolean;
|
|
535
|
-
```
|
|
536
|
-
|
|
537
|
-
Surface-dependent. With no DOM surface attached, returns `false` and does nothing — the headless editor cannot mount a `<textarea>` / DOM input. With a DOM surface attached, mounts an in-place text editor on the target `<text>` node.
|
|
538
|
-
|
|
539
|
-
#### Naming convention
|
|
540
|
-
|
|
541
|
-
Public API uses `snake_case` throughout (`set_property`, `node_paint`, `subscribe_with_selector`). User-facing strings that mirror SVG attribute names stay `kebab-case` exactly as the spec writes them (`set_property("stroke-width", …)`). The exceptions are `FileIOProvider.openSvg` / `saveSvg` and `FontResolver.resolve(...).metrics.unitsPerEm` — these match CSS Font-Metrics / file-API ergonomic naming and stay camelCase.
|
|
584
|
+
(Naming convention for the API surface is `snake_case` to match the SVG / CSS property naming the editor already echoes — `set_property("stroke-width", …)` reads cleanly next to `set_paint("fill", …)`. JavaScript identifiers use `snake_case`; user-facing strings that mirror SVG attribute names stay `kebab-case` exactly as the spec writes them.)
|
|
542
585
|
|
|
543
586
|
### Providers
|
|
544
587
|
|
|
@@ -563,8 +606,6 @@ type FileIOProvider = {
|
|
|
563
606
|
};
|
|
564
607
|
```
|
|
565
608
|
|
|
566
|
-
Once constructed, the provider bag is readable as `editor.providers` for code that wants to share the same clipboard / font / file-io plumbing (a Save toolbar, an Open menu item, etc.).
|
|
567
|
-
|
|
568
609
|
### Style
|
|
569
610
|
|
|
570
611
|
`style` is the HUD chrome's appearance spec. It is **values, not slots** — consumers cannot replace the chrome, they restyle it. Field names are `snake_case`. The spec is small and additive.
|
|
@@ -577,18 +618,18 @@ type EditorStyle = {
|
|
|
577
618
|
handle_stroke: string;
|
|
578
619
|
endpoint_dot_radius: number;
|
|
579
620
|
selection_outline_width: number;
|
|
580
|
-
|
|
621
|
+
// ...
|
|
581
622
|
};
|
|
582
623
|
|
|
583
624
|
editor.style: Readonly<EditorStyle>;
|
|
584
625
|
editor.set_style(partial: Partial<EditorStyle>): void;
|
|
585
626
|
```
|
|
586
627
|
|
|
587
|
-
`set_style` is the legitimate runtime path — wire it up for theme switching.
|
|
588
|
-
|
|
589
628
|
### React API (thin wrapper)
|
|
590
629
|
|
|
591
|
-
The React layer is intentionally thin. We ship a provider, a canvas component, and
|
|
630
|
+
The React layer is intentionally thin. We ship a provider, a canvas component, two core subscription primitives (`useEditorState` + `useCommands`), and a small set of bundled hooks for the patterns that turned out the same across every consumer. Hooks for **per-node** observation patterns (paint, properties, gradients list, document tree) are not exported — those are 5-line recipes consumers write against the editor's own API, tailored to their re-render needs.
|
|
631
|
+
|
|
632
|
+
#### Core (the primitives)
|
|
592
633
|
|
|
593
634
|
```tsx
|
|
594
635
|
import {
|
|
@@ -600,14 +641,43 @@ import {
|
|
|
600
641
|
} from "@grida/svg-editor/react";
|
|
601
642
|
```
|
|
602
643
|
|
|
603
|
-
That's the whole public surface.
|
|
604
|
-
|
|
605
644
|
- `SvgEditorProvider` — owns the headless editor, puts it in context.
|
|
606
|
-
- `SvgEditorCanvas` — the only UI component we ship; internally calls `attach_dom_surface(editor, { container
|
|
645
|
+
- `SvgEditorCanvas` — the only UI component we ship; internally calls `attach_dom_surface(editor, { container })` on mount and `handle.detach()` on unmount. Receives the `DomSurfaceHandle` via an `onAttach` callback so consumers can thread `handle.camera` / `handle.gestures` into surrounding chrome.
|
|
607
646
|
- `useSvgEditor()` — returns the editor instance from context.
|
|
608
647
|
- `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change. The subscription primitive.
|
|
609
648
|
- `useCommands()` — sugar for `useSvgEditor().commands`.
|
|
610
649
|
|
|
650
|
+
#### Bundled hooks (state-slice convenience + lifecycle-aware sessions)
|
|
651
|
+
|
|
652
|
+
These are not internals to be replaced — they're documented sugar over `useEditorState` and the imperative APIs, with stable contracts. They exist because every consumer wrote the same recipe; per P6, they earned promotion.
|
|
653
|
+
|
|
654
|
+
```tsx
|
|
655
|
+
import {
|
|
656
|
+
// state slices (one-line wrappers over useEditorState)
|
|
657
|
+
useSelection, // → readonly NodeId[]
|
|
658
|
+
useTool, // → Tool
|
|
659
|
+
useMode, // → Mode
|
|
660
|
+
useCanUndo, // → boolean
|
|
661
|
+
useCanRedo, // → boolean
|
|
662
|
+
|
|
663
|
+
// lifecycle-aware preview sessions — unmount = discard (never commit)
|
|
664
|
+
usePaintPreview, // (channel) → PaintPreviewSession
|
|
665
|
+
usePropertyPreview, // (name) → PreviewSession
|
|
666
|
+
|
|
667
|
+
// bound imperative actions, stable identity across renders
|
|
668
|
+
useEditorLoad, // → (svg: string) => void
|
|
669
|
+
useEditorSerialize, // → () => string
|
|
670
|
+
|
|
671
|
+
// RAII hover override (clears on unmount if this hook set the override)
|
|
672
|
+
useHoverOverride, // → (id: NodeId | null) => void
|
|
673
|
+
|
|
674
|
+
// camera bridge (subscribe to a slice of handle.camera without bumping state.version)
|
|
675
|
+
useCameraSnapshot, // (handle, selector, fallback) → T
|
|
676
|
+
} from "@grida/svg-editor/react";
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
The preview hooks (`usePaintPreview` / `usePropertyPreview`) wrap `commands.preview_*` with a React-lifecycle-aware shell whose contract is: **unmount discards, the host commits**. The session returned is reference-stable across renders within one key — `picker open → commit → reopen` works without remounting.
|
|
680
|
+
|
|
611
681
|
Top-level wiring:
|
|
612
682
|
|
|
613
683
|
```tsx
|
|
@@ -642,8 +712,14 @@ function Toolbar() {
|
|
|
642
712
|
↖
|
|
643
713
|
</ToolButton>
|
|
644
714
|
<ToolButton
|
|
645
|
-
active={mode === "
|
|
646
|
-
onClick={() => cmd.set_mode("
|
|
715
|
+
active={mode === "insert-rect"}
|
|
716
|
+
onClick={() => cmd.set_mode("insert-rect")}
|
|
717
|
+
>
|
|
718
|
+
▭
|
|
719
|
+
</ToolButton>
|
|
720
|
+
<ToolButton
|
|
721
|
+
active={mode === "insert-text"}
|
|
722
|
+
onClick={() => cmd.set_mode("insert-text")}
|
|
647
723
|
>
|
|
648
724
|
T
|
|
649
725
|
</ToolButton>
|
|
@@ -684,7 +760,7 @@ function PropertyPanel() {
|
|
|
684
760
|
const selection = useEditorState((s) => s.selection);
|
|
685
761
|
const cmd = useCommands();
|
|
686
762
|
|
|
687
|
-
//
|
|
763
|
+
// v0: single-selection path. Multi-selection arrives with the mixed-values layer.
|
|
688
764
|
if (selection.length !== 1)
|
|
689
765
|
return <MultiSelectionPlaceholder count={selection.length} />;
|
|
690
766
|
const id = selection[0];
|
|
@@ -696,7 +772,8 @@ function PropertyPanel() {
|
|
|
696
772
|
return (
|
|
697
773
|
<>
|
|
698
774
|
{/* PaintInput is consumer-built. `provenance` tells the user whether this
|
|
699
|
-
value came from an attribute, inline style,
|
|
775
|
+
value came from an attribute, inline style, a stylesheet rule, or was
|
|
776
|
+
inherited / defaulted. */}
|
|
700
777
|
<PaintInput
|
|
701
778
|
label="Fill"
|
|
702
779
|
declared={fill.declared}
|
|
@@ -720,111 +797,33 @@ function PropertyPanel() {
|
|
|
720
797
|
}
|
|
721
798
|
```
|
|
722
799
|
|
|
723
|
-
The pattern is the same for `useDocumentTree`, `useNodeProperties`, etc. — consumers compose against the editor's existing `subscribe()` / `.list()` / `.get()` methods. The package does not ship those hooks because every consumer's re-render needs are slightly different (which IDs to watch, which equality function to use, how to memoize the snapshot), and a one-size-fits-all hook is the wrong layer.
|
|
800
|
+
The pattern is the same for `useDocumentTree`, `useNodeProperties`, `useMarkers`, `useSymbols`, etc. — consumers compose against the editor's existing `subscribe()` / `.list()` / `.get()` methods. The package does not ship those hooks because every consumer's re-render needs are slightly different (which IDs to watch, which equality function to use, how to memoize the snapshot), and a one-size-fits-all hook is the wrong layer.
|
|
724
801
|
|
|
725
802
|
What this means in practice:
|
|
726
803
|
|
|
727
|
-
- The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs
|
|
804
|
+
- The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.*.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
|
|
728
805
|
- A consumer who decides to use TanStack Query, Jotai, or Zustand instead of `useSyncExternalStore` reaches the same primitives the same way.
|
|
729
806
|
- Adding a built-in hook later (e.g. `useNodePaint`) requires a P6 justification: ≥2 internal consumers and a stable contract.
|
|
730
807
|
|
|
731
|
-
## Low-level API
|
|
732
|
-
|
|
733
|
-
Semver-stable from v1, deliberately minimal. These are the escape hatches consumers use when the high-level API doesn't yet cover what they need — typically because v1's scope is intentionally narrow.
|
|
734
|
-
|
|
735
|
-
```ts
|
|
736
|
-
editor.document; // raw IR handle
|
|
737
|
-
editor.dom_computed_property(id, name); // getComputedStyle when DOM-attached
|
|
738
|
-
editor.dom_computed_paint(id, channel);
|
|
739
|
-
editor.commands.register(id, handler);
|
|
740
|
-
editor.commands.invoke(id, args);
|
|
741
|
-
editor.commands.has(id);
|
|
742
|
-
editor.keymap; // host-controlled bindings
|
|
743
|
-
```
|
|
744
|
-
|
|
745
|
-
### `editor.document`
|
|
746
|
-
|
|
747
|
-
The raw in-memory IR handle. Use this for things the high-level API doesn't cover — walking `<defs>` for non-gradient resources, reading attributes the property cascade doesn't surface, inspecting comment / CDATA nodes. **Mutating the IR directly bypasses history.** For app code, prefer the high-level `editor.commands.*`; reach for `editor.document` for tooling, instrumentation, and one-off inspections.
|
|
748
|
-
|
|
749
|
-
### `editor.dom_computed_property` / `editor.dom_computed_paint`
|
|
750
|
-
|
|
751
|
-
```ts
|
|
752
|
-
editor.dom_computed_property(id: NodeId, name: string): string | null;
|
|
753
|
-
editor.dom_computed_paint(
|
|
754
|
-
id: NodeId,
|
|
755
|
-
channel: "fill" | "stroke"
|
|
756
|
-
): { computed: string; resolved_paint: Paint | null } | null;
|
|
757
|
-
```
|
|
758
|
-
|
|
759
|
-
DOM-only computed reads. Returns `null` when no DOM surface is attached. When a DOM surface is attached, delegates to `getComputedStyle()` against the mounted SVG element — which honors `<style>` block matching, `var()` substitution, `currentColor`, and the rest of the CSS cascade the headless engine doesn't implement.
|
|
760
|
-
|
|
761
|
-
These exist because v1 doesn't ship a full headless cascade engine, and we'd rather expose an honest escape hatch than silently lie about `computed` values. As the headless cascade engine grows (post-v1), these methods stay — they remain the only way to ask "what does the browser actually paint?" for cases that depend on user-agent stylesheet, font fallbacks, or rendering-context decisions.
|
|
762
|
-
|
|
763
|
-
### `editor.commands.register / invoke / has`
|
|
764
|
-
|
|
765
|
-
```ts
|
|
766
|
-
editor.commands.register(id: CommandId, handler: CommandHandler): () => void;
|
|
767
|
-
editor.commands.invoke(id: CommandId, args?: unknown): boolean;
|
|
768
|
-
editor.commands.has(id: CommandId): boolean;
|
|
769
|
-
|
|
770
|
-
type CommandId = string; // dotted: "history.undo", "selection.remove", "host.copy"
|
|
771
|
-
type CommandHandler = (args?: unknown) => boolean | void;
|
|
772
|
-
```
|
|
773
|
-
|
|
774
|
-
Id-keyed command surface for host-registered commands. The package's built-in commands are pre-registered under stable ids (`history.undo`, `selection.deselect`, `transform.nudge`, …); hosts can register their own and address them from the keymap. Handlers return `true` if they consumed the invocation, `false` / `undefined` if they didn't (the keymap will then try the next candidate registered for the same key — ProseMirror-style chain semantics).
|
|
775
|
-
|
|
776
|
-
### `editor.keymap`
|
|
777
|
-
|
|
778
|
-
```ts
|
|
779
|
-
editor.keymap.bind(binding: KeymapBinding): () => void;
|
|
780
|
-
editor.keymap.unbind(spec: { keybinding?: Keybinding; command?: CommandId }): void;
|
|
781
|
-
editor.keymap.bindings(): readonly KeymapBinding[];
|
|
782
|
-
editor.keymap.dispatch(event: KeyboardEvent): boolean;
|
|
783
|
-
```
|
|
784
|
-
|
|
785
|
-
Declarative bindings of `Keybinding` → command id with chain-style dispatch. The package's own keybindings are registered at construction (Undo/Redo, arrow-nudge, bracket-reorder, etc.); host bindings layer on top and chain ahead of defaults when their priority is higher. The DOM surface calls `dispatch(event)` automatically; for non-DOM hosts or custom input loops, call it yourself.
|
|
786
|
-
|
|
787
|
-
The `@grida/keybinding` package provides the `Keybinding` type, `kb()` / `seq()` / `platformKb()` builders, and the `KeyCode` enum.
|
|
788
|
-
|
|
789
|
-
---
|
|
790
|
-
|
|
791
|
-
Anything that earns enough use to be a stable primitive graduates out of this section into the high-level API. Anything we add but later regret stays here under a deprecation marker until it can be removed.
|
|
792
|
-
|
|
793
|
-
## Deferred for v1
|
|
794
|
-
|
|
795
|
-
What v1 explicitly doesn't ship. Each is documented elsewhere with the v1 workaround.
|
|
796
|
-
|
|
797
|
-
- **`<style>` block matching, `var()` substitution, `currentColor` resolution** in the headless cascade. Use `dom_computed_property` / `dom_computed_paint` when DOM-attached (see [Low-level API](#low-level-api)).
|
|
798
|
-
- **Defs registries other than gradients** (patterns, symbols, markers, clip-paths, masks, filters). Walk `editor.document` directly.
|
|
799
|
-
- **`commands.resize` (top-level), `commands.rotate`, `commands.tidy`**. Resize gestures are HUD-driven via a preview session; rotate and tidy are unimplemented.
|
|
800
|
-
- **Multi-selection aggregation** (mixed-value property/paint views).
|
|
801
|
-
- **Insertion modes / shape tools.** Anti-goal — "Not a vector authoring tool."
|
|
802
|
-
- **Copy/paste, alt-drag, snap-to-geometry, snap-to-pixel-grid, alignment hotkeys, duplicate (Cmd+D), select-all.**
|
|
803
|
-
- **Non-DOM surfaces** (worker, React Native, headless test harness).
|
|
804
|
-
- **Paint-server validity warnings** (dangling `url(#id)` references).
|
|
805
|
-
- **Rotation gesture in the HUD.**
|
|
806
|
-
|
|
807
808
|
## Anti-goals
|
|
808
809
|
|
|
809
810
|
What this editor will never be. Each one is a defensive perimeter for the principles above.
|
|
810
811
|
|
|
811
812
|
- **Not a vector authoring tool.** No pen tool, no boolean ops, no path-node sculpting beyond what an SVG-natural edit supports.
|
|
812
813
|
- **Not an animation editor.** SMIL is preserved verbatim, never authored or mutated.
|
|
813
|
-
- **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers.
|
|
814
|
+
- **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.)
|
|
814
815
|
- **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
|
|
815
816
|
- **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
|
|
816
|
-
- **Not a private IR.** SVG is the source of truth
|
|
817
|
+
- **Not a private IR.** SVG is the source of truth. The editor does not maintain an alternative on-disk format, and the bytes are not projected from any in-memory canonical store. (The internal typed element IR described under [Paradigm § Element IR (internal)](#element-ir-internal) is a typed view over the parsed AST, not a store the file is derived from — the AST and the file are the source of truth, and the IR is rebuilt from them on each load.)
|
|
817
818
|
- **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
|
|
818
819
|
|
|
819
820
|
If a consumer needs any of the above, the right answer is "this is the wrong tool." Saying yes to any one is the path that turned the Grida main editor into a 6,800-line god-class.
|
|
820
821
|
|
|
821
822
|
## Status
|
|
822
823
|
|
|
823
|
-
`
|
|
824
|
-
|
|
825
|
-
Round-trip fidelity (P1) is the design invariant; the v1 surface is deliberately minimal and the [Low-level API](#low-level-api) is the escape hatch when the high-level API doesn't yet cover what you need.
|
|
824
|
+
- `v0.0.0` — selection only, no mutation.
|
|
826
825
|
|
|
827
|
-
The shape
|
|
826
|
+
The shape of the API, the mental model, the file-format guarantees, and the scope are all unsettled. Nothing here is stable. Do not depend on it from production code.
|
|
828
827
|
|
|
829
828
|
## License
|
|
830
829
|
|