@grida/svg-editor 1.0.0-alpha.15 → 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.
@@ -22,6 +22,54 @@ type Rect = {
22
22
  width: number;
23
23
  height: number;
24
24
  };
25
+ /**
26
+ * A 2×3 affine transform in SVG `matrix(a b c d e f)` order — the same
27
+ * six-number tuple the SVG `transform="matrix(...)"` function takes.
28
+ *
29
+ * Applied to a point `(x, y)`:
30
+ * x' = a·x + c·y + e
31
+ * y' = b·x + d·y + f
32
+ *
33
+ * This is the wire shape `commands.transform` accepts. Examples:
34
+ * - `[-1, 0, 0, 1, 0, 0]` — horizontal flip (mirror x about the origin)
35
+ * - `[1, 0, 0, -1, 0, 0]` — vertical flip (mirror y about the origin)
36
+ * - `[1, 0, 0, 1, 0, 0]` — identity (no-op)
37
+ *
38
+ * `commands.transform` re-centers this about a pivot, so the bare flip
39
+ * tuples become in-place flips about the selection center.
40
+ */
41
+ type Matrix2D = readonly [a: number, b: number, c: number, d: number, e: number, f: number];
42
+ /**
43
+ * Observe-only outcome of a discrete pointer **tap** on the canvas: the user
44
+ * pressed and released within the drag threshold, without dragging. Delivered
45
+ * through {@link SvgEditor.subscribe_pick} — a transient event, never part of
46
+ * `EditorState` (it would be stale on the next snapshot).
47
+ *
48
+ * A pick is deliberately **separate from selection**. Selection answers "what
49
+ * do commands target"; a pick answers "what did the user just click, and
50
+ * where". A primary tap on a node both selects it and emits a pick; a tap on
51
+ * empty canvas emits a pick with `node_id: null` (distinguishable from "nothing
52
+ * is selected"); a secondary (right-button) tap emits a pick and does NOT
53
+ * change selection. This is what a click-driven host tool (annotation, context
54
+ * menu, custom selection) needs and selection alone cannot express.
55
+ *
56
+ * Observe-only: a pick reports a click that already happened. It cannot
57
+ * prevent or replace the editor's own selection handling.
58
+ *
59
+ * @unstable Shape is provisional until ≥2 consumers exercise it. Fields may
60
+ * change without a semver bump until then.
61
+ */
62
+ type PickEvent = {
63
+ /** Document-space point the tap resolved against (the pointer-DOWN point). */point: Vec2; /** Topmost node under `point`, or `null` for empty canvas / background. */
64
+ node_id: NodeId | null; /** Which button produced the tap. `"middle"` is pan and never taps. */
65
+ button: "primary" | "secondary"; /** Modifier snapshot at press time. */
66
+ mods: {
67
+ shift: boolean;
68
+ alt: boolean;
69
+ meta: boolean;
70
+ ctrl: boolean;
71
+ };
72
+ };
25
73
  type Mode = "select" | "edit-content";
26
74
  /**
27
75
  * SVG element tags inserted by the **drag-to-size** subsystem. Closed set;
@@ -596,6 +644,8 @@ declare class SvgDocument implements DocumentEvents {
596
644
  * stays the same and React skips the re-render of the whole tree.
597
645
  */
598
646
  private _structure_version;
647
+ /** Total listener-visible mutation count. See the `revision` getter. */
648
+ private _revision;
599
649
  /** Bumps on writes that can shift world-space bounds (`GEOMETRY_ATTRS`,
600
650
  * `set_text`, `insert`, `remove`). Cache key for `GeometryProvider`;
601
651
  * see ../../docs/geometry.md. */
@@ -609,8 +659,37 @@ declare class SvgDocument implements DocumentEvents {
609
659
  on_change(fn: () => void): () => void;
610
660
  /** See `_structure_version` for what this counter signals. */
611
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;
612
674
  /** See `_geometry_version` for what this counter signals. */
613
675
  get geometry_version(): number;
676
+ /**
677
+ * Advance `_geometry_version` by exactly 1 WITHOUT touching the tree,
678
+ * any attribute, `structure_version`, or the `on_change` listeners.
679
+ *
680
+ * The one geometry mutation with no attribute write: a `<text>` /
681
+ * `<tspan>` reflow the IR cannot see — a web font finishing load AFTER
682
+ * the `font-family` / `font-size` write was already serialized. The DOM
683
+ * surface observes the reflow (`document.fonts` `loadingdone`) and asks
684
+ * the geometry channel to advance so the bounds cache re-reads the
685
+ * settled glyph metrics. See ../../docs/geometry.md §Limitations.
686
+ *
687
+ * Deliberately does NOT call `emit()`: this is not a document edit, so
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.
691
+ */
692
+ bump_geometry(): void;
614
693
  private emit;
615
694
  /** Notify subscribers — for callers that mutate directly via setAttr/etc. */
616
695
  notify(): void;
@@ -781,6 +860,89 @@ declare class SvgDocument implements DocumentEvents {
781
860
  prefix?: string | null;
782
861
  ns?: string | null;
783
862
  }): NodeId;
863
+ /** Fresh internal NodeId, guaranteed unique within this document's node
864
+ * map. Shared by `create_element` and fragment adoption — collisions
865
+ * matter for the latter because the parser assigns sequential per-parse
866
+ * ids that a second parse would repeat. */
867
+ private fresh_node_id;
868
+ /**
869
+ * Parse an SVG **fragment** string and adopt its element subtrees into
870
+ * this document's node store — registered like {@link create_element}
871
+ * but NOT inserted into the tree (no version bump, no emit). Callers
872
+ * attach the returned roots via {@link insert}; the editor's
873
+ * `commands.insert_fragment` is the history-bracketed consumer.
874
+ *
875
+ * Input shapes:
876
+ * - A **bare fragment** — one or more sibling elements
877
+ * (`<path …/><path …/>`, or a single `<g>…</g>`). The top-level
878
+ * elements become the returned roots, in source order.
879
+ * - A **full SVG document** — when the input's only top-level element
880
+ * is an `<svg>`, that element is treated as a document SHELL, not
881
+ * content: its element children become the roots and the shell
882
+ * itself (viewBox, width/height, prolog, doctype) is discarded. Its
883
+ * `xmlns:*` prefix declarations are harvested into `xmlns` so the
884
+ * caller can re-declare prefixes the adopted content still uses.
885
+ * An `<svg>` that appears as one of SEVERAL top-level elements (or
886
+ * anywhere below the top level) is content, adopted as-is.
887
+ *
888
+ * Top-level non-element nodes (whitespace between roots, comments, PIs,
889
+ * doctype) are dropped — adoption takes elements, and the host
890
+ * document's own trivia stays untouched. WITHIN each adopted subtree
891
+ * every byte of source trivia survives verbatim (attribute order, quote
892
+ * styles, whitespace, comments), so the inserted markup serializes back
893
+ * exactly as authored — same rules as the initial parse.
894
+ *
895
+ * Authored `id=""` attributes are adopted verbatim — never rewritten,
896
+ * even when they collide with ids already in the document. Silent id
897
+ * renaming is exactly the proprietary noise this editor refuses (README
898
+ * "What clean means" §3); deduplication belongs to the explicit Tidy
899
+ * command. Internal NodeIds ARE freshly assigned (see
900
+ * {@link fresh_node_id}) so adopted nodes never collide in the id map.
901
+ *
902
+ * Throws `TypeError` on a non-string input and `Error` on markup the
903
+ * parser rejects (unclosed / mismatched tags, malformed attributes). An
904
+ * input with no top-level elements (empty string, whitespace, comments
905
+ * only) returns `{ roots: [], xmlns: [] }`.
906
+ */
907
+ create_fragment(markup: string): {
908
+ roots: NodeId[];
909
+ xmlns: ReadonlyArray<{
910
+ prefix: string;
911
+ uri: string;
912
+ }>;
913
+ };
914
+ /**
915
+ * Register `node` and its whole subtree (from a foreign parse) into this
916
+ * document's node map under fresh NodeIds. The parser assigns sequential
917
+ * per-parse ids (`n0`, `n1`, …), so adopting without a remap would
918
+ * collide with this document's own nodes. Children links are rewritten;
919
+ * the subtree root arrives detached (`parent: null`), like
920
+ * `create_element`. Mutates the parsed nodes in place — a parse result
921
+ * is single-use.
922
+ */
923
+ private adopt_parsed_subtree;
924
+ /**
925
+ * Namespace prefixes USED within `id`'s subtree (element tags and
926
+ * attribute names) that are not DECLARED within the subtree itself —
927
+ * i.e. prefixes the subtree borrows from ancestor scope. `xml` and
928
+ * `xmlns` are excluded (bound by the XML spec, never declared).
929
+ * Declaration scoping is honored per use-site: a prefix declared on the
930
+ * using element or any of its ancestors up to (and including) the
931
+ * subtree root counts as declared.
932
+ *
933
+ * Structural fact only — the caller decides what an unbound prefix
934
+ * means (e.g. `commands.insert_fragment` hoists a resolvable
935
+ * declaration onto the document root).
936
+ */
937
+ undeclared_ns_prefixes(id: NodeId): ReadonlySet<string>;
938
+ /**
939
+ * Declare a namespace prefix on the ROOT element: appends
940
+ * `xmlns:<prefix>="<uri>"` when the root doesn't already declare that
941
+ * prefix. An authored declaration always wins — this never rebinds.
942
+ * Policy wrapper over {@link set_attr} in the `XMLNS_NS` space; removal
943
+ * works through `set_attr(root, prefix, null, XMLNS_NS)` as usual.
944
+ */
945
+ declare_xmlns(prefix: string, uri: string): void;
784
946
  serialize(): string;
785
947
  /**
786
948
  * Serialize a single element's subtree as an SVG **fragment**, using the
@@ -1233,6 +1395,129 @@ declare class Keymap {
1233
1395
  private chunkKeysFor;
1234
1396
  }
1235
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
1236
1521
  //#region src/core/geometry.d.ts
1237
1522
  /**
1238
1523
  * Read-only access to world-space node bounds and hit-tests.
@@ -1241,6 +1526,19 @@ declare class Keymap {
1241
1526
  * will query dozens of nodes per pointermove. The driver wraps SVG
1242
1527
  * `getBBox` + `getCTM`; the memoizer caches per-`NodeId` to survive
1243
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`).
1244
1542
  */
1245
1543
  interface GeometryProvider {
1246
1544
  /**
@@ -1482,16 +1780,46 @@ type Commands = {
1482
1780
  * member.
1483
1781
  *
1484
1782
  * The default selection is `state.selection`. Pass `opts.ids` to
1485
- * override. Members whose tag is not resizable
1486
- * (e.g. `<g>`) are skipped silently; the gesture is a no-op when no
1487
- * resizable member remains. Returns `true` when a history step was
1488
- * pushed.
1783
+ * override. Members that are not resizable are skipped silently: this
1784
+ * means both an unresizable tag (e.g. `<g>`) AND a resizable tag carrying
1785
+ * a non-trivial transform (rotate-without-pivot, matrix, scale, skew),
1786
+ * which can't be resized in local space without breaking round-trip — the
1787
+ * same `is_resizable_node` gate the resize HUD applies. The gesture is a
1788
+ * no-op when no resizable member remains. Returns `true` when a history
1789
+ * step was pushed. `opts.label` overrides the atomic history label
1790
+ * (default `"resize-to"`).
1489
1791
  */
1490
1792
  resize_to(target: {
1491
1793
  x: number;
1492
1794
  y: number;
1493
1795
  width: number;
1494
1796
  height: number;
1797
+ }, opts?: {
1798
+ ids?: ReadonlyArray<NodeId>;
1799
+ label?: string;
1800
+ }): boolean;
1801
+ /**
1802
+ * Resize the selection by a delta — PER-ELEMENT: each selected member
1803
+ * grows/shrinks around its OWN NW corner, so members keep their positions
1804
+ * relative to one another (NOT a union/group resize — contrast
1805
+ * {@link resize_to}, which scales the whole selection around the shared
1806
+ * union origin and so translates off-origin members). `delta.dw` /
1807
+ * `delta.dh` are applied additively to each member (clamped to >= 0). The
1808
+ * core verb behind keyboard nudge-resize.
1809
+ *
1810
+ * ALL-OR-NOTHING gate: refuses (returns `false`, no history step) unless
1811
+ * EVERY member passes `is_resizable_node` — the same tag + transform-class
1812
+ * check the resize HUD uses, applied wholesale (a mixed selection is
1813
+ * refused, not partially resized — matches a HUD handle-drag, which is
1814
+ * rejected when any member is unsafe). Also refuses on empty selection or
1815
+ * when no geometry provider (DOM surface) is attached.
1816
+ *
1817
+ * Per-tag constraints (circle uniform, text edge no-op) apply per member.
1818
+ * The default selection is `state.selection`; pass `opts.ids` to override.
1819
+ */
1820
+ resize_by(delta: {
1821
+ dw: number;
1822
+ dh: number;
1495
1823
  }, opts?: {
1496
1824
  ids?: ReadonlyArray<NodeId>;
1497
1825
  }): boolean;
@@ -1527,6 +1855,39 @@ type Commands = {
1527
1855
  y: number;
1528
1856
  };
1529
1857
  }): boolean;
1858
+ /**
1859
+ * Compose an arbitrary 2×3 affine onto the selection, **relative** and
1860
+ * applied in **world space about a pivot**. `matrix` is in SVG
1861
+ * `matrix(a b c d e f)` order (see {@link Matrix2D}).
1862
+ *
1863
+ * Semantics: the effective affine written to each member is
1864
+ * `E = T(pivot) · matrix · T(-pivot)`, so the bare flip tuples become
1865
+ * in-place flips about the pivot. Pivot defaults to the selection
1866
+ * union-bbox center (via the attached surface's `geometry_provider`);
1867
+ * pass `opts.pivot` to override.
1868
+ *
1869
+ * Round-trip: `E` is folded onto each member's transform list as a
1870
+ * single LEADING `matrix` op — existing `rotate`/`translate` tokens are
1871
+ * preserved after it, repeated applies collapse into one matrix, and a
1872
+ * net-identity leading matrix is dropped (so flip-then-flip restores
1873
+ * the original). One atomic history step labelled `"transform"`.
1874
+ *
1875
+ * Refusal (returns `false`, no-op, no history): empty selection, no
1876
+ * `geometry_provider`, or any member failing `is_rotatable` (the same
1877
+ * non-trivial-transform / `<text rotate>` / CSS-property / animated
1878
+ * gate `rotate` uses). All-or-nothing — no partial writes.
1879
+ *
1880
+ * Flat-doc limitation: only each element's OWN transform is folded;
1881
+ * the pivot is treated as world ≡ parent space. Nested transformed
1882
+ * ancestors (`<g transform=…>`) are out of scope.
1883
+ */
1884
+ transform(matrix: Matrix2D, opts?: {
1885
+ ids?: ReadonlyArray<NodeId>;
1886
+ pivot?: {
1887
+ x: number;
1888
+ y: number;
1889
+ };
1890
+ }): boolean;
1530
1891
  /**
1531
1892
  * Collapse each selected member's `transform=` to a single `matrix(...)`
1532
1893
  * token, baking accumulated translates / rotates / scales / skews into
@@ -1554,6 +1915,93 @@ type Commands = {
1554
1915
  }): boolean;
1555
1916
  reorder(direction: ReorderDirection): void;
1556
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[];
1557
2005
  /**
1558
2006
  * Wrap the current selection in a new plain `<g>`. Returns `true` if
1559
2007
  * the wrap was performed (a history step was pushed and the new group
@@ -1561,6 +2009,30 @@ type Commands = {
1561
2009
  * rejected the call.
1562
2010
  */
1563
2011
  group(): boolean;
2012
+ /**
2013
+ * Dissolve the selected `<g>` (or `opts.id`), hoisting its children
2014
+ * into the group's parent at the group's z-position. Returns `true`
2015
+ * when a history step was pushed (children hoisted, group removed, the
2016
+ * former children selected); `false` when the call was refused.
2017
+ *
2018
+ * Only the **safe clean-structural subset** is accepted (see
2019
+ * `core/group.ts:plan_ungroup` and `../docs/grouping.md` §Ungrouping).
2020
+ * Refused — with NO mutation and NO history entry — when: the target
2021
+ * is not a single `<g>`; the group is inside `<defs>`; the group has
2022
+ * no element children; the group carries any own attribute beyond
2023
+ * `{ transform, id, data-grida-id }` (i.e. any visual / cascade state
2024
+ * such as `opacity` / `class` / `style` / `filter` / `clip-path` /
2025
+ * `mask` / `fill`); the group's `id` is referenced by a `<use>`; a
2026
+ * direct child is an SMIL animation element; or — when the group has a
2027
+ * `transform` — any child's own transform is unparseable.
2028
+ *
2029
+ * When the group has a `transform`, it is BAKED into each child by
2030
+ * prepending the group's parsed ops to the child's (clean token
2031
+ * compose, not a matrix collapse), so paint output round-trips.
2032
+ */
2033
+ ungroup(opts?: {
2034
+ id?: NodeId;
2035
+ }): boolean;
1564
2036
  /**
1565
2037
  * Atomic one-shot insertion. Creates a new element of the given SVG
1566
2038
  * tag with the supplied attributes (merged on top of the package's
@@ -1577,6 +2049,63 @@ type Commands = {
1577
2049
  index?: number;
1578
2050
  select?: boolean;
1579
2051
  }): NodeId;
2052
+ /**
2053
+ * Atomic insertion of a pre-authored SVG **fragment** — one or more
2054
+ * sibling elements as markup (`"<g …><path …/></g>"`), or a full
2055
+ * `<svg>` document whose element children are taken as the content
2056
+ * (the `<svg>` shell — viewBox, width/height, prolog, doctype — is
2057
+ * discarded; an `<svg>` that is one of several top-level elements is
2058
+ * content and inserted as-is). The element subtrees are adopted
2059
+ * verbatim — every byte of trivia inside each element survives
2060
+ * (attribute order, quote styles, whitespace, comments) — inserted
2061
+ * contiguously in source order at `opts.parent` / `opts.index`, and
2062
+ * selected. ONE history step regardless of fragment size; a single
2063
+ * `undo()` restores the exact pre-insert serialization. Returns the
2064
+ * inserted top-level ids in document order.
2065
+ *
2066
+ * This is the markup-shaped sibling of {@link insert} — the primitive
2067
+ * paste and asset-stamping flows compose. Use `insert` for a tag +
2068
+ * attrs; use `insert_fragment` for markup.
2069
+ *
2070
+ * **Position is authored content.** There is deliberately no placement
2071
+ * opt: to land a fragment at a document-space point, author the
2072
+ * position into the markup before inserting — wrap it in
2073
+ * `<g transform="translate(x y)">…</g>` or set the elements' own
2074
+ * geometry attrs. Placement then round-trips as ordinary markup and
2075
+ * the whole drop is the same single undo step.
2076
+ *
2077
+ * **`id` collisions:** authored `id=""` attributes are inserted
2078
+ * verbatim, NEVER rewritten — silent id renaming is proprietary noise
2079
+ * (P1; README "What clean means" §3). When a fragment id collides
2080
+ * with an existing one, reference resolution (`url(#…)`, `href`)
2081
+ * follows the document-order rules of the host renderer; resolving
2082
+ * the duplication is the explicit Tidy command's job, not insertion's.
2083
+ *
2084
+ * **Namespaces:** when the fragment uses a prefix the document root
2085
+ * doesn't declare, the declaration is hoisted onto the root as part
2086
+ * of the same history step — `xlink` (well-known URI) and any prefix
2087
+ * the discarded `<svg>` shell declared. A prefix whose URI is not
2088
+ * discoverable is left as authored (the input was equally unbound as
2089
+ * a standalone document). An authored root declaration always wins —
2090
+ * never rebound.
2091
+ *
2092
+ * **Refusals:** an input with no top-level elements (empty /
2093
+ * whitespace / comments-only) returns `[]` with NO history step.
2094
+ * Throws on malformed markup (parser errors propagate), on a
2095
+ * non-string input, and on an `opts.parent` that isn't a live element
2096
+ * of the current document — a silent no-op there would hide consumer
2097
+ * bugs (same stance as `serialize_node`).
2098
+ *
2099
+ * `opts.parent` defaults to root; `opts.index` (position in the
2100
+ * parent's element-children list; the whole fragment lands
2101
+ * contiguously at it) defaults to append; `opts.select` defaults to
2102
+ * `true`.
2103
+ */
2104
+ insert_fragment(svg: string, opts?: {
2105
+ parent?: NodeId;
2106
+ index?: number;
2107
+ select?: boolean;
2108
+ }): NodeId[];
1580
2109
  /**
1581
2110
  * Preview-bracketed insertion for drag-to-size gestures. Creates and
1582
2111
  * inserts the node immediately (so HUD selection chrome renders);
@@ -1685,6 +2214,17 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
1685
2214
  * Cheap channel — does NOT bump `state.version`.
1686
2215
  */
1687
2216
  subscribe_surface_hover(cb: () => void): () => void;
2217
+ /**
2218
+ * Subscribe to pick (tap) outcomes — a discrete click on the canvas,
2219
+ * reporting the document-space point and the node under it (`null` for
2220
+ * empty canvas), plus the button and modifier snapshot. Fires once per
2221
+ * tap, after the editor's own selection handling. Observe-only: a pick
2222
+ * cannot alter selection, and the channel does NOT bump `state.version`.
2223
+ * See {@link PickEvent}.
2224
+ *
2225
+ * @unstable
2226
+ */
2227
+ subscribe_pick(cb: (e: PickEvent) => void): Unsubscribe;
1688
2228
  /**
1689
2229
  * Subscribe to bounds-affecting changes. Fires when any document
1690
2230
  * mutation advances `state.geometry_version` — drag, resize, text
@@ -1726,6 +2266,11 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
1726
2266
  doc: SvgDocument;
1727
2267
  history: {
1728
2268
  preview: (label: string) => import("@grida/history").Preview;
2269
+ undo_label: () => string | null;
2270
+ };
2271
+ clipboard: {
2272
+ copy: () => string | null;
2273
+ cut: () => string | null;
1729
2274
  };
1730
2275
  insert_text_preview: (initial: Readonly<Record<string, string>>, opts?: {
1731
2276
  parent?: NodeId;
@@ -1737,11 +2282,14 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
1737
2282
  emit: () => void;
1738
2283
  subscribe_translate_commit(cb: () => void): () => void;
1739
2284
  notify_translate_commit: () => void;
2285
+ seed_duplication(record: subtree.DuplicationRecord): void;
1740
2286
  set_content_edit_driver(fn: ((target: NodeId) => boolean) | null): void;
1741
2287
  set_surface_hover_override_driver(fn: ((id: NodeId | null) => void) | null): void;
1742
2288
  push_surface_hover(id: NodeId | null): void;
2289
+ push_pick(e: PickEvent): void;
1743
2290
  set_computed_resolver(fn: DomComputedResolver | null): void;
1744
2291
  set_geometry(p: GeometryProvider | null): void;
2292
+ bump_geometry(): void;
1745
2293
  };
1746
2294
  keymap: Keymap;
1747
2295
  };
@@ -1754,4 +2302,4 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
1754
2302
  */
1755
2303
  declare function createSvgEditor(opts: CreateSvgEditorOptions): SvgEditor;
1756
2304
  //#endregion
1757
- export { ReorderDirection as $, EditorState as A, LinearGradientDefinition as B, BoundsResolver as C, ClipboardProvider as D, CameraOptions as E, GradientEntry as F, PaintPreviewSession as G, NodeId as H, GradientStop as I, PropertyValue as J, PaintValue as K, InsertPreviewSession as L, FileIOProvider as M, FontResolver as N, Color as O, GradientDefinition as P, Rect as Q, InsertableTag as R, AlignDirection as S, CameraConstraints as T, Paint as U, Mode as V, PaintFallback as W, Providers as X, Provenance as Y, RadialGradientDefinition as Z, PathModel as _, SelectMode as a, Verb as b, SvgEditor as c, GestureContext as d, TOOL_CURSOR as et, GestureId as f, MemoizedGeometryProvider as g, GeometrySignals as h, DomComputedResolver as i, EditorStyle as j, DEFAULT_STYLE as k, createSvgEditor as l, GeometryProvider as m, CreateSvgEditorOptions as n, Unsubscribe as nt, Surface as o, Gestures as p, PreviewSession as q, DomComputedPaint as r, Vec2 as rt, SurfaceHandle as s, Commands as t, Tool as tt, GestureBinding as u, PathSnapshot as v, Camera as w, VertexId as x, SegmentId as y, InvalidComputedValue as z };
2305
+ export { RadialGradientDefinition as $, EditorState as A, LinearGradientDefinition as B, BoundsResolver as C, ClipboardProvider as D, CameraOptions as E, GradientEntry as F, PaintFallback as G, Mode as H, GradientStop as I, PickEvent as J, PaintPreviewSession as K, InsertPreviewSession as L, FileIOProvider as M, FontResolver as N, Color as O, GradientDefinition as P, Providers as Q, InsertableTag as R, AlignDirection as S, CameraConstraints as T, NodeId as U, Matrix2D as V, Paint as W, PropertyValue as X, PreviewSession as Y, Provenance as Z, PathModel as _, SelectMode as a, Vec2 as at, Verb as b, SvgEditor as c, GestureContext as d, Rect as et, GestureId as f, MemoizedGeometryProvider as g, GeometrySignals as h, DomComputedResolver as i, Unsubscribe as it, EditorStyle as j, DEFAULT_STYLE as k, createSvgEditor as l, GeometryProvider as m, CreateSvgEditorOptions as n, TOOL_CURSOR as nt, Surface as o, Gestures as p, PaintValue as q, DomComputedPaint as r, Tool as rt, SurfaceHandle as s, Commands as t, ReorderDirection as tt, GestureBinding as u, PathSnapshot as v, Camera as w, VertexId as x, SegmentId as y, InvalidComputedValue as z };