@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 { Keybinding, Platform } from "@grida/keybinding";
2
2
  import cmath from "@grida/cmath";
3
- import { AnyNode } from "@grida/svg/parser";
3
+ import { AnyNode, AttrToken } from "@grida/svg/parser";
4
4
  import vn from "@grida/vn";
5
5
  import { SelectMode } from "@grida/hud";
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$1) => SurfaceHandle;
1552
1722
  detach: () => void;
@@ -1,4 +1,4 @@
1
- const require_model = require("./model-DqGqV1H4.js");
1
+ const require_model = require("./model-CJ1Ctq14.js");
2
2
  let _grida_history = require("@grida/history");
3
3
  let _grida_keybinding = require("@grida/keybinding");
4
4
  let _grida_cmath = require("@grida/cmath");
@@ -757,6 +757,55 @@ function create_defs(doc) {
757
757
  }
758
758
  //#endregion
759
759
  //#region src/core/document.ts
760
+ /** The native vector tags `retype_to_path` can re-type, keyed by tag → the
761
+ * native geometry attributes it consumes (so no orphaned geometry attr
762
+ * survives on the resulting `<path>`). Covers the geometry primitives
763
+ * (rect / circle / ellipse — always re-typed) and the vertex tags (line /
764
+ * polyline / polygon — re-typed only when an edit escapes their native
765
+ * form). */
766
+ const RETYPABLE_GEOMETRY_ATTRS = {
767
+ line: new Set([
768
+ "x1",
769
+ "y1",
770
+ "x2",
771
+ "y2"
772
+ ]),
773
+ polyline: new Set(["points"]),
774
+ polygon: new Set(["points"]),
775
+ rect: new Set([
776
+ "x",
777
+ "y",
778
+ "width",
779
+ "height",
780
+ "rx",
781
+ "ry"
782
+ ]),
783
+ circle: new Set([
784
+ "cx",
785
+ "cy",
786
+ "r"
787
+ ]),
788
+ ellipse: new Set([
789
+ "cx",
790
+ "cy",
791
+ "rx",
792
+ "ry"
793
+ ])
794
+ };
795
+ /**
796
+ * Parse a single SVG length attribute as a plain user-unit number. Returns
797
+ * `null` for absent, non-finite, or unit/percentage values (`50%`, `5px`,
798
+ * `5em`) — those are an out-of-scope geometry gap, and refusing them here
799
+ * means the editor never offers a promotion it cannot perform faithfully.
800
+ */
801
+ function parse_user_unit(raw) {
802
+ if (raw === null) return null;
803
+ const s = raw.trim();
804
+ if (s === "") return null;
805
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
806
+ const n = Number(s);
807
+ return Number.isFinite(n) ? n : null;
808
+ }
760
809
  /**
761
810
  * Attribute names whose writes can shift a node's rendered bounds.
762
811
  * Membership drives `_geometry_version` bumps in `set_attr`. Only
@@ -1074,26 +1123,53 @@ var SvgDocument = class SvgDocument {
1074
1123
  * Returns a tag-discriminated snapshot of the authored geometry attrs
1075
1124
  * if this node is eligible for vector (vertex) editing — else `null`.
1076
1125
  *
1077
- * v1 eligibility:
1126
+ * Eligibility:
1078
1127
  * - `<path>` — requires non-empty `d`.
1128
+ * - `<line>` — requires two distinct finite user-unit endpoints.
1079
1129
  * - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
1080
1130
  * - `<polygon>` — same as polyline.
1131
+ * - `<rect>` — requires finite user-unit `width`/`height` > 0.
1132
+ * - `<circle>` — requires finite user-unit `r` > 0.
1133
+ * - `<ellipse>` — requires finite user-unit `rx`/`ry` > 0.
1134
+ *
1135
+ * The vertex tags (`line` / `polyline` / `polygon`) write edits back to
1136
+ * their native attributes while the geometry stays expressible there; an
1137
+ * edit that escapes the native form (a curve, or a topology change that
1138
+ * leaves the canonical chain) re-types the element to `<path>`. The
1139
+ * geometry primitives (`rect` / `circle` / `ellipse`) have no native
1140
+ * vector form, so any vector edit re-types them. In all cases the native
1141
+ * tag is preserved byte-for-byte until the first re-typing edit commits
1142
+ * (see `retype_to_path`). Design:
1143
+ * `docs/wg/feat-svg-editor/promote-to-path.md`.
1081
1144
  *
1082
- * Deliberately rejects `<line>` in v1: the only useful vertex-edit
1083
- * gestures on a `<line>` are (a) introducing a new vertex (which would
1084
- * have to promote it to `<polyline>`) and (b) bending it with a tangent
1085
- * (which would have to promote it to `<path>`). Both promotions are
1086
- * out of scope for v1, so opening a `<line>` in vector-edit mode would
1087
- * advertise capabilities that don't work.
1145
+ * Geometry that is not a plain user-unit number (`%`, `px`, `em`, …) is
1146
+ * an out-of-scope gap, so such an element returns `null` rather than
1147
+ * advertising an edit the editor cannot perform faithfully.
1088
1148
  *
1089
- * Also rejects `<rect>`, `<circle>`, `<ellipse>`, `<image>`, `<use>`
1090
- * those would force the same promotion-to-`<path>` machinery (trivia
1091
- * transfer, cross-cutting attr carry, DOM-element swap, history-bracket
1092
- * changes) that v1 keeps out of scope.
1149
+ * Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
1150
+ * editable outline).
1093
1151
  */
1152
+ /**
1153
+ * Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
1154
+ * endpoints). An **absent** attribute takes the SVG default (`0`); a
1155
+ * **present** attribute that is not a plain user-unit number (`%`, `px`,
1156
+ * `em`, …) is out of scope and yields `null` so the caller refuses the
1157
+ * element — the same gate required attrs (width / radius) already apply.
1158
+ *
1159
+ * The absent-vs-present distinction is the point: a bare `?? 0` would
1160
+ * silently coerce an authored `x1="5px"` to `0`, then the first native
1161
+ * writeback would overwrite that authored value. Refusing keeps the
1162
+ * editor from misrepresenting geometry it cannot read faithfully.
1163
+ */
1164
+ optional_user_unit_coord(id, name) {
1165
+ const raw = this.get_attr(id, name);
1166
+ if (raw === null) return 0;
1167
+ return parse_user_unit(raw);
1168
+ }
1094
1169
  is_vector_edit_target(id) {
1095
1170
  const n = this.nodes.get(id);
1096
1171
  if (!n || n.kind !== "element") return null;
1172
+ if (RETYPABLE_GEOMETRY_ATTRS[n.local] && n.attrs.some((a) => a.prefix === null && a.ns === null && a.local === "d")) return null;
1097
1173
  switch (n.local) {
1098
1174
  case "path": {
1099
1175
  const d = this.get_attr(id, "d");
@@ -1103,6 +1179,21 @@ var SvgDocument = class SvgDocument {
1103
1179
  d
1104
1180
  };
1105
1181
  }
1182
+ case "line": {
1183
+ const x1 = this.optional_user_unit_coord(id, "x1");
1184
+ const y1 = this.optional_user_unit_coord(id, "y1");
1185
+ const x2 = this.optional_user_unit_coord(id, "x2");
1186
+ const y2 = this.optional_user_unit_coord(id, "y2");
1187
+ if (x1 === null || y1 === null || x2 === null || y2 === null) return null;
1188
+ if (x1 === x2 && y1 === y2) return null;
1189
+ return {
1190
+ kind: "line",
1191
+ x1,
1192
+ y1,
1193
+ x2,
1194
+ y2
1195
+ };
1196
+ }
1106
1197
  case "polyline":
1107
1198
  case "polygon": {
1108
1199
  const raw = this.get_attr(id, "points") ?? "";
@@ -1117,10 +1208,172 @@ var SvgDocument = class SvgDocument {
1117
1208
  points
1118
1209
  };
1119
1210
  }
1211
+ case "rect": {
1212
+ const x = this.optional_user_unit_coord(id, "x");
1213
+ const y = this.optional_user_unit_coord(id, "y");
1214
+ if (x === null || y === null) return null;
1215
+ const width = parse_user_unit(this.get_attr(id, "width"));
1216
+ const height = parse_user_unit(this.get_attr(id, "height"));
1217
+ if (width === null || height === null) return null;
1218
+ if (width <= 0 || height <= 0) return null;
1219
+ const rx_attr = this.get_attr(id, "rx");
1220
+ const ry_attr = this.get_attr(id, "ry");
1221
+ const rx_parsed = rx_attr === null ? null : parse_user_unit(rx_attr);
1222
+ const ry_parsed = ry_attr === null ? null : parse_user_unit(ry_attr);
1223
+ if (rx_attr !== null && rx_parsed === null) return null;
1224
+ if (ry_attr !== null && ry_parsed === null) return null;
1225
+ let rx = rx_parsed ?? ry_parsed ?? 0;
1226
+ let ry = ry_parsed ?? rx_parsed ?? 0;
1227
+ rx = Math.max(0, Math.min(rx, width / 2));
1228
+ ry = Math.max(0, Math.min(ry, height / 2));
1229
+ return {
1230
+ kind: "rect",
1231
+ x,
1232
+ y,
1233
+ width,
1234
+ height,
1235
+ rx,
1236
+ ry
1237
+ };
1238
+ }
1239
+ case "circle": {
1240
+ const cx = this.optional_user_unit_coord(id, "cx");
1241
+ const cy = this.optional_user_unit_coord(id, "cy");
1242
+ if (cx === null || cy === null) return null;
1243
+ const r = parse_user_unit(this.get_attr(id, "r"));
1244
+ if (r === null || r <= 0) return null;
1245
+ return {
1246
+ kind: "circle",
1247
+ cx,
1248
+ cy,
1249
+ r
1250
+ };
1251
+ }
1252
+ case "ellipse": {
1253
+ const cx = this.optional_user_unit_coord(id, "cx");
1254
+ const cy = this.optional_user_unit_coord(id, "cy");
1255
+ if (cx === null || cy === null) return null;
1256
+ const rx = parse_user_unit(this.get_attr(id, "rx"));
1257
+ const ry = parse_user_unit(this.get_attr(id, "ry"));
1258
+ if (rx === null || ry === null) return null;
1259
+ if (rx <= 0 || ry <= 0) return null;
1260
+ return {
1261
+ kind: "ellipse",
1262
+ cx,
1263
+ cy,
1264
+ rx,
1265
+ ry
1266
+ };
1267
+ }
1120
1268
  default: return null;
1121
1269
  }
1122
1270
  }
1123
1271
  /**
1272
+ * Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
1273
+ * `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
1274
+ * its native geometry attributes and setting `d`. A structural mutation:
1275
+ * this layer executes the re-type; it does not decide when one is
1276
+ * warranted.
1277
+ *
1278
+ * Idempotent: returns `null` if `id` is not currently one of those tags
1279
+ * (so it is safe to call repeatedly — once re-typed, e.g. already a
1280
+ * `<path>`, further calls are no-ops). Otherwise mutates the node and
1281
+ * returns an opaque {@link RetypeRecord} reversal token.
1282
+ *
1283
+ * Identity, children, `self_closing`, non-geometry attributes, and all
1284
+ * source trivia are preserved unchanged — only the tag and the geometry
1285
+ * attributes move. Pass the token to {@link revert_retype} to restore
1286
+ * the original primitive byte-for-byte.
1287
+ *
1288
+ * (see test/svg-editor-vector-promote-to-path.md)
1289
+ */
1290
+ retype_to_path(id, d) {
1291
+ const n = this.nodes.get(id);
1292
+ if (!n || n.kind !== "element") return null;
1293
+ const geom = RETYPABLE_GEOMETRY_ATTRS[n.local];
1294
+ if (!geom) return null;
1295
+ const prev_local = n.local;
1296
+ const prev_raw_tag = n.raw_tag;
1297
+ const removed = [];
1298
+ for (let i = n.attrs.length - 1; i >= 0; i--) {
1299
+ const a = n.attrs[i];
1300
+ if (a.prefix === null && a.ns === null && geom.has(a.local)) {
1301
+ removed.push({
1302
+ index: i,
1303
+ token: a
1304
+ });
1305
+ n.attrs.splice(i, 1);
1306
+ }
1307
+ }
1308
+ removed.reverse();
1309
+ n.local = "path";
1310
+ n.raw_tag = n.prefix ? `${n.prefix}:path` : "path";
1311
+ n.attrs.push({
1312
+ raw_name: "d",
1313
+ prefix: null,
1314
+ local: "d",
1315
+ ns: null,
1316
+ value: d,
1317
+ pre: " ",
1318
+ eq_trivia: "",
1319
+ quote: "\""
1320
+ });
1321
+ let added_fill_none = false;
1322
+ if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
1323
+ n.attrs.push({
1324
+ raw_name: "fill",
1325
+ prefix: null,
1326
+ local: "fill",
1327
+ ns: null,
1328
+ value: "none",
1329
+ pre: " ",
1330
+ eq_trivia: "",
1331
+ quote: "\""
1332
+ });
1333
+ added_fill_none = true;
1334
+ }
1335
+ this._structure_version++;
1336
+ this._geometry_version++;
1337
+ this.emit();
1338
+ return {
1339
+ prev_local,
1340
+ prev_raw_tag,
1341
+ removed,
1342
+ added_fill_none
1343
+ };
1344
+ }
1345
+ /**
1346
+ * Reverse a {@link retype_to_path}: restore the original tag, remove the
1347
+ * `d` attribute the promotion added, and splice the captured geometry
1348
+ * attribute tokens back at their original positions (preserving their
1349
+ * trivia, so a later `serialize()` is byte-equal to the pre-promotion
1350
+ * source).
1351
+ */
1352
+ revert_retype(id, token) {
1353
+ const n = this.nodes.get(id);
1354
+ if (!n || n.kind !== "element") return;
1355
+ for (let i = n.attrs.length - 1; i >= 0; i--) {
1356
+ const a = n.attrs[i];
1357
+ if (a.prefix === null && a.ns === null && a.local === "d") {
1358
+ n.attrs.splice(i, 1);
1359
+ break;
1360
+ }
1361
+ }
1362
+ if (token.added_fill_none) for (let i = n.attrs.length - 1; i >= 0; i--) {
1363
+ const a = n.attrs[i];
1364
+ if (a.prefix === null && a.ns === null && a.local === "fill") {
1365
+ n.attrs.splice(i, 1);
1366
+ break;
1367
+ }
1368
+ }
1369
+ n.local = token.prev_local;
1370
+ n.raw_tag = token.prev_raw_tag;
1371
+ for (const { index, token: t } of token.removed) n.attrs.splice(index, 0, t);
1372
+ this._structure_version++;
1373
+ this._geometry_version++;
1374
+ this.emit();
1375
+ }
1376
+ /**
1124
1377
  * True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
1125
1378
  * per-glyph attribute (which conflicts with element-level rotation).
1126
1379
  */
@@ -1243,6 +1496,37 @@ var SvgDocument = class SvgDocument {
1243
1496
  for (const e of this.epilog) out += this.emit_node(e);
1244
1497
  return out;
1245
1498
  }
1499
+ /**
1500
+ * Serialize a single element's subtree as an SVG **fragment**, using the
1501
+ * same trivia-preserving rules as {@link serialize} (attribute order,
1502
+ * quote style, whitespace, comments — emitted exactly as authored).
1503
+ *
1504
+ * This is NOT {@link serialize} scoped to a node — it is a deliberately
1505
+ * weaker output (sdk-design D3, asymmetric outputs stay separate):
1506
+ *
1507
+ * - `serialize()` emits the whole document and carries the P1
1508
+ * whole-document round-trip guarantee.
1509
+ * - `serialize_node()` emits a fragment and does NOT. Namespace
1510
+ * declarations that live on an ancestor (`xmlns:xlink` and friends,
1511
+ * normally on the root `<svg>`) are NOT inlined — a node using
1512
+ * `xlink:href` serializes without `xmlns:xlink`. The fragment is the
1513
+ * element's markup as authored, not a standalone parseable document.
1514
+ *
1515
+ * Throws on an unknown id, a non-element node, or a node detached from
1516
+ * the live tree: the contract is "the markup for a selected element,"
1517
+ * selections are always live elements, and a string return of `""` for a
1518
+ * bad id would hide consumer bugs. The detached case matters because
1519
+ * `remove()` keeps the node in the id map for undo — a stale id from a
1520
+ * removed node would otherwise serialize content no longer in the
1521
+ * document, silently feeding a consumer deleted markup.
1522
+ */
1523
+ serialize_node(id) {
1524
+ const n = this.nodes.get(id);
1525
+ if (!n) throw new Error(`serialize_node: unknown node id ${JSON.stringify(id)}`);
1526
+ if (n.kind !== "element") throw new Error(`serialize_node: node ${JSON.stringify(id)} is a ${n.kind} node, not an element`);
1527
+ if (!this.contains(this.root, id)) throw new Error(`serialize_node: node ${JSON.stringify(id)} is detached from the current document`);
1528
+ return this.emit_node(n);
1529
+ }
1246
1530
  emit_node(n) {
1247
1531
  switch (n.kind) {
1248
1532
  case "text": return (0, _grida_svg_parser.encode_text)(n.value);
@@ -1846,7 +2130,8 @@ function _create_svg_editor_internal(opts) {
1846
2130
  snap_threshold_px: style.snap_threshold_px
1847
2131
  },
1848
2132
  emit,
1849
- stages
2133
+ stages,
2134
+ project: (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d
1850
2135
  });
1851
2136
  apply();
1852
2137
  history.atomic(label, (tx) => {
@@ -2692,6 +2977,21 @@ function _create_svg_editor_internal(opts) {
2692
2977
  set_style,
2693
2978
  load,
2694
2979
  serialize,
2980
+ /**
2981
+ * Serialize a single element's subtree as an SVG **fragment**, using the
2982
+ * same trivia-preserving rules as {@link serialize} — for handing "the
2983
+ * markup of the element the user selected" to a downstream consumer
2984
+ * (e.g. an AI agent) without re-serializing the whole document.
2985
+ *
2986
+ * Fragment, not document (see `SvgDocument.serialize_node`): it does NOT
2987
+ * carry `serialize()`'s whole-document round-trip guarantee. Namespace
2988
+ * declarations on an ancestor (`xmlns:xlink`, normally on the root
2989
+ * `<svg>`) are NOT inlined — a node using `xlink:href` serializes without
2990
+ * `xmlns:xlink`. Throws on an unknown id or a non-element node.
2991
+ */
2992
+ serialize_node(id) {
2993
+ return doc.serialize_node(id);
2994
+ },
2695
2995
  reset,
2696
2996
  attach,
2697
2997
  detach,
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { $ as ReorderDirection, A as EditorState, B as LinearGradientDefinition, D as ClipboardProvider, F as GradientEntry, G as PaintPreviewSession, H as NodeId, I as GradientStop, J as PropertyValue, K as PaintValue, L as InsertPreviewSession, M as FileIOProvider, N as FontResolver, O as Color, P as GradientDefinition, Q as Rect, R as InsertableTag, S as AlignDirection, U as Paint, V as Mode, W as PaintFallback, X as Providers, Y as Provenance, Z as RadialGradientDefinition, _ as PathModel, a as SelectMode, b as Verb, c as SvgEditor, et as TOOL_CURSOR, j as EditorStyle, k as DEFAULT_STYLE, l as createSvgEditor, n as CreateSvgEditorOptions, nt as Unsubscribe, o as Surface, q as PreviewSession, rt as Vec2, s as SurfaceHandle, t as Commands, tt as Tool, v as PathSnapshot, x as VertexId, y as SegmentId, z as InvalidComputedValue } from "./editor-YQwdWHBb.mjs";
1
+ import { $ as ReorderDirection, A as EditorState, B as LinearGradientDefinition, D as ClipboardProvider, F as GradientEntry, G as PaintPreviewSession, H as NodeId, I as GradientStop, J as PropertyValue, K as PaintValue, L as InsertPreviewSession, M as FileIOProvider, N as FontResolver, O as Color, P as GradientDefinition, Q as Rect, R as InsertableTag, S as AlignDirection, U as Paint, V as Mode, W as PaintFallback, X as Providers, Y as Provenance, Z as RadialGradientDefinition, _ as PathModel, a as SelectMode, b as Verb, c as SvgEditor, et as TOOL_CURSOR, j as EditorStyle, k as DEFAULT_STYLE, l as createSvgEditor, n as CreateSvgEditorOptions, nt as Unsubscribe, o as Surface, q as PreviewSession, rt as Vec2, s as SurfaceHandle, t as Commands, tt as Tool, v as PathSnapshot, x as VertexId, y as SegmentId, z as InvalidComputedValue } from "./editor-Dl7c0q5A.mjs";
2
2
  export { type AlignDirection, type ClipboardProvider, type Color, type Commands, type CreateSvgEditorOptions, DEFAULT_STYLE, type EditorState, type EditorStyle, type FileIOProvider, type FontResolver, type GradientDefinition, type GradientEntry, type GradientStop, type InsertPreviewSession, type InsertableTag, type InvalidComputedValue, type LinearGradientDefinition, type Mode, type NodeId, type Paint, type PaintFallback, type PaintPreviewSession, type PaintValue, PathModel, type PathSnapshot, type PreviewSession, type PropertyValue, type Provenance, type Providers, type RadialGradientDefinition, type Rect, type ReorderDirection, type SegmentId, type SelectMode, type Surface, type SurfaceHandle, type SvgEditor, TOOL_CURSOR, type Tool, type Unsubscribe, type Vec2, type Verb, type VertexId, createSvgEditor };