@grida/svg-editor 1.0.0-alpha.13 → 1.0.0-alpha.15

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.
@@ -1,6 +1,6 @@
1
1
  import cmath from "@grida/cmath";
2
- import { AnyNode } from "@grida/svg/parser";
3
- import * as _$_grida_history0 from "@grida/history";
2
+ import vn from "@grida/vn";
3
+ import { AnyNode, AttrToken } from "@grida/svg/parser";
4
4
  import { SelectMode } from "@grida/hud";
5
5
  import { Keybinding, Platform } from "@grida/keybinding";
6
6
 
@@ -24,12 +24,14 @@ type Rect = {
24
24
  };
25
25
  type Mode = "select" | "edit-content";
26
26
  /**
27
- * SVG element tags supported by the insertion subsystem. Closed set; adding
28
- * a new insertable tag requires a PR.
27
+ * SVG element tags inserted by the **drag-to-size** subsystem. Closed set;
28
+ * adding a new insertable tag requires a PR.
29
29
  *
30
- * `text` is deliberately out of v1: `<text>` has no intrinsic size and any
31
- * usable text-insert UX must immediately mount the inline content-editor on
32
- * the new node rather than dropping a placeholder. Reserved for a future PR.
30
+ * `text` is intentionally NOT here. It is creatable (the `insert-text`
31
+ * tool see {@link Tool}), but via a **click-only** gesture, not
32
+ * drag-to-size: `<text>` has no intrinsic size, so there is nothing for a
33
+ * drag to set. Its creation path mounts the inline content-editor
34
+ * immediately. Design: `docs/wg/feat-svg-editor/text-tool.md`.
33
35
  */
34
36
  type InsertableTag = "rect" | "ellipse" | "line";
35
37
  /**
@@ -48,6 +50,39 @@ type Tool = {
48
50
  } | {
49
51
  type: "insert";
50
52
  tag: InsertableTag;
53
+ }
54
+ /**
55
+ * Text creation tool. A select-mode tool like `insert`, but **click-only**
56
+ * rather than drag-to-size: pointer-down creates a single-line `<text>` at
57
+ * the click point with default appearance and immediately enters
58
+ * content-edit (caret active). `<text>` has no intrinsic size, so the
59
+ * drag-to-size model doesn't apply; a drag box would mean SVG 2 wrapped
60
+ * text, which is a separate (out-of-scope) model. Reverts to `cursor`
61
+ * after the node is placed. Design:
62
+ * `docs/wg/feat-svg-editor/text-tool.md`.
63
+ */
64
+ | {
65
+ type: "insert-text";
66
+ }
67
+ /**
68
+ * Vector content-edit lasso (Q). Empty-space drag draws a freeform
69
+ * polygon that picks vertices + tangents inside it (segments are NOT
70
+ * tested — matches the main editor decision; see `@grida/hud`
71
+ * `VectorSelectionMode`). Valid only while `state.mode === "edit-content"`
72
+ * on a path; tool reverts to cursor on path-content-edit exit.
73
+ */
74
+ | {
75
+ type: "lasso";
76
+ }
77
+ /**
78
+ * Vector content-edit bend. Sticky version of holding Meta — every
79
+ * segment-body drag bends instead of translating, regardless of
80
+ * Meta state. See `@grida/hud` `VectorBendMode`. Valid only while
81
+ * `state.mode === "edit-content"` on a path; tool reverts to cursor
82
+ * on path-content-edit exit.
83
+ */
84
+ | {
85
+ type: "bend";
51
86
  };
52
87
  declare const TOOL_CURSOR: Tool;
53
88
  type Provenance = {
@@ -221,8 +256,24 @@ type EditorState = {
221
256
  * any change — selection, history, mutation. NOT a good cache key for
222
257
  * tree-shape views because it fires on attribute writes too (e.g. x/y
223
258
  * during a drag).
259
+ *
260
+ * NOT a content fingerprint either: this bumps on UI-state emissions
261
+ * (selection, scope, mode, tool) that leave the serialized SVG
262
+ * unchanged. For "did the document content change?" use
263
+ * {@link content_version}.
224
264
  */
225
265
  readonly version: number;
266
+ /**
267
+ * Bumps on every document mutation — insert, remove, reorder, attribute
268
+ * write, style write, undo, redo, load. Stable across pure UI-state
269
+ * emissions (selection, scope, mode, tool).
270
+ *
271
+ * The honest fingerprint for serialized content: if `content_version`
272
+ * is unchanged, `editor.serialize()` returns the same bytes. Use this
273
+ * — not `version` — as the freshness token when persisting, diffing,
274
+ * or hashing the document.
275
+ */
276
+ readonly content_version: number;
226
277
  /**
227
278
  * Bumps only when the document's tree shape or display-label-affecting
228
279
  * data changes — node added/removed/reordered, text content, or the
@@ -434,6 +485,91 @@ declare class Camera {
434
485
  type AlignDirection = "left" | "right" | "top" | "bottom" | "horizontal_centers" | "vertical_centers";
435
486
  //#endregion
436
487
  //#region src/core/document.d.ts
488
+ /**
489
+ * What `is_vector_edit_target` returns when a node is eligible for
490
+ * vector (vertex) editing — a tag-discriminated snapshot of the authored
491
+ * geometry attributes at enter time.
492
+ *
493
+ * Consumed by `VectorEditSession` (which holds it as `source_attrs`) and by
494
+ * `PathModel` (whose `toNativeAttrs(source_tag)` decides on each commit
495
+ * whether the post-edit form is still expressible in the source tag, or
496
+ * whether the element must promote to `<path d="…">`).
497
+ *
498
+ * Geometry conventions:
499
+ * - All coordinates are in the element's own local space, exactly as
500
+ * authored. No `transform=` resolution, no parent CTM, no viewport
501
+ * remap.
502
+ * - `line` carries its two endpoints; `polyline` / `polygon` points are
503
+ * `[x, y]` tuples so the consumer can hand them straight to
504
+ * `vn.fromPolyline` / `vn.fromPolygon`.
505
+ * - `rect` / `circle` / `ellipse` carry their native geometry numbers.
506
+ * These geometry primitives have no addressable interior vertices in
507
+ * their native form, so editing one as vector geometry re-types the
508
+ * element to `<path>` (see `retype_to_path`). The document holds the
509
+ * native tag until that re-type is committed. Design:
510
+ * `docs/wg/feat-svg-editor/promote-to-path.md`.
511
+ *
512
+ * Re-type vs. native writeback is decided per edit, not per tag: an edit
513
+ * that the source tag can still express (a straight vertex move on
514
+ * `line` / `polyline` / `polygon`) writes back natively; one it cannot (a
515
+ * curve, or a topology change that leaves the tag's canonical form)
516
+ * re-types the element to `<path>`.
517
+ */
518
+ type VectorEditSource = {
519
+ kind: "path";
520
+ d: string;
521
+ } | {
522
+ kind: "line";
523
+ x1: number;
524
+ y1: number;
525
+ x2: number;
526
+ y2: number;
527
+ } | {
528
+ kind: "polyline";
529
+ points: ReadonlyArray<readonly [number, number]>;
530
+ } | {
531
+ kind: "polygon";
532
+ points: ReadonlyArray<readonly [number, number]>;
533
+ } | {
534
+ kind: "rect";
535
+ x: number;
536
+ y: number;
537
+ width: number;
538
+ height: number; /** Corner radii; `0` when the rect has square corners. */
539
+ rx: number;
540
+ ry: number;
541
+ } | {
542
+ kind: "circle";
543
+ cx: number;
544
+ cy: number;
545
+ r: number;
546
+ } | {
547
+ kind: "ellipse";
548
+ cx: number;
549
+ cy: number;
550
+ rx: number;
551
+ ry: number;
552
+ };
553
+ /**
554
+ * Opaque reversal token returned by `retype_to_path`. Callers hold it and
555
+ * hand it back to `revert_retype` to restore the original primitive
556
+ * byte-for-byte; they do not inspect it. All trivia / attribute-token
557
+ * knowledge stays inside `SvgDocument`.
558
+ */
559
+ type RetypeRecord = {
560
+ readonly prev_local: string;
561
+ readonly prev_raw_tag: string;
562
+ /** Geometry attribute tokens removed on re-type, with their original
563
+ * index in the element's `attrs` array. Ascending by index so they can
564
+ * be spliced back in order. Typed as the document's internal attr token. */
565
+ readonly removed: ReadonlyArray<{
566
+ index: number;
567
+ token: AttrToken;
568
+ }>;
569
+ /** True iff the re-type added a synthetic `fill="none"` (the `<line>`
570
+ * fidelity guard — see `retype_to_path`). `revert_retype` removes it. */
571
+ readonly added_fill_none?: boolean;
572
+ };
437
573
  interface DocumentEvents {
438
574
  /** Fires after any structural mutation. */
439
575
  on_change(fn: () => void): () => void;
@@ -462,7 +598,7 @@ declare class SvgDocument implements DocumentEvents {
462
598
  private _structure_version;
463
599
  /** Bumps on writes that can shift world-space bounds (`GEOMETRY_ATTRS`,
464
600
  * `set_text`, `insert`, `remove`). Cache key for `GeometryProvider`;
465
- * see docs/wg/feat-svg-editor/geometry.md. */
601
+ * see ../../docs/geometry.md. */
466
602
  private _geometry_version;
467
603
  constructor(svg: string);
468
604
  static parse(svg: string): SvgDocument;
@@ -547,6 +683,94 @@ declare class SvgDocument implements DocumentEvents {
547
683
  * or its parent text.
548
684
  */
549
685
  is_text_edit_target(id: NodeId): boolean;
686
+ /**
687
+ * Returns a tag-discriminated snapshot of the authored geometry attrs
688
+ * if this node is eligible for vector (vertex) editing — else `null`.
689
+ *
690
+ * Eligibility:
691
+ * - `<path>` — requires non-empty `d`.
692
+ * - `<line>` — requires two distinct finite user-unit endpoints.
693
+ * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
694
+ * - `<polygon>` — same as polyline.
695
+ * - `<rect>` — requires finite user-unit `width`/`height` > 0.
696
+ * - `<circle>` — requires finite user-unit `r` > 0.
697
+ * - `<ellipse>` — requires finite user-unit `rx`/`ry` > 0.
698
+ *
699
+ * The vertex tags (`line` / `polyline` / `polygon`) write edits back to
700
+ * their native attributes while the geometry stays expressible there; an
701
+ * edit that escapes the native form (a curve, or a topology change that
702
+ * leaves the canonical chain) re-types the element to `<path>`. The
703
+ * geometry primitives (`rect` / `circle` / `ellipse`) have no native
704
+ * vector form, so any vector edit re-types them. In all cases the native
705
+ * tag is preserved byte-for-byte until the first re-typing edit commits
706
+ * (see `retype_to_path`). Design:
707
+ * `docs/wg/feat-svg-editor/promote-to-path.md`.
708
+ *
709
+ * Geometry that is not a plain user-unit number (`%`, `px`, `em`, …) is
710
+ * an out-of-scope gap, so such an element returns `null` rather than
711
+ * advertising an edit the editor cannot perform faithfully.
712
+ *
713
+ * Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
714
+ * editable outline).
715
+ */
716
+ /**
717
+ * Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
718
+ * endpoints). An **absent** attribute takes the SVG default (`0`); a
719
+ * **present** attribute that is not a plain user-unit number (`%`, `px`,
720
+ * `em`, …) is out of scope and yields `null` so the caller refuses the
721
+ * element — the same gate required attrs (width / radius) already apply.
722
+ *
723
+ * The absent-vs-present distinction is the point: a bare `?? 0` would
724
+ * silently coerce an authored `x1="5px"` to `0`, then the first native
725
+ * writeback would overwrite that authored value. Refusing keeps the
726
+ * editor from misrepresenting geometry it cannot read faithfully.
727
+ */
728
+ private optional_user_unit_coord;
729
+ is_vector_edit_target(id: NodeId): VectorEditSource | null;
730
+ /**
731
+ * Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
732
+ * `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
733
+ * its native geometry attributes and setting `d`. A structural mutation:
734
+ * this layer executes the re-type; it does not decide when one is
735
+ * warranted.
736
+ *
737
+ * Idempotent: returns `null` if `id` is not currently one of those tags
738
+ * (so it is safe to call repeatedly — once re-typed, e.g. already a
739
+ * `<path>`, further calls are no-ops). Otherwise mutates the node and
740
+ * returns an opaque {@link RetypeRecord} reversal token.
741
+ *
742
+ * Identity, children, `self_closing`, non-geometry attributes, and all
743
+ * source trivia are preserved unchanged — only the tag and the geometry
744
+ * attributes move. Pass the token to {@link revert_retype} to restore
745
+ * the original primitive byte-for-byte.
746
+ *
747
+ * (see test/svg-editor-vector-promote-to-path.md)
748
+ */
749
+ retype_to_path(id: NodeId, d: string): RetypeRecord | null;
750
+ /**
751
+ * Reverse a {@link retype_to_path}: restore the original tag, remove the
752
+ * `d` attribute the promotion added, and splice the captured geometry
753
+ * attribute tokens back at their original positions (preserving their
754
+ * trivia, so a later `serialize()` is byte-equal to the pre-promotion
755
+ * source).
756
+ */
757
+ revert_retype(id: NodeId, token: RetypeRecord): void;
758
+ /**
759
+ * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
760
+ * per-glyph attribute (which conflicts with element-level rotation).
761
+ */
762
+ has_glyph_rotate(id: NodeId): boolean;
763
+ /**
764
+ * True iff this element's inline `style=""` declares a `transform:`
765
+ * CSS property (which would shadow the editor's `transform=` writes).
766
+ */
767
+ has_inline_css_transform(id: NodeId): boolean;
768
+ /**
769
+ * True iff this element has a direct `<animateTransform>` child
770
+ * (which produces a time-varying transform invisible to attribute writes).
771
+ * Only direct children are checked — nested cases attach to the nearer ancestor.
772
+ */
773
+ has_animate_transform_child(id: NodeId): boolean;
550
774
  text_of(id: NodeId): string;
551
775
  /** Replace all direct text children with a single text node carrying `value`. */
552
776
  set_text(id: NodeId, value: string): void;
@@ -558,10 +782,297 @@ declare class SvgDocument implements DocumentEvents {
558
782
  ns?: string | null;
559
783
  }): NodeId;
560
784
  serialize(): string;
785
+ /**
786
+ * Serialize a single element's subtree as an SVG **fragment**, using the
787
+ * same trivia-preserving rules as {@link serialize} (attribute order,
788
+ * quote style, whitespace, comments — emitted exactly as authored).
789
+ *
790
+ * This is NOT {@link serialize} scoped to a node — it is a deliberately
791
+ * weaker output (sdk-design D3, asymmetric outputs stay separate):
792
+ *
793
+ * - `serialize()` emits the whole document and carries the P1
794
+ * whole-document round-trip guarantee.
795
+ * - `serialize_node()` emits a fragment and does NOT. Namespace
796
+ * declarations that live on an ancestor (`xmlns:xlink` and friends,
797
+ * normally on the root `<svg>`) are NOT inlined — a node using
798
+ * `xlink:href` serializes without `xmlns:xlink`. The fragment is the
799
+ * element's markup as authored, not a standalone parseable document.
800
+ *
801
+ * Throws on an unknown id, a non-element node, or a node detached from
802
+ * the live tree: the contract is "the markup for a selected element,"
803
+ * selections are always live elements, and a string return of `""` for a
804
+ * bad id would hide consumer bugs. The detached case matters because
805
+ * `remove()` keeps the node in the id map for undo — a stale id from a
806
+ * removed node would otherwise serialize content no longer in the
807
+ * document, silently feeding a consumer deleted markup.
808
+ */
809
+ serialize_node(id: NodeId): string;
561
810
  private emit_node;
562
811
  private emit_attr;
563
812
  }
564
813
  //#endregion
814
+ //#region src/core/vector-edit/model.d.ts
815
+ type Verb = "M" | "L" | "H" | "V" | "C" | "S" | "Q" | "T" | "A" | "Z";
816
+ type VertexId = number;
817
+ type SegmentId = number;
818
+ /** `[vertex_idx, 0]` = ta on segment whose `a === vertex_idx`; `[vertex_idx, 1]` = tb where `b === vertex_idx`. */
819
+ type TangentRef = readonly [VertexId, 0 | 1];
820
+ /**
821
+ * Tangent mirroring policy applied around a vertex when one tangent moves.
822
+ * Mirrors `vn.TangentMirroringMode`.
823
+ *
824
+ * - `auto` — infer from current state (smooth join → mirror angle+length;
825
+ * broken / asymmetric → don't mirror).
826
+ * - `none` — only move the chosen tangent. Other tangents at this vertex
827
+ * stay put.
828
+ * - `angle` — keep opposite tangent collinear (mirror angle), preserve its
829
+ * length. Used when the user wants a sharp-vs-smooth-but-asymmetric
830
+ * handle (Figma's "Mirror angle" mode).
831
+ * - `all` — mirror both angle and length. Standard "smooth" handle pair.
832
+ */
833
+ type TangentMirrorMode = "auto" | "none" | "angle" | "all";
834
+ type SegmentView = {
835
+ a: VertexId;
836
+ b: VertexId;
837
+ ta: readonly [number, number];
838
+ tb: readonly [number, number];
839
+ source_verb?: Verb;
840
+ };
841
+ type PathSnapshot = {
842
+ vertices: ReadonlyArray<readonly [number, number]>;
843
+ segments: ReadonlyArray<SegmentView>;
844
+ };
845
+ type SubSelection = {
846
+ vertices: ReadonlyArray<VertexId>;
847
+ segments: ReadonlyArray<SegmentId>;
848
+ tangents: ReadonlyArray<TangentRef>;
849
+ };
850
+ /**
851
+ * Per-segment metadata maintained alongside vn's segment array.
852
+ * `meta[i]` corresponds to `network.segments[i]`.
853
+ */
854
+ type SegmentMeta = {
855
+ /** The SVG verb that originally produced this segment, if known. */source_verb?: Verb; /** Arc-specific metadata for segments born from an `A` command. */
856
+ arc?: ArcMeta; /** True iff this segment was emitted by a `Z` command (closing the subpath). */
857
+ is_close_segment?: boolean;
858
+ };
859
+ /**
860
+ * When an `A` command is parsed, it decomposes to N cubic segments.
861
+ * All segments in the same arc share the same `group_id` and the same
862
+ * arc parameters, plus each carries a snapshot of its original tangents
863
+ * (used at emit time to detect "still an arc" vs "user has edited").
864
+ */
865
+ type ArcMeta = {
866
+ group_id: number;
867
+ rx: number;
868
+ ry: number;
869
+ x_rot: number;
870
+ large_arc_flag: 0 | 1;
871
+ sweep_flag: 0 | 1; /** Snapshot of this segment's ta at parse time (relative). */
872
+ baseline_ta: cmath.Vector2; /** Snapshot of this segment's tb at parse time (relative). */
873
+ baseline_tb: cmath.Vector2; /** Snapshot of this segment's end-vertex absolute position at parse time. */
874
+ baseline_b_abs: cmath.Vector2; /** Sequence index within the arc group (0..N-1). */
875
+ seq: number; /** Total segments in the arc group. */
876
+ count: number;
877
+ /** Original SVG arc command's `(x, y)` endpoint. Only populated on the
878
+ * LAST segment of the arc group (seq === count - 1). Used by the emitter
879
+ * to write back the exact coordinate the author wrote, avoiding floating-
880
+ * point drift from arc-to-cubic decomposition. */
881
+ original_end?: cmath.Vector2;
882
+ };
883
+ /**
884
+ * Canonical vector-network model for a single SVG path's `d` string.
885
+ *
886
+ * `PathModel` is a self-contained geometry primitive — it parses an SVG
887
+ * path `d` into a vertex/segment graph (with verb hints preserved for
888
+ * round-trip honesty), exposes POJO observers, and serializes back to
889
+ * `d`. It does not hold or reference an `SvgDocument`, an editor
890
+ * instance, the DOM, or any host. It is safe to construct in any
891
+ * environment that can run the package.
892
+ *
893
+ * Public re-exported as a top-level Layer-A primitive from
894
+ * `@grida/svg-editor` for callers that want canonical path geometry
895
+ * without mounting an editor. The full mutation surface (translate /
896
+ * bend / set-tangent / split, etc.) is package-internal and may shift;
897
+ * the publicly-stable contract for external callers is the construction
898
+ * + serialization + observation methods documented at the entry point.
899
+ *
900
+ * @experimental Surface shape is v0; signatures may change before the
901
+ * package reaches semver stability.
902
+ */
903
+ declare class PathModel {
904
+ private readonly _network;
905
+ private readonly _meta;
906
+ private constructor();
907
+ static fromSvgPathD(d: string): PathModel;
908
+ /** Construct from a vn network with no verb info (every segment defaults to undefined verb). */
909
+ static fromVectorNetwork(network: vn.VectorNetwork): PathModel;
910
+ toSvgPathD(): string;
911
+ snapshot(): PathSnapshot;
912
+ bbox(): cmath.Rectangle;
913
+ vertexCount(): number;
914
+ segmentCount(): number;
915
+ /**
916
+ * If the model's current geometry is still expressible in the source
917
+ * SVG tag's native attribute form, return the equivalent
918
+ * `VectorEditSource` (which is also the writeable shape) — else `null`.
919
+ *
920
+ * This is the decider that gates per-gesture native-attrs writeback in
921
+ * `VectorEditSession.apply_d`. `null` means "the user's edit cannot be
922
+ * faithfully written back to the source tag" — in v1 with no
923
+ * promotion, the gesture is refused; in v1.1+ with promotion, the
924
+ * element is rewritten to `<path d="…">`.
925
+ *
926
+ * v1 expressibility (all source kinds require every segment's `ta` and
927
+ * `tb` to be exactly zero — any tangent edit forces promotion):
928
+ *
929
+ * - **path** — always `null` (no native fallback; the canonical form
930
+ * IS `<path d>`, so callers should just write `d` directly).
931
+ * - **line** — exactly two vertices joined by one straight segment
932
+ * `0→1`. (Topology after a 2-point `vn.fromPolyline` and any sequence
933
+ * of endpoint translates.)
934
+ * - **polyline** — segments form the canonical open chain
935
+ * `0→1, 1→2, …, (n-2)→(n-1)`. (Topology after `vn.fromPolyline` and
936
+ * any sequence of vertex translates.)
937
+ * - **polygon** — segments form the canonical closed chain
938
+ * `0→1, 1→2, …, (n-1)→0`. (Topology after `vn.fromPolygon` and any
939
+ * sequence of vertex translates.)
940
+ * - **rect / circle / ellipse** — always `null`. These geometry
941
+ * primitives have no native writeback target; any vector gesture on
942
+ * them re-types the element to `<path>` (see `vector_apply` /
943
+ * `SvgDocument.retype_to_path`), so they never round-trip through here.
944
+ *
945
+ * Anything that changes segment topology (insert-vertex, delete-vertex,
946
+ * close/open shape) or introduces a curve leaves the canonical chain and
947
+ * returns `null` here; the caller re-types the element to `<path>`.
948
+ */
949
+ toNativeAttrs(source_tag: VectorEditSource["kind"]): Extract<VectorEditSource, {
950
+ kind: "line" | "polyline" | "polygon";
951
+ }> | null;
952
+ /** Translate one vertex by `delta`. Connected segments follow because
953
+ * tangents are stored relative to vertices. Verb metadata is preserved
954
+ * as-is; emit-time honesty handles cases where the shape no longer
955
+ * matches the recorded verb (e.g. an H whose endpoint y-coord drifts). */
956
+ translateVertex(v: VertexId, delta: readonly [number, number]): PathModel;
957
+ /** Bulk-translate a set of vertices by the same delta. Atomic — either
958
+ * every move succeeds or none (input is validated up-front). */
959
+ translateVertices(indices: ReadonlyArray<VertexId>, delta: readonly [number, number]): PathModel;
960
+ /** Translate one segment by `delta` — moves both endpoints, dragging
961
+ * their tangents along (tangents are stored relative to vertices, so
962
+ * this is automatic). Other segments connected to the moved endpoints
963
+ * also follow at the shared vertex. */
964
+ translateSegment(seg: SegmentId, delta: readonly [number, number]): PathModel;
965
+ /**
966
+ * Bend a curve segment by dragging a point at parameter `ca` to `cb`
967
+ * (cb is in absolute doc-space). Delegates to vn's `bendSegment` —
968
+ * which solves for the new ta/tb that put `B(ca) === cb`, holding the
969
+ * endpoints fixed.
970
+ *
971
+ * The "frozen" snapshot of the segment at gesture start is the caller's
972
+ * responsibility. Convention: call this from a preview session where
973
+ * each frame replays from the baseline (same pattern as translate).
974
+ */
975
+ bendSegment(seg: SegmentId, ca: number, cb: readonly [number, number], frozen: {
976
+ a: readonly [number, number];
977
+ b: readonly [number, number];
978
+ ta: readonly [number, number];
979
+ tb: readonly [number, number];
980
+ }): PathModel;
981
+ /**
982
+ * Move one tangent control point to a new absolute position. Mirror
983
+ * policy follows vn's `updateTangent`. The other tangent at the same
984
+ * vertex is updated according to the policy.
985
+ *
986
+ * Returns a new PathModel; verb metadata is preserved verbatim.
987
+ * `toSvgPathD` will demote (e.g. L → C) if the new tangents make the
988
+ * recorded verb no longer match the geometry.
989
+ */
990
+ setTangent(t: TangentRef, abs_pos: readonly [number, number], mirror?: TangentMirrorMode): PathModel;
991
+ /**
992
+ * Split segment `seg` at parametric position `t ∈ [0,1]`, inserting a
993
+ * new vertex. Returns the new model and the **canonical (path-order)**
994
+ * index of the inserted vertex.
995
+ *
996
+ * Verb metadata for the split: the original segment's verb propagates
997
+ * to BOTH halves if it was a curve type (`C`/`S`/`Q`/`T`/`A`); for
998
+ * straight verbs (`L`/`H`/`V`), the split halves stay straight (their
999
+ * tangents are zero from vn's `preserveZero` path when both originals
1000
+ * were zero). Arc-group identity is dropped from the halves — the
1001
+ * arc is broken once split (the emitter will fall back to `C`/`L`).
1002
+ *
1003
+ * **Index space contract.** `VectorNetworkEditor.splitSegment` APPENDS
1004
+ * the new vertex at the end of the network's vertices array — its
1005
+ * index is the in-memory insertion order. But `toSvgPathD` / `fromSvgPathD`
1006
+ * canonicalize vertices in path order, so the same vertex gets a
1007
+ * DIFFERENT index in the d-derived model that consumers re-parse each
1008
+ * frame (e.g., the host's `handle_translate_vertices`). Returning the
1009
+ * insertion-order index causes the classic split-and-drag bug: the
1010
+ * surface holds index N (insertion-order) but the live model has
1011
+ * index M (path-order) at that position — drag moves the wrong vertex
1012
+ * and the user sees "split happened but the new vertex doesn't move".
1013
+ *
1014
+ * To prevent that, we round-trip the post-split model through
1015
+ * `toSvgPathD` → `fromSvgPathD` and return the canonical (path-order)
1016
+ * index of the new vertex. The returned `model` is the canonical
1017
+ * one, so any subsequent op on it uses the same index space the d
1018
+ * roundtrip exposes. See `__tests__/README.md` §"index identity
1019
+ * across the `d` round-trip" for the test pattern that pins this.
1020
+ */
1021
+ splitSegment(seg: SegmentId, t: number): {
1022
+ model: PathModel;
1023
+ new_vertex: VertexId;
1024
+ };
1025
+ /**
1026
+ * Doc-space position of a tangent control point. `t` references a
1027
+ * segment and which end (`a` or `b`) the tangent belongs to; the
1028
+ * result is `vertex + tangent_value + origin`. Returns null if no
1029
+ * segment has this tangent (e.g. the vertex is isolated).
1030
+ */
1031
+ tangentAbsolute(t: TangentRef, origin: readonly [number, number]): [number, number] | null;
1032
+ /**
1033
+ * Vertices "neighbouring" the current selection — these are the
1034
+ * vertices whose tangent handles should render in chrome.
1035
+ *
1036
+ * Two-phase, mirrors `editor/grida-canvas/reducers/methods/vector.ts`
1037
+ * `getUXNeighbouringVertices`:
1038
+ *
1039
+ * 1. Collect "active" vertices:
1040
+ * - every selected vertex
1041
+ * - every tangent-owning vertex
1042
+ * - both endpoints of every selected segment
1043
+ * 2. Expand uniformly to 1-hop neighbours (vertices sharing a segment
1044
+ * with any active vertex).
1045
+ *
1046
+ * Without phase 2 for tangent / segment selections, selecting only a
1047
+ * tangent would hide neighbouring-vertex tangents — the user loses
1048
+ * spatial context. Phase 2 makes the affordance symmetric: whatever
1049
+ * triggered selection, the 1-hop ring of tangent handles is visible.
1050
+ *
1051
+ * Sorted ascending; deduped.
1052
+ */
1053
+ neighbouringVertices(sel: SubSelection): VertexId[];
1054
+ /**
1055
+ * True iff segment `seg`'s curve is entirely contained in the rect.
1056
+ * Delegates to `cmath.bezier.containedByRect`.
1057
+ */
1058
+ segmentContainedByRect(seg: SegmentId, rect: cmath.Rectangle, origin?: readonly [number, number]): boolean;
1059
+ /** @internal */
1060
+ _rawNetwork(): vn.VectorNetwork;
1061
+ /** @internal */
1062
+ _rawMeta(): ReadonlyArray<SegmentMeta>;
1063
+ /**
1064
+ * Map a `TangentRef` to a concrete `(segment_index, control)` pair.
1065
+ *
1066
+ * `[v, 0]` → first segment whose `a === v` (its `ta`).
1067
+ * `[v, 1]` → first segment whose `b === v` (its `tb`).
1068
+ *
1069
+ * Y-junctions (multi-outgoing or multi-incoming) are uncommon for SVG
1070
+ * `<path>` content; v1 picks the first match. If we ever support those
1071
+ * cleanly, extend `TangentRef` to carry the segment id explicitly.
1072
+ */
1073
+ private _locateTangent;
1074
+ }
1075
+ //#endregion
565
1076
  //#region src/core/defs.d.ts
566
1077
  interface GradientsApi {
567
1078
  list(): ReadonlyArray<GradientEntry>;
@@ -630,6 +1141,21 @@ type KeymapBinding = {
630
1141
  command: CommandId; /** Forwarded as the `args` parameter to the command handler. */
631
1142
  args?: unknown; /** Higher priorities run first in the chain. Default 0. */
632
1143
  priority?: number;
1144
+ /**
1145
+ * Bypass the form-element focus guard. When `true`, this binding fires
1146
+ * even if a text input is focused (`<input>`, `<textarea>`, or any
1147
+ * `contentEditable` element).
1148
+ *
1149
+ * Default `false`. The platform's native text-editing shortcuts —
1150
+ * Cmd+A (select all), Cmd+Z/Y (input undo/redo), Cmd+C/V/X
1151
+ * (clipboard), arrow keys, Backspace, Tab, Enter — must win over
1152
+ * editor shortcuts while the user is typing.
1153
+ *
1154
+ * Opt in sparingly. Reasonable candidates: truly global app shortcuts
1155
+ * like Cmd+S (save). Unreasonable candidates: anything that has a
1156
+ * native text-editing meaning.
1157
+ */
1158
+ allowInFormElement?: boolean;
633
1159
  /**
634
1160
  * Reserved for V2; not honored by the V1 dispatcher. When added, this
635
1161
  * will be evaluated before the handler runs; if false, the binding is
@@ -675,8 +1201,9 @@ declare class Keymap {
675
1201
  * bar even when the binding's handler rejects.
676
1202
  *
677
1203
  * Pure read; runs no handlers, no side effects. Honors the same
678
- * text-input-focused guard `dispatch` uses, so a typing user's
679
- * keystroke isn't "claimed" by an unrelated unmodified key.
1204
+ * form-element focus guard `dispatch` uses, so a typing user's
1205
+ * keystroke isn't "claimed" and the browser's native text-editing
1206
+ * default (Cmd+A select all, Cmd+Z undo, etc.) wins.
680
1207
  */
681
1208
  claims(event: KeyboardEvent): boolean;
682
1209
  /**
@@ -684,6 +1211,12 @@ declare class Keymap {
684
1211
  * order. Returns `true` on the first handler that consumes; returns
685
1212
  * `false` if nothing matched or all matches fell through.
686
1213
  *
1214
+ * **Form-element focus guard.** When a text input is focused
1215
+ * (`<input>`, `<textarea>`, contentEditable), bindings are suppressed
1216
+ * by default so the platform's native shortcuts (Cmd+A, Cmd+Z, Cmd+C,
1217
+ * arrow nav, …) are preserved. A binding can opt out of this guard
1218
+ * with `allowInFormElement: true` — see `KeymapBinding`.
1219
+ *
687
1220
  * `dispatch` is browser-agnostic: it does NOT call `preventDefault()`
688
1221
  * or touch the event in any way. The host decides what to do with the
689
1222
  * platform default — typically `if (keymap.claims(e)) e.preventDefault()`,
@@ -698,7 +1231,6 @@ declare class Keymap {
698
1231
  * V1.
699
1232
  */
700
1233
  private chunkKeysFor;
701
- private has_safe_mod;
702
1234
  }
703
1235
  //#endregion
704
1236
  //#region src/core/geometry.d.ts
@@ -731,6 +1263,20 @@ interface GeometryProvider {
731
1263
  * is hit. "Topmost" is defined by the renderer's z-order.
732
1264
  */
733
1265
  node_at_point(p: Vec2): NodeId | null;
1266
+ /**
1267
+ * Re-express a **world-space** delta vector in the frame a node's
1268
+ * position attributes are written in — its parent user-space. For a
1269
+ * node under a scaled/rotated `<g>` ancestor, or inside a nested
1270
+ * `<svg>` viewport that scales its user space, the local frame differs
1271
+ * from world by that linear transform; a translate must be written in
1272
+ * the local frame so the on-screen motion matches the world delta
1273
+ * (otherwise it moves `scale ×` too far).
1274
+ *
1275
+ * Optional: only DOM-backed providers (with a real layout engine) can
1276
+ * derive the frame. Providers that omit it imply the flat-doc identity
1277
+ * (world ≡ local), and callers fall back to the raw delta.
1278
+ */
1279
+ world_delta_to_local?(id: NodeId, delta: Vec2): Vec2;
734
1280
  }
735
1281
  type GeometrySignals = {
736
1282
  /** Fires when tree shape changes (insert/remove/reorder). */subscribe_structure: (cb: () => void) => Unsubscribe; /** Fires when any bounds-affecting change occurs. */
@@ -738,7 +1284,7 @@ type GeometrySignals = {
738
1284
  };
739
1285
  /**
740
1286
  * Caches `bounds_of` results keyed on `NodeId`; full-clears on either
741
- * `structure_version` or `geometry_version` bump. See docs/wg/feat-svg-editor/geometry.md for
1287
+ * `structure_version` or `geometry_version` bump. See ../../docs/geometry.md for
742
1288
  * why the cache is load-bearing under the surface's per-tick re-render.
743
1289
  */
744
1290
  declare class MemoizedGeometryProvider implements GeometryProvider {
@@ -755,6 +1301,10 @@ declare class MemoizedGeometryProvider implements GeometryProvider {
755
1301
  */
756
1302
  nodes_in_rect(rect: Rect): NodeId[];
757
1303
  node_at_point(p: Vec2): NodeId | null;
1304
+ /** Pass-through. Frame projection depends on live layout, not on the
1305
+ * bounds cache, so there is nothing to memoize. Falls back to the raw
1306
+ * delta when the driver can't resolve a frame. */
1307
+ world_delta_to_local(id: NodeId, delta: Vec2): Vec2;
758
1308
  /** Unsubscribe from both signals. Call on surface detach. */
759
1309
  dispose(): void;
760
1310
  }
@@ -777,6 +1327,14 @@ type GestureContext = {
777
1327
  camera: Camera; /** Editor for keymap dispatch / state reads. */
778
1328
  editor: SvgEditor; /** Handle for advanced bindings (e.g. wanting `camera.fit("<selection>")`). */
779
1329
  handle: SurfaceHandle;
1330
+ /**
1331
+ * Predicate returning `true` iff the surface is currently "attended" —
1332
+ * focus inside the container subtree OR pointer over the container.
1333
+ * Gesture bindings whose keydown handlers call `preventDefault()` MUST
1334
+ * consult this before claiming, so the surface doesn't steal page-level
1335
+ * shortcuts when embedded in a larger document. See `util/attention.ts`.
1336
+ */
1337
+ is_attended: () => boolean;
780
1338
  };
781
1339
  type GestureBinding = {
782
1340
  /** Stable id used by `unbind` / `bindings()`. */id: GestureId;
@@ -835,11 +1393,41 @@ type CreateSvgEditorOptions = {
835
1393
  providers?: Providers;
836
1394
  style?: Partial<EditorStyle>;
837
1395
  };
838
- type SvgEditor = ReturnType<typeof createSvgEditor>;
1396
+ /**
1397
+ * Internal-only members the package's own surfaces reach for. NOT part of
1398
+ * the published API — `_internal` is the surface↔editor bridge, `keymap`
1399
+ * is the keymap dispatcher the DOM surface forwards keyboard events to.
1400
+ * Lives on the runtime object for in-package callers; stripped from the
1401
+ * public `SvgEditor` type so the published `.d.ts` doesn't advertise them.
1402
+ */
1403
+ type SvgEditorInternalMembers = "_internal" | "keymap";
1404
+ /** Internal handle. Use only inside `@grida/svg-editor`. */
1405
+ type SvgEditorInternal = ReturnType<typeof _create_svg_editor_internal>;
1406
+ /**
1407
+ * The published editor type. `Omit`s the in-package-only surfaces (see
1408
+ * `SvgEditorInternalMembers`) so consumers don't see them in IntelliSense
1409
+ * or the dist `.d.ts`. They still exist on the runtime object — casting
1410
+ * to `SvgEditorInternal` reaches them, but the contract is clear: stay
1411
+ * inside the package.
1412
+ */
1413
+ type SvgEditor = Omit<SvgEditorInternal, SvgEditorInternalMembers>;
1414
+ /**
1415
+ * Host-provided rendering and input boundary. v0 contract is pure
1416
+ * lifecycle: the editor calls `dispose()` on `editor.detach()` /
1417
+ * `editor.dispose()`; the surface owns its own teardown there (event
1418
+ * listeners, DOM nodes, retained refs).
1419
+ *
1420
+ * **Why so narrow?** The cross-surface vocabulary — paint push, input
1421
+ * routing, hit-testing — isn't pinned yet. The DOM surface re-serializes
1422
+ * the document via `editor.subscribe`, attaches its own listeners, and
1423
+ * owns its own pick. The editor reaches into the DOM surface through
1424
+ * the in-package `_internal` channel (`SurfaceBridge` in
1425
+ * `core/surface-bridge.ts`), not through the public `Surface` type. A
1426
+ * cross-surface paint / input / hit-test contract is deferred until a
1427
+ * second surface implementation arrives and pins each shape (P6 —
1428
+ * public only after dogfooding).
1429
+ */
839
1430
  type Surface = {
840
- paint(snapshot: unknown): void;
841
- hit_test(x: number, y: number): NodeId | null;
842
- on_input(listener: (event: unknown) => void): Unsubscribe;
843
1431
  dispose(): void;
844
1432
  };
845
1433
  type SurfaceHandle = {
@@ -1021,7 +1609,13 @@ type Commands = {
1021
1609
  invoke(id: CommandId, args?: unknown): boolean; /** Whether an id has a registered handler. */
1022
1610
  has(id: CommandId): boolean;
1023
1611
  };
1024
- declare function createSvgEditor(opts: CreateSvgEditorOptions): {
1612
+ /**
1613
+ * Wide internal factory — returns the full object including the
1614
+ * `_internal` / `keymap` surfaces in its inferred type. Stays private.
1615
+ * The public `createSvgEditor` below wraps this and narrows the return
1616
+ * to `SvgEditor` so the published `.d.ts` doesn't advertise internals.
1617
+ */
1618
+ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
1025
1619
  /**
1026
1620
  * Low-level IR handle. Mutating directly bypasses history; prefer
1027
1621
  * `editor.commands` for app code.
@@ -1110,6 +1704,19 @@ declare function createSvgEditor(opts: CreateSvgEditorOptions): {
1110
1704
  set_style: (partial: Partial<EditorStyle>) => void;
1111
1705
  load: (svg: string) => void;
1112
1706
  serialize: () => string;
1707
+ /**
1708
+ * Serialize a single element's subtree as an SVG **fragment**, using the
1709
+ * same trivia-preserving rules as {@link serialize} — for handing "the
1710
+ * markup of the element the user selected" to a downstream consumer
1711
+ * (e.g. an AI agent) without re-serializing the whole document.
1712
+ *
1713
+ * Fragment, not document (see `SvgDocument.serialize_node`): it does NOT
1714
+ * carry `serialize()`'s whole-document round-trip guarantee. Namespace
1715
+ * declarations on an ancestor (`xmlns:xlink`, normally on the root
1716
+ * `<svg>`) are NOT inlined — a node using `xlink:href` serializes without
1717
+ * `xmlns:xlink`. Throws on an unknown id or a non-element node.
1718
+ */
1719
+ serialize_node(id: NodeId): string;
1113
1720
  reset: () => void;
1114
1721
  attach: (surface: Surface) => SurfaceHandle;
1115
1722
  detach: () => void;
@@ -1118,22 +1725,33 @@ declare function createSvgEditor(opts: CreateSvgEditorOptions): {
1118
1725
  _internal: {
1119
1726
  doc: SvgDocument;
1120
1727
  history: {
1121
- preview: (label: string) => _$_grida_history0.Preview;
1728
+ preview: (label: string) => import("@grida/history").Preview;
1729
+ };
1730
+ insert_text_preview: (initial: Readonly<Record<string, string>>, opts?: {
1731
+ parent?: NodeId;
1732
+ }) => {
1733
+ id: NodeId;
1734
+ commit(): void;
1735
+ discard(): void;
1122
1736
  };
1123
1737
  emit: () => void;
1124
- /** Fires after a drag-commit (via orchestrator), `commands.nudge`, or
1125
- * `commands.translate`. The nudge-dwell watcher subscribes here. */
1126
- subscribe_translate_commit(cb: () => void): () => void; /** Drag-commit publisher; nudge/translate commands publish directly. */
1738
+ subscribe_translate_commit(cb: () => void): () => void;
1127
1739
  notify_translate_commit: () => void;
1128
1740
  set_content_edit_driver(fn: ((target: NodeId) => boolean) | null): void;
1129
- /** Fires the driver immediately with the current override so the
1130
- * surface can sync state on attach. */
1131
1741
  set_surface_hover_override_driver(fn: ((id: NodeId | null) => void) | null): void;
1132
1742
  push_surface_hover(id: NodeId | null): void;
1133
- set_computed_resolver(fn: DomComputedResolver | null): void; /** Surface registers its geometry provider on attach; clears on detach. */
1743
+ set_computed_resolver(fn: DomComputedResolver | null): void;
1134
1744
  set_geometry(p: GeometryProvider | null): void;
1135
1745
  };
1136
1746
  keymap: Keymap;
1137
1747
  };
1748
+ /**
1749
+ * Construct a headless SVG editor. The returned object is the public
1750
+ * editor surface — observation (`state`, `subscribe`), commands
1751
+ * (`commands.*`), lifecycle (`attach` / `dispose`), and the typed-read
1752
+ * caches (`node_paint`, `node_properties`). Surfaces (DOM, headless)
1753
+ * attach later via `editor.attach(surface)`.
1754
+ */
1755
+ declare function createSvgEditor(opts: CreateSvgEditorOptions): SvgEditor;
1138
1756
  //#endregion
1139
- export { GradientEntry as A, PaintPreviewSession as B, Color as C, FileIOProvider as D, EditorStyle as E, LinearGradientDefinition as F, Providers as G, PreviewSession as H, Mode as I, ReorderDirection as J, RadialGradientDefinition as K, NodeId as L, InsertPreviewSession as M, InsertableTag as N, FontResolver as O, InvalidComputedValue as P, Vec2 as Q, Paint as R, ClipboardProvider as S, EditorState as T, PropertyValue as U, PaintValue as V, Provenance as W, Tool as X, TOOL_CURSOR as Y, Unsubscribe as Z, AlignDirection as _, SelectMode as a, CameraConstraints as b, SvgEditor as c, GestureContext as d, GestureId as f, MemoizedGeometryProvider as g, GeometrySignals as h, DomComputedResolver as i, GradientStop as j, GradientDefinition as k, createSvgEditor as l, GeometryProvider as m, CreateSvgEditorOptions as n, Surface as o, Gestures as p, Rect as q, DomComputedPaint as r, SurfaceHandle as s, Commands as t, GestureBinding as u, BoundsResolver as v, DEFAULT_STYLE as w, CameraOptions as x, Camera as y, PaintFallback as z };
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 };