@grida/svg-editor 1.0.0-alpha.16 → 1.0.0-alpha.17
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 +33 -0
- package/dist/{dom-BO2-E9oK.d.ts → dom-BMzX1CXZ.d.ts} +13 -1
- package/dist/{dom-DOvcMvl4.mjs → dom-Bjj9xySE.mjs} +105 -12
- package/dist/{dom-U6ae5fQF.js → dom-CaByuo6C.js} +105 -12
- package/dist/{dom-98AUOfsP.d.mts → dom-TctdgRnn.d.mts} +13 -1
- package/dist/dom.d.mts +2 -2
- package/dist/dom.d.ts +2 -2
- package/dist/dom.js +1 -1
- package/dist/dom.mjs +1 -1
- package/dist/{editor-DKQOIKuU.mjs → editor-BLsELHSZ.mjs} +384 -31
- package/dist/{editor-CYoGJ3Hf.d.ts → editor-BSxTUsW_.d.ts} +246 -4
- package/dist/{editor-D2eQe8lB.d.mts → editor-KqpIW1qm.d.mts} +246 -4
- package/dist/{editor-C6Lj1In-.js → editor-N9af0JD2.js} +383 -31
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{model-L3t9ixT_.mjs → model-DMaN5GnH.mjs} +286 -24
- package/dist/{model-D0nU_EkL.js → model-GpysNbOv.js} +297 -23
- 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 +10 -3
- package/dist/react.d.ts +10 -3
- package/dist/react.js +6 -4
- package/dist/react.mjs +6 -4
- package/package.json +5 -5
|
@@ -644,6 +644,8 @@ declare class SvgDocument implements DocumentEvents {
|
|
|
644
644
|
* stays the same and React skips the re-render of the whole tree.
|
|
645
645
|
*/
|
|
646
646
|
private _structure_version;
|
|
647
|
+
/** Total listener-visible mutation count. See the `revision` getter. */
|
|
648
|
+
private _revision;
|
|
647
649
|
/** Bumps on writes that can shift world-space bounds (`GEOMETRY_ATTRS`,
|
|
648
650
|
* `set_text`, `insert`, `remove`). Cache key for `GeometryProvider`;
|
|
649
651
|
* see ../../docs/geometry.md. */
|
|
@@ -657,6 +659,18 @@ declare class SvgDocument implements DocumentEvents {
|
|
|
657
659
|
on_change(fn: () => void): () => void;
|
|
658
660
|
/** See `_structure_version` for what this counter signals. */
|
|
659
661
|
get structure_version(): number;
|
|
662
|
+
/**
|
|
663
|
+
* Total mutation counter — advances on EVERY listener-visible mutation
|
|
664
|
+
* (attribute, style, text, topology, load/reset), unlike the selective
|
|
665
|
+
* `structure_version` / `geometry_version` channels. The single
|
|
666
|
+
* edit-version source: anything derived from this document — the
|
|
667
|
+
* editor's `content_version` / `dirty`, memoized reads, a rendered
|
|
668
|
+
* projection — answers "am I current?" by comparing values, with no
|
|
669
|
+
* event-ordering dependence. Advances BEFORE listeners fire, so a
|
|
670
|
+
* read issued from inside a change listener already sees the new
|
|
671
|
+
* value.
|
|
672
|
+
*/
|
|
673
|
+
get revision(): number;
|
|
660
674
|
/** See `_geometry_version` for what this counter signals. */
|
|
661
675
|
get geometry_version(): number;
|
|
662
676
|
/**
|
|
@@ -671,10 +685,9 @@ declare class SvgDocument implements DocumentEvents {
|
|
|
671
685
|
* settled glyph metrics. See ../../docs/geometry.md §Limitations.
|
|
672
686
|
*
|
|
673
687
|
* Deliberately does NOT call `emit()`: this is not a document edit, so
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
* `
|
|
677
|
-
* out the geometry listeners itself.
|
|
688
|
+
* `revision` must not advance — no dirty flag, no undo, no render
|
|
689
|
+
* flush. The editor's `_internal.bump_geometry` advances
|
|
690
|
+
* `geometry_version` here and fans out the geometry listeners itself.
|
|
678
691
|
*/
|
|
679
692
|
bump_geometry(): void;
|
|
680
693
|
private emit;
|
|
@@ -1382,6 +1395,129 @@ declare class Keymap {
|
|
|
1382
1395
|
private chunkKeysFor;
|
|
1383
1396
|
}
|
|
1384
1397
|
//#endregion
|
|
1398
|
+
//#region src/core/subtree.d.ts
|
|
1399
|
+
declare namespace subtree {
|
|
1400
|
+
/**
|
|
1401
|
+
* Document-order comparator over node ids. Builds the index once per
|
|
1402
|
+
* call (one full tree walk) — create one comparator per operation and
|
|
1403
|
+
* reuse it, as `clipboard.extract_payload` does.
|
|
1404
|
+
*/
|
|
1405
|
+
function by_document_order(doc: SvgDocument): (a: NodeId, b: NodeId) => number;
|
|
1406
|
+
/**
|
|
1407
|
+
* Selection normalization — the half of extraction that payload
|
|
1408
|
+
* extraction (copy) and subtree clone share, per the FRD:
|
|
1409
|
+
* dedupe → live elements only → ancestor subtrees subsume selected
|
|
1410
|
+
* descendants (`prune_nested_nodes`) → DOCUMENT order regardless of
|
|
1411
|
+
* selection order (sibling order is paint order, and paint order is
|
|
1412
|
+
* meaning). Stale / non-element / detached ids are skipped, never
|
|
1413
|
+
* thrown — normalization is a filter, not a validator.
|
|
1414
|
+
*/
|
|
1415
|
+
function normalize_roots(doc: SvgDocument, selection: ReadonlyArray<NodeId>, order?: (a: NodeId, b: NodeId) => number): NodeId[];
|
|
1416
|
+
/** One origin → clone pairing with the clone's placement, captured at
|
|
1417
|
+
* plan time. `before` is the origin's next sibling NODE (element or
|
|
1418
|
+
* trivia) so the clone lands immediately after its origin. */
|
|
1419
|
+
type SubtreeClonePlanEntry = {
|
|
1420
|
+
origin: NodeId;
|
|
1421
|
+
/** Registered in the document's node map, DETACHED (`create_fragment`
|
|
1422
|
+
* style) — the consumer inserts it inside its own history closure. */
|
|
1423
|
+
clone: NodeId; /** `parent_of(origin)` at plan time. */
|
|
1424
|
+
parent: NodeId; /** `next_sibling_of(origin)` at plan time; `null` = append. */
|
|
1425
|
+
before: NodeId | null;
|
|
1426
|
+
};
|
|
1427
|
+
type SubtreeClonePlan = ReadonlyArray<SubtreeClonePlanEntry>;
|
|
1428
|
+
/**
|
|
1429
|
+
* Build a clone plan for the selection: for each normalized origin,
|
|
1430
|
+
* serialize its subtree verbatim and re-adopt it under fresh runtime
|
|
1431
|
+
* NodeIds via `create_fragment` — the markup round-trip rides the same
|
|
1432
|
+
* trivia-preserving emit and never-rewrite-ids adoption the package
|
|
1433
|
+
* already guarantees, so `serialize_node(clone) === serialize_node(origin)`
|
|
1434
|
+
* byte-for-byte once inserted.
|
|
1435
|
+
*
|
|
1436
|
+
* Clones are returned DETACHED (plan, don't insert): consumers own
|
|
1437
|
+
* insertion inside their history closures so redo can re-insert the
|
|
1438
|
+
* same NodeIds (`remove` keeps nodes in the id map).
|
|
1439
|
+
*
|
|
1440
|
+
* Skipped origins (refusals, normalized away — not errors):
|
|
1441
|
+
* - the document root and any other parentless node (no sibling slot);
|
|
1442
|
+
* - nested `<svg>` elements — `create_fragment` deliberately treats a
|
|
1443
|
+
* lone `<svg>` root as a full-document shell and discards it (the
|
|
1444
|
+
* FRD's paste rule), which would silently unwrap the clone; refusing
|
|
1445
|
+
* beats mishandling.
|
|
1446
|
+
*
|
|
1447
|
+
* An empty selection (or one that normalizes to nothing) yields an
|
|
1448
|
+
* empty plan — the caller's no-op.
|
|
1449
|
+
*/
|
|
1450
|
+
function clone_plan(doc: SvgDocument, selection: ReadonlyArray<NodeId>): SubtreeClonePlan;
|
|
1451
|
+
/**
|
|
1452
|
+
* Attach every plan clone at its captured anchor. Anchors predate the
|
|
1453
|
+
* plan (captured before any insertion), so no entry anchors on another
|
|
1454
|
+
* entry's clone — each insert is independent and the interleaved
|
|
1455
|
+
* `A, A′, B, B′` order falls out of the per-origin anchors.
|
|
1456
|
+
*
|
|
1457
|
+
* Idempotent: `doc.insert` detaches-then-splices, so re-attaching an
|
|
1458
|
+
* already-live clone repositions it to the same slot. History redo
|
|
1459
|
+
* closures rely on this.
|
|
1460
|
+
*/
|
|
1461
|
+
function insert_plan(doc: SvgDocument, plan: SubtreeClonePlan): void;
|
|
1462
|
+
/**
|
|
1463
|
+
* Detach every plan clone. Order-independent for the same reason
|
|
1464
|
+
* {@link insert_plan} is: anchors are never plan clones. Removed nodes
|
|
1465
|
+
* stay in the document's id map (standard removed-node policy), so a
|
|
1466
|
+
* later {@link insert_plan} over the same plan restores them.
|
|
1467
|
+
*/
|
|
1468
|
+
function remove_plan(doc: SvgDocument, plan: SubtreeClonePlan): void;
|
|
1469
|
+
/**
|
|
1470
|
+
* The last committed duplication — the memory the repeating-offset
|
|
1471
|
+
* behavior reads (gridaco/grida#825; spec:
|
|
1472
|
+
* [docs/wg/feat-svg-editor/subtree-clone.md](../../../../docs/wg/feat-svg-editor/subtree-clone.md)
|
|
1473
|
+
* §Repeating offset). Armed by `commands.duplicate` and by a cloned
|
|
1474
|
+
* translate commit (Alt-drag); consumed and re-armed by the next
|
|
1475
|
+
* `duplicate()`. Editor-session state — never observable, never in
|
|
1476
|
+
* history. Staleness is caught at use by {@link repeat_delta}, not by
|
|
1477
|
+
* per-mutation bookkeeping.
|
|
1478
|
+
*
|
|
1479
|
+
* The arrays are INDEX-PAIRED: `clones[i]` is the clone of
|
|
1480
|
+
* `origins[i]` (both producers derive them from the same
|
|
1481
|
+
* {@link SubtreeClonePlan}). {@link repeat_delta}'s per-member
|
|
1482
|
+
* rigidity check depends on that pairing.
|
|
1483
|
+
*/
|
|
1484
|
+
type DuplicationRecord = {
|
|
1485
|
+
origins: ReadonlyArray<NodeId>;
|
|
1486
|
+
clones: ReadonlyArray<NodeId>;
|
|
1487
|
+
};
|
|
1488
|
+
/**
|
|
1489
|
+
* The repeating-offset delta (gridaco/grida#825): given the previous
|
|
1490
|
+
* duplication's record and the CURRENT duplicate's normalized origins,
|
|
1491
|
+
* return the world-space offset the fresh clones should repeat, or
|
|
1492
|
+
* `null` for "no repeat — duplicate in place".
|
|
1493
|
+
*
|
|
1494
|
+
* The repeat fires only when the record still witnesses
|
|
1495
|
+
* "duplicate, then rigidly translate the copies":
|
|
1496
|
+
* - `targets` is exactly `record.clones`, in the same (document)
|
|
1497
|
+
* order — the user is duplicating the previous duplication's
|
|
1498
|
+
* copies and nothing else;
|
|
1499
|
+
* - `bounds_of` answers for EVERY member of both sets (a detached
|
|
1500
|
+
* member, a measureless tag, or a missing geometry provider all
|
|
1501
|
+
* refuse);
|
|
1502
|
+
* - EVERY clone is rigid against its own origin (the record's
|
|
1503
|
+
* arrays are index-paired): same size, displaced by the same
|
|
1504
|
+
* delta as the union, within {@link REPEAT_RIGID_EPSILON}. The
|
|
1505
|
+
* check is per member, not per envelope — a rearranged or
|
|
1506
|
+
* resized inner copy is no longer a translate even when the
|
|
1507
|
+
* envelope-defining copies keep the union bbox intact;
|
|
1508
|
+
* - the union top-left delta exceeds the same tolerance (a copy
|
|
1509
|
+
* that never moved — or drifted by float noise only — repeats
|
|
1510
|
+
* nothing; the in-place duplicate stays byte-equal instead of
|
|
1511
|
+
* inheriting noise-sized attribute writes).
|
|
1512
|
+
*
|
|
1513
|
+
* Pure and gesture-grade: reads only through `bounds_of`, never
|
|
1514
|
+
* throws — every failed precondition degrades to `null` (the main
|
|
1515
|
+
* editor's `active_duplication` assert-on-mismatch is deliberately
|
|
1516
|
+
* NOT copied; ⌘D must never crash on a stale record).
|
|
1517
|
+
*/
|
|
1518
|
+
function repeat_delta(record: DuplicationRecord | null, targets: ReadonlyArray<NodeId>, bounds_of: (id: NodeId) => Rect | null): Vec2 | null;
|
|
1519
|
+
}
|
|
1520
|
+
//#endregion
|
|
1385
1521
|
//#region src/core/geometry.d.ts
|
|
1386
1522
|
/**
|
|
1387
1523
|
* Read-only access to world-space node bounds and hit-tests.
|
|
@@ -1390,6 +1526,19 @@ declare class Keymap {
|
|
|
1390
1526
|
* will query dozens of nodes per pointermove. The driver wraps SVG
|
|
1391
1527
|
* `getBBox` + `getCTM`; the memoizer caches per-`NodeId` to survive
|
|
1392
1528
|
* the surface re-rendering the SVG tree every editor tick.
|
|
1529
|
+
*
|
|
1530
|
+
* **Freshness contract.** Every read MUST reflect the CURRENT document
|
|
1531
|
+
* — including when issued synchronously from inside a doc-change
|
|
1532
|
+
* listener (`subscribe_geometry` fires mid-mutation, before any
|
|
1533
|
+
* render). An implementation backed by a lazily-synced projection
|
|
1534
|
+
* (e.g. a rendered DOM tree) must flush that projection before
|
|
1535
|
+
* reading; compare `SvgDocument.revision` against the projection's
|
|
1536
|
+
* last-rendered revision. Returning the previous document's geometry
|
|
1537
|
+
* is not a transient glitch: the `MemoizedGeometryProvider` wrapper
|
|
1538
|
+
* caches whatever the driver returns, so one stale read poisons every
|
|
1539
|
+
* later consumer until the next invalidation (align/resize then plan
|
|
1540
|
+
* against one-mutation-old bounds and oscillate — see
|
|
1541
|
+
* `__tests__/geometry-stale-read.browser.test.ts`).
|
|
1393
1542
|
*/
|
|
1394
1543
|
interface GeometryProvider {
|
|
1395
1544
|
/**
|
|
@@ -1766,6 +1915,93 @@ type Commands = {
|
|
|
1766
1915
|
}): boolean;
|
|
1767
1916
|
reorder(direction: ReorderDirection): void;
|
|
1768
1917
|
remove(): void;
|
|
1918
|
+
/**
|
|
1919
|
+
* Copy the selection as a **standalone SVG document** (the payload is
|
|
1920
|
+
* the file format — no private envelope). The payload carries the
|
|
1921
|
+
* outbound `url(#…)` / `href` reference closure in one `<defs>` block
|
|
1922
|
+
* and declares every namespace prefix the fragment borrows from
|
|
1923
|
+
* ancestor scope; ancestor transforms, inherited presentation, and the
|
|
1924
|
+
* viewport are deliberately NOT carried (verbatim policy — see the FRD).
|
|
1925
|
+
*
|
|
1926
|
+
* Pure read: no document mutation, no history entry. The payload is
|
|
1927
|
+
* always written to the editor's internal clipboard buffer (the
|
|
1928
|
+
* transport floor — cannot fail) and, when a `ClipboardProvider` is
|
|
1929
|
+
* configured, delivered to it best-effort (a failed provider write is
|
|
1930
|
+
* dev-warned, never a copy failure).
|
|
1931
|
+
*
|
|
1932
|
+
* Returns the payload string, or `null` on empty / non-live selection
|
|
1933
|
+
* (a no-op, not an error — copy has no refusal path).
|
|
1934
|
+
*/
|
|
1935
|
+
copy(): string | null;
|
|
1936
|
+
/**
|
|
1937
|
+
* Copy, then delete the selection — ONE history step labeled `"cut"`
|
|
1938
|
+
* with {@link remove}'s exact capture/revert semantics. The payload is
|
|
1939
|
+
* secured in the internal buffer BEFORE the deletion commits, so a
|
|
1940
|
+
* failed external write never strands the user with deleted content
|
|
1941
|
+
* and no copy. The clipboard write is not part of the history step:
|
|
1942
|
+
* undo restores the document and leaves the buffer holding the payload
|
|
1943
|
+
* (cut → undo → paste works as a move idiom).
|
|
1944
|
+
*
|
|
1945
|
+
* Returns the payload string, or `null` on empty selection (no
|
|
1946
|
+
* mutation, no history).
|
|
1947
|
+
*/
|
|
1948
|
+
cut(): string | null;
|
|
1949
|
+
/**
|
|
1950
|
+
* Paste SVG markup — `text` when given, else the internal clipboard
|
|
1951
|
+
* buffer. Synchronous over delivered text: acquisition from a native
|
|
1952
|
+
* clipboard event or an async provider read is the invoking channel's
|
|
1953
|
+
* job and completes before this command runs.
|
|
1954
|
+
*
|
|
1955
|
+
* Accepts anything {@link insert_fragment} parses (bare fragment or
|
|
1956
|
+
* full document — the editor's own payloads are an ordinary case, not
|
|
1957
|
+
* a privileged one) and inserts it with the same atomic semantics:
|
|
1958
|
+
* one history step, subtrees adopted verbatim, ids never rewritten,
|
|
1959
|
+
* namespace declarations hoisted, appended at the document top level,
|
|
1960
|
+
* inserted roots selected.
|
|
1961
|
+
*
|
|
1962
|
+
* **Gesture-grade refusal table** (deliberately weaker than
|
|
1963
|
+
* `insert_fragment`'s): paste's input is environment-supplied — prose,
|
|
1964
|
+
* URLs, and JSON are what clipboards hold most of the day — so
|
|
1965
|
+
* non-parseable input is a **no-op refusal** (`[]`, no mutation, no
|
|
1966
|
+
* history), never a thrown error. A non-string argument still throws
|
|
1967
|
+
* `TypeError` (caller bug — no acquisition channel produces one).
|
|
1968
|
+
* Empty selection→buffer misses (`undefined` text, empty buffer) also
|
|
1969
|
+
* return `[]`.
|
|
1970
|
+
*/
|
|
1971
|
+
paste(text?: string): NodeId[];
|
|
1972
|
+
/**
|
|
1973
|
+
* Duplicate the selection in place — the **subtree-clone** operation
|
|
1974
|
+
* (the clipboard FRD's second extraction operation; design note:
|
|
1975
|
+
* `docs/wg/feat-svg-editor/subtree-clone.md`). Each normalized
|
|
1976
|
+
* selection root is cloned verbatim (byte-equal subtree markup — and
|
|
1977
|
+
* therefore NO defs closure, NO namespace shell: the destination is
|
|
1978
|
+
* the source document) and inserted as its origin's next sibling, so
|
|
1979
|
+
* the clone paints directly above its origin. Selection moves to the
|
|
1980
|
+
* clones. ONE history step; a single `undo()` removes the clones and
|
|
1981
|
+
* restores the prior selection.
|
|
1982
|
+
*
|
|
1983
|
+
* Authored `id=""` attributes are cloned verbatim, NEVER rewritten —
|
|
1984
|
+
* the document gains colliding ids that resolve first-in-document-order
|
|
1985
|
+
* (so a clone's internal self-reference resolves to the ORIGINAL);
|
|
1986
|
+
* dedup is the explicit Tidy command's job.
|
|
1987
|
+
*
|
|
1988
|
+
* **Repeating offset** (gridaco/grida#825, spec §Repeating offset):
|
|
1989
|
+
* duplicate, move the copy, duplicate again — the next copy lands at
|
|
1990
|
+
* the same relative offset from the previous one (Figma's repeating
|
|
1991
|
+
* duplicate; an Alt-drag clone commit arms the same memory, so ⌘D
|
|
1992
|
+
* after a clone-drag repeats the drag offset). Still ONE history
|
|
1993
|
+
* step: a single `undo()` removes copy + offset together. Requires an
|
|
1994
|
+
* attached geometry provider; when the repeat's preconditions don't
|
|
1995
|
+
* hold (selection isn't the previous clones, a copy was resized,
|
|
1996
|
+
* nothing moved, no geometry) the command degrades to the plain
|
|
1997
|
+
* in-place duplicate above — never an error.
|
|
1998
|
+
*
|
|
1999
|
+
* Refusal (no mutation, no history): an empty selection, or one that
|
|
2000
|
+
* normalizes to nothing cloneable (document root, nested `<svg>`,
|
|
2001
|
+
* stale / non-element ids) → `[]`. Returns the clone ids in document
|
|
2002
|
+
* order otherwise.
|
|
2003
|
+
*/
|
|
2004
|
+
duplicate(): NodeId[];
|
|
1769
2005
|
/**
|
|
1770
2006
|
* Wrap the current selection in a new plain `<g>`. Returns `true` if
|
|
1771
2007
|
* the wrap was performed (a history step was pushed and the new group
|
|
@@ -2030,6 +2266,11 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
|
|
|
2030
2266
|
doc: SvgDocument;
|
|
2031
2267
|
history: {
|
|
2032
2268
|
preview: (label: string) => import("@grida/history").Preview;
|
|
2269
|
+
undo_label: () => string | null;
|
|
2270
|
+
};
|
|
2271
|
+
clipboard: {
|
|
2272
|
+
copy: () => string | null;
|
|
2273
|
+
cut: () => string | null;
|
|
2033
2274
|
};
|
|
2034
2275
|
insert_text_preview: (initial: Readonly<Record<string, string>>, opts?: {
|
|
2035
2276
|
parent?: NodeId;
|
|
@@ -2041,6 +2282,7 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
|
|
|
2041
2282
|
emit: () => void;
|
|
2042
2283
|
subscribe_translate_commit(cb: () => void): () => void;
|
|
2043
2284
|
notify_translate_commit: () => void;
|
|
2285
|
+
seed_duplication(record: subtree.DuplicationRecord): void;
|
|
2044
2286
|
set_content_edit_driver(fn: ((target: NodeId) => boolean) | null): void;
|
|
2045
2287
|
set_surface_hover_override_driver(fn: ((id: NodeId | null) => void) | null): void;
|
|
2046
2288
|
push_surface_hover(id: NodeId | null): void;
|
|
@@ -644,6 +644,8 @@ declare class SvgDocument implements DocumentEvents {
|
|
|
644
644
|
* stays the same and React skips the re-render of the whole tree.
|
|
645
645
|
*/
|
|
646
646
|
private _structure_version;
|
|
647
|
+
/** Total listener-visible mutation count. See the `revision` getter. */
|
|
648
|
+
private _revision;
|
|
647
649
|
/** Bumps on writes that can shift world-space bounds (`GEOMETRY_ATTRS`,
|
|
648
650
|
* `set_text`, `insert`, `remove`). Cache key for `GeometryProvider`;
|
|
649
651
|
* see ../../docs/geometry.md. */
|
|
@@ -657,6 +659,18 @@ declare class SvgDocument implements DocumentEvents {
|
|
|
657
659
|
on_change(fn: () => void): () => void;
|
|
658
660
|
/** See `_structure_version` for what this counter signals. */
|
|
659
661
|
get structure_version(): number;
|
|
662
|
+
/**
|
|
663
|
+
* Total mutation counter — advances on EVERY listener-visible mutation
|
|
664
|
+
* (attribute, style, text, topology, load/reset), unlike the selective
|
|
665
|
+
* `structure_version` / `geometry_version` channels. The single
|
|
666
|
+
* edit-version source: anything derived from this document — the
|
|
667
|
+
* editor's `content_version` / `dirty`, memoized reads, a rendered
|
|
668
|
+
* projection — answers "am I current?" by comparing values, with no
|
|
669
|
+
* event-ordering dependence. Advances BEFORE listeners fire, so a
|
|
670
|
+
* read issued from inside a change listener already sees the new
|
|
671
|
+
* value.
|
|
672
|
+
*/
|
|
673
|
+
get revision(): number;
|
|
660
674
|
/** See `_geometry_version` for what this counter signals. */
|
|
661
675
|
get geometry_version(): number;
|
|
662
676
|
/**
|
|
@@ -671,10 +685,9 @@ declare class SvgDocument implements DocumentEvents {
|
|
|
671
685
|
* settled glyph metrics. See ../../docs/geometry.md §Limitations.
|
|
672
686
|
*
|
|
673
687
|
* Deliberately does NOT call `emit()`: this is not a document edit, so
|
|
674
|
-
*
|
|
675
|
-
*
|
|
676
|
-
* `
|
|
677
|
-
* out the geometry listeners itself.
|
|
688
|
+
* `revision` must not advance — no dirty flag, no undo, no render
|
|
689
|
+
* flush. The editor's `_internal.bump_geometry` advances
|
|
690
|
+
* `geometry_version` here and fans out the geometry listeners itself.
|
|
678
691
|
*/
|
|
679
692
|
bump_geometry(): void;
|
|
680
693
|
private emit;
|
|
@@ -1382,6 +1395,129 @@ declare class Keymap {
|
|
|
1382
1395
|
private chunkKeysFor;
|
|
1383
1396
|
}
|
|
1384
1397
|
//#endregion
|
|
1398
|
+
//#region src/core/subtree.d.ts
|
|
1399
|
+
declare namespace subtree {
|
|
1400
|
+
/**
|
|
1401
|
+
* Document-order comparator over node ids. Builds the index once per
|
|
1402
|
+
* call (one full tree walk) — create one comparator per operation and
|
|
1403
|
+
* reuse it, as `clipboard.extract_payload` does.
|
|
1404
|
+
*/
|
|
1405
|
+
function by_document_order(doc: SvgDocument): (a: NodeId, b: NodeId) => number;
|
|
1406
|
+
/**
|
|
1407
|
+
* Selection normalization — the half of extraction that payload
|
|
1408
|
+
* extraction (copy) and subtree clone share, per the FRD:
|
|
1409
|
+
* dedupe → live elements only → ancestor subtrees subsume selected
|
|
1410
|
+
* descendants (`prune_nested_nodes`) → DOCUMENT order regardless of
|
|
1411
|
+
* selection order (sibling order is paint order, and paint order is
|
|
1412
|
+
* meaning). Stale / non-element / detached ids are skipped, never
|
|
1413
|
+
* thrown — normalization is a filter, not a validator.
|
|
1414
|
+
*/
|
|
1415
|
+
function normalize_roots(doc: SvgDocument, selection: ReadonlyArray<NodeId>, order?: (a: NodeId, b: NodeId) => number): NodeId[];
|
|
1416
|
+
/** One origin → clone pairing with the clone's placement, captured at
|
|
1417
|
+
* plan time. `before` is the origin's next sibling NODE (element or
|
|
1418
|
+
* trivia) so the clone lands immediately after its origin. */
|
|
1419
|
+
type SubtreeClonePlanEntry = {
|
|
1420
|
+
origin: NodeId;
|
|
1421
|
+
/** Registered in the document's node map, DETACHED (`create_fragment`
|
|
1422
|
+
* style) — the consumer inserts it inside its own history closure. */
|
|
1423
|
+
clone: NodeId; /** `parent_of(origin)` at plan time. */
|
|
1424
|
+
parent: NodeId; /** `next_sibling_of(origin)` at plan time; `null` = append. */
|
|
1425
|
+
before: NodeId | null;
|
|
1426
|
+
};
|
|
1427
|
+
type SubtreeClonePlan = ReadonlyArray<SubtreeClonePlanEntry>;
|
|
1428
|
+
/**
|
|
1429
|
+
* Build a clone plan for the selection: for each normalized origin,
|
|
1430
|
+
* serialize its subtree verbatim and re-adopt it under fresh runtime
|
|
1431
|
+
* NodeIds via `create_fragment` — the markup round-trip rides the same
|
|
1432
|
+
* trivia-preserving emit and never-rewrite-ids adoption the package
|
|
1433
|
+
* already guarantees, so `serialize_node(clone) === serialize_node(origin)`
|
|
1434
|
+
* byte-for-byte once inserted.
|
|
1435
|
+
*
|
|
1436
|
+
* Clones are returned DETACHED (plan, don't insert): consumers own
|
|
1437
|
+
* insertion inside their history closures so redo can re-insert the
|
|
1438
|
+
* same NodeIds (`remove` keeps nodes in the id map).
|
|
1439
|
+
*
|
|
1440
|
+
* Skipped origins (refusals, normalized away — not errors):
|
|
1441
|
+
* - the document root and any other parentless node (no sibling slot);
|
|
1442
|
+
* - nested `<svg>` elements — `create_fragment` deliberately treats a
|
|
1443
|
+
* lone `<svg>` root as a full-document shell and discards it (the
|
|
1444
|
+
* FRD's paste rule), which would silently unwrap the clone; refusing
|
|
1445
|
+
* beats mishandling.
|
|
1446
|
+
*
|
|
1447
|
+
* An empty selection (or one that normalizes to nothing) yields an
|
|
1448
|
+
* empty plan — the caller's no-op.
|
|
1449
|
+
*/
|
|
1450
|
+
function clone_plan(doc: SvgDocument, selection: ReadonlyArray<NodeId>): SubtreeClonePlan;
|
|
1451
|
+
/**
|
|
1452
|
+
* Attach every plan clone at its captured anchor. Anchors predate the
|
|
1453
|
+
* plan (captured before any insertion), so no entry anchors on another
|
|
1454
|
+
* entry's clone — each insert is independent and the interleaved
|
|
1455
|
+
* `A, A′, B, B′` order falls out of the per-origin anchors.
|
|
1456
|
+
*
|
|
1457
|
+
* Idempotent: `doc.insert` detaches-then-splices, so re-attaching an
|
|
1458
|
+
* already-live clone repositions it to the same slot. History redo
|
|
1459
|
+
* closures rely on this.
|
|
1460
|
+
*/
|
|
1461
|
+
function insert_plan(doc: SvgDocument, plan: SubtreeClonePlan): void;
|
|
1462
|
+
/**
|
|
1463
|
+
* Detach every plan clone. Order-independent for the same reason
|
|
1464
|
+
* {@link insert_plan} is: anchors are never plan clones. Removed nodes
|
|
1465
|
+
* stay in the document's id map (standard removed-node policy), so a
|
|
1466
|
+
* later {@link insert_plan} over the same plan restores them.
|
|
1467
|
+
*/
|
|
1468
|
+
function remove_plan(doc: SvgDocument, plan: SubtreeClonePlan): void;
|
|
1469
|
+
/**
|
|
1470
|
+
* The last committed duplication — the memory the repeating-offset
|
|
1471
|
+
* behavior reads (gridaco/grida#825; spec:
|
|
1472
|
+
* [docs/wg/feat-svg-editor/subtree-clone.md](../../../../docs/wg/feat-svg-editor/subtree-clone.md)
|
|
1473
|
+
* §Repeating offset). Armed by `commands.duplicate` and by a cloned
|
|
1474
|
+
* translate commit (Alt-drag); consumed and re-armed by the next
|
|
1475
|
+
* `duplicate()`. Editor-session state — never observable, never in
|
|
1476
|
+
* history. Staleness is caught at use by {@link repeat_delta}, not by
|
|
1477
|
+
* per-mutation bookkeeping.
|
|
1478
|
+
*
|
|
1479
|
+
* The arrays are INDEX-PAIRED: `clones[i]` is the clone of
|
|
1480
|
+
* `origins[i]` (both producers derive them from the same
|
|
1481
|
+
* {@link SubtreeClonePlan}). {@link repeat_delta}'s per-member
|
|
1482
|
+
* rigidity check depends on that pairing.
|
|
1483
|
+
*/
|
|
1484
|
+
type DuplicationRecord = {
|
|
1485
|
+
origins: ReadonlyArray<NodeId>;
|
|
1486
|
+
clones: ReadonlyArray<NodeId>;
|
|
1487
|
+
};
|
|
1488
|
+
/**
|
|
1489
|
+
* The repeating-offset delta (gridaco/grida#825): given the previous
|
|
1490
|
+
* duplication's record and the CURRENT duplicate's normalized origins,
|
|
1491
|
+
* return the world-space offset the fresh clones should repeat, or
|
|
1492
|
+
* `null` for "no repeat — duplicate in place".
|
|
1493
|
+
*
|
|
1494
|
+
* The repeat fires only when the record still witnesses
|
|
1495
|
+
* "duplicate, then rigidly translate the copies":
|
|
1496
|
+
* - `targets` is exactly `record.clones`, in the same (document)
|
|
1497
|
+
* order — the user is duplicating the previous duplication's
|
|
1498
|
+
* copies and nothing else;
|
|
1499
|
+
* - `bounds_of` answers for EVERY member of both sets (a detached
|
|
1500
|
+
* member, a measureless tag, or a missing geometry provider all
|
|
1501
|
+
* refuse);
|
|
1502
|
+
* - EVERY clone is rigid against its own origin (the record's
|
|
1503
|
+
* arrays are index-paired): same size, displaced by the same
|
|
1504
|
+
* delta as the union, within {@link REPEAT_RIGID_EPSILON}. The
|
|
1505
|
+
* check is per member, not per envelope — a rearranged or
|
|
1506
|
+
* resized inner copy is no longer a translate even when the
|
|
1507
|
+
* envelope-defining copies keep the union bbox intact;
|
|
1508
|
+
* - the union top-left delta exceeds the same tolerance (a copy
|
|
1509
|
+
* that never moved — or drifted by float noise only — repeats
|
|
1510
|
+
* nothing; the in-place duplicate stays byte-equal instead of
|
|
1511
|
+
* inheriting noise-sized attribute writes).
|
|
1512
|
+
*
|
|
1513
|
+
* Pure and gesture-grade: reads only through `bounds_of`, never
|
|
1514
|
+
* throws — every failed precondition degrades to `null` (the main
|
|
1515
|
+
* editor's `active_duplication` assert-on-mismatch is deliberately
|
|
1516
|
+
* NOT copied; ⌘D must never crash on a stale record).
|
|
1517
|
+
*/
|
|
1518
|
+
function repeat_delta(record: DuplicationRecord | null, targets: ReadonlyArray<NodeId>, bounds_of: (id: NodeId) => Rect | null): Vec2 | null;
|
|
1519
|
+
}
|
|
1520
|
+
//#endregion
|
|
1385
1521
|
//#region src/core/geometry.d.ts
|
|
1386
1522
|
/**
|
|
1387
1523
|
* Read-only access to world-space node bounds and hit-tests.
|
|
@@ -1390,6 +1526,19 @@ declare class Keymap {
|
|
|
1390
1526
|
* will query dozens of nodes per pointermove. The driver wraps SVG
|
|
1391
1527
|
* `getBBox` + `getCTM`; the memoizer caches per-`NodeId` to survive
|
|
1392
1528
|
* the surface re-rendering the SVG tree every editor tick.
|
|
1529
|
+
*
|
|
1530
|
+
* **Freshness contract.** Every read MUST reflect the CURRENT document
|
|
1531
|
+
* — including when issued synchronously from inside a doc-change
|
|
1532
|
+
* listener (`subscribe_geometry` fires mid-mutation, before any
|
|
1533
|
+
* render). An implementation backed by a lazily-synced projection
|
|
1534
|
+
* (e.g. a rendered DOM tree) must flush that projection before
|
|
1535
|
+
* reading; compare `SvgDocument.revision` against the projection's
|
|
1536
|
+
* last-rendered revision. Returning the previous document's geometry
|
|
1537
|
+
* is not a transient glitch: the `MemoizedGeometryProvider` wrapper
|
|
1538
|
+
* caches whatever the driver returns, so one stale read poisons every
|
|
1539
|
+
* later consumer until the next invalidation (align/resize then plan
|
|
1540
|
+
* against one-mutation-old bounds and oscillate — see
|
|
1541
|
+
* `__tests__/geometry-stale-read.browser.test.ts`).
|
|
1393
1542
|
*/
|
|
1394
1543
|
interface GeometryProvider {
|
|
1395
1544
|
/**
|
|
@@ -1766,6 +1915,93 @@ type Commands = {
|
|
|
1766
1915
|
}): boolean;
|
|
1767
1916
|
reorder(direction: ReorderDirection): void;
|
|
1768
1917
|
remove(): void;
|
|
1918
|
+
/**
|
|
1919
|
+
* Copy the selection as a **standalone SVG document** (the payload is
|
|
1920
|
+
* the file format — no private envelope). The payload carries the
|
|
1921
|
+
* outbound `url(#…)` / `href` reference closure in one `<defs>` block
|
|
1922
|
+
* and declares every namespace prefix the fragment borrows from
|
|
1923
|
+
* ancestor scope; ancestor transforms, inherited presentation, and the
|
|
1924
|
+
* viewport are deliberately NOT carried (verbatim policy — see the FRD).
|
|
1925
|
+
*
|
|
1926
|
+
* Pure read: no document mutation, no history entry. The payload is
|
|
1927
|
+
* always written to the editor's internal clipboard buffer (the
|
|
1928
|
+
* transport floor — cannot fail) and, when a `ClipboardProvider` is
|
|
1929
|
+
* configured, delivered to it best-effort (a failed provider write is
|
|
1930
|
+
* dev-warned, never a copy failure).
|
|
1931
|
+
*
|
|
1932
|
+
* Returns the payload string, or `null` on empty / non-live selection
|
|
1933
|
+
* (a no-op, not an error — copy has no refusal path).
|
|
1934
|
+
*/
|
|
1935
|
+
copy(): string | null;
|
|
1936
|
+
/**
|
|
1937
|
+
* Copy, then delete the selection — ONE history step labeled `"cut"`
|
|
1938
|
+
* with {@link remove}'s exact capture/revert semantics. The payload is
|
|
1939
|
+
* secured in the internal buffer BEFORE the deletion commits, so a
|
|
1940
|
+
* failed external write never strands the user with deleted content
|
|
1941
|
+
* and no copy. The clipboard write is not part of the history step:
|
|
1942
|
+
* undo restores the document and leaves the buffer holding the payload
|
|
1943
|
+
* (cut → undo → paste works as a move idiom).
|
|
1944
|
+
*
|
|
1945
|
+
* Returns the payload string, or `null` on empty selection (no
|
|
1946
|
+
* mutation, no history).
|
|
1947
|
+
*/
|
|
1948
|
+
cut(): string | null;
|
|
1949
|
+
/**
|
|
1950
|
+
* Paste SVG markup — `text` when given, else the internal clipboard
|
|
1951
|
+
* buffer. Synchronous over delivered text: acquisition from a native
|
|
1952
|
+
* clipboard event or an async provider read is the invoking channel's
|
|
1953
|
+
* job and completes before this command runs.
|
|
1954
|
+
*
|
|
1955
|
+
* Accepts anything {@link insert_fragment} parses (bare fragment or
|
|
1956
|
+
* full document — the editor's own payloads are an ordinary case, not
|
|
1957
|
+
* a privileged one) and inserts it with the same atomic semantics:
|
|
1958
|
+
* one history step, subtrees adopted verbatim, ids never rewritten,
|
|
1959
|
+
* namespace declarations hoisted, appended at the document top level,
|
|
1960
|
+
* inserted roots selected.
|
|
1961
|
+
*
|
|
1962
|
+
* **Gesture-grade refusal table** (deliberately weaker than
|
|
1963
|
+
* `insert_fragment`'s): paste's input is environment-supplied — prose,
|
|
1964
|
+
* URLs, and JSON are what clipboards hold most of the day — so
|
|
1965
|
+
* non-parseable input is a **no-op refusal** (`[]`, no mutation, no
|
|
1966
|
+
* history), never a thrown error. A non-string argument still throws
|
|
1967
|
+
* `TypeError` (caller bug — no acquisition channel produces one).
|
|
1968
|
+
* Empty selection→buffer misses (`undefined` text, empty buffer) also
|
|
1969
|
+
* return `[]`.
|
|
1970
|
+
*/
|
|
1971
|
+
paste(text?: string): NodeId[];
|
|
1972
|
+
/**
|
|
1973
|
+
* Duplicate the selection in place — the **subtree-clone** operation
|
|
1974
|
+
* (the clipboard FRD's second extraction operation; design note:
|
|
1975
|
+
* `docs/wg/feat-svg-editor/subtree-clone.md`). Each normalized
|
|
1976
|
+
* selection root is cloned verbatim (byte-equal subtree markup — and
|
|
1977
|
+
* therefore NO defs closure, NO namespace shell: the destination is
|
|
1978
|
+
* the source document) and inserted as its origin's next sibling, so
|
|
1979
|
+
* the clone paints directly above its origin. Selection moves to the
|
|
1980
|
+
* clones. ONE history step; a single `undo()` removes the clones and
|
|
1981
|
+
* restores the prior selection.
|
|
1982
|
+
*
|
|
1983
|
+
* Authored `id=""` attributes are cloned verbatim, NEVER rewritten —
|
|
1984
|
+
* the document gains colliding ids that resolve first-in-document-order
|
|
1985
|
+
* (so a clone's internal self-reference resolves to the ORIGINAL);
|
|
1986
|
+
* dedup is the explicit Tidy command's job.
|
|
1987
|
+
*
|
|
1988
|
+
* **Repeating offset** (gridaco/grida#825, spec §Repeating offset):
|
|
1989
|
+
* duplicate, move the copy, duplicate again — the next copy lands at
|
|
1990
|
+
* the same relative offset from the previous one (Figma's repeating
|
|
1991
|
+
* duplicate; an Alt-drag clone commit arms the same memory, so ⌘D
|
|
1992
|
+
* after a clone-drag repeats the drag offset). Still ONE history
|
|
1993
|
+
* step: a single `undo()` removes copy + offset together. Requires an
|
|
1994
|
+
* attached geometry provider; when the repeat's preconditions don't
|
|
1995
|
+
* hold (selection isn't the previous clones, a copy was resized,
|
|
1996
|
+
* nothing moved, no geometry) the command degrades to the plain
|
|
1997
|
+
* in-place duplicate above — never an error.
|
|
1998
|
+
*
|
|
1999
|
+
* Refusal (no mutation, no history): an empty selection, or one that
|
|
2000
|
+
* normalizes to nothing cloneable (document root, nested `<svg>`,
|
|
2001
|
+
* stale / non-element ids) → `[]`. Returns the clone ids in document
|
|
2002
|
+
* order otherwise.
|
|
2003
|
+
*/
|
|
2004
|
+
duplicate(): NodeId[];
|
|
1769
2005
|
/**
|
|
1770
2006
|
* Wrap the current selection in a new plain `<g>`. Returns `true` if
|
|
1771
2007
|
* the wrap was performed (a history step was pushed and the new group
|
|
@@ -2030,6 +2266,11 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
|
|
|
2030
2266
|
doc: SvgDocument;
|
|
2031
2267
|
history: {
|
|
2032
2268
|
preview: (label: string) => import("@grida/history").Preview;
|
|
2269
|
+
undo_label: () => string | null;
|
|
2270
|
+
};
|
|
2271
|
+
clipboard: {
|
|
2272
|
+
copy: () => string | null;
|
|
2273
|
+
cut: () => string | null;
|
|
2033
2274
|
};
|
|
2034
2275
|
insert_text_preview: (initial: Readonly<Record<string, string>>, opts?: {
|
|
2035
2276
|
parent?: NodeId;
|
|
@@ -2041,6 +2282,7 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
|
|
|
2041
2282
|
emit: () => void;
|
|
2042
2283
|
subscribe_translate_commit(cb: () => void): () => void;
|
|
2043
2284
|
notify_translate_commit: () => void;
|
|
2285
|
+
seed_duplication(record: subtree.DuplicationRecord): void;
|
|
2044
2286
|
set_content_edit_driver(fn: ((target: NodeId) => boolean) | null): void;
|
|
2045
2287
|
set_surface_hover_override_driver(fn: ((id: NodeId | null) => void) | null): void;
|
|
2046
2288
|
push_surface_hover(id: NodeId | null): void;
|