@grida/svg-editor 1.0.0-alpha.14 → 1.0.0-alpha.16
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 +59 -0
- package/dist/{dom-Dz_V6q0Y.d.mts → dom-98AUOfsP.d.mts} +44 -2
- package/dist/{dom-D4dy6kq5.d.ts → dom-BO2-E9oK.d.ts} +44 -2
- package/dist/{dom-DSjfCllZ.mjs → dom-DOvcMvl4.mjs} +295 -176
- package/dist/{dom-BuD8TKmL.js → dom-U6ae5fQF.js} +300 -175
- package/dist/dom.d.mts +3 -3
- package/dist/dom.d.ts +3 -3
- package/dist/dom.js +2 -1
- package/dist/dom.mjs +2 -2
- package/dist/{editor-BHHU_Nvz.js → editor-C6Lj1In-.js} +433 -582
- package/dist/{editor-CJ2KuRh5.d.ts → editor-CYoGJ3Hf.d.ts} +500 -24
- package/dist/{editor-YQwdWHBb.d.mts → editor-D2eQe8lB.d.mts} +500 -24
- package/dist/{editor-B6pchGYk.mjs → editor-DKQOIKuU.mjs} +432 -582
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{model-DqGqV1H4.js → model-D0nU_EkL.js} +1245 -79
- package/dist/{model-DIzZmeyf.mjs → model-L3t9ixT_.mjs} +1240 -80
- package/dist/presets.d.mts +2 -2
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +2 -2
- package/dist/presets.mjs +1 -1
- package/dist/react.d.mts +20 -3
- package/dist/react.d.ts +20 -3
- package/dist/react.js +25 -2
- package/dist/react.mjs +25 -3
- package/package.json +29 -5
package/README.md
CHANGED
|
@@ -249,9 +249,32 @@ editor.dispose(): void; // permanent teardown
|
|
|
249
249
|
```ts
|
|
250
250
|
editor.load(svg: string): void; // replace the document (e.g. file-on-disk changed)
|
|
251
251
|
editor.serialize(): string; // emit clean SVG — guaranteed round-trip per P1
|
|
252
|
+
editor.serialize_node(id: NodeId): string; // emit ONE element's subtree — a fragment, see below
|
|
252
253
|
editor.reset(): void; // back to last load() input, clears history
|
|
253
254
|
```
|
|
254
255
|
|
|
256
|
+
`serialize_node(id)` exports the markup of a single element — the bridge from
|
|
257
|
+
"what the user selected" (a `NodeId`) to "the SVG for that element," e.g. to
|
|
258
|
+
hand a downstream consumer (an AI agent) the selected subtree without
|
|
259
|
+
re-serializing the whole document. It reuses `serialize()`'s trivia-preserving
|
|
260
|
+
rules (attribute order, quotes, whitespace, comments — emitted as authored).
|
|
261
|
+
|
|
262
|
+
It is deliberately **weaker** than `serialize()`, and the two must not be
|
|
263
|
+
conflated: `serialize()` emits the whole document and carries the P1
|
|
264
|
+
round-trip guarantee; `serialize_node()` emits a **fragment** and does not.
|
|
265
|
+
Namespace declarations that live on an ancestor (`xmlns:xlink` and friends,
|
|
266
|
+
normally on the root `<svg>`) are **not** inlined into the fragment — a node
|
|
267
|
+
using `xlink:href` serializes without `xmlns:xlink`. The fragment is the
|
|
268
|
+
element's markup as authored, not a standalone parseable document. Throws on
|
|
269
|
+
an unknown id or a non-element node (selections are always elements).
|
|
270
|
+
|
|
271
|
+
> A stable reference to a node that survives a `load()` — and survives an
|
|
272
|
+
> external rewrite of the file — is a separate, unsolved problem (`NodeId`
|
|
273
|
+
> regenerates on each parse). Positional child-index paths address only the
|
|
274
|
+
> deterministic-re-parse case, not structural edits; durable node identity is
|
|
275
|
+
> under design — see
|
|
276
|
+
> [durable node identity](https://grida.co/docs/wg/feat-svg-editor/durable-node-identity).
|
|
277
|
+
|
|
255
278
|
### Observation — state
|
|
256
279
|
|
|
257
280
|
```ts
|
|
@@ -281,6 +304,25 @@ editor.subscribe_with_selector<T>(
|
|
|
281
304
|
|
|
282
305
|
`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.
|
|
283
306
|
|
|
307
|
+
### Observation — pick (tap)
|
|
308
|
+
|
|
309
|
+
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`.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
type PickEvent = {
|
|
313
|
+
point: Vec2; // document-space — the pointer-DOWN point the tap resolved against
|
|
314
|
+
node_id: NodeId | null; // topmost node under point; null = empty canvas
|
|
315
|
+
button: "primary" | "secondary"; // middle is pan, never taps
|
|
316
|
+
mods: { shift: boolean; alt: boolean; meta: boolean; ctrl: boolean };
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
editor.subscribe_pick(fn: (e: PickEvent) => void): Unsubscribe;
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
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)`.
|
|
323
|
+
|
|
324
|
+
> **Status:** `@unstable` — shipped against one consumer; the shape is open until a second click-driven tool exercises it (P6).
|
|
325
|
+
|
|
284
326
|
### Observation — properties
|
|
285
327
|
|
|
286
328
|
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.
|
|
@@ -585,6 +627,8 @@ editor.commands.{
|
|
|
585
627
|
resize_to(target: { width: number; height: number; anchor?: ResizeAnchor }): void;
|
|
586
628
|
rotate(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
587
629
|
rotate_to(args: { angle: number; pivot?: { x: number; y: number } }): void;
|
|
630
|
+
// `matrix` is SVG `matrix(a b c d e f)` order (the `Matrix2D` tuple).
|
|
631
|
+
transform(matrix: Matrix2D, opts?: { ids?: NodeId[]; pivot?: { x: number; y: number } }): boolean;
|
|
588
632
|
flatten_transform(): void; // bake `transform=` into native attrs where possible
|
|
589
633
|
|
|
590
634
|
// alignment (operates on selection of ≥2 nodes against their union bbox)
|
|
@@ -593,12 +637,24 @@ editor.commands.{
|
|
|
593
637
|
// structure
|
|
594
638
|
reorder(direction: "bring_forward" | "send_backward" | "bring_to_front" | "send_to_back"): void;
|
|
595
639
|
group(): void; // wrap selection in a new <g>
|
|
640
|
+
ungroup(opts?: { id?: NodeId }): boolean; // dissolve a plain structural <g>
|
|
641
|
+
// (clean-structural subset only; refuses
|
|
642
|
+
// groups with visual state — see TODO §10)
|
|
596
643
|
remove(): void;
|
|
597
644
|
|
|
598
645
|
// insertion — `tag` is an open string (so paste / RPC can create any element,
|
|
599
646
|
// e.g. "path"); only the closed `InsertableTag` set gets a pointer-driven
|
|
600
647
|
// draw gesture and default paint.
|
|
601
648
|
insert(tag: string, attrs?: Readonly<Record<string, string>>): NodeId;
|
|
649
|
+
// markup-shaped sibling of `insert` — one or more sibling elements, or a
|
|
650
|
+
// full `<svg>` doc (the shell is discarded; its children are the content).
|
|
651
|
+
// Subtrees adopted verbatim; ONE history step; returns root ids in
|
|
652
|
+
// document order. Authored ids are NEVER rewritten (dedup is Tidy's job);
|
|
653
|
+
// undeclared `xlink:` / shell-declared prefixes are hoisted onto the root
|
|
654
|
+
// in the same step. Position is authored content: wrap the fragment in
|
|
655
|
+
// `<g transform="translate(x y)">` to land it at a point — same single
|
|
656
|
+
// undo step, no placement opt.
|
|
657
|
+
insert_fragment(svg: string, opts?: { parent?: NodeId; index?: number; select?: boolean }): NodeId[];
|
|
602
658
|
insert_preview(tag: string, initial?: Readonly<Record<string, string>>): InsertPreviewSession;
|
|
603
659
|
|
|
604
660
|
// content
|
|
@@ -620,6 +676,8 @@ editor.commands.{
|
|
|
620
676
|
|
|
621
677
|
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.
|
|
622
678
|
|
|
679
|
+
`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.
|
|
680
|
+
|
|
623
681
|
(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.)
|
|
624
682
|
|
|
625
683
|
### Providers
|
|
@@ -857,6 +915,7 @@ What this editor will never be. Each one is a defensive perimeter for the princi
|
|
|
857
915
|
- **Not customizable in HUD layout.** Style spec only — no overlay slots, no handle replacement, no custom chrome components.
|
|
858
916
|
- **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.)
|
|
859
917
|
- **Not a serializer playground.** Round-trip rules are fixed (P1). No "compact mode," no "Prettier mode," no consumer-supplied formatter.
|
|
918
|
+
- **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.
|
|
860
919
|
|
|
861
920
|
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.
|
|
862
921
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-
|
|
1
|
+
import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-D2eQe8lB.mjs";
|
|
2
2
|
import cmath from "@grida/cmath";
|
|
3
3
|
import { guide } from "@grida/cmath/_snap";
|
|
4
4
|
|
|
@@ -13,6 +13,35 @@ type SnapOptions = {
|
|
|
13
13
|
declare const DEFAULT_SNAP_OPTIONS: SnapOptions;
|
|
14
14
|
//#endregion
|
|
15
15
|
//#region src/dom.d.ts
|
|
16
|
+
/**
|
|
17
|
+
* Wire a web-font settle source to the editor's geometry channel.
|
|
18
|
+
*
|
|
19
|
+
* The DOM surface re-serializes the `<svg>` on every editor tick, but a
|
|
20
|
+
* `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
|
|
21
|
+
* finishing load AFTER its `font-family` / `font-size` was already written.
|
|
22
|
+
* The IR never sees that reflow, so nothing bumps `geometry_version` and
|
|
23
|
+
* every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
|
|
24
|
+
* the fallback-face metrics until the next real edit.
|
|
25
|
+
*
|
|
26
|
+
* Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
|
|
27
|
+
* `EventTarget` in tests) and calls `bump` once per settle. COARSE on
|
|
28
|
+
* purpose: one bump clears the WHOLE bounds cache, not just text nodes —
|
|
29
|
+
* consistent with the package's pessimistic-invalidation stance, and far
|
|
30
|
+
* cheaper than scoping the bump to the (possibly many) reflowed runs.
|
|
31
|
+
*
|
|
32
|
+
* Also bumps once when `source.ready` resolves (when present): fonts that
|
|
33
|
+
* settled before attach — a cache hit, or `font-display` resolving the same
|
|
34
|
+
* tick the surface mounts — never re-fire `loadingdone`, so a document
|
|
35
|
+
* mounted post-settle still needs one bump to re-read at the real metrics.
|
|
36
|
+
*
|
|
37
|
+
* Returns a teardown that removes the listener and neutralizes the pending
|
|
38
|
+
* `ready` bump (leak guard) — call it on surface detach.
|
|
39
|
+
*
|
|
40
|
+
* Factored out of the surface so it can be unit-tested with a fake
|
|
41
|
+
* `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
|
|
42
|
+
* incomplete); never a real font / network.
|
|
43
|
+
*/
|
|
44
|
+
declare function install_font_load_geometry_bump(source: EventTarget | null, bump: () => void): () => void;
|
|
16
45
|
type DomSurfaceOptions = {
|
|
17
46
|
/** Mount the SVG inside this container. */container: HTMLElement;
|
|
18
47
|
/**
|
|
@@ -33,6 +62,19 @@ type DomSurfaceOptions = {
|
|
|
33
62
|
* when `fit: true`.
|
|
34
63
|
*/
|
|
35
64
|
initial_camera?: cmath.Transform;
|
|
65
|
+
/**
|
|
66
|
+
* Font-load settle source — the `EventTarget` whose `loadingdone` event
|
|
67
|
+
* signals "web fonts finished loading, text may have reflowed." Defaults
|
|
68
|
+
* to `container.ownerDocument.fonts` (the live `FontFaceSet`). The
|
|
69
|
+
* surface installs a `loadingdone` listener that advances the editor's
|
|
70
|
+
* geometry channel so text bounds re-read at the settled glyph metrics
|
|
71
|
+
* (see ../docs/geometry.md §Limitations "Text bbox depends on font").
|
|
72
|
+
*
|
|
73
|
+
* Injectable as a DOM seam: jsdom's `FontFaceSet` is incomplete, so
|
|
74
|
+
* tests pass a plain `EventTarget` stub and `dispatchEvent(new Event(
|
|
75
|
+
* "loadingdone"))` to simulate a settle without a real font / network.
|
|
76
|
+
*/
|
|
77
|
+
font_load_source?: EventTarget;
|
|
36
78
|
};
|
|
37
79
|
/**
|
|
38
80
|
* Surface handle for the DOM surface. Extends the editor's core
|
|
@@ -111,4 +153,4 @@ declare function inverse_project_rect(rect: {
|
|
|
111
153
|
f: number;
|
|
112
154
|
}, offset: readonly [number, number]): cmath.Rectangle | null;
|
|
113
155
|
//#endregion
|
|
114
|
-
export {
|
|
156
|
+
export { inverse_project_rect as a, DEFAULT_SNAP_OPTIONS as c, install_font_load_geometry_bump as i, SnapOptions as l, DomSurfaceOptions as n, project_delta_inverse_ctm as o, attach_dom_surface as r, project_point_through_ctm as s, DomSurfaceHandle as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-
|
|
1
|
+
import { c as SvgEditor, p as Gestures, s as SurfaceHandle, w as Camera } from "./editor-CYoGJ3Hf.js";
|
|
2
2
|
import cmath from "@grida/cmath";
|
|
3
3
|
//#region src/core/snap/options.d.ts
|
|
4
4
|
type SnapOptions = {
|
|
@@ -11,6 +11,35 @@ type SnapOptions = {
|
|
|
11
11
|
declare const DEFAULT_SNAP_OPTIONS: SnapOptions;
|
|
12
12
|
//#endregion
|
|
13
13
|
//#region src/dom.d.ts
|
|
14
|
+
/**
|
|
15
|
+
* Wire a web-font settle source to the editor's geometry channel.
|
|
16
|
+
*
|
|
17
|
+
* The DOM surface re-serializes the `<svg>` on every editor tick, but a
|
|
18
|
+
* `<text>` / `<tspan>` bbox can change with NO attribute write: a web font
|
|
19
|
+
* finishing load AFTER its `font-family` / `font-size` was already written.
|
|
20
|
+
* The IR never sees that reflow, so nothing bumps `geometry_version` and
|
|
21
|
+
* every bounds-keyed consumer (snap, HUD chrome, size meter) stays stuck at
|
|
22
|
+
* the fallback-face metrics until the next real edit.
|
|
23
|
+
*
|
|
24
|
+
* Listens for `loadingdone` on `source` (a `FontFaceSet`, or any injected
|
|
25
|
+
* `EventTarget` in tests) and calls `bump` once per settle. COARSE on
|
|
26
|
+
* purpose: one bump clears the WHOLE bounds cache, not just text nodes —
|
|
27
|
+
* consistent with the package's pessimistic-invalidation stance, and far
|
|
28
|
+
* cheaper than scoping the bump to the (possibly many) reflowed runs.
|
|
29
|
+
*
|
|
30
|
+
* Also bumps once when `source.ready` resolves (when present): fonts that
|
|
31
|
+
* settled before attach — a cache hit, or `font-display` resolving the same
|
|
32
|
+
* tick the surface mounts — never re-fire `loadingdone`, so a document
|
|
33
|
+
* mounted post-settle still needs one bump to re-read at the real metrics.
|
|
34
|
+
*
|
|
35
|
+
* Returns a teardown that removes the listener and neutralizes the pending
|
|
36
|
+
* `ready` bump (leak guard) — call it on surface detach.
|
|
37
|
+
*
|
|
38
|
+
* Factored out of the surface so it can be unit-tested with a fake
|
|
39
|
+
* `EventTarget` in the node-only test env (jsdom's `FontFaceSet` is
|
|
40
|
+
* incomplete); never a real font / network.
|
|
41
|
+
*/
|
|
42
|
+
declare function install_font_load_geometry_bump(source: EventTarget | null, bump: () => void): () => void;
|
|
14
43
|
type DomSurfaceOptions = {
|
|
15
44
|
/** Mount the SVG inside this container. */container: HTMLElement;
|
|
16
45
|
/**
|
|
@@ -31,6 +60,19 @@ type DomSurfaceOptions = {
|
|
|
31
60
|
* when `fit: true`.
|
|
32
61
|
*/
|
|
33
62
|
initial_camera?: cmath.Transform;
|
|
63
|
+
/**
|
|
64
|
+
* Font-load settle source — the `EventTarget` whose `loadingdone` event
|
|
65
|
+
* signals "web fonts finished loading, text may have reflowed." Defaults
|
|
66
|
+
* to `container.ownerDocument.fonts` (the live `FontFaceSet`). The
|
|
67
|
+
* surface installs a `loadingdone` listener that advances the editor's
|
|
68
|
+
* geometry channel so text bounds re-read at the settled glyph metrics
|
|
69
|
+
* (see ../docs/geometry.md §Limitations "Text bbox depends on font").
|
|
70
|
+
*
|
|
71
|
+
* Injectable as a DOM seam: jsdom's `FontFaceSet` is incomplete, so
|
|
72
|
+
* tests pass a plain `EventTarget` stub and `dispatchEvent(new Event(
|
|
73
|
+
* "loadingdone"))` to simulate a settle without a real font / network.
|
|
74
|
+
*/
|
|
75
|
+
font_load_source?: EventTarget;
|
|
34
76
|
};
|
|
35
77
|
/**
|
|
36
78
|
* Surface handle for the DOM surface. Extends the editor's core
|
|
@@ -109,4 +151,4 @@ declare function inverse_project_rect(rect: {
|
|
|
109
151
|
f: number;
|
|
110
152
|
}, offset: readonly [number, number]): cmath.Rectangle | null;
|
|
111
153
|
//#endregion
|
|
112
|
-
export {
|
|
154
|
+
export { inverse_project_rect as a, DEFAULT_SNAP_OPTIONS as c, install_font_load_geometry_bump as i, SnapOptions as l, DomSurfaceOptions as n, project_delta_inverse_ctm as o, attach_dom_surface as r, project_point_through_ctm as s, DomSurfaceHandle as t };
|