@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
package/README.md
CHANGED
|
@@ -45,6 +45,22 @@ 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`](https://grida.co/docs/wg/feat-svg-editor/element-ir). The phased migration sketch lands with the implementation slice.
|
|
55
|
+
|
|
56
|
+
### Defined terms
|
|
57
|
+
|
|
58
|
+
The editor's design docs use capitalised terms with precise meanings. The one most often referenced in everyday review:
|
|
59
|
+
|
|
60
|
+
- **[Policy Class](https://grida.co/docs/wg/feat-svg-editor/glossary/policy-class)** — the minimal partition of editable elements such that every editing intent admits the same set of legal solutions within a class. `<circle>` and `<ellipse>` are different Policy Classes because their resize solution spaces fork differently, even though both are conics. The unit at which a host's policy decision (refuse / native / promote / via-transform) maps onto. When a design discussion asks "should X and Y be treated the same?" — apply the Policy Class fork test, not authoring intuition.
|
|
61
|
+
|
|
62
|
+
Full glossary: [`docs/wg/feat-svg-editor/glossary/`](https://github.com/gridaco/grida/tree/main/docs/wg/feat-svg-editor/glossary/).
|
|
63
|
+
|
|
48
64
|
## Principles
|
|
49
65
|
|
|
50
66
|
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 +79,11 @@ A small, named set of concerns belongs to the embedding product: clipboard, file
|
|
|
63
79
|
|
|
64
80
|
### P4. Subscribe to outcomes, not events.
|
|
65
81
|
|
|
66
|
-
The public observation surface is **designed**, not raw. It exposes purpose-built views — `selection`, `
|
|
82
|
+
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
83
|
|
|
68
84
|
### P5. A separate layer earns its separateness by reuse or isolated testability.
|
|
69
85
|
|
|
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/
|
|
86
|
+
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
87
|
|
|
72
88
|
### P6. Public only after dogfooding.
|
|
73
89
|
|
|
@@ -89,11 +105,13 @@ When a new design decision lands, walk these in order. The first match wins.
|
|
|
89
105
|
|
|
90
106
|
These are the design principles guiding the implementation.
|
|
91
107
|
|
|
92
|
-
- Built as an SDK, not an app. Headless, backend-agnostic — no DOM or window assumptions in the core.
|
|
108
|
+
- Built as an SDK, not an app. Headless, backend-agnostic — no DOM or window assumptions in the core. Plug into any rendering surface.
|
|
93
109
|
- The IR carries source-position trivia, so the serializer can rewrite only the bytes that actually changed.
|
|
94
110
|
- 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
111
|
- 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
|
|
112
|
+
- 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.
|
|
113
|
+
|
|
114
|
+
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
115
|
|
|
98
116
|
## Examples
|
|
99
117
|
|
|
@@ -107,13 +125,13 @@ A few scenarios this is designed to handle well.
|
|
|
107
125
|
>
|
|
108
126
|
> 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
127
|
|
|
110
|
-
> _"I
|
|
128
|
+
> _"I rotated a rect by 12 degrees."_
|
|
111
129
|
>
|
|
112
|
-
> The editor
|
|
130
|
+
> 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
131
|
|
|
114
132
|
> _"This SVG has a `<style>` block with `.brand { fill: var(--brand) }`. I want to change the fill of one element to red."_
|
|
115
133
|
>
|
|
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.
|
|
134
|
+
> 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
135
|
|
|
118
136
|
> _"This file has an Inkscape `<sodipodi:namedview>` block and `inkscape:label` attributes."_
|
|
119
137
|
>
|
|
@@ -131,7 +149,7 @@ npm install @grida/svg-editor
|
|
|
131
149
|
|
|
132
150
|
## API
|
|
133
151
|
|
|
134
|
-
> **
|
|
152
|
+
> **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
153
|
|
|
136
154
|
### Construction
|
|
137
155
|
|
|
@@ -153,13 +171,34 @@ const editor = createSvgEditor({
|
|
|
153
171
|
});
|
|
154
172
|
```
|
|
155
173
|
|
|
156
|
-
`createSvgEditor` is the only constructor. The returned `SvgEditor` is the only
|
|
174
|
+
`createSvgEditor` is the only **editor** constructor. The returned `SvgEditor` is the only editor instance consumers ever hold. A small set of Layer-A geometry primitives (see [Geometry primitives](#geometry-primitives) below) is also exported for callers that need canonical SVG geometry without mounting an editor — those are not editor instances and have no lifecycle.
|
|
175
|
+
|
|
176
|
+
The editor core is **headless**. It parses the SVG, owns the document IR, accepts commands, and emits state — but it does not import, reference, or call into `window`, `document`, `HTMLElement`, or any DOM type. To render or take input, the host attaches a `Surface` (next section).
|
|
177
|
+
|
|
178
|
+
### Geometry primitives
|
|
179
|
+
|
|
180
|
+
A small set of Layer-A primitives is exported for callers that want canonical SVG geometry without mounting an editor. These are not part of the editor lifecycle, do not subscribe, and do not produce diffs against an `SvgDocument` — they are pure value classes over the bytes you hand them.
|
|
181
|
+
|
|
182
|
+
#### `PathModel`
|
|
183
|
+
|
|
184
|
+
Models a single SVG path's vector network for callers that want path geometry without an editor. Construct from a `d` string, observe vertex/segment shape, compute a bbox, serialize back to `d`. No editor, no document, no DOM.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { PathModel } from "@grida/svg-editor";
|
|
188
|
+
|
|
189
|
+
const m = PathModel.fromSvgPathD("M 10 10 L 100 10 L 100 100 Z");
|
|
190
|
+
m.vertexCount(); // 3
|
|
191
|
+
m.segmentCount(); // 3
|
|
192
|
+
m.snapshot(); // { vertices, segments } — POJO
|
|
193
|
+
m.bbox(); // { x, y, width, height }
|
|
194
|
+
m.toSvgPathD(); // canonical d
|
|
195
|
+
```
|
|
157
196
|
|
|
158
|
-
|
|
197
|
+
`@experimental` — the externally-stable contract for v0 is construction (`fromSvgPathD`) plus `snapshot()` / `bbox()` / `vertexCount()` / `segmentCount()` / `toSvgPathD()`. Mutation methods on the class exist for the editor's internal use and are not part of the documented public surface.
|
|
159
198
|
|
|
160
199
|
### Surface
|
|
161
200
|
|
|
162
|
-
A
|
|
201
|
+
A `Surface` is the host-provided rendering and input boundary. The shipped `domSurface` is the reference implementation used by the React layer; non-DOM hosts (React Native, worker-side renderer, headless test harness) would implement the same interface — though only one implementation exists today (P6: public only after dogfooding).
|
|
163
202
|
|
|
164
203
|
```ts
|
|
165
204
|
import { attach_dom_surface } from "@grida/svg-editor/dom";
|
|
@@ -169,7 +208,41 @@ const handle = attach_dom_surface(editor, { container });
|
|
|
169
208
|
handle.detach();
|
|
170
209
|
```
|
|
171
210
|
|
|
172
|
-
|
|
211
|
+
The v0 contract is pure lifecycle:
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
interface Surface {
|
|
215
|
+
/** Teardown: detach listeners, drop retained refs. Called from
|
|
216
|
+
* `editor.detach()` and `editor.dispose()`. */
|
|
217
|
+
dispose(): void;
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
What's deliberately **not** part of the contract yet:
|
|
222
|
+
|
|
223
|
+
- **Paint push.** There is no `paint(snapshot)` channel. The surface re-serializes the document by subscribing to the editor and writing to its own rendering target.
|
|
224
|
+
- **Normalized input events.** Input routing is surface-private — the DOM surface attaches pointer/keyboard listeners on its own container and reaches the editor through the in-package `_internal` channel.
|
|
225
|
+
- **Hit-testing.** Picking is surface-private: the DOM surface owns its own pointer → node-id resolver against its rendered scene. World-space geometry queries (`bounds_of`, `node_at_point` for non-pointer callers) route through `editor.geometry` instead. A cross-surface `hit_test` contract is deferred until a second surface needs one — its shape (screen vs. world units, z-order tie-breaks, hit-record vs. id) isn't pinned.
|
|
226
|
+
|
|
227
|
+
Each will become a public seam when a second surface implementation arrives and pins its shape. Until then, exporting `paint(snapshot: unknown)` / `on_input(event: unknown)` / `hit_test(x, y)` would be contracts a foreign implementer cannot honestly satisfy (P6 — public only after dogfooding).
|
|
228
|
+
|
|
229
|
+
Geometry (world-space bboxes, screen ↔ local projection) is exposed via `editor.geometry`, not the `Surface` itself — the DOM surface registers a `MemoizedGeometryProvider` with the editor on attach so headless callers can query bounds without going through the surface.
|
|
230
|
+
|
|
231
|
+
`@grida/svg-editor/dom` exports `attach_dom_surface(editor, { container, ... })` as the default DOM implementation, plus the surface-scoped types (`Camera`, `Gestures`, `SnapOptions`, `AttentionScope`, `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.
|
|
232
|
+
|
|
233
|
+
The container is **exclusively owned** by the surface. Render toolbars, layer lists, inspectors, and any other interactive chrome as **siblings** of the container, not children. Children of the container interfere with pointer routing (capture redirects, hit-test ordering) and produce silent click breakage. The shipped `SvgEditorCanvas` React component enforces this by creating its own internal div; hosts using `domSurface` / `keynote.attach` directly are responsible for the same discipline. In development, the surface emits a `console.warn` at attach time when the container is non-empty. Sibling chrome that drives editor commands should be registered into the attention scope (`handle.attention`, below) so the keymap stays live while the user works in it.
|
|
234
|
+
|
|
235
|
+
**Attention gate.** The DOM surface installs document- and window-level keydown listeners (so a user with focus on a side-panel button can still hit editor shortcuts while the pointer is on the canvas). Those listeners are gated by an internal attention predicate: a key is claimed (and `preventDefault()`-ed) only when **focus is inside the attention scope OR the pointer is over it**. Body-focus alone — the natural state when the surface is embedded as a block in a longer document — is not attended, so the editor stays out of the way of page-level shortcuts (Space / arrows to scroll, Cmd+= to zoom, etc.). Passive observation listeners (modifier mirrors, blur resets) are not gated — they don't call `preventDefault()` and need to stay live across focus boundaries.
|
|
236
|
+
|
|
237
|
+
The scope starts as the container alone. Editor-adjacent host chrome — an inspector, a toolbar, a zoom menu; anything that drives `commands.*` — is a DOM sibling of the container (per the exclusive-ownership rule above), so by default the gate cannot tell it from unrelated app surface: clicking its buttons moves focus out of the container, hovering it leaves the container, and the whole keymap (undo / delete / tool keys) goes dark until the user re-attends the canvas. Register such chrome into the scope via the handle:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
const handle = attach_dom_surface(editor, { container });
|
|
241
|
+
handle.attention.add(inspectorEl); // counts for focus-within + pointer-over
|
|
242
|
+
handle.attention.remove(inspectorEl); // e.g. on unmount
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Registered elements get the full keymap — with text inputs inside them still excluded by the keymap's own guard. The native clipboard gate (deliberately stricter: focus-only, never pointer-over) honors the registered set's focus arm the same way. `add` is idempotent; registrations live until removed or the surface detaches.
|
|
173
246
|
|
|
174
247
|
### Lifecycle
|
|
175
248
|
|
|
@@ -179,16 +252,39 @@ editor.detach(): void; // detach current surface, keep editor state
|
|
|
179
252
|
editor.dispose(): void; // permanent teardown
|
|
180
253
|
```
|
|
181
254
|
|
|
182
|
-
`load()`, `serialize()`, `reset()`, commands, and subscriptions all work on the headless editor regardless of whether a surface is attached.
|
|
255
|
+
`load()`, `serialize()`, `reset()`, commands, and subscriptions all work on the headless editor regardless of whether a surface is attached.
|
|
183
256
|
|
|
184
257
|
### External control
|
|
185
258
|
|
|
186
259
|
```ts
|
|
187
260
|
editor.load(svg: string): void; // replace the document (e.g. file-on-disk changed)
|
|
188
261
|
editor.serialize(): string; // emit clean SVG — guaranteed round-trip per P1
|
|
262
|
+
editor.serialize_node(id: NodeId): string; // emit ONE element's subtree — a fragment, see below
|
|
189
263
|
editor.reset(): void; // back to last load() input, clears history
|
|
190
264
|
```
|
|
191
265
|
|
|
266
|
+
`serialize_node(id)` exports the markup of a single element — the bridge from
|
|
267
|
+
"what the user selected" (a `NodeId`) to "the SVG for that element," e.g. to
|
|
268
|
+
hand a downstream consumer (an AI agent) the selected subtree without
|
|
269
|
+
re-serializing the whole document. It reuses `serialize()`'s trivia-preserving
|
|
270
|
+
rules (attribute order, quotes, whitespace, comments — emitted as authored).
|
|
271
|
+
|
|
272
|
+
It is deliberately **weaker** than `serialize()`, and the two must not be
|
|
273
|
+
conflated: `serialize()` emits the whole document and carries the P1
|
|
274
|
+
round-trip guarantee; `serialize_node()` emits a **fragment** and does not.
|
|
275
|
+
Namespace declarations that live on an ancestor (`xmlns:xlink` and friends,
|
|
276
|
+
normally on the root `<svg>`) are **not** inlined into the fragment — a node
|
|
277
|
+
using `xlink:href` serializes without `xmlns:xlink`. The fragment is the
|
|
278
|
+
element's markup as authored, not a standalone parseable document. Throws on
|
|
279
|
+
an unknown id or a non-element node (selections are always elements).
|
|
280
|
+
|
|
281
|
+
> A stable reference to a node that survives a `load()` — and survives an
|
|
282
|
+
> external rewrite of the file — is a separate, unsolved problem (`NodeId`
|
|
283
|
+
> regenerates on each parse). Positional child-index paths address only the
|
|
284
|
+
> deterministic-re-parse case, not structural edits; durable node identity is
|
|
285
|
+
> under design — see
|
|
286
|
+
> [durable node identity](https://grida.co/docs/wg/feat-svg-editor/durable-node-identity).
|
|
287
|
+
|
|
192
288
|
### Observation — state
|
|
193
289
|
|
|
194
290
|
```ts
|
|
@@ -196,12 +292,14 @@ editor.state: {
|
|
|
196
292
|
readonly selection: ReadonlyArray<NodeId>;
|
|
197
293
|
readonly scope: NodeId | null; // active isolation (group entered via dblclick)
|
|
198
294
|
readonly mode: Mode; // "select" | "edit-content"
|
|
295
|
+
readonly tool: Tool; // { type: "cursor" } | { type: "insert", tag } — orthogonal to mode
|
|
199
296
|
readonly dirty: boolean; // unsaved changes since load() / serialize()
|
|
200
297
|
readonly can_undo: boolean;
|
|
201
298
|
readonly can_redo: boolean;
|
|
202
|
-
readonly version: number; // bumps on
|
|
203
|
-
readonly structure_version: number; // bumps only
|
|
204
|
-
|
|
299
|
+
readonly version: number; // bumps on any emission — drag, history, mutation
|
|
300
|
+
readonly structure_version: number; // bumps only when tree shape or display-label inputs change
|
|
301
|
+
readonly geometry_version: number; // bumps only when something that could shift world bounds changes
|
|
302
|
+
readonly load_version: number; // bumps once per `editor.load()` call (constructor doesn't count)
|
|
205
303
|
};
|
|
206
304
|
|
|
207
305
|
editor.subscribe(fn: (state: EditorState) => void): Unsubscribe;
|
|
@@ -212,13 +310,32 @@ editor.subscribe_with_selector<T>(
|
|
|
212
310
|
): Unsubscribe;
|
|
213
311
|
```
|
|
214
312
|
|
|
313
|
+
`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.
|
|
314
|
+
|
|
215
315
|
`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.
|
|
216
316
|
|
|
217
|
-
|
|
317
|
+
### Observation — pick (tap)
|
|
318
|
+
|
|
319
|
+
A **pick** is a discrete tap on the canvas — a press and release within the drag threshold, no drag. It is observe-only and deliberately **separate from selection**: selection answers "what do commands target," a pick answers "what did the user just click, and where." A primary tap on a node both selects it _and_ emits a pick; a tap on empty canvas emits a pick with `node_id: null` (distinguishable from "nothing is selected," which selection alone cannot express); a secondary (right-button) tap emits a pick and does **not** change selection. This is the seam a click-driven host tool needs — a comment / annotation tool anchors UI at `point` and scopes its action to `node_id`, or to the whole document when `null`.
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
type PickEvent = {
|
|
323
|
+
point: Vec2; // document-space — the pointer-DOWN point the tap resolved against
|
|
324
|
+
node_id: NodeId | null; // topmost node under point; null = empty canvas
|
|
325
|
+
button: "primary" | "secondary"; // middle is pan, never taps
|
|
326
|
+
mods: { shift: boolean; alt: boolean; meta: boolean; ctrl: boolean };
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
editor.subscribe_pick(fn: (e: PickEvent) => void): Unsubscribe;
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The point is document-space and always the pointer-**down** point — so it stays correct even for a tap on an already-selected node (whose selection commits on pointer-up). The channel does **not** bump `state.version`. In React, wire it with `useEditorPick(handler)`.
|
|
333
|
+
|
|
334
|
+
> **Status:** `@unstable` — shipped against one consumer; the shape is open until a second click-driven tool exercises it (P6).
|
|
218
335
|
|
|
219
336
|
### Observation — properties
|
|
220
337
|
|
|
221
|
-
This section is about **property semantics on a single node**, following the CSS / SVG spec. Multi-selection
|
|
338
|
+
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
339
|
|
|
223
340
|
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
341
|
|
|
@@ -227,9 +344,9 @@ The CSS Cascading and Inheritance spec defines a value pipeline of six stages: *
|
|
|
227
344
|
|
|
228
345
|
Plus the editor's own metadata, marked as such:
|
|
229
346
|
|
|
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.
|
|
347
|
+
- **`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
348
|
|
|
232
|
-
Intermediate stages (`specified`) and downstream stages (`used`, `actual`) are not exposed.
|
|
349
|
+
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
350
|
|
|
234
351
|
```ts
|
|
235
352
|
type Provenance = {
|
|
@@ -247,30 +364,13 @@ type InvalidComputedValue = {
|
|
|
247
364
|
reason: string; // e.g. "var(--brand) is not defined and has no fallback"
|
|
248
365
|
};
|
|
249
366
|
|
|
250
|
-
type PropertyValue<T = string
|
|
367
|
+
type PropertyValue<T = string> = {
|
|
251
368
|
declared: string | null;
|
|
252
369
|
computed: T | InvalidComputedValue | null;
|
|
253
370
|
provenance: Provenance;
|
|
254
371
|
};
|
|
255
372
|
```
|
|
256
373
|
|
|
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
374
|
Read — per node:
|
|
275
375
|
|
|
276
376
|
```ts
|
|
@@ -282,12 +382,15 @@ editor.node_properties(
|
|
|
282
382
|
|
|
283
383
|
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
384
|
|
|
285
|
-
|
|
385
|
+
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.
|
|
386
|
+
|
|
387
|
+
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
388
|
|
|
287
389
|
```ts
|
|
288
390
|
editor.commands.set_property(name: string, value: string | null): void;
|
|
289
391
|
|
|
290
392
|
editor.commands.preview_property(name: string): {
|
|
393
|
+
readonly live: boolean; // false once the session has ended, for any reason
|
|
291
394
|
update(value: string): void;
|
|
292
395
|
commit(): void;
|
|
293
396
|
discard(): void;
|
|
@@ -296,16 +399,22 @@ editor.commands.preview_property(name: string): {
|
|
|
296
399
|
|
|
297
400
|
The editor decides whether to write a presentation attribute vs. inline style for each selected node based on whichever wins the cascade for that element (P1). The preview session is what a number-input scrub or color-picker drag uses: many `update()` calls during drag, one `commit()` on pointer-up.
|
|
298
401
|
|
|
402
|
+
A preview session ends as soon as its result can no longer become the document's next state — and after it ends, every method is a no-op and `session.live` is `false`. A discrete write to the same property (`set_property(name, …)`, or `set_paint` / `set_paint_from_gradient` on the same channel) **supersedes** an open session on that name, so a UI that mixes a picker drag with preset buttons cannot replay the stale dragged value when it commits on close; sessions on other property names are unaffected by discrete writes. Operations that detach the session's targets (`remove` / `cut`, `ungroup`), a document swap (`load` / `reset`), and `undo` / `redo` end open sessions on every name. Hosts that cache a session should consult `live` and lazily reopen — the bundled React hooks do.
|
|
403
|
+
|
|
299
404
|
### Observation — paint (`fill` / `stroke`)
|
|
300
405
|
|
|
301
406
|
`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
407
|
|
|
408
|
+
This section, like Properties above, is **per-node and spec-aligned**. Multi-selection aggregation is in the [Multi-selection](#multi-selection-mixed-values) section.
|
|
409
|
+
|
|
303
410
|
The `Paint` type follows the [SVG 2 `<paint>` production](https://www.w3.org/TR/SVG2/painting.html#SpecifyingPaint) literally:
|
|
304
411
|
|
|
305
412
|
```
|
|
306
413
|
<paint> = none | <color> | <url> [none | <color>]? | context-fill | context-stroke
|
|
307
414
|
```
|
|
308
415
|
|
|
416
|
+
`<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`.
|
|
417
|
+
|
|
309
418
|
```ts
|
|
310
419
|
type Paint =
|
|
311
420
|
| { kind: "none" } // fill="none"
|
|
@@ -316,13 +425,17 @@ type Paint =
|
|
|
316
425
|
|
|
317
426
|
type PaintFallback = { kind: "none" } | { kind: "color"; value: Color };
|
|
318
427
|
|
|
428
|
+
// Color preserves currentColor as a keyword at computed time (CSS Color 4 §4.4); the
|
|
429
|
+
// rgb resolution happens at *used* value, which requires the surface's painting context.
|
|
319
430
|
type Color =
|
|
320
|
-
| { kind: "rgb"; value: string } //
|
|
321
|
-
|
|
431
|
+
| { kind: "rgb"; value: string } // canonical lowercase hex (#rrggbb / #rrggbbaa) for any
|
|
432
|
+
// literal resolvable without a rendering context (named / hex / rgb() / hsl() / hwb());
|
|
433
|
+
// unresolved spaces (lab() / oklch() / color()) pass through as authored
|
|
434
|
+
| { kind: "current_color" }; // unresolved keyword; surface dereferences at paint time
|
|
322
435
|
|
|
323
436
|
type PaintValue = {
|
|
324
437
|
declared: string | null; // raw, e.g. "var(--brand, currentColor)" or "url(#g1) red"
|
|
325
|
-
computed: Paint | InvalidComputedValue | null; // post-defaulting
|
|
438
|
+
computed: Paint | InvalidComputedValue | null; // post-defaulting, post-var
|
|
326
439
|
provenance: Provenance;
|
|
327
440
|
};
|
|
328
441
|
```
|
|
@@ -333,11 +446,11 @@ Read — per node:
|
|
|
333
446
|
editor.node_paint(id: NodeId, channel: "fill" | "stroke"): PaintValue;
|
|
334
447
|
```
|
|
335
448
|
|
|
336
|
-
Notes per spec:
|
|
449
|
+
Notes on the `<url>` reference, per spec:
|
|
337
450
|
|
|
338
|
-
-
|
|
339
|
-
-
|
|
340
|
-
-
|
|
451
|
+
- 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)).
|
|
452
|
+
- 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()`.
|
|
453
|
+
- `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
454
|
|
|
342
455
|
Write — selection-scoped (same reasoning as for generic properties):
|
|
343
456
|
|
|
@@ -345,21 +458,50 @@ Write — selection-scoped (same reasoning as for generic properties):
|
|
|
345
458
|
editor.commands.set_paint(channel: "fill" | "stroke", paint: Paint): void;
|
|
346
459
|
|
|
347
460
|
editor.commands.preview_paint(channel: "fill" | "stroke"): {
|
|
461
|
+
readonly live: boolean; // false once the session has ended, for any reason
|
|
348
462
|
update(paint: Paint): void;
|
|
349
463
|
commit(): void;
|
|
350
464
|
discard(): void;
|
|
351
465
|
};
|
|
352
466
|
```
|
|
353
467
|
|
|
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.
|
|
468
|
+
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.
|
|
469
|
+
|
|
470
|
+
### Multi-selection (mixed values)
|
|
471
|
+
|
|
472
|
+
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.
|
|
473
|
+
|
|
474
|
+
This layer is **not deeply designed yet**. The shape will likely look something like:
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
// Provisional — names, contract, and ergonomics subject to design before v0.
|
|
478
|
+
type MixedView<V> =
|
|
479
|
+
| { status: "single"; value: V } // every selected node agrees
|
|
480
|
+
| { status: "mixed"; per_node: ReadonlyMap<NodeId, V> } // values differ
|
|
481
|
+
| { status: "unsupported" } // no selected node has this property
|
|
482
|
+
| { status: "empty" }; // no selection
|
|
483
|
+
|
|
484
|
+
editor.selection_properties(names): { readonly [name: string]: MixedView<PropertyValue> };
|
|
485
|
+
editor.selection_paint(channel): MixedView<PaintValue>;
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
`@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.
|
|
489
|
+
|
|
490
|
+
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
491
|
|
|
356
492
|
### Observation — defs (resources)
|
|
357
493
|
|
|
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)`.
|
|
494
|
+
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
495
|
|
|
360
496
|
```ts
|
|
361
497
|
editor.defs: {
|
|
362
498
|
gradients: GradientsApi;
|
|
499
|
+
patterns: PatternsApi;
|
|
500
|
+
symbols: SymbolsApi;
|
|
501
|
+
markers: MarkersApi;
|
|
502
|
+
clip_paths: ClipPathsApi;
|
|
503
|
+
masks: MasksApi;
|
|
504
|
+
filters: FiltersApi;
|
|
363
505
|
};
|
|
364
506
|
|
|
365
507
|
interface GradientsApi {
|
|
@@ -422,6 +564,10 @@ editor.commands.set_paint_from_gradient(
|
|
|
422
564
|
|
|
423
565
|
This is one undo step.
|
|
424
566
|
|
|
567
|
+
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>`.
|
|
568
|
+
|
|
569
|
+
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)`.
|
|
570
|
+
|
|
425
571
|
### Observation — tree
|
|
426
572
|
|
|
427
573
|
```ts
|
|
@@ -440,45 +586,29 @@ editor.tree(): {
|
|
|
440
586
|
};
|
|
441
587
|
```
|
|
442
588
|
|
|
443
|
-
Returns a shallow snapshot. Cheap to call after a `
|
|
589
|
+
Returns a shallow snapshot. Cheap to call after a `version` bump.
|
|
444
590
|
|
|
445
|
-
|
|
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.
|
|
591
|
+
### Modes and tools
|
|
465
592
|
|
|
466
|
-
|
|
593
|
+
"What does a click do" is governed by **two orthogonal axes**, both editor-internal — consumers observe them and flip them via commands, but cannot define new values for either.
|
|
467
594
|
|
|
468
|
-
|
|
595
|
+
- **`Mode`** — what the editor is _doing_. Two values: `select` (normal interaction — pick / marquee / drag) and `edit-content` (inline text edit, or vector content edit on a path).
|
|
596
|
+
- **`Tool`** — what pointer-down _means_ within the current mode. `cursor` (the default — select / marquee / drag), `insert` (a tag — pointer-down draws a new element of that tag, drag-to-size), `insert-text` (click-only — places a single-line `<text>` and enters content-edit immediately; `<text>` has no intrinsic size so it doesn't drag-to-size), and the content-edit-only `lasso` / `bend` (valid only while `mode === "edit-content"` on a path).
|
|
469
597
|
|
|
470
598
|
```ts
|
|
471
|
-
editor.modes: ReadonlyArray<Mode>; // frozen after construction
|
|
472
|
-
|
|
599
|
+
editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction — ["select", "edit-content"]
|
|
600
|
+
editor.state.mode: Mode;
|
|
601
|
+
editor.state.tool: Tool;
|
|
473
602
|
|
|
474
603
|
editor.commands.set_mode(mode: Mode): void;
|
|
604
|
+
editor.set_tool(tool: Tool): void; // also dispatchable as the `tool.set` command (keymap V/R/O/L/T)
|
|
475
605
|
```
|
|
476
606
|
|
|
477
|
-
|
|
607
|
+
When a tool-driven gesture completes (a shape is drawn, a text element placed), the tool reverts to `cursor` automatically. Modifier keys can override this (e.g. hold to stay in the insert tool); that behavior is bundled, not customizable.
|
|
478
608
|
|
|
479
609
|
### Commands
|
|
480
610
|
|
|
481
|
-
The full closed set
|
|
611
|
+
The full closed set. Adding a command requires a PR to this package.
|
|
482
612
|
|
|
483
613
|
```ts
|
|
484
614
|
editor.commands.{
|
|
@@ -488,8 +618,10 @@ editor.commands.{
|
|
|
488
618
|
enter_scope(group: NodeId): void;
|
|
489
619
|
exit_scope(): void;
|
|
490
620
|
|
|
491
|
-
// mode
|
|
621
|
+
// mode + tool
|
|
492
622
|
set_mode(mode: Mode): void;
|
|
623
|
+
// `set_tool` is also accessible as `editor.set_tool(...)`; the command form
|
|
624
|
+
// is provided so keymap bindings (V/R/O/L) can dispatch via the registry.
|
|
493
625
|
|
|
494
626
|
// generic property (any SVG/CSS attribute)
|
|
495
627
|
set_property(name: string, value: string | null): void;
|
|
@@ -504,20 +636,87 @@ editor.commands.{
|
|
|
504
636
|
opts?: { reuse_existing?: boolean },
|
|
505
637
|
): { gradient_id: string };
|
|
506
638
|
|
|
507
|
-
// transforms
|
|
639
|
+
// transforms (atomic — the bundled HUD drives drag-resize-rotate internally)
|
|
508
640
|
translate(delta: { dx: number; dy: number }): void;
|
|
641
|
+
nudge(direction: "left" | "right" | "up" | "down", step?: number): void;
|
|
642
|
+
resize(target: { width?: number; height?: number; anchor?: ResizeAnchor }): void;
|
|
643
|
+
resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
|
|
644
|
+
rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
645
|
+
rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
646
|
+
// `matrix` is SVG `matrix(a b c d e f)` order (the `Matrix2D` tuple).
|
|
647
|
+
transform(matrix: Matrix2D, opts?: { ids?: NodeId[]; pivot?: { x: number; y: number } }): boolean;
|
|
648
|
+
flatten_transform(): void; // bake `transform=` into native attrs where possible
|
|
649
|
+
|
|
650
|
+
// alignment (operates on selection of ≥2 nodes against their union bbox)
|
|
651
|
+
align(direction: AlignDirection): void;
|
|
509
652
|
|
|
510
653
|
// structure
|
|
511
654
|
reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
|
|
655
|
+
group(): void; // wrap selection in a new <g>
|
|
656
|
+
ungroup(opts?: { id?: NodeId }): boolean; // dissolve a plain structural <g>
|
|
657
|
+
// (clean-structural subset only; refuses
|
|
658
|
+
// groups with visual state — see TODO §10)
|
|
512
659
|
remove(): void;
|
|
513
660
|
|
|
661
|
+
// clipboard — the payload is a STANDALONE SVG DOCUMENT, not a private
|
|
662
|
+
// format (the file is the IR, so the clipboard is the file format).
|
|
663
|
+
// Copy carries the outbound url(#…)/href reference closure in one
|
|
664
|
+
// <defs> block and declares borrowed xmlns prefixes on the payload
|
|
665
|
+
// shell; ancestor transforms / inherited presentation / viewport are
|
|
666
|
+
// deliberately NOT carried (verbatim policy). Cut = copy + remove as
|
|
667
|
+
// ONE history step labeled "cut"; undo restores the document and the
|
|
668
|
+
// clipboard keeps the payload (cut → undo → paste = move). Paste is
|
|
669
|
+
// synchronous over delivered text (`text ?? internal buffer`) and has
|
|
670
|
+
// a gesture-grade refusal table: non-parseable environment input is a
|
|
671
|
+
// no-op `[]`, never a throw (insert_fragment keeps strict semantics).
|
|
672
|
+
// System-clipboard wiring is the DOM surface's native ClipboardEvent
|
|
673
|
+
// transport (text/plain = the markup itself) plus the optional
|
|
674
|
+
// ClipboardProvider seam. Full contract:
|
|
675
|
+
// https://grida.co/docs/wg/feat-svg-editor/clipboard
|
|
676
|
+
copy(): string | null; // payload | null on empty selection; no history
|
|
677
|
+
cut(): string | null; // one undoable step; buffer secured before delete
|
|
678
|
+
paste(text?: string): NodeId[]; // inserted roots (selected); [] = refusal
|
|
679
|
+
|
|
680
|
+
// duplicate — the clipboard FRD's SECOND extraction operation
|
|
681
|
+
// (subtree clone): in-document, so NO defs closure and NO xmlns
|
|
682
|
+
// shell are carried; subtrees and authored ids clone verbatim
|
|
683
|
+
// (colliding ids resolve first-in-document-order; Tidy dedups).
|
|
684
|
+
// Each clone lands as its origin's next sibling (paints above it);
|
|
685
|
+
// selection moves to the clones; ONE history step. Alt-drag
|
|
686
|
+
// translate-with-clone consumes the same operation. Repeating
|
|
687
|
+
// offset: duplicate → move the copy → duplicate repeats the
|
|
688
|
+
// translate delta (an Alt-drag clone commit arms the same memory);
|
|
689
|
+
// still one undo step, degrades to in-place when the preconditions
|
|
690
|
+
// don't hold. Contract:
|
|
691
|
+
// https://grida.co/docs/wg/feat-svg-editor/subtree-clone
|
|
692
|
+
duplicate(): NodeId[]; // clone ids (selected); [] = refusal
|
|
693
|
+
|
|
694
|
+
// insertion — `tag` is an open string (so paste / RPC can create any element,
|
|
695
|
+
// e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
|
|
696
|
+
// draw gesture and default paint.
|
|
697
|
+
insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
|
|
698
|
+
// markup-shaped sibling of `insert` — one or more sibling elements, or a
|
|
699
|
+
// full `<svg>` doc (the shell is discarded; its children are the content).
|
|
700
|
+
// Subtrees adopted verbatim; ONE history step; returns root ids in
|
|
701
|
+
// document order. Authored ids are NEVER rewritten (dedup is Tidy's job);
|
|
702
|
+
// undeclared `xlink:` / shell-declared prefixes are hoisted onto the root
|
|
703
|
+
// in the same step. Position is authored content: wrap the fragment in
|
|
704
|
+
// `<g transform="translate(x y)">` to land it at a point — same single
|
|
705
|
+
// undo step, no placement opt.
|
|
706
|
+
insert_fragment(svg: string, opts?: { parent?: NodeId; index?: number; select?: boolean }): NodeId[];
|
|
707
|
+
insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
|
|
708
|
+
|
|
514
709
|
// content
|
|
515
710
|
set_text(value: string): void;
|
|
711
|
+
enter_content_edit(target?: NodeId): boolean;
|
|
516
712
|
|
|
517
713
|
// file
|
|
518
714
|
load_svg(svg: string): void;
|
|
519
715
|
serialize_svg(): string;
|
|
520
716
|
|
|
717
|
+
// cleanup — never silent, never automatic
|
|
718
|
+
tidy(opts?: TidyOptions): void;
|
|
719
|
+
|
|
521
720
|
// history
|
|
522
721
|
undo(): void;
|
|
523
722
|
redo(): void;
|
|
@@ -526,19 +725,9 @@ editor.commands.{
|
|
|
526
725
|
|
|
527
726
|
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
727
|
|
|
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
|
|
728
|
+
`transform` composes a general 2×3 affine onto the selection **relative** and **in world space about a pivot** (default: the selection union-bbox center) — `E = T(pivot) · matrix · T(-pivot)` — so a bare `[-1, 0, 0, 1, 0, 0]` is an in-place horizontal flip and `[1, 0, 0, -1, 0, 0]` a vertical one. The editor owns the round-trip: `E` is folded onto each member's transform list as a **single leading `matrix` op** (existing `rotate`/`translate` tokens are preserved after it; repeated applies collapse into one matrix; a net-identity leading matrix is dropped). It refuses (returns `false`, no history) on empty selection, no attached surface, or any member that isn't rotatable (matrix / scale / skew / `<text rotate>` / CSS-property / animated transforms — same gate as `rotate`; Flatten Transform is the recovery path). Flat-doc only: nested transformed ancestors are out of scope.
|
|
540
729
|
|
|
541
|
-
|
|
730
|
+
(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
731
|
|
|
543
732
|
### Providers
|
|
544
733
|
|
|
@@ -563,8 +752,6 @@ type FileIOProvider = {
|
|
|
563
752
|
};
|
|
564
753
|
```
|
|
565
754
|
|
|
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
755
|
### Style
|
|
569
756
|
|
|
570
757
|
`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 +764,18 @@ type EditorStyle = {
|
|
|
577
764
|
handle_stroke: string;
|
|
578
765
|
endpoint_dot_radius: number;
|
|
579
766
|
selection_outline_width: number;
|
|
580
|
-
|
|
767
|
+
// ...
|
|
581
768
|
};
|
|
582
769
|
|
|
583
770
|
editor.style: Readonly<EditorStyle>;
|
|
584
771
|
editor.set_style(partial: Partial<EditorStyle>): void;
|
|
585
772
|
```
|
|
586
773
|
|
|
587
|
-
`set_style` is the legitimate runtime path — wire it up for theme switching.
|
|
588
|
-
|
|
589
774
|
### React API (thin wrapper)
|
|
590
775
|
|
|
591
|
-
The React layer is intentionally thin. We ship a provider, a canvas component, and
|
|
776
|
+
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.
|
|
777
|
+
|
|
778
|
+
#### Core (the primitives)
|
|
592
779
|
|
|
593
780
|
```tsx
|
|
594
781
|
import {
|
|
@@ -600,14 +787,43 @@ import {
|
|
|
600
787
|
} from "@grida/svg-editor/react";
|
|
601
788
|
```
|
|
602
789
|
|
|
603
|
-
That's the whole public surface.
|
|
604
|
-
|
|
605
790
|
- `SvgEditorProvider` — owns the headless editor, puts it in context.
|
|
606
|
-
- `SvgEditorCanvas` — the only UI component we ship; internally calls `attach_dom_surface(editor, { container
|
|
791
|
+
- `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
792
|
- `useSvgEditor()` — returns the editor instance from context.
|
|
608
793
|
- `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change. The subscription primitive.
|
|
609
794
|
- `useCommands()` — sugar for `useSvgEditor().commands`.
|
|
610
795
|
|
|
796
|
+
#### Bundled hooks (state-slice convenience + lifecycle-aware sessions)
|
|
797
|
+
|
|
798
|
+
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.
|
|
799
|
+
|
|
800
|
+
```tsx
|
|
801
|
+
import {
|
|
802
|
+
// state slices (one-line wrappers over useEditorState)
|
|
803
|
+
useSelection, // → readonly NodeId[]
|
|
804
|
+
useTool, // → Tool
|
|
805
|
+
useMode, // → Mode
|
|
806
|
+
useCanUndo, // → boolean
|
|
807
|
+
useCanRedo, // → boolean
|
|
808
|
+
|
|
809
|
+
// lifecycle-aware preview sessions — unmount = discard (never commit)
|
|
810
|
+
usePaintPreview, // (channel) → PaintPreviewSession
|
|
811
|
+
usePropertyPreview, // (name) → PreviewSession
|
|
812
|
+
|
|
813
|
+
// bound imperative actions, stable identity across renders
|
|
814
|
+
useEditorLoad, // → (svg: string) => void
|
|
815
|
+
useEditorSerialize, // → () => string
|
|
816
|
+
|
|
817
|
+
// RAII hover override (clears on unmount if this hook set the override)
|
|
818
|
+
useHoverOverride, // → (id: NodeId | null) => void
|
|
819
|
+
|
|
820
|
+
// camera bridge (subscribe to a slice of handle.camera without bumping state.version)
|
|
821
|
+
useCameraSnapshot, // (handle, selector, fallback) → T
|
|
822
|
+
} from "@grida/svg-editor/react";
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
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.
|
|
826
|
+
|
|
611
827
|
Top-level wiring:
|
|
612
828
|
|
|
613
829
|
```tsx
|
|
@@ -631,19 +847,27 @@ Everything else is consumer-built against the editor's API. The two patterns:
|
|
|
631
847
|
|
|
632
848
|
```tsx
|
|
633
849
|
function Toolbar() {
|
|
634
|
-
|
|
635
|
-
|
|
850
|
+
// Insertion is the `Tool` axis, not `Mode` — `Mode` is only
|
|
851
|
+
// "select" / "edit-content". Flip tools via `editor.set_tool(...)`.
|
|
852
|
+
const tool = useEditorState((s) => s.tool);
|
|
853
|
+
const editor = useSvgEditor();
|
|
636
854
|
return (
|
|
637
855
|
<>
|
|
638
856
|
<ToolButton
|
|
639
|
-
active={
|
|
640
|
-
onClick={() =>
|
|
857
|
+
active={tool.type === "cursor"}
|
|
858
|
+
onClick={() => editor.set_tool({ type: "cursor" })}
|
|
641
859
|
>
|
|
642
860
|
↖
|
|
643
861
|
</ToolButton>
|
|
644
862
|
<ToolButton
|
|
645
|
-
active={
|
|
646
|
-
onClick={() =>
|
|
863
|
+
active={tool.type === "insert" && tool.tag === "rect"}
|
|
864
|
+
onClick={() => editor.set_tool({ type: "insert", tag: "rect" })}
|
|
865
|
+
>
|
|
866
|
+
▭
|
|
867
|
+
</ToolButton>
|
|
868
|
+
<ToolButton
|
|
869
|
+
active={tool.type === "insert-text"}
|
|
870
|
+
onClick={() => editor.set_tool({ type: "insert-text" })}
|
|
647
871
|
>
|
|
648
872
|
T
|
|
649
873
|
</ToolButton>
|
|
@@ -684,7 +908,7 @@ function PropertyPanel() {
|
|
|
684
908
|
const selection = useEditorState((s) => s.selection);
|
|
685
909
|
const cmd = useCommands();
|
|
686
910
|
|
|
687
|
-
//
|
|
911
|
+
// v0: single-selection path. Multi-selection arrives with the mixed-values layer.
|
|
688
912
|
if (selection.length !== 1)
|
|
689
913
|
return <MultiSelectionPlaceholder count={selection.length} />;
|
|
690
914
|
const id = selection[0];
|
|
@@ -696,7 +920,8 @@ function PropertyPanel() {
|
|
|
696
920
|
return (
|
|
697
921
|
<>
|
|
698
922
|
{/* PaintInput is consumer-built. `provenance` tells the user whether this
|
|
699
|
-
value came from an attribute, inline style,
|
|
923
|
+
value came from an attribute, inline style, a stylesheet rule, or was
|
|
924
|
+
inherited / defaulted. */}
|
|
700
925
|
<PaintInput
|
|
701
926
|
label="Fill"
|
|
702
927
|
declared={fill.declared}
|
|
@@ -720,111 +945,40 @@ function PropertyPanel() {
|
|
|
720
945
|
}
|
|
721
946
|
```
|
|
722
947
|
|
|
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.
|
|
948
|
+
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
949
|
|
|
725
950
|
What this means in practice:
|
|
726
951
|
|
|
727
|
-
- The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs
|
|
952
|
+
- The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.*.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
|
|
728
953
|
- A consumer who decides to use TanStack Query, Jotai, or Zustand instead of `useSyncExternalStore` reaches the same primitives the same way.
|
|
729
954
|
- Adding a built-in hook later (e.g. `useNodePaint`) requires a P6 justification: ≥2 internal consumers and a stable contract.
|
|
730
955
|
|
|
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
956
|
## Anti-goals
|
|
808
957
|
|
|
809
958
|
What this editor will never be. Each one is a defensive perimeter for the principles above.
|
|
810
959
|
|
|
811
960
|
- **Not a vector authoring tool.** No pen tool, no boolean ops, no path-node sculpting beyond what an SVG-natural edit supports.
|
|
812
961
|
- **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.
|
|
962
|
+
- **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.)
|
|
814
963
|
- **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
|
|
815
964
|
- **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
|
|
816
|
-
- **Not a
|
|
965
|
+
- **Not a customizable selection policy.** What a pick or a marquee selects (the marquee shadow rule, meta routing, additive, and the group-first targeting that lifts a click to its container) is a fixed product decision, owned by the labeled policy layer (`src/selection/`, specs [`docs/marquee-selection.md`](./docs/marquee-selection.md) and [`docs/group-first-targeting.md`](./docs/group-first-targeting.md)). No host hook, provider, or registry swaps it; the opinion lives above the engine, never inside it, and never as a public knob.
|
|
966
|
+
- **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
967
|
- **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
|
|
968
|
+
- **Not an input-interception hook.** The pick/tap observation (`subscribe_pick`) reports a click that already happened; it cannot prevent, delay, or replace the editor's own selection and gesture handling. A host that needs to intercept input owns the container and splices its own layer in (the DOM escape hatch) — it does not get a veto through the observation surface.
|
|
818
969
|
|
|
819
970
|
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
971
|
|
|
821
972
|
## Status
|
|
822
973
|
|
|
823
|
-
`
|
|
974
|
+
- `v0.x` — selection, transform, insert (rect / ellipse / line), inline text
|
|
975
|
+
edit, and the click-to-place text tool. Experimental.
|
|
976
|
+
|
|
977
|
+
The shape of the API, the mental model, the file-format guarantees, and the scope are all unsettled. Nothing here is stable — public types still in flux include the `Tool` union (a planned axis split, see `TODO.md` F2). Do not depend on it from production code.
|
|
824
978
|
|
|
825
|
-
|
|
979
|
+
## Contributing
|
|
826
980
|
|
|
827
|
-
|
|
981
|
+
- [`TODO.md`](./TODO.md) — open questions and deferred work, grouped by area.
|
|
828
982
|
|
|
829
983
|
## License
|
|
830
984
|
|