@grida/svg-editor 1.0.0-alpha.15 → 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.
@@ -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;
@@ -611,6 +659,24 @@ declare class SvgDocument implements DocumentEvents {
611
659
  get structure_version(): number;
612
660
  /** See `_geometry_version` for what this counter signals. */
613
661
  get geometry_version(): number;
662
+ /**
663
+ * Advance `_geometry_version` by exactly 1 WITHOUT touching the tree,
664
+ * any attribute, `structure_version`, or the `on_change` listeners.
665
+ *
666
+ * The one geometry mutation with no attribute write: a `<text>` /
667
+ * `<tspan>` reflow the IR cannot see — a web font finishing load AFTER
668
+ * the `font-family` / `font-size` write was already serialized. The DOM
669
+ * surface observes the reflow (`document.fonts` `loadingdone`) and asks
670
+ * the geometry channel to advance so the bounds cache re-reads the
671
+ * settled glyph metrics. See ../../docs/geometry.md §Limitations.
672
+ *
673
+ * Deliberately does NOT call `emit()`: this is not a document edit, so
674
+ * it must not bump `doc_version` / mark the doc dirty / touch undo
675
+ * (the editor's `on_change` handler does all three). The editor's
676
+ * `_internal.bump_geometry` advances `geometry_version` here and fans
677
+ * out the geometry listeners itself.
678
+ */
679
+ bump_geometry(): void;
614
680
  private emit;
615
681
  /** Notify subscribers — for callers that mutate directly via setAttr/etc. */
616
682
  notify(): void;
@@ -781,6 +847,89 @@ declare class SvgDocument implements DocumentEvents {
781
847
  prefix?: string | null;
782
848
  ns?: string | null;
783
849
  }): NodeId;
850
+ /** Fresh internal NodeId, guaranteed unique within this document's node
851
+ * map. Shared by `create_element` and fragment adoption — collisions
852
+ * matter for the latter because the parser assigns sequential per-parse
853
+ * ids that a second parse would repeat. */
854
+ private fresh_node_id;
855
+ /**
856
+ * Parse an SVG **fragment** string and adopt its element subtrees into
857
+ * this document's node store — registered like {@link create_element}
858
+ * but NOT inserted into the tree (no version bump, no emit). Callers
859
+ * attach the returned roots via {@link insert}; the editor's
860
+ * `commands.insert_fragment` is the history-bracketed consumer.
861
+ *
862
+ * Input shapes:
863
+ * - A **bare fragment** — one or more sibling elements
864
+ * (`<path …/><path …/>`, or a single `<g>…</g>`). The top-level
865
+ * elements become the returned roots, in source order.
866
+ * - A **full SVG document** — when the input's only top-level element
867
+ * is an `<svg>`, that element is treated as a document SHELL, not
868
+ * content: its element children become the roots and the shell
869
+ * itself (viewBox, width/height, prolog, doctype) is discarded. Its
870
+ * `xmlns:*` prefix declarations are harvested into `xmlns` so the
871
+ * caller can re-declare prefixes the adopted content still uses.
872
+ * An `<svg>` that appears as one of SEVERAL top-level elements (or
873
+ * anywhere below the top level) is content, adopted as-is.
874
+ *
875
+ * Top-level non-element nodes (whitespace between roots, comments, PIs,
876
+ * doctype) are dropped — adoption takes elements, and the host
877
+ * document's own trivia stays untouched. WITHIN each adopted subtree
878
+ * every byte of source trivia survives verbatim (attribute order, quote
879
+ * styles, whitespace, comments), so the inserted markup serializes back
880
+ * exactly as authored — same rules as the initial parse.
881
+ *
882
+ * Authored `id=""` attributes are adopted verbatim — never rewritten,
883
+ * even when they collide with ids already in the document. Silent id
884
+ * renaming is exactly the proprietary noise this editor refuses (README
885
+ * "What clean means" §3); deduplication belongs to the explicit Tidy
886
+ * command. Internal NodeIds ARE freshly assigned (see
887
+ * {@link fresh_node_id}) so adopted nodes never collide in the id map.
888
+ *
889
+ * Throws `TypeError` on a non-string input and `Error` on markup the
890
+ * parser rejects (unclosed / mismatched tags, malformed attributes). An
891
+ * input with no top-level elements (empty string, whitespace, comments
892
+ * only) returns `{ roots: [], xmlns: [] }`.
893
+ */
894
+ create_fragment(markup: string): {
895
+ roots: NodeId[];
896
+ xmlns: ReadonlyArray<{
897
+ prefix: string;
898
+ uri: string;
899
+ }>;
900
+ };
901
+ /**
902
+ * Register `node` and its whole subtree (from a foreign parse) into this
903
+ * document's node map under fresh NodeIds. The parser assigns sequential
904
+ * per-parse ids (`n0`, `n1`, …), so adopting without a remap would
905
+ * collide with this document's own nodes. Children links are rewritten;
906
+ * the subtree root arrives detached (`parent: null`), like
907
+ * `create_element`. Mutates the parsed nodes in place — a parse result
908
+ * is single-use.
909
+ */
910
+ private adopt_parsed_subtree;
911
+ /**
912
+ * Namespace prefixes USED within `id`'s subtree (element tags and
913
+ * attribute names) that are not DECLARED within the subtree itself —
914
+ * i.e. prefixes the subtree borrows from ancestor scope. `xml` and
915
+ * `xmlns` are excluded (bound by the XML spec, never declared).
916
+ * Declaration scoping is honored per use-site: a prefix declared on the
917
+ * using element or any of its ancestors up to (and including) the
918
+ * subtree root counts as declared.
919
+ *
920
+ * Structural fact only — the caller decides what an unbound prefix
921
+ * means (e.g. `commands.insert_fragment` hoists a resolvable
922
+ * declaration onto the document root).
923
+ */
924
+ undeclared_ns_prefixes(id: NodeId): ReadonlySet<string>;
925
+ /**
926
+ * Declare a namespace prefix on the ROOT element: appends
927
+ * `xmlns:<prefix>="<uri>"` when the root doesn't already declare that
928
+ * prefix. An authored declaration always wins — this never rebinds.
929
+ * Policy wrapper over {@link set_attr} in the `XMLNS_NS` space; removal
930
+ * works through `set_attr(root, prefix, null, XMLNS_NS)` as usual.
931
+ */
932
+ declare_xmlns(prefix: string, uri: string): void;
784
933
  serialize(): string;
785
934
  /**
786
935
  * Serialize a single element's subtree as an SVG **fragment**, using the
@@ -1482,16 +1631,46 @@ type Commands = {
1482
1631
  * member.
1483
1632
  *
1484
1633
  * 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.
1634
+ * override. Members that are not resizable are skipped silently: this
1635
+ * means both an unresizable tag (e.g. `<g>`) AND a resizable tag carrying
1636
+ * a non-trivial transform (rotate-without-pivot, matrix, scale, skew),
1637
+ * which can't be resized in local space without breaking round-trip — the
1638
+ * same `is_resizable_node` gate the resize HUD applies. The gesture is a
1639
+ * no-op when no resizable member remains. Returns `true` when a history
1640
+ * step was pushed. `opts.label` overrides the atomic history label
1641
+ * (default `"resize-to"`).
1489
1642
  */
1490
1643
  resize_to(target: {
1491
1644
  x: number;
1492
1645
  y: number;
1493
1646
  width: number;
1494
1647
  height: number;
1648
+ }, opts?: {
1649
+ ids?: ReadonlyArray<NodeId>;
1650
+ label?: string;
1651
+ }): boolean;
1652
+ /**
1653
+ * Resize the selection by a delta — PER-ELEMENT: each selected member
1654
+ * grows/shrinks around its OWN NW corner, so members keep their positions
1655
+ * relative to one another (NOT a union/group resize — contrast
1656
+ * {@link resize_to}, which scales the whole selection around the shared
1657
+ * union origin and so translates off-origin members). `delta.dw` /
1658
+ * `delta.dh` are applied additively to each member (clamped to >= 0). The
1659
+ * core verb behind keyboard nudge-resize.
1660
+ *
1661
+ * ALL-OR-NOTHING gate: refuses (returns `false`, no history step) unless
1662
+ * EVERY member passes `is_resizable_node` — the same tag + transform-class
1663
+ * check the resize HUD uses, applied wholesale (a mixed selection is
1664
+ * refused, not partially resized — matches a HUD handle-drag, which is
1665
+ * rejected when any member is unsafe). Also refuses on empty selection or
1666
+ * when no geometry provider (DOM surface) is attached.
1667
+ *
1668
+ * Per-tag constraints (circle uniform, text edge no-op) apply per member.
1669
+ * The default selection is `state.selection`; pass `opts.ids` to override.
1670
+ */
1671
+ resize_by(delta: {
1672
+ dw: number;
1673
+ dh: number;
1495
1674
  }, opts?: {
1496
1675
  ids?: ReadonlyArray<NodeId>;
1497
1676
  }): boolean;
@@ -1527,6 +1706,39 @@ type Commands = {
1527
1706
  y: number;
1528
1707
  };
1529
1708
  }): boolean;
1709
+ /**
1710
+ * Compose an arbitrary 2×3 affine onto the selection, **relative** and
1711
+ * applied in **world space about a pivot**. `matrix` is in SVG
1712
+ * `matrix(a b c d e f)` order (see {@link Matrix2D}).
1713
+ *
1714
+ * Semantics: the effective affine written to each member is
1715
+ * `E = T(pivot) · matrix · T(-pivot)`, so the bare flip tuples become
1716
+ * in-place flips about the pivot. Pivot defaults to the selection
1717
+ * union-bbox center (via the attached surface's `geometry_provider`);
1718
+ * pass `opts.pivot` to override.
1719
+ *
1720
+ * Round-trip: `E` is folded onto each member's transform list as a
1721
+ * single LEADING `matrix` op — existing `rotate`/`translate` tokens are
1722
+ * preserved after it, repeated applies collapse into one matrix, and a
1723
+ * net-identity leading matrix is dropped (so flip-then-flip restores
1724
+ * the original). One atomic history step labelled `"transform"`.
1725
+ *
1726
+ * Refusal (returns `false`, no-op, no history): empty selection, no
1727
+ * `geometry_provider`, or any member failing `is_rotatable` (the same
1728
+ * non-trivial-transform / `<text rotate>` / CSS-property / animated
1729
+ * gate `rotate` uses). All-or-nothing — no partial writes.
1730
+ *
1731
+ * Flat-doc limitation: only each element's OWN transform is folded;
1732
+ * the pivot is treated as world ≡ parent space. Nested transformed
1733
+ * ancestors (`<g transform=…>`) are out of scope.
1734
+ */
1735
+ transform(matrix: Matrix2D, opts?: {
1736
+ ids?: ReadonlyArray<NodeId>;
1737
+ pivot?: {
1738
+ x: number;
1739
+ y: number;
1740
+ };
1741
+ }): boolean;
1530
1742
  /**
1531
1743
  * Collapse each selected member's `transform=` to a single `matrix(...)`
1532
1744
  * token, baking accumulated translates / rotates / scales / skews into
@@ -1561,6 +1773,30 @@ type Commands = {
1561
1773
  * rejected the call.
1562
1774
  */
1563
1775
  group(): boolean;
1776
+ /**
1777
+ * Dissolve the selected `<g>` (or `opts.id`), hoisting its children
1778
+ * into the group's parent at the group's z-position. Returns `true`
1779
+ * when a history step was pushed (children hoisted, group removed, the
1780
+ * former children selected); `false` when the call was refused.
1781
+ *
1782
+ * Only the **safe clean-structural subset** is accepted (see
1783
+ * `core/group.ts:plan_ungroup` and `../docs/grouping.md` §Ungrouping).
1784
+ * Refused — with NO mutation and NO history entry — when: the target
1785
+ * is not a single `<g>`; the group is inside `<defs>`; the group has
1786
+ * no element children; the group carries any own attribute beyond
1787
+ * `{ transform, id, data-grida-id }` (i.e. any visual / cascade state
1788
+ * such as `opacity` / `class` / `style` / `filter` / `clip-path` /
1789
+ * `mask` / `fill`); the group's `id` is referenced by a `<use>`; a
1790
+ * direct child is an SMIL animation element; or — when the group has a
1791
+ * `transform` — any child's own transform is unparseable.
1792
+ *
1793
+ * When the group has a `transform`, it is BAKED into each child by
1794
+ * prepending the group's parsed ops to the child's (clean token
1795
+ * compose, not a matrix collapse), so paint output round-trips.
1796
+ */
1797
+ ungroup(opts?: {
1798
+ id?: NodeId;
1799
+ }): boolean;
1564
1800
  /**
1565
1801
  * Atomic one-shot insertion. Creates a new element of the given SVG
1566
1802
  * tag with the supplied attributes (merged on top of the package's
@@ -1577,6 +1813,63 @@ type Commands = {
1577
1813
  index?: number;
1578
1814
  select?: boolean;
1579
1815
  }): NodeId;
1816
+ /**
1817
+ * Atomic insertion of a pre-authored SVG **fragment** — one or more
1818
+ * sibling elements as markup (`"<g …><path …/></g>"`), or a full
1819
+ * `<svg>` document whose element children are taken as the content
1820
+ * (the `<svg>` shell — viewBox, width/height, prolog, doctype — is
1821
+ * discarded; an `<svg>` that is one of several top-level elements is
1822
+ * content and inserted as-is). The element subtrees are adopted
1823
+ * verbatim — every byte of trivia inside each element survives
1824
+ * (attribute order, quote styles, whitespace, comments) — inserted
1825
+ * contiguously in source order at `opts.parent` / `opts.index`, and
1826
+ * selected. ONE history step regardless of fragment size; a single
1827
+ * `undo()` restores the exact pre-insert serialization. Returns the
1828
+ * inserted top-level ids in document order.
1829
+ *
1830
+ * This is the markup-shaped sibling of {@link insert} — the primitive
1831
+ * paste and asset-stamping flows compose. Use `insert` for a tag +
1832
+ * attrs; use `insert_fragment` for markup.
1833
+ *
1834
+ * **Position is authored content.** There is deliberately no placement
1835
+ * opt: to land a fragment at a document-space point, author the
1836
+ * position into the markup before inserting — wrap it in
1837
+ * `<g transform="translate(x y)">…</g>` or set the elements' own
1838
+ * geometry attrs. Placement then round-trips as ordinary markup and
1839
+ * the whole drop is the same single undo step.
1840
+ *
1841
+ * **`id` collisions:** authored `id=""` attributes are inserted
1842
+ * verbatim, NEVER rewritten — silent id renaming is proprietary noise
1843
+ * (P1; README "What clean means" §3). When a fragment id collides
1844
+ * with an existing one, reference resolution (`url(#…)`, `href`)
1845
+ * follows the document-order rules of the host renderer; resolving
1846
+ * the duplication is the explicit Tidy command's job, not insertion's.
1847
+ *
1848
+ * **Namespaces:** when the fragment uses a prefix the document root
1849
+ * doesn't declare, the declaration is hoisted onto the root as part
1850
+ * of the same history step — `xlink` (well-known URI) and any prefix
1851
+ * the discarded `<svg>` shell declared. A prefix whose URI is not
1852
+ * discoverable is left as authored (the input was equally unbound as
1853
+ * a standalone document). An authored root declaration always wins —
1854
+ * never rebound.
1855
+ *
1856
+ * **Refusals:** an input with no top-level elements (empty /
1857
+ * whitespace / comments-only) returns `[]` with NO history step.
1858
+ * Throws on malformed markup (parser errors propagate), on a
1859
+ * non-string input, and on an `opts.parent` that isn't a live element
1860
+ * of the current document — a silent no-op there would hide consumer
1861
+ * bugs (same stance as `serialize_node`).
1862
+ *
1863
+ * `opts.parent` defaults to root; `opts.index` (position in the
1864
+ * parent's element-children list; the whole fragment lands
1865
+ * contiguously at it) defaults to append; `opts.select` defaults to
1866
+ * `true`.
1867
+ */
1868
+ insert_fragment(svg: string, opts?: {
1869
+ parent?: NodeId;
1870
+ index?: number;
1871
+ select?: boolean;
1872
+ }): NodeId[];
1580
1873
  /**
1581
1874
  * Preview-bracketed insertion for drag-to-size gestures. Creates and
1582
1875
  * inserts the node immediately (so HUD selection chrome renders);
@@ -1685,6 +1978,17 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
1685
1978
  * Cheap channel — does NOT bump `state.version`.
1686
1979
  */
1687
1980
  subscribe_surface_hover(cb: () => void): () => void;
1981
+ /**
1982
+ * Subscribe to pick (tap) outcomes — a discrete click on the canvas,
1983
+ * reporting the document-space point and the node under it (`null` for
1984
+ * empty canvas), plus the button and modifier snapshot. Fires once per
1985
+ * tap, after the editor's own selection handling. Observe-only: a pick
1986
+ * cannot alter selection, and the channel does NOT bump `state.version`.
1987
+ * See {@link PickEvent}.
1988
+ *
1989
+ * @unstable
1990
+ */
1991
+ subscribe_pick(cb: (e: PickEvent) => void): Unsubscribe;
1688
1992
  /**
1689
1993
  * Subscribe to bounds-affecting changes. Fires when any document
1690
1994
  * mutation advances `state.geometry_version` — drag, resize, text
@@ -1740,8 +2044,10 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
1740
2044
  set_content_edit_driver(fn: ((target: NodeId) => boolean) | null): void;
1741
2045
  set_surface_hover_override_driver(fn: ((id: NodeId | null) => void) | null): void;
1742
2046
  push_surface_hover(id: NodeId | null): void;
2047
+ push_pick(e: PickEvent): void;
1743
2048
  set_computed_resolver(fn: DomComputedResolver | null): void;
1744
2049
  set_geometry(p: GeometryProvider | null): void;
2050
+ bump_geometry(): void;
1745
2051
  };
1746
2052
  keymap: Keymap;
1747
2053
  };
@@ -1754,4 +2060,4 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
1754
2060
  */
1755
2061
  declare function createSvgEditor(opts: CreateSvgEditorOptions): SvgEditor;
1756
2062
  //#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 };
2063
+ 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 };