@grida/svg-editor 1.0.0-alpha.14 → 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
2
  import vn from "@grida/vn";
3
- import { AnyNode } from "@grida/svg/parser";
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
 
@@ -499,18 +499,76 @@ type AlignDirection = "left" | "right" | "top" | "bottom" | "horizontal_centers"
499
499
  * - All coordinates are in the element's own local space, exactly as
500
500
  * authored. No `transform=` resolution, no parent CTM, no viewport
501
501
  * remap.
502
- * - `polyline` / `polygon` points are `[x, y]` tuples so the consumer
503
- * can hand them straight to `vn.fromPolyline` / `vn.fromPolygon`.
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>`.
504
517
  */
505
518
  type VectorEditSource = {
506
519
  kind: "path";
507
520
  d: string;
521
+ } | {
522
+ kind: "line";
523
+ x1: number;
524
+ y1: number;
525
+ x2: number;
526
+ y2: number;
508
527
  } | {
509
528
  kind: "polyline";
510
529
  points: ReadonlyArray<readonly [number, number]>;
511
530
  } | {
512
531
  kind: "polygon";
513
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;
514
572
  };
515
573
  interface DocumentEvents {
516
574
  /** Fires after any structural mutation. */
@@ -629,24 +687,74 @@ declare class SvgDocument implements DocumentEvents {
629
687
  * Returns a tag-discriminated snapshot of the authored geometry attrs
630
688
  * if this node is eligible for vector (vertex) editing — else `null`.
631
689
  *
632
- * v1 eligibility:
690
+ * Eligibility:
633
691
  * - `<path>` — requires non-empty `d`.
692
+ * - `<line>` — requires two distinct finite user-unit endpoints.
634
693
  * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
635
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`.
636
708
  *
637
- * Deliberately rejects `<line>` in v1: the only useful vertex-edit
638
- * gestures on a `<line>` are (a) introducing a new vertex (which would
639
- * have to promote it to `<polyline>`) and (b) bending it with a tangent
640
- * (which would have to promote it to `<path>`). Both promotions are
641
- * out of scope for v1, so opening a `<line>` in vector-edit mode would
642
- * advertise capabilities that don't work.
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.
643
712
  *
644
- * Also rejects `<rect>`, `<circle>`, `<ellipse>`, `<image>`, `<use>`
645
- * those would force the same promotion-to-`<path>` machinery (trivia
646
- * transfer, cross-cutting attr carry, DOM-element swap, history-bracket
647
- * changes) that v1 keeps out of scope.
713
+ * Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
714
+ * editable outline).
648
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;
649
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;
650
758
  /**
651
759
  * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
652
760
  * per-glyph attribute (which conflicts with element-level rotation).
@@ -674,6 +782,31 @@ declare class SvgDocument implements DocumentEvents {
674
782
  ns?: string | null;
675
783
  }): NodeId;
676
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;
677
810
  private emit_node;
678
811
  private emit_attr;
679
812
  }
@@ -795,20 +928,26 @@ declare class PathModel {
795
928
  *
796
929
  * - **path** — always `null` (no native fallback; the canonical form
797
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.)
798
934
  * - **polyline** — segments form the canonical open chain
799
935
  * `0→1, 1→2, …, (n-2)→(n-1)`. (Topology after `vn.fromPolyline` and
800
936
  * any sequence of vertex translates.)
801
937
  * - **polygon** — segments form the canonical closed chain
802
938
  * `0→1, 1→2, …, (n-1)→0`. (Topology after `vn.fromPolygon` and any
803
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.
804
944
  *
805
945
  * Anything that changes segment topology (insert-vertex, delete-vertex,
806
- * close/open shape) leaves the canonical chain and returns `null` here;
807
- * the higher layer is responsible for routing those to tag-promotion
808
- * (intra-Vertex or to-path).
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>`.
809
948
  */
810
- toNativeAttrs(source_tag: VectorEditSource["kind"]): Exclude<VectorEditSource, {
811
- kind: "path";
949
+ toNativeAttrs(source_tag: VectorEditSource["kind"]): Extract<VectorEditSource, {
950
+ kind: "line" | "polyline" | "polygon";
812
951
  }> | null;
813
952
  /** Translate one vertex by `delta`. Connected segments follow because
814
953
  * tangents are stored relative to vertices. Verb metadata is preserved
@@ -1124,6 +1263,20 @@ interface GeometryProvider {
1124
1263
  * is hit. "Topmost" is defined by the renderer's z-order.
1125
1264
  */
1126
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;
1127
1280
  }
1128
1281
  type GeometrySignals = {
1129
1282
  /** Fires when tree shape changes (insert/remove/reorder). */subscribe_structure: (cb: () => void) => Unsubscribe; /** Fires when any bounds-affecting change occurs. */
@@ -1148,6 +1301,10 @@ declare class MemoizedGeometryProvider implements GeometryProvider {
1148
1301
  */
1149
1302
  nodes_in_rect(rect: Rect): NodeId[];
1150
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;
1151
1308
  /** Unsubscribe from both signals. Call on surface detach. */
1152
1309
  dispose(): void;
1153
1310
  }
@@ -1547,6 +1704,19 @@ declare function _create_svg_editor_internal(opts: CreateSvgEditorOptions): {
1547
1704
  set_style: (partial: Partial<EditorStyle>) => void;
1548
1705
  load: (svg: string) => void;
1549
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;
1550
1720
  reset: () => void;
1551
1721
  attach: (surface: Surface) => SurfaceHandle;
1552
1722
  detach: () => void;
@@ -1,4 +1,4 @@
1
- import { _ as is_text_input_focused, a as paint, g as array_shallow_equal, h as group, i as TOOL_CURSOR, m as transform, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline } from "./model-DIzZmeyf.mjs";
1
+ import { _ as is_text_input_focused, a as paint, g as array_shallow_equal, h as group, i as TOOL_CURSOR, m as transform, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline } from "./model-B2UWgViT.mjs";
2
2
  import { HistoryImpl } from "@grida/history";
3
3
  import { KeyCode, M, chunkKey, eventToChunk, getKeyboardOS, kb, keybindingsToKeyCodes } from "@grida/keybinding";
4
4
  import cmath from "@grida/cmath";
@@ -756,6 +756,55 @@ function create_defs(doc) {
756
756
  }
757
757
  //#endregion
758
758
  //#region src/core/document.ts
759
+ /** The native vector tags `retype_to_path` can re-type, keyed by tag → the
760
+ * native geometry attributes it consumes (so no orphaned geometry attr
761
+ * survives on the resulting `<path>`). Covers the geometry primitives
762
+ * (rect / circle / ellipse — always re-typed) and the vertex tags (line /
763
+ * polyline / polygon — re-typed only when an edit escapes their native
764
+ * form). */
765
+ const RETYPABLE_GEOMETRY_ATTRS = {
766
+ line: new Set([
767
+ "x1",
768
+ "y1",
769
+ "x2",
770
+ "y2"
771
+ ]),
772
+ polyline: new Set(["points"]),
773
+ polygon: new Set(["points"]),
774
+ rect: new Set([
775
+ "x",
776
+ "y",
777
+ "width",
778
+ "height",
779
+ "rx",
780
+ "ry"
781
+ ]),
782
+ circle: new Set([
783
+ "cx",
784
+ "cy",
785
+ "r"
786
+ ]),
787
+ ellipse: new Set([
788
+ "cx",
789
+ "cy",
790
+ "rx",
791
+ "ry"
792
+ ])
793
+ };
794
+ /**
795
+ * Parse a single SVG length attribute as a plain user-unit number. Returns
796
+ * `null` for absent, non-finite, or unit/percentage values (`50%`, `5px`,
797
+ * `5em`) — those are an out-of-scope geometry gap, and refusing them here
798
+ * means the editor never offers a promotion it cannot perform faithfully.
799
+ */
800
+ function parse_user_unit(raw) {
801
+ if (raw === null) return null;
802
+ const s = raw.trim();
803
+ if (s === "") return null;
804
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
805
+ const n = Number(s);
806
+ return Number.isFinite(n) ? n : null;
807
+ }
759
808
  /**
760
809
  * Attribute names whose writes can shift a node's rendered bounds.
761
810
  * Membership drives `_geometry_version` bumps in `set_attr`. Only
@@ -1073,26 +1122,53 @@ var SvgDocument = class SvgDocument {
1073
1122
  * Returns a tag-discriminated snapshot of the authored geometry attrs
1074
1123
  * if this node is eligible for vector (vertex) editing — else `null`.
1075
1124
  *
1076
- * v1 eligibility:
1125
+ * Eligibility:
1077
1126
  * - `<path>` — requires non-empty `d`.
1127
+ * - `<line>` — requires two distinct finite user-unit endpoints.
1078
1128
  * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
1079
1129
  * - `<polygon>` — same as polyline.
1130
+ * - `<rect>` — requires finite user-unit `width`/`height` > 0.
1131
+ * - `<circle>` — requires finite user-unit `r` > 0.
1132
+ * - `<ellipse>` — requires finite user-unit `rx`/`ry` > 0.
1133
+ *
1134
+ * The vertex tags (`line` / `polyline` / `polygon`) write edits back to
1135
+ * their native attributes while the geometry stays expressible there; an
1136
+ * edit that escapes the native form (a curve, or a topology change that
1137
+ * leaves the canonical chain) re-types the element to `<path>`. The
1138
+ * geometry primitives (`rect` / `circle` / `ellipse`) have no native
1139
+ * vector form, so any vector edit re-types them. In all cases the native
1140
+ * tag is preserved byte-for-byte until the first re-typing edit commits
1141
+ * (see `retype_to_path`). Design:
1142
+ * `docs/wg/feat-svg-editor/promote-to-path.md`.
1080
1143
  *
1081
- * Deliberately rejects `<line>` in v1: the only useful vertex-edit
1082
- * gestures on a `<line>` are (a) introducing a new vertex (which would
1083
- * have to promote it to `<polyline>`) and (b) bending it with a tangent
1084
- * (which would have to promote it to `<path>`). Both promotions are
1085
- * out of scope for v1, so opening a `<line>` in vector-edit mode would
1086
- * advertise capabilities that don't work.
1144
+ * Geometry that is not a plain user-unit number (`%`, `px`, `em`, …) is
1145
+ * an out-of-scope gap, so such an element returns `null` rather than
1146
+ * advertising an edit the editor cannot perform faithfully.
1087
1147
  *
1088
- * Also rejects `<rect>`, `<circle>`, `<ellipse>`, `<image>`, `<use>`
1089
- * those would force the same promotion-to-`<path>` machinery (trivia
1090
- * transfer, cross-cutting attr carry, DOM-element swap, history-bracket
1091
- * changes) that v1 keeps out of scope.
1148
+ * Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
1149
+ * editable outline).
1092
1150
  */
1151
+ /**
1152
+ * Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
1153
+ * endpoints). An **absent** attribute takes the SVG default (`0`); a
1154
+ * **present** attribute that is not a plain user-unit number (`%`, `px`,
1155
+ * `em`, …) is out of scope and yields `null` so the caller refuses the
1156
+ * element — the same gate required attrs (width / radius) already apply.
1157
+ *
1158
+ * The absent-vs-present distinction is the point: a bare `?? 0` would
1159
+ * silently coerce an authored `x1="5px"` to `0`, then the first native
1160
+ * writeback would overwrite that authored value. Refusing keeps the
1161
+ * editor from misrepresenting geometry it cannot read faithfully.
1162
+ */
1163
+ optional_user_unit_coord(id, name) {
1164
+ const raw = this.get_attr(id, name);
1165
+ if (raw === null) return 0;
1166
+ return parse_user_unit(raw);
1167
+ }
1093
1168
  is_vector_edit_target(id) {
1094
1169
  const n = this.nodes.get(id);
1095
1170
  if (!n || n.kind !== "element") return null;
1171
+ if (RETYPABLE_GEOMETRY_ATTRS[n.local] && n.attrs.some((a) => a.prefix === null && a.ns === null && a.local === "d")) return null;
1096
1172
  switch (n.local) {
1097
1173
  case "path": {
1098
1174
  const d = this.get_attr(id, "d");
@@ -1102,6 +1178,21 @@ var SvgDocument = class SvgDocument {
1102
1178
  d
1103
1179
  };
1104
1180
  }
1181
+ case "line": {
1182
+ const x1 = this.optional_user_unit_coord(id, "x1");
1183
+ const y1 = this.optional_user_unit_coord(id, "y1");
1184
+ const x2 = this.optional_user_unit_coord(id, "x2");
1185
+ const y2 = this.optional_user_unit_coord(id, "y2");
1186
+ if (x1 === null || y1 === null || x2 === null || y2 === null) return null;
1187
+ if (x1 === x2 && y1 === y2) return null;
1188
+ return {
1189
+ kind: "line",
1190
+ x1,
1191
+ y1,
1192
+ x2,
1193
+ y2
1194
+ };
1195
+ }
1105
1196
  case "polyline":
1106
1197
  case "polygon": {
1107
1198
  const raw = this.get_attr(id, "points") ?? "";
@@ -1116,10 +1207,172 @@ var SvgDocument = class SvgDocument {
1116
1207
  points
1117
1208
  };
1118
1209
  }
1210
+ case "rect": {
1211
+ const x = this.optional_user_unit_coord(id, "x");
1212
+ const y = this.optional_user_unit_coord(id, "y");
1213
+ if (x === null || y === null) return null;
1214
+ const width = parse_user_unit(this.get_attr(id, "width"));
1215
+ const height = parse_user_unit(this.get_attr(id, "height"));
1216
+ if (width === null || height === null) return null;
1217
+ if (width <= 0 || height <= 0) return null;
1218
+ const rx_attr = this.get_attr(id, "rx");
1219
+ const ry_attr = this.get_attr(id, "ry");
1220
+ const rx_parsed = rx_attr === null ? null : parse_user_unit(rx_attr);
1221
+ const ry_parsed = ry_attr === null ? null : parse_user_unit(ry_attr);
1222
+ if (rx_attr !== null && rx_parsed === null) return null;
1223
+ if (ry_attr !== null && ry_parsed === null) return null;
1224
+ let rx = rx_parsed ?? ry_parsed ?? 0;
1225
+ let ry = ry_parsed ?? rx_parsed ?? 0;
1226
+ rx = Math.max(0, Math.min(rx, width / 2));
1227
+ ry = Math.max(0, Math.min(ry, height / 2));
1228
+ return {
1229
+ kind: "rect",
1230
+ x,
1231
+ y,
1232
+ width,
1233
+ height,
1234
+ rx,
1235
+ ry
1236
+ };
1237
+ }
1238
+ case "circle": {
1239
+ const cx = this.optional_user_unit_coord(id, "cx");
1240
+ const cy = this.optional_user_unit_coord(id, "cy");
1241
+ if (cx === null || cy === null) return null;
1242
+ const r = parse_user_unit(this.get_attr(id, "r"));
1243
+ if (r === null || r <= 0) return null;
1244
+ return {
1245
+ kind: "circle",
1246
+ cx,
1247
+ cy,
1248
+ r
1249
+ };
1250
+ }
1251
+ case "ellipse": {
1252
+ const cx = this.optional_user_unit_coord(id, "cx");
1253
+ const cy = this.optional_user_unit_coord(id, "cy");
1254
+ if (cx === null || cy === null) return null;
1255
+ const rx = parse_user_unit(this.get_attr(id, "rx"));
1256
+ const ry = parse_user_unit(this.get_attr(id, "ry"));
1257
+ if (rx === null || ry === null) return null;
1258
+ if (rx <= 0 || ry <= 0) return null;
1259
+ return {
1260
+ kind: "ellipse",
1261
+ cx,
1262
+ cy,
1263
+ rx,
1264
+ ry
1265
+ };
1266
+ }
1119
1267
  default: return null;
1120
1268
  }
1121
1269
  }
1122
1270
  /**
1271
+ * Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
1272
+ * `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
1273
+ * its native geometry attributes and setting `d`. A structural mutation:
1274
+ * this layer executes the re-type; it does not decide when one is
1275
+ * warranted.
1276
+ *
1277
+ * Idempotent: returns `null` if `id` is not currently one of those tags
1278
+ * (so it is safe to call repeatedly — once re-typed, e.g. already a
1279
+ * `<path>`, further calls are no-ops). Otherwise mutates the node and
1280
+ * returns an opaque {@link RetypeRecord} reversal token.
1281
+ *
1282
+ * Identity, children, `self_closing`, non-geometry attributes, and all
1283
+ * source trivia are preserved unchanged — only the tag and the geometry
1284
+ * attributes move. Pass the token to {@link revert_retype} to restore
1285
+ * the original primitive byte-for-byte.
1286
+ *
1287
+ * (see test/svg-editor-vector-promote-to-path.md)
1288
+ */
1289
+ retype_to_path(id, d) {
1290
+ const n = this.nodes.get(id);
1291
+ if (!n || n.kind !== "element") return null;
1292
+ const geom = RETYPABLE_GEOMETRY_ATTRS[n.local];
1293
+ if (!geom) return null;
1294
+ const prev_local = n.local;
1295
+ const prev_raw_tag = n.raw_tag;
1296
+ const removed = [];
1297
+ for (let i = n.attrs.length - 1; i >= 0; i--) {
1298
+ const a = n.attrs[i];
1299
+ if (a.prefix === null && a.ns === null && geom.has(a.local)) {
1300
+ removed.push({
1301
+ index: i,
1302
+ token: a
1303
+ });
1304
+ n.attrs.splice(i, 1);
1305
+ }
1306
+ }
1307
+ removed.reverse();
1308
+ n.local = "path";
1309
+ n.raw_tag = n.prefix ? `${n.prefix}:path` : "path";
1310
+ n.attrs.push({
1311
+ raw_name: "d",
1312
+ prefix: null,
1313
+ local: "d",
1314
+ ns: null,
1315
+ value: d,
1316
+ pre: " ",
1317
+ eq_trivia: "",
1318
+ quote: "\""
1319
+ });
1320
+ let added_fill_none = false;
1321
+ if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
1322
+ n.attrs.push({
1323
+ raw_name: "fill",
1324
+ prefix: null,
1325
+ local: "fill",
1326
+ ns: null,
1327
+ value: "none",
1328
+ pre: " ",
1329
+ eq_trivia: "",
1330
+ quote: "\""
1331
+ });
1332
+ added_fill_none = true;
1333
+ }
1334
+ this._structure_version++;
1335
+ this._geometry_version++;
1336
+ this.emit();
1337
+ return {
1338
+ prev_local,
1339
+ prev_raw_tag,
1340
+ removed,
1341
+ added_fill_none
1342
+ };
1343
+ }
1344
+ /**
1345
+ * Reverse a {@link retype_to_path}: restore the original tag, remove the
1346
+ * `d` attribute the promotion added, and splice the captured geometry
1347
+ * attribute tokens back at their original positions (preserving their
1348
+ * trivia, so a later `serialize()` is byte-equal to the pre-promotion
1349
+ * source).
1350
+ */
1351
+ revert_retype(id, token) {
1352
+ const n = this.nodes.get(id);
1353
+ if (!n || n.kind !== "element") return;
1354
+ for (let i = n.attrs.length - 1; i >= 0; i--) {
1355
+ const a = n.attrs[i];
1356
+ if (a.prefix === null && a.ns === null && a.local === "d") {
1357
+ n.attrs.splice(i, 1);
1358
+ break;
1359
+ }
1360
+ }
1361
+ if (token.added_fill_none) for (let i = n.attrs.length - 1; i >= 0; i--) {
1362
+ const a = n.attrs[i];
1363
+ if (a.prefix === null && a.ns === null && a.local === "fill") {
1364
+ n.attrs.splice(i, 1);
1365
+ break;
1366
+ }
1367
+ }
1368
+ n.local = token.prev_local;
1369
+ n.raw_tag = token.prev_raw_tag;
1370
+ for (const { index, token: t } of token.removed) n.attrs.splice(index, 0, t);
1371
+ this._structure_version++;
1372
+ this._geometry_version++;
1373
+ this.emit();
1374
+ }
1375
+ /**
1123
1376
  * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
1124
1377
  * per-glyph attribute (which conflicts with element-level rotation).
1125
1378
  */
@@ -1242,6 +1495,37 @@ var SvgDocument = class SvgDocument {
1242
1495
  for (const e of this.epilog) out += this.emit_node(e);
1243
1496
  return out;
1244
1497
  }
1498
+ /**
1499
+ * Serialize a single element's subtree as an SVG **fragment**, using the
1500
+ * same trivia-preserving rules as {@link serialize} (attribute order,
1501
+ * quote style, whitespace, comments — emitted exactly as authored).
1502
+ *
1503
+ * This is NOT {@link serialize} scoped to a node — it is a deliberately
1504
+ * weaker output (sdk-design D3, asymmetric outputs stay separate):
1505
+ *
1506
+ * - `serialize()` emits the whole document and carries the P1
1507
+ * whole-document round-trip guarantee.
1508
+ * - `serialize_node()` emits a fragment and does NOT. Namespace
1509
+ * declarations that live on an ancestor (`xmlns:xlink` and friends,
1510
+ * normally on the root `<svg>`) are NOT inlined — a node using
1511
+ * `xlink:href` serializes without `xmlns:xlink`. The fragment is the
1512
+ * element's markup as authored, not a standalone parseable document.
1513
+ *
1514
+ * Throws on an unknown id, a non-element node, or a node detached from
1515
+ * the live tree: the contract is "the markup for a selected element,"
1516
+ * selections are always live elements, and a string return of `""` for a
1517
+ * bad id would hide consumer bugs. The detached case matters because
1518
+ * `remove()` keeps the node in the id map for undo — a stale id from a
1519
+ * removed node would otherwise serialize content no longer in the
1520
+ * document, silently feeding a consumer deleted markup.
1521
+ */
1522
+ serialize_node(id) {
1523
+ const n = this.nodes.get(id);
1524
+ if (!n) throw new Error(`serialize_node: unknown node id ${JSON.stringify(id)}`);
1525
+ if (n.kind !== "element") throw new Error(`serialize_node: node ${JSON.stringify(id)} is a ${n.kind} node, not an element`);
1526
+ if (!this.contains(this.root, id)) throw new Error(`serialize_node: node ${JSON.stringify(id)} is detached from the current document`);
1527
+ return this.emit_node(n);
1528
+ }
1245
1529
  emit_node(n) {
1246
1530
  switch (n.kind) {
1247
1531
  case "text": return encode_text(n.value);
@@ -1845,7 +2129,8 @@ function _create_svg_editor_internal(opts) {
1845
2129
  snap_threshold_px: style.snap_threshold_px
1846
2130
  },
1847
2131
  emit,
1848
- stages
2132
+ stages,
2133
+ project: (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d
1849
2134
  });
1850
2135
  apply();
1851
2136
  history.atomic(label, (tx) => {
@@ -2691,6 +2976,21 @@ function _create_svg_editor_internal(opts) {
2691
2976
  set_style,
2692
2977
  load,
2693
2978
  serialize,
2979
+ /**
2980
+ * Serialize a single element's subtree as an SVG **fragment**, using the
2981
+ * same trivia-preserving rules as {@link serialize} — for handing "the
2982
+ * markup of the element the user selected" to a downstream consumer
2983
+ * (e.g. an AI agent) without re-serializing the whole document.
2984
+ *
2985
+ * Fragment, not document (see `SvgDocument.serialize_node`): it does NOT
2986
+ * carry `serialize()`'s whole-document round-trip guarantee. Namespace
2987
+ * declarations on an ancestor (`xmlns:xlink`, normally on the root
2988
+ * `<svg>`) are NOT inlined — a node using `xlink:href` serializes without
2989
+ * `xmlns:xlink`. Throws on an unknown id or a non-element node.
2990
+ */
2991
+ serialize_node(id) {
2992
+ return doc.serialize_node(id);
2993
+ },
2694
2994
  reset,
2695
2995
  attach,
2696
2996
  detach,