@grida/svg-editor 1.0.0-alpha.4 → 1.0.0
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 +365 -384
- package/dist/dom-BEjG2bSw.d.mts +320 -0
- package/dist/dom-DYD1BHTo.js +6050 -0
- package/dist/dom-Y2QR7QSi.d.ts +318 -0
- package/dist/dom-tM3Dr1EK.mjs +5971 -0
- package/dist/dom.d.mts +3 -2
- package/dist/dom.d.ts +3 -2
- package/dist/dom.js +13 -1
- package/dist/dom.mjs +2 -2
- package/dist/editor-CWNtt1vu.mjs +2998 -0
- package/dist/editor-CZgwg8BK.d.mts +2537 -0
- package/dist/editor-CesShk9o.js +3004 -0
- package/dist/editor-kFTYSb_8.d.ts +2537 -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-D0iEDcg3.mjs +5451 -0
- package/dist/model-tywvTGZv.js +5640 -0
- package/dist/presets.d.mts +17 -2
- package/dist/presets.d.ts +17 -2
- package/dist/presets.js +20 -15
- package/dist/presets.mjs +19 -15
- package/dist/react.d.mts +88 -10
- package/dist/react.d.ts +88 -10
- package/dist/react.js +169 -14
- package/dist/react.mjs +159 -16
- package/package.json +35 -9
- package/dist/dom-CmOu0HvI.mjs +0 -1623
- package/dist/dom-Cn-RtjRL.d.ts +0 -48
- package/dist/dom-CoVZzFqy.js +0 -1672
- package/dist/dom-DJnZhtOd.d.mts +0 -48
- package/dist/editor-CjK56cgb.mjs +0 -1823
- package/dist/editor-D2l_CDr0.d.ts +0 -818
- package/dist/editor-D2zZAyny.js +0 -1835
- package/dist/editor-Uu6dZX4y.d.mts +0 -818
- package/dist/index-CHiXYO9-.d.ts +0 -1
- package/dist/index-ThDLM4Am.d.mts +0 -1
- package/dist/paint-Cfiw4g_J.mjs +0 -477
- package/dist/paint-dDV-Trt9.js +0 -531
- /package/dist/{chunk-CfYAbeIz.mjs → chunk-D7D4PA-g.mjs} +0 -0
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
|
>
|
|
@@ -123,108 +141,6 @@ A few scenarios this is designed to handle well.
|
|
|
123
141
|
>
|
|
124
142
|
> The editor freezes the animation at `t=0` for editing, applies the position change to the static `cx` attribute, and preserves the `<animate>` block verbatim. The inspector shows a clear "animated property" badge so the user understands what they're editing.
|
|
125
143
|
|
|
126
|
-
### Free-form canvas (Figma-style infinite canvas)
|
|
127
|
-
|
|
128
|
-
Identity camera, default gestures, no auto-fit. Pan with the wheel, Cmd+wheel to zoom at cursor, space-drag for hand-tool, `Shift+1` to fit, `Shift+0` to reset.
|
|
129
|
-
|
|
130
|
-
```tsx
|
|
131
|
-
import {
|
|
132
|
-
SvgEditorCanvas,
|
|
133
|
-
SvgEditorProvider,
|
|
134
|
-
useCameraSnapshot,
|
|
135
|
-
} from "@grida/svg-editor/react";
|
|
136
|
-
import type { DomSurfaceHandle } from "@grida/svg-editor/dom";
|
|
137
|
-
import { useState } from "react";
|
|
138
|
-
|
|
139
|
-
export function FreeFormCanvas({ svg }: { svg: string }) {
|
|
140
|
-
const [handle, setHandle] = useState<DomSurfaceHandle | null>(null);
|
|
141
|
-
const zoom = useCameraSnapshot(handle, (c) => c.zoom, 1);
|
|
142
|
-
return (
|
|
143
|
-
<SvgEditorProvider svg={svg}>
|
|
144
|
-
<div style={{ position: "relative", width: "100%", height: "100vh" }}>
|
|
145
|
-
<SvgEditorCanvas
|
|
146
|
-
onAttach={setHandle}
|
|
147
|
-
style={{ width: "100%", height: "100%" }}
|
|
148
|
-
/>
|
|
149
|
-
<div style={{ position: "absolute", bottom: 12, right: 12 }}>
|
|
150
|
-
<button onClick={() => handle?.camera.fit("<root>")}>Fit</button>
|
|
151
|
-
<button onClick={() => handle?.camera.reset()}>100%</button>
|
|
152
|
-
<span>{Math.round(zoom * 100)}%</span>
|
|
153
|
-
</div>
|
|
154
|
-
</div>
|
|
155
|
-
</SvgEditorProvider>
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
### Keynote-like canvas (fixed-viewBox slide with bounded camera)
|
|
161
|
-
|
|
162
|
-
The keynote case wants more than "fit on load" — it wants **"the slide is the world"**: no zooming out past fit, no panning when the slide already fills the viewport, and clamped panning when zoomed in so the slide always covers the viewport edge-to-edge.
|
|
163
|
-
|
|
164
|
-
This bundle ships as a one-import preset at the **`@grida/svg-editor/presets`** subpath. The preset composes the public primitives:
|
|
165
|
-
|
|
166
|
-
1. `attach_dom_surface` with `fit: true` — slide visible on first frame.
|
|
167
|
-
2. `camera.constraints = { type: 'cover', bounds: '<root>', padding }` — typed [camera constraint](#camera-constraints) that clamps zoom + pan.
|
|
168
|
-
3. `editor.subscribe_with_selector(s => s.load_version, () => camera.fit('<root>'))` — refit only on fresh document loads, not on edits.
|
|
169
|
-
|
|
170
|
-
The preset is opt-in by import: the main `@grida/svg-editor` entry never touches `presets/`, so hosts that don't want the bundle aren't paying for it.
|
|
171
|
-
|
|
172
|
-
```tsx
|
|
173
|
-
import { useEffect, useRef, useState } from "react";
|
|
174
|
-
import { SvgEditorProvider, useSvgEditor } from "@grida/svg-editor/react";
|
|
175
|
-
import { keynote, type KeynoteSurfaceHandle } from "@grida/svg-editor/presets";
|
|
176
|
-
|
|
177
|
-
export function KeynoteCanvas({ svg }: { svg: string }) {
|
|
178
|
-
return (
|
|
179
|
-
<SvgEditorProvider svg={svg}>
|
|
180
|
-
<KeynoteHost />
|
|
181
|
-
</SvgEditorProvider>
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function KeynoteHost() {
|
|
186
|
-
const editor = useSvgEditor();
|
|
187
|
-
const container_ref = useRef<HTMLDivElement | null>(null);
|
|
188
|
-
const [handle, setHandle] = useState<KeynoteSurfaceHandle | null>(null);
|
|
189
|
-
|
|
190
|
-
// Mount the preset on first render; dispose on unmount. The preset
|
|
191
|
-
// owns fit-on-attach, the cover constraint, and the load-version refit
|
|
192
|
-
// subscription — the host writes ~10 lines of React lifecycle, no math.
|
|
193
|
-
useEffect(() => {
|
|
194
|
-
const container = container_ref.current;
|
|
195
|
-
if (!container) return;
|
|
196
|
-
const h = keynote.attach(editor, { container, padding: 80 });
|
|
197
|
-
setHandle(h);
|
|
198
|
-
return () => {
|
|
199
|
-
setHandle(null);
|
|
200
|
-
h.detach();
|
|
201
|
-
};
|
|
202
|
-
}, [editor]);
|
|
203
|
-
|
|
204
|
-
return (
|
|
205
|
-
<div style={{ padding: 24, background: "#1f2937", height: "100vh" }}>
|
|
206
|
-
<div
|
|
207
|
-
ref={container_ref}
|
|
208
|
-
style={{
|
|
209
|
-
width: "100%",
|
|
210
|
-
height: "100%",
|
|
211
|
-
background: "#fff",
|
|
212
|
-
boxShadow: "0 10px 40px rgba(0,0,0,0.3)",
|
|
213
|
-
}}
|
|
214
|
-
/>
|
|
215
|
-
{/* Use `handle` for chrome — `useCameraSnapshot(handle, c => c.zoom, 1)`
|
|
216
|
-
for a zoom badge, `handle.set_padding(0)` for a Present-mode toggle, etc. */}
|
|
217
|
-
</div>
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
The handle is `KeynoteSurfaceHandle = DomSurfaceHandle & { set_padding(p: number): void }`. `set_padding` is preset-specific sugar for "I want a present-mode toggle that varies padding at runtime"; it mutates the live constraint and refits in one call.
|
|
223
|
-
|
|
224
|
-
Both examples ship as live demos at `/canvas/svg` (free-form, no preset) and `/canvas/svg/keynote` (preset) in the dogfood app. Their diff is the spec for what the SDK owes its users: same editor, same camera, same gestures — the keynote demo opts into the preset; the free-form demo doesn't.
|
|
225
|
-
|
|
226
|
-
> **Going deeper.** If you need to compose the building blocks differently — different constraint shape, conditional refit, host-managed bounds — read [`src/presets/keynote.ts`](https://github.com/gridaco/grida/blob/main/packages/grida-svg-editor/src/presets/keynote.ts) (≤ 70 lines of pure composition over public API) as the canonical reference.
|
|
227
|
-
|
|
228
144
|
## Install
|
|
229
145
|
|
|
230
146
|
```sh
|
|
@@ -233,7 +149,7 @@ npm install @grida/svg-editor
|
|
|
233
149
|
|
|
234
150
|
## API
|
|
235
151
|
|
|
236
|
-
> **
|
|
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.
|
|
237
153
|
|
|
238
154
|
### Construction
|
|
239
155
|
|
|
@@ -255,100 +171,78 @@ const editor = createSvgEditor({
|
|
|
255
171
|
});
|
|
256
172
|
```
|
|
257
173
|
|
|
258
|
-
`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.
|
|
259
175
|
|
|
260
|
-
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
|
|
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).
|
|
261
177
|
|
|
262
|
-
###
|
|
178
|
+
### Geometry primitives
|
|
263
179
|
|
|
264
|
-
A
|
|
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.
|
|
265
181
|
|
|
266
|
-
|
|
267
|
-
import { attach_dom_surface } from "@grida/svg-editor/dom";
|
|
182
|
+
#### `PathModel`
|
|
268
183
|
|
|
269
|
-
|
|
270
|
-
container,
|
|
271
|
-
gestures: true, // default — install the bundled gesture set (see "Camera")
|
|
272
|
-
fit: false, // default — start with identity camera (no auto-fit)
|
|
273
|
-
});
|
|
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.
|
|
274
185
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
278
195
|
```
|
|
279
196
|
|
|
280
|
-
`@
|
|
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.
|
|
281
198
|
|
|
282
|
-
###
|
|
199
|
+
### Surface
|
|
283
200
|
|
|
284
|
-
|
|
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).
|
|
285
202
|
|
|
286
203
|
```ts
|
|
287
|
-
|
|
288
|
-
handle.camera.zoom; // uniform scale; 1 = 100 %
|
|
289
|
-
handle.camera.bounds; // world Rect visible in the viewport
|
|
290
|
-
handle.camera.viewport_size; // { width, height } in screen px
|
|
291
|
-
handle.camera.transform; // underlying cmath.Transform (advanced read)
|
|
292
|
-
|
|
293
|
-
handle.camera.pan({ x, y }); // translate by a screen-space delta
|
|
294
|
-
handle.camera.zoom_at(factor, origin_screen); // pinch / wheel-at-cursor
|
|
295
|
-
handle.camera.set_center({ x, y });
|
|
296
|
-
handle.camera.set_zoom(z, pivot_screen?);
|
|
297
|
-
handle.camera.set_transform(t); // idempotent — no notify when t === current
|
|
298
|
-
|
|
299
|
-
handle.camera.fit("<root>"); // fit the document into the viewport
|
|
300
|
-
handle.camera.fit("<selection>"); // fit current selection
|
|
301
|
-
handle.camera.fit(nodeId);
|
|
302
|
-
handle.camera.fit(rect, { margin: 64 });
|
|
303
|
-
handle.camera.reset(); // identity
|
|
304
|
-
|
|
305
|
-
handle.camera.subscribe(cb): Unsubscribe; // transient channel — no state.version bump
|
|
306
|
-
```
|
|
204
|
+
import { attach_dom_surface } from "@grida/svg-editor/dom";
|
|
307
205
|
|
|
308
|
-
|
|
206
|
+
const handle = attach_dom_surface(editor, { container });
|
|
207
|
+
// later:
|
|
208
|
+
handle.detach();
|
|
209
|
+
```
|
|
309
210
|
|
|
310
|
-
|
|
211
|
+
The v0 contract is pure lifecycle:
|
|
311
212
|
|
|
312
213
|
```ts
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
()
|
|
316
|
-
);
|
|
214
|
+
interface Surface {
|
|
215
|
+
/** Teardown: detach listeners, drop retained refs. Called from
|
|
216
|
+
* `editor.detach()` and `editor.dispose()`. */
|
|
217
|
+
dispose(): void;
|
|
218
|
+
}
|
|
317
219
|
```
|
|
318
220
|
|
|
319
|
-
|
|
221
|
+
What's deliberately **not** part of the contract yet:
|
|
320
222
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
bounds: "<root>", // or an explicit Rect
|
|
325
|
-
padding: 80, // screen-pixel breathing room
|
|
326
|
-
};
|
|
327
|
-
handle.camera.constraints = null; // clear
|
|
328
|
-
```
|
|
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.
|
|
329
226
|
|
|
330
|
-
|
|
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).
|
|
331
228
|
|
|
332
|
-
|
|
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.
|
|
333
230
|
|
|
334
|
-
|
|
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.
|
|
335
232
|
|
|
336
|
-
|
|
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.
|
|
337
234
|
|
|
338
|
-
|
|
339
|
-
| ------------------ | --------------------------------- | -------------------------------- |
|
|
340
|
-
| `wheel-pan-zoom` | plain wheel | `camera.pan(delta)` |
|
|
341
|
-
| `wheel-pan-zoom` | Ctrl/Cmd + wheel, native pinch | `camera.zoom_at(...)` at cursor |
|
|
342
|
-
| `space-drag-pan` | Space-down + drag | hand-tool pan |
|
|
343
|
-
| `middle-mouse-pan` | middle-button drag | pan |
|
|
344
|
-
| `keyboard-zoom` | `Shift+0` / `Shift+1` / `Shift+2` | reset / fit root / fit selection |
|
|
345
|
-
| `keyboard-zoom` | `Cmd/Ctrl + =` / `Cmd/Ctrl + -` | zoom in / out |
|
|
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.
|
|
346
236
|
|
|
347
|
-
|
|
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:
|
|
348
238
|
|
|
349
|
-
|
|
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
|
+
```
|
|
350
244
|
|
|
351
|
-
|
|
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.
|
|
352
246
|
|
|
353
247
|
### Lifecycle
|
|
354
248
|
|
|
@@ -358,16 +252,39 @@ editor.detach(): void; // detach current surface, keep editor state
|
|
|
358
252
|
editor.dispose(): void; // permanent teardown
|
|
359
253
|
```
|
|
360
254
|
|
|
361
|
-
`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.
|
|
362
256
|
|
|
363
257
|
### External control
|
|
364
258
|
|
|
365
259
|
```ts
|
|
366
260
|
editor.load(svg: string): void; // replace the document (e.g. file-on-disk changed)
|
|
367
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
|
|
368
263
|
editor.reset(): void; // back to last load() input, clears history
|
|
369
264
|
```
|
|
370
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
|
+
|
|
371
288
|
### Observation — state
|
|
372
289
|
|
|
373
290
|
```ts
|
|
@@ -375,14 +292,14 @@ editor.state: {
|
|
|
375
292
|
readonly selection: ReadonlyArray<NodeId>;
|
|
376
293
|
readonly scope: NodeId | null; // active isolation (group entered via dblclick)
|
|
377
294
|
readonly mode: Mode; // "select" | "edit-content"
|
|
295
|
+
readonly tool: Tool; // { type: "cursor" } | { type: "insert", tag } — orthogonal to mode
|
|
378
296
|
readonly dirty: boolean; // unsaved changes since load() / serialize()
|
|
379
297
|
readonly can_undo: boolean;
|
|
380
298
|
readonly can_redo: boolean;
|
|
381
|
-
readonly version: number; // bumps on
|
|
382
|
-
readonly structure_version: number; // bumps only
|
|
383
|
-
|
|
384
|
-
readonly load_version: number; // bumps once per editor.load(
|
|
385
|
-
// the right "fresh document" signal — stable across edits
|
|
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)
|
|
386
303
|
};
|
|
387
304
|
|
|
388
305
|
editor.subscribe(fn: (state: EditorState) => void): Unsubscribe;
|
|
@@ -393,13 +310,52 @@ editor.subscribe_with_selector<T>(
|
|
|
393
310
|
): Unsubscribe;
|
|
394
311
|
```
|
|
395
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
|
+
|
|
396
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.
|
|
397
316
|
|
|
398
|
-
|
|
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).
|
|
335
|
+
|
|
336
|
+
### Observation — vector sub-selection
|
|
337
|
+
|
|
338
|
+
While a path is open in vector content-edit, the **sub-selection** (which vertices / segments / tangents are selected) is its own piece of state, distinct from `state.selection` (which names the whole node under edit). It is exposed as a designed read view — the read half of [#790](https://github.com/gridaco/grida/issues/790), paired with the `set_vector_selection` command.
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
type VectorSubSelection = {
|
|
342
|
+
node_id: NodeId;
|
|
343
|
+
vertices: readonly number[]; // ordinal vertex indices (PathModel order)
|
|
344
|
+
segments: readonly number[]; // ordinal segment indices
|
|
345
|
+
tangents: readonly (readonly [number, 0 | 1])[]; // [vertexIndex, 0=ta | 1=tb]
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
editor.vector_subselection(): VectorSubSelection | null; // null = no session
|
|
349
|
+
editor.subscribe_vector_subselection(
|
|
350
|
+
fn: (sel: VectorSubSelection | null) => void,
|
|
351
|
+
): Unsubscribe;
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The channel fires on every sub-selection change (click, marquee, lasso, programmatic `set_vector_selection`, undo/redo) and on session enter/exit (`null` on exit). Like pick and surface-hover it is kept **off `state.version`** — sub-selection changes at pointer rate during marquee/lasso, so folding it into the version stream would re-render the whole app per knob (P4). Subscribe to this channel instead of polling `state`.
|
|
399
355
|
|
|
400
356
|
### Observation — properties
|
|
401
357
|
|
|
402
|
-
This section is about **property semantics on a single node**, following the CSS / SVG spec. Multi-selection
|
|
358
|
+
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.
|
|
403
359
|
|
|
404
360
|
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:
|
|
405
361
|
|
|
@@ -408,9 +364,9 @@ The CSS Cascading and Inheritance spec defines a value pipeline of six stages: *
|
|
|
408
364
|
|
|
409
365
|
Plus the editor's own metadata, marked as such:
|
|
410
366
|
|
|
411
|
-
- **`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.
|
|
367
|
+
- **`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.
|
|
412
368
|
|
|
413
|
-
Intermediate stages (`specified`) and downstream stages (`used`, `actual`) are not exposed.
|
|
369
|
+
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.
|
|
414
370
|
|
|
415
371
|
```ts
|
|
416
372
|
type Provenance = {
|
|
@@ -428,30 +384,13 @@ type InvalidComputedValue = {
|
|
|
428
384
|
reason: string; // e.g. "var(--brand) is not defined and has no fallback"
|
|
429
385
|
};
|
|
430
386
|
|
|
431
|
-
type PropertyValue<T = string
|
|
387
|
+
type PropertyValue<T = string> = {
|
|
432
388
|
declared: string | null;
|
|
433
389
|
computed: T | InvalidComputedValue | null;
|
|
434
390
|
provenance: Provenance;
|
|
435
391
|
};
|
|
436
392
|
```
|
|
437
393
|
|
|
438
|
-
#### What v1's cascade engine covers
|
|
439
|
-
|
|
440
|
-
The headless cascade engine resolves values synchronously without a surface, covering:
|
|
441
|
-
|
|
442
|
-
- Presentation attributes (`<rect fill="red">`)
|
|
443
|
-
- Inline `style="..."`
|
|
444
|
-
- Inheritance from parent elements
|
|
445
|
-
- The property's initial value
|
|
446
|
-
|
|
447
|
-
#### What v1's cascade engine does **not** cover
|
|
448
|
-
|
|
449
|
-
- `<style>` block matching
|
|
450
|
-
- `var()` (custom-property) substitution
|
|
451
|
-
- `currentColor` resolution
|
|
452
|
-
|
|
453
|
-
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?".
|
|
454
|
-
|
|
455
394
|
Read — per node:
|
|
456
395
|
|
|
457
396
|
```ts
|
|
@@ -463,12 +402,15 @@ editor.node_properties(
|
|
|
463
402
|
|
|
464
403
|
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`.
|
|
465
404
|
|
|
466
|
-
|
|
405
|
+
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.
|
|
406
|
+
|
|
407
|
+
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:
|
|
467
408
|
|
|
468
409
|
```ts
|
|
469
410
|
editor.commands.set_property(name: string, value: string | null): void;
|
|
470
411
|
|
|
471
412
|
editor.commands.preview_property(name: string): {
|
|
413
|
+
readonly live: boolean; // false once the session has ended, for any reason
|
|
472
414
|
update(value: string): void;
|
|
473
415
|
commit(): void;
|
|
474
416
|
discard(): void;
|
|
@@ -477,16 +419,22 @@ editor.commands.preview_property(name: string): {
|
|
|
477
419
|
|
|
478
420
|
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.
|
|
479
421
|
|
|
422
|
+
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.
|
|
423
|
+
|
|
480
424
|
### Observation — paint (`fill` / `stroke`)
|
|
481
425
|
|
|
482
426
|
`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.
|
|
483
427
|
|
|
428
|
+
This section, like Properties above, is **per-node and spec-aligned**. Multi-selection aggregation is in the [Multi-selection](#multi-selection-mixed-values) section.
|
|
429
|
+
|
|
484
430
|
The `Paint` type follows the [SVG 2 `<paint>` production](https://www.w3.org/TR/SVG2/painting.html#SpecifyingPaint) literally:
|
|
485
431
|
|
|
486
432
|
```
|
|
487
433
|
<paint> = none | <color> | <url> [none | <color>]? | context-fill | context-stroke
|
|
488
434
|
```
|
|
489
435
|
|
|
436
|
+
`<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`.
|
|
437
|
+
|
|
490
438
|
```ts
|
|
491
439
|
type Paint =
|
|
492
440
|
| { kind: "none" } // fill="none"
|
|
@@ -497,13 +445,17 @@ type Paint =
|
|
|
497
445
|
|
|
498
446
|
type PaintFallback = { kind: "none" } | { kind: "color"; value: Color };
|
|
499
447
|
|
|
448
|
+
// Color preserves currentColor as a keyword at computed time (CSS Color 4 §4.4); the
|
|
449
|
+
// rgb resolution happens at *used* value, which requires the surface's painting context.
|
|
500
450
|
type Color =
|
|
501
|
-
| { kind: "rgb"; value: string } //
|
|
502
|
-
|
|
451
|
+
| { kind: "rgb"; value: string } // canonical lowercase hex (#rrggbb / #rrggbbaa) for any
|
|
452
|
+
// literal resolvable without a rendering context (named / hex / rgb() / hsl() / hwb());
|
|
453
|
+
// unresolved spaces (lab() / oklch() / color()) pass through as authored
|
|
454
|
+
| { kind: "current_color" }; // unresolved keyword; surface dereferences at paint time
|
|
503
455
|
|
|
504
456
|
type PaintValue = {
|
|
505
457
|
declared: string | null; // raw, e.g. "var(--brand, currentColor)" or "url(#g1) red"
|
|
506
|
-
computed: Paint | InvalidComputedValue | null; // post-defaulting
|
|
458
|
+
computed: Paint | InvalidComputedValue | null; // post-defaulting, post-var
|
|
507
459
|
provenance: Provenance;
|
|
508
460
|
};
|
|
509
461
|
```
|
|
@@ -514,11 +466,11 @@ Read — per node:
|
|
|
514
466
|
editor.node_paint(id: NodeId, channel: "fill" | "stroke"): PaintValue;
|
|
515
467
|
```
|
|
516
468
|
|
|
517
|
-
Notes per spec:
|
|
469
|
+
Notes on the `<url>` reference, per spec:
|
|
518
470
|
|
|
519
|
-
-
|
|
520
|
-
-
|
|
521
|
-
-
|
|
471
|
+
- 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)).
|
|
472
|
+
- 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()`.
|
|
473
|
+
- `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.
|
|
522
474
|
|
|
523
475
|
Write — selection-scoped (same reasoning as for generic properties):
|
|
524
476
|
|
|
@@ -526,21 +478,50 @@ Write — selection-scoped (same reasoning as for generic properties):
|
|
|
526
478
|
editor.commands.set_paint(channel: "fill" | "stroke", paint: Paint): void;
|
|
527
479
|
|
|
528
480
|
editor.commands.preview_paint(channel: "fill" | "stroke"): {
|
|
481
|
+
readonly live: boolean; // false once the session has ended, for any reason
|
|
529
482
|
update(paint: Paint): void;
|
|
530
483
|
commit(): void;
|
|
531
484
|
discard(): void;
|
|
532
485
|
};
|
|
533
486
|
```
|
|
534
487
|
|
|
535
|
-
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.
|
|
488
|
+
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.
|
|
489
|
+
|
|
490
|
+
### Multi-selection (mixed values)
|
|
491
|
+
|
|
492
|
+
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.
|
|
493
|
+
|
|
494
|
+
This layer is **not deeply designed yet**. The shape will likely look something like:
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
// Provisional — names, contract, and ergonomics subject to design before v0.
|
|
498
|
+
type MixedView<V> =
|
|
499
|
+
| { status: "single"; value: V } // every selected node agrees
|
|
500
|
+
| { status: "mixed"; per_node: ReadonlyMap<NodeId, V> } // values differ
|
|
501
|
+
| { status: "unsupported" } // no selected node has this property
|
|
502
|
+
| { status: "empty" }; // no selection
|
|
503
|
+
|
|
504
|
+
editor.selection_properties(names): { readonly [name: string]: MixedView<PropertyValue> };
|
|
505
|
+
editor.selection_paint(channel): MixedView<PaintValue>;
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
`@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.
|
|
509
|
+
|
|
510
|
+
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.
|
|
536
511
|
|
|
537
512
|
### Observation — defs (resources)
|
|
538
513
|
|
|
539
|
-
SVG forces gradients, patterns, symbols, markers, clip-paths, masks, and filters to live as named entries in `<defs>` and be referenced by `url(#id)`.
|
|
514
|
+
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)`.
|
|
540
515
|
|
|
541
516
|
```ts
|
|
542
517
|
editor.defs: {
|
|
543
518
|
gradients: GradientsApi;
|
|
519
|
+
patterns: PatternsApi;
|
|
520
|
+
symbols: SymbolsApi;
|
|
521
|
+
markers: MarkersApi;
|
|
522
|
+
clip_paths: ClipPathsApi;
|
|
523
|
+
masks: MasksApi;
|
|
524
|
+
filters: FiltersApi;
|
|
544
525
|
};
|
|
545
526
|
|
|
546
527
|
interface GradientsApi {
|
|
@@ -603,6 +584,10 @@ editor.commands.set_paint_from_gradient(
|
|
|
603
584
|
|
|
604
585
|
This is one undo step.
|
|
605
586
|
|
|
587
|
+
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>`.
|
|
588
|
+
|
|
589
|
+
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)`.
|
|
590
|
+
|
|
606
591
|
### Observation — tree
|
|
607
592
|
|
|
608
593
|
```ts
|
|
@@ -621,45 +606,29 @@ editor.tree(): {
|
|
|
621
606
|
};
|
|
622
607
|
```
|
|
623
608
|
|
|
624
|
-
Returns a shallow snapshot. Cheap to call after a `
|
|
609
|
+
Returns a shallow snapshot. Cheap to call after a `version` bump.
|
|
625
610
|
|
|
626
|
-
|
|
627
|
-
editor.display_label(
|
|
628
|
-
id: NodeId,
|
|
629
|
-
opts?: { tagLabel?: (tag: string) => string }
|
|
630
|
-
): string;
|
|
631
|
-
```
|
|
611
|
+
### Modes and tools
|
|
632
612
|
|
|
633
|
-
|
|
613
|
+
"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.
|
|
634
614
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
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.
|
|
615
|
+
- **`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).
|
|
616
|
+
- **`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).
|
|
638
617
|
|
|
639
618
|
```ts
|
|
640
|
-
editor.
|
|
641
|
-
editor.
|
|
642
|
-
editor.
|
|
643
|
-
```
|
|
644
|
-
|
|
645
|
-
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.
|
|
646
|
-
|
|
647
|
-
### Modes
|
|
648
|
-
|
|
649
|
-
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.
|
|
650
|
-
|
|
651
|
-
```ts
|
|
652
|
-
editor.modes: ReadonlyArray<Mode>; // frozen after construction
|
|
653
|
-
// v1: ["select", "edit-content"]
|
|
619
|
+
editor.modes: ReadonlyArray<Mode>; // discoverable, frozen after construction — ["select", "edit-content"]
|
|
620
|
+
editor.state.mode: Mode;
|
|
621
|
+
editor.state.tool: Tool;
|
|
654
622
|
|
|
655
623
|
editor.commands.set_mode(mode: Mode): void;
|
|
624
|
+
editor.set_tool(tool: Tool): void; // also dispatchable as the `tool.set` command (keymap V/R/O/L/T)
|
|
656
625
|
```
|
|
657
626
|
|
|
658
|
-
|
|
627
|
+
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.
|
|
659
628
|
|
|
660
629
|
### Commands
|
|
661
630
|
|
|
662
|
-
The full closed set
|
|
631
|
+
The full closed set. Adding a command requires a PR to this package.
|
|
663
632
|
|
|
664
633
|
```ts
|
|
665
634
|
editor.commands.{
|
|
@@ -669,8 +638,10 @@ editor.commands.{
|
|
|
669
638
|
enter_scope(group: NodeId): void;
|
|
670
639
|
exit_scope(): void;
|
|
671
640
|
|
|
672
|
-
// mode
|
|
641
|
+
// mode + tool
|
|
673
642
|
set_mode(mode: Mode): void;
|
|
643
|
+
// `set_tool` is also accessible as `editor.set_tool(...)`; the command form
|
|
644
|
+
// is provided so keymap bindings (V/R/O/L) can dispatch via the registry.
|
|
674
645
|
|
|
675
646
|
// generic property (any SVG/CSS attribute)
|
|
676
647
|
set_property(name: string, value: string | null): void;
|
|
@@ -685,20 +656,102 @@ editor.commands.{
|
|
|
685
656
|
opts?: { reuse_existing?: boolean },
|
|
686
657
|
): { gradient_id: string };
|
|
687
658
|
|
|
688
|
-
//
|
|
659
|
+
// opacity — typed, clamped sugar for set_property("opacity", …); one
|
|
660
|
+
// history step across the selection. `value` is clamped to [0, 1]. This
|
|
661
|
+
// is the command behind the digit → opacity shortcut; the digit
|
|
662
|
+
// keybindings are NOT shipped in the default keymap (host-wired).
|
|
663
|
+
set_opacity(value: number): void;
|
|
664
|
+
|
|
665
|
+
// transforms (atomic — the bundled HUD drives drag-resize-rotate internally)
|
|
689
666
|
translate(delta: { dx: number; dy: number }): void;
|
|
667
|
+
nudge(direction: "left" | "right" | "up" | "down", step?: number): void;
|
|
668
|
+
resize(target: { width?: number; height?: number; anchor?: ResizeAnchor }): void;
|
|
669
|
+
resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
|
|
670
|
+
rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
671
|
+
rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
672
|
+
// `matrix` is SVG `matrix(a b c d e f)` order (the `Matrix2D` tuple).
|
|
673
|
+
transform(matrix: Matrix2D, opts?: { ids?: NodeId[]; pivot?: { x: number; y: number } }): boolean;
|
|
674
|
+
flatten_transform(): void; // bake `transform=` into native attrs where possible
|
|
675
|
+
|
|
676
|
+
// alignment (operates on selection of ≥2 nodes against their union bbox)
|
|
677
|
+
align(direction: AlignDirection): void;
|
|
690
678
|
|
|
691
679
|
// structure
|
|
692
680
|
reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
|
|
681
|
+
group(): void; // wrap selection in a new <g>
|
|
682
|
+
ungroup(opts?: { id?: NodeId }): boolean; // dissolve a plain structural <g>
|
|
683
|
+
// (clean-structural subset only; refuses
|
|
684
|
+
// groups with visual state — see TODO §10)
|
|
693
685
|
remove(): void;
|
|
694
686
|
|
|
687
|
+
// clipboard — the payload is a STANDALONE SVG DOCUMENT, not a private
|
|
688
|
+
// format (the file is the IR, so the clipboard is the file format).
|
|
689
|
+
// Copy carries the outbound url(#…)/href reference closure in one
|
|
690
|
+
// <defs> block and declares borrowed xmlns prefixes on the payload
|
|
691
|
+
// shell; ancestor transforms / inherited presentation / viewport are
|
|
692
|
+
// deliberately NOT carried (verbatim policy). Cut = copy + remove as
|
|
693
|
+
// ONE history step labeled "cut"; undo restores the document and the
|
|
694
|
+
// clipboard keeps the payload (cut → undo → paste = move). Paste is
|
|
695
|
+
// synchronous over delivered text (`text ?? internal buffer`) and has
|
|
696
|
+
// a gesture-grade refusal table: non-parseable environment input is a
|
|
697
|
+
// no-op `[]`, never a throw (insert_fragment keeps strict semantics).
|
|
698
|
+
// System-clipboard wiring is the DOM surface's native ClipboardEvent
|
|
699
|
+
// transport (text/plain = the markup itself) plus the optional
|
|
700
|
+
// ClipboardProvider seam. Full contract:
|
|
701
|
+
// https://grida.co/docs/wg/feat-svg-editor/clipboard
|
|
702
|
+
copy(): string | null; // payload | null on empty selection; no history
|
|
703
|
+
cut(): string | null; // one undoable step; buffer secured before delete
|
|
704
|
+
paste(text?: string): NodeId[]; // inserted roots (selected); [] = refusal
|
|
705
|
+
|
|
706
|
+
// duplicate — the clipboard FRD's SECOND extraction operation
|
|
707
|
+
// (subtree clone): in-document, so NO defs closure and NO xmlns
|
|
708
|
+
// shell are carried; subtrees and authored ids clone verbatim
|
|
709
|
+
// (colliding ids resolve first-in-document-order; Tidy dedups).
|
|
710
|
+
// Each clone lands as its origin's next sibling (paints above it);
|
|
711
|
+
// selection moves to the clones; ONE history step. Alt-drag
|
|
712
|
+
// translate-with-clone consumes the same operation. Repeating
|
|
713
|
+
// offset: duplicate → move the copy → duplicate repeats the
|
|
714
|
+
// translate delta (an Alt-drag clone commit arms the same memory);
|
|
715
|
+
// still one undo step, degrades to in-place when the preconditions
|
|
716
|
+
// don't hold. Contract:
|
|
717
|
+
// https://grida.co/docs/wg/feat-svg-editor/subtree-clone
|
|
718
|
+
duplicate(): NodeId[]; // clone ids (selected); [] = refusal
|
|
719
|
+
|
|
720
|
+
// insertion — `tag` is an open string (so paste / RPC can create any element,
|
|
721
|
+
// e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
|
|
722
|
+
// draw gesture and default paint.
|
|
723
|
+
insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
|
|
724
|
+
// markup-shaped sibling of `insert` — one or more sibling elements, or a
|
|
725
|
+
// full `<svg>` doc (the shell is discarded; its children are the content).
|
|
726
|
+
// Subtrees adopted verbatim; ONE history step; returns root ids in
|
|
727
|
+
// document order. Authored ids are NEVER rewritten (dedup is Tidy's job);
|
|
728
|
+
// undeclared `xlink:` / shell-declared prefixes are hoisted onto the root
|
|
729
|
+
// in the same step. Position is authored content: wrap the fragment in
|
|
730
|
+
// `<g transform="translate(x y)">` to land it at a point — same single
|
|
731
|
+
// undo step, no placement opt.
|
|
732
|
+
insert_fragment(svg: string, opts?: { parent?: NodeId; index?: number; select?: boolean }): NodeId[];
|
|
733
|
+
insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
|
|
734
|
+
|
|
695
735
|
// content
|
|
696
736
|
set_text(value: string): void;
|
|
737
|
+
enter_content_edit(target?: NodeId, opts?: VectorSubSelectionInput): boolean;
|
|
738
|
+
// vector content-edit — set the vertex / segment / tangent sub-selection
|
|
739
|
+
// while a path is open in content-edit (the write half of #790). `mode`
|
|
740
|
+
// defaults to "replace"; "add"/"toggle" fold into the existing selection.
|
|
741
|
+
// One undoable step, like a knob click. Returns false (no-op) when no vector
|
|
742
|
+
// session is active, no surface is attached, the input is out of range, or
|
|
743
|
+
// it resolves to the current selection — a strict surface refuses rather
|
|
744
|
+
// than silently mishandling. To open a path with an initial sub-selection in
|
|
745
|
+
// ONE step, pass the same input as `enter_content_edit`'s `opts`.
|
|
746
|
+
set_vector_selection(input: VectorSubSelectionInput, mode?: SelectMode): boolean;
|
|
697
747
|
|
|
698
748
|
// file
|
|
699
749
|
load_svg(svg: string): void;
|
|
700
750
|
serialize_svg(): string;
|
|
701
751
|
|
|
752
|
+
// cleanup — never silent, never automatic
|
|
753
|
+
tidy(opts?: TidyOptions): void;
|
|
754
|
+
|
|
702
755
|
// history
|
|
703
756
|
undo(): void;
|
|
704
757
|
redo(): void;
|
|
@@ -707,19 +760,9 @@ editor.commands.{
|
|
|
707
760
|
|
|
708
761
|
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.
|
|
709
762
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
#### Content edit
|
|
713
|
-
|
|
714
|
-
```ts
|
|
715
|
-
editor.enter_content_edit(target?: NodeId): boolean;
|
|
716
|
-
```
|
|
717
|
-
|
|
718
|
-
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.
|
|
719
|
-
|
|
720
|
-
#### Naming convention
|
|
763
|
+
`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.
|
|
721
764
|
|
|
722
|
-
|
|
765
|
+
(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.)
|
|
723
766
|
|
|
724
767
|
### Providers
|
|
725
768
|
|
|
@@ -744,8 +787,6 @@ type FileIOProvider = {
|
|
|
744
787
|
};
|
|
745
788
|
```
|
|
746
789
|
|
|
747
|
-
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.).
|
|
748
|
-
|
|
749
790
|
### Style
|
|
750
791
|
|
|
751
792
|
`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.
|
|
@@ -758,18 +799,18 @@ type EditorStyle = {
|
|
|
758
799
|
handle_stroke: string;
|
|
759
800
|
endpoint_dot_radius: number;
|
|
760
801
|
selection_outline_width: number;
|
|
761
|
-
|
|
802
|
+
// ...
|
|
762
803
|
};
|
|
763
804
|
|
|
764
805
|
editor.style: Readonly<EditorStyle>;
|
|
765
806
|
editor.set_style(partial: Partial<EditorStyle>): void;
|
|
766
807
|
```
|
|
767
808
|
|
|
768
|
-
`set_style` is the legitimate runtime path — wire it up for theme switching.
|
|
769
|
-
|
|
770
809
|
### React API (thin wrapper)
|
|
771
810
|
|
|
772
|
-
The React layer is intentionally thin. We ship a provider, a canvas component, and
|
|
811
|
+
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.
|
|
812
|
+
|
|
813
|
+
#### Core (the primitives)
|
|
773
814
|
|
|
774
815
|
```tsx
|
|
775
816
|
import {
|
|
@@ -778,18 +819,45 @@ import {
|
|
|
778
819
|
useSvgEditor,
|
|
779
820
|
useEditorState,
|
|
780
821
|
useCommands,
|
|
781
|
-
useCameraSnapshot,
|
|
782
822
|
} from "@grida/svg-editor/react";
|
|
783
823
|
```
|
|
784
824
|
|
|
785
|
-
That's the whole public surface.
|
|
786
|
-
|
|
787
825
|
- `SvgEditorProvider` — owns the headless editor, puts it in context.
|
|
788
|
-
- `SvgEditorCanvas` — the only UI component we ship; internally calls `attach_dom_surface(editor, { container
|
|
826
|
+
- `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.
|
|
789
827
|
- `useSvgEditor()` — returns the editor instance from context.
|
|
790
|
-
- `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change.
|
|
828
|
+
- `useEditorState(selector, equals?)` — subscribes to a slice of `editor.state` and re-renders on change. The subscription primitive.
|
|
791
829
|
- `useCommands()` — sugar for `useSvgEditor().commands`.
|
|
792
|
-
|
|
830
|
+
|
|
831
|
+
#### Bundled hooks (state-slice convenience + lifecycle-aware sessions)
|
|
832
|
+
|
|
833
|
+
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.
|
|
834
|
+
|
|
835
|
+
```tsx
|
|
836
|
+
import {
|
|
837
|
+
// state slices (one-line wrappers over useEditorState)
|
|
838
|
+
useSelection, // → readonly NodeId[]
|
|
839
|
+
useTool, // → Tool
|
|
840
|
+
useMode, // → Mode
|
|
841
|
+
useCanUndo, // → boolean
|
|
842
|
+
useCanRedo, // → boolean
|
|
843
|
+
|
|
844
|
+
// lifecycle-aware preview sessions — unmount = discard (never commit)
|
|
845
|
+
usePaintPreview, // (channel) → PaintPreviewSession
|
|
846
|
+
usePropertyPreview, // (name) → PreviewSession
|
|
847
|
+
|
|
848
|
+
// bound imperative actions, stable identity across renders
|
|
849
|
+
useEditorLoad, // → (svg: string) => void
|
|
850
|
+
useEditorSerialize, // → () => string
|
|
851
|
+
|
|
852
|
+
// RAII hover override (clears on unmount if this hook set the override)
|
|
853
|
+
useHoverOverride, // → (id: NodeId | null) => void
|
|
854
|
+
|
|
855
|
+
// camera bridge (subscribe to a slice of handle.camera without bumping state.version)
|
|
856
|
+
useCameraSnapshot, // (handle, selector, fallback) → T
|
|
857
|
+
} from "@grida/svg-editor/react";
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
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.
|
|
793
861
|
|
|
794
862
|
Top-level wiring:
|
|
795
863
|
|
|
@@ -814,19 +882,27 @@ Everything else is consumer-built against the editor's API. The two patterns:
|
|
|
814
882
|
|
|
815
883
|
```tsx
|
|
816
884
|
function Toolbar() {
|
|
817
|
-
|
|
818
|
-
|
|
885
|
+
// Insertion is the `Tool` axis, not `Mode` — `Mode` is only
|
|
886
|
+
// "select" / "edit-content". Flip tools via `editor.set_tool(...)`.
|
|
887
|
+
const tool = useEditorState((s) => s.tool);
|
|
888
|
+
const editor = useSvgEditor();
|
|
819
889
|
return (
|
|
820
890
|
<>
|
|
821
891
|
<ToolButton
|
|
822
|
-
active={
|
|
823
|
-
onClick={() =>
|
|
892
|
+
active={tool.type === "cursor"}
|
|
893
|
+
onClick={() => editor.set_tool({ type: "cursor" })}
|
|
824
894
|
>
|
|
825
895
|
↖
|
|
826
896
|
</ToolButton>
|
|
827
897
|
<ToolButton
|
|
828
|
-
active={
|
|
829
|
-
onClick={() =>
|
|
898
|
+
active={tool.type === "insert" && tool.tag === "rect"}
|
|
899
|
+
onClick={() => editor.set_tool({ type: "insert", tag: "rect" })}
|
|
900
|
+
>
|
|
901
|
+
▭
|
|
902
|
+
</ToolButton>
|
|
903
|
+
<ToolButton
|
|
904
|
+
active={tool.type === "insert-text"}
|
|
905
|
+
onClick={() => editor.set_tool({ type: "insert-text" })}
|
|
830
906
|
>
|
|
831
907
|
T
|
|
832
908
|
</ToolButton>
|
|
@@ -867,7 +943,7 @@ function PropertyPanel() {
|
|
|
867
943
|
const selection = useEditorState((s) => s.selection);
|
|
868
944
|
const cmd = useCommands();
|
|
869
945
|
|
|
870
|
-
//
|
|
946
|
+
// v0: single-selection path. Multi-selection arrives with the mixed-values layer.
|
|
871
947
|
if (selection.length !== 1)
|
|
872
948
|
return <MultiSelectionPlaceholder count={selection.length} />;
|
|
873
949
|
const id = selection[0];
|
|
@@ -879,7 +955,8 @@ function PropertyPanel() {
|
|
|
879
955
|
return (
|
|
880
956
|
<>
|
|
881
957
|
{/* PaintInput is consumer-built. `provenance` tells the user whether this
|
|
882
|
-
value came from an attribute, inline style,
|
|
958
|
+
value came from an attribute, inline style, a stylesheet rule, or was
|
|
959
|
+
inherited / defaulted. */}
|
|
883
960
|
<PaintInput
|
|
884
961
|
label="Fill"
|
|
885
962
|
declared={fill.declared}
|
|
@@ -903,136 +980,40 @@ function PropertyPanel() {
|
|
|
903
980
|
}
|
|
904
981
|
```
|
|
905
982
|
|
|
906
|
-
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.
|
|
983
|
+
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.
|
|
907
984
|
|
|
908
985
|
What this means in practice:
|
|
909
986
|
|
|
910
|
-
- The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs
|
|
987
|
+
- The editor's API (`editor.subscribe`, `editor.node_*`, `editor.defs.*.subscribe`, etc.) is the contract. The React wrapper is just plumbing.
|
|
911
988
|
- A consumer who decides to use TanStack Query, Jotai, or Zustand instead of `useSyncExternalStore` reaches the same primitives the same way.
|
|
912
989
|
- Adding a built-in hook later (e.g. `useNodePaint`) requires a P6 justification: ≥2 internal consumers and a stable contract.
|
|
913
990
|
|
|
914
|
-
## Low-level API
|
|
915
|
-
|
|
916
|
-
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.
|
|
917
|
-
|
|
918
|
-
```ts
|
|
919
|
-
editor.document; // raw IR handle
|
|
920
|
-
editor.dom_computed_property(id, name); // getComputedStyle when DOM-attached
|
|
921
|
-
editor.dom_computed_paint(id, channel);
|
|
922
|
-
editor.commands.register(id, handler);
|
|
923
|
-
editor.commands.invoke(id, args);
|
|
924
|
-
editor.commands.has(id);
|
|
925
|
-
editor.keymap; // host-controlled bindings
|
|
926
|
-
```
|
|
927
|
-
|
|
928
|
-
### `editor.document`
|
|
929
|
-
|
|
930
|
-
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.
|
|
931
|
-
|
|
932
|
-
### `editor.dom_computed_property` / `editor.dom_computed_paint`
|
|
933
|
-
|
|
934
|
-
```ts
|
|
935
|
-
editor.dom_computed_property(id: NodeId, name: string): string | null;
|
|
936
|
-
editor.dom_computed_paint(
|
|
937
|
-
id: NodeId,
|
|
938
|
-
channel: "fill" | "stroke"
|
|
939
|
-
): { computed: string; resolved_paint: Paint | null } | null;
|
|
940
|
-
```
|
|
941
|
-
|
|
942
|
-
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.
|
|
943
|
-
|
|
944
|
-
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.
|
|
945
|
-
|
|
946
|
-
### `editor.commands.register / invoke / has`
|
|
947
|
-
|
|
948
|
-
```ts
|
|
949
|
-
editor.commands.register(id: CommandId, handler: CommandHandler): () => void;
|
|
950
|
-
editor.commands.invoke(id: CommandId, args?: unknown): boolean;
|
|
951
|
-
editor.commands.has(id: CommandId): boolean;
|
|
952
|
-
|
|
953
|
-
type CommandId = string; // dotted: "history.undo", "selection.remove", "host.copy"
|
|
954
|
-
type CommandHandler = (args?: unknown) => boolean | void;
|
|
955
|
-
```
|
|
956
|
-
|
|
957
|
-
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).
|
|
958
|
-
|
|
959
|
-
### `editor.keymap`
|
|
960
|
-
|
|
961
|
-
```ts
|
|
962
|
-
editor.keymap.bind(binding: KeymapBinding): () => void;
|
|
963
|
-
editor.keymap.unbind(spec: { keybinding?: Keybinding; command?: CommandId }): void;
|
|
964
|
-
editor.keymap.bindings(): readonly KeymapBinding[];
|
|
965
|
-
editor.keymap.dispatch(event: KeyboardEvent): boolean;
|
|
966
|
-
```
|
|
967
|
-
|
|
968
|
-
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.
|
|
969
|
-
|
|
970
|
-
The `@grida/keybinding` package provides the `Keybinding` type, `kb()` / `seq()` / `platformKb()` builders, and the `KeyCode` enum.
|
|
971
|
-
|
|
972
|
-
---
|
|
973
|
-
|
|
974
|
-
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.
|
|
975
|
-
|
|
976
|
-
## Presets
|
|
977
|
-
|
|
978
|
-
```ts
|
|
979
|
-
import { keynote } from "@grida/svg-editor/presets";
|
|
980
|
-
|
|
981
|
-
const handle = keynote.attach(editor, { container, padding: 80 });
|
|
982
|
-
// later: handle.set_padding(0); // present-mode toggle
|
|
983
|
-
// handle.detach();
|
|
984
|
-
```
|
|
985
|
-
|
|
986
|
-
Presets are **opinionated bundles** that compose the public primitives (`attach_dom_surface`, `camera.constraints`, `load_version`, etc.) into one-import setups for common UX shapes. They live at the **`@grida/svg-editor/presets`** subpath — an explicit opt-in. The main `@grida/svg-editor` entry never references this subpath, so hosts that don't want a preset don't pay for it.
|
|
987
|
-
|
|
988
|
-
**v1.1 ships one preset:**
|
|
989
|
-
|
|
990
|
-
- **`keynote`** — slide-shaped canvas. Attaches the DOM surface with `fit: true`, installs `camera.constraints = { type: 'cover', bounds: '<root>', padding }`, subscribes to `editor.state.load_version` for refit-on-load. Returns a `KeynoteSurfaceHandle = DomSurfaceHandle & { set_padding(p): void }` — the `set_padding` method swaps the constraint padding at runtime (use for "Present mode" toggles).
|
|
991
|
-
|
|
992
|
-
The preset's source ([`src/presets/keynote.ts`](https://github.com/gridaco/grida/blob/main/packages/grida-svg-editor/src/presets/keynote.ts)) is ≤ 70 lines of pure composition over the public package surface — no `_internal` reach, no private coupling. If you need a different shape, copy the file and modify.
|
|
993
|
-
|
|
994
|
-
**Discipline.** Code in `src/presets/` is allowed to import only from `@grida/svg-editor` / `@grida/svg-editor/dom` / `@grida/svg-editor/react` and third-party packages — never from `src/core/`, `src/commands/`, `src/keymap/`, or `src/gestures/`. This means every preset is composable against what an external consumer has access to: if a preset works, the same composition works for any host.
|
|
995
|
-
|
|
996
|
-
A second preset graduates when a second use case asks for one (`slidedeck`, `sticker`, `canvas`, …) — discovery-loop discipline.
|
|
997
|
-
|
|
998
|
-
## Deferred for v1
|
|
999
|
-
|
|
1000
|
-
What v1 explicitly doesn't ship. Each is documented elsewhere with the v1 workaround.
|
|
1001
|
-
|
|
1002
|
-
- **`<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)).
|
|
1003
|
-
- **Defs registries other than gradients** (patterns, symbols, markers, clip-paths, masks, filters). Walk `editor.document` directly.
|
|
1004
|
-
- **`commands.resize` (top-level), `commands.rotate`, `commands.tidy`**. Resize gestures are HUD-driven via a preview session; rotate and tidy are unimplemented.
|
|
1005
|
-
- **Multi-selection aggregation** (mixed-value property/paint views).
|
|
1006
|
-
- **Insertion modes / shape tools.** Anti-goal — "Not a vector authoring tool."
|
|
1007
|
-
- **Copy/paste, alt-drag, snap-to-geometry, snap-to-pixel-grid, alignment hotkeys, duplicate (Cmd+D), select-all.**
|
|
1008
|
-
- **Non-DOM surfaces** (worker, React Native, headless test harness).
|
|
1009
|
-
- **Paint-server validity warnings** (dangling `url(#id)` references).
|
|
1010
|
-
- **Rotation gesture in the HUD.**
|
|
1011
|
-
- **Persisted camera state.** Camera is surface-scoped and never enters `editor.state` / `editor.serialize()` / history. Hosts that want view bookmarks persist `handle.camera.transform` themselves.
|
|
1012
|
-
- **Gesture momentum / inertia / animated `fit`.** The `animate` shape isn't reserved in the type — it'll be added when delivered, not before.
|
|
1013
|
-
- **`CameraConstraints` variants beyond `'cover'`.** v1.1 ships `type: 'cover'` only (bounds cover viewport — slide / keynote UX). The discriminator is a tagged union so future variants land as additive arms: `type: 'contain'` (bounds always fully visible inside viewport — bounded artwork) and `type: 'pan-region'` (d3-zoom `translateExtent` style — pan clamped to a region with free zoom) are reserved names but not yet implemented. Peer-validated direction: tldraw's `TLCameraConstraints.behavior: 'inside' | 'outside'`, mapped to CSS-style `'cover'` / `'contain'` naming.
|
|
1014
|
-
|
|
1015
991
|
## Anti-goals
|
|
1016
992
|
|
|
1017
993
|
What this editor will never be. Each one is a defensive perimeter for the principles above.
|
|
1018
994
|
|
|
1019
995
|
- **Not a vector authoring tool.** No pen tool, no boolean ops, no path-node sculpting beyond what an SVG-natural edit supports.
|
|
1020
996
|
- **Not an animation editor.** SMIL is preserved verbatim, never authored or mutated.
|
|
1021
|
-
- **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers.
|
|
997
|
+
- **Not a plugin host.** No public registry for tools, capabilities, gestures, HUD overlays, or serializers. (P1, P6.)
|
|
1022
998
|
- **Not a Figma-style multiplayer canvas.** State is local. Sync is the consumer's problem.
|
|
1023
999
|
- **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
|
|
1024
|
-
- **Not a
|
|
1000
|
+
- **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.
|
|
1001
|
+
- **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.)
|
|
1025
1002
|
- **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
|
|
1003
|
+
- **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.
|
|
1026
1004
|
|
|
1027
1005
|
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.
|
|
1028
1006
|
|
|
1029
1007
|
## Status
|
|
1030
1008
|
|
|
1031
|
-
`
|
|
1009
|
+
- `v0.x` — selection, transform, insert (rect / ellipse / line), inline text
|
|
1010
|
+
edit, and the click-to-place text tool. Experimental.
|
|
1011
|
+
|
|
1012
|
+
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.
|
|
1032
1013
|
|
|
1033
|
-
|
|
1014
|
+
## Contributing
|
|
1034
1015
|
|
|
1035
|
-
|
|
1016
|
+
- [`TODO.md`](./TODO.md) — open questions and deferred work, grouped by area.
|
|
1036
1017
|
|
|
1037
1018
|
## License
|
|
1038
1019
|
|