@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.
- package/README.md +23 -0
- package/dist/{dom-Dz_V6q0Y.d.mts → dom-CK6GlgFF.d.mts} +1 -1
- package/dist/{dom-D4dy6kq5.d.ts → dom-CsKXTaNw.d.ts} +1 -1
- package/dist/{dom-DSjfCllZ.mjs → dom-DILY80j7.mjs} +229 -175
- package/dist/{dom-BuD8TKmL.js → dom-Dee6FtgZ.js} +229 -175
- package/dist/dom.d.mts +2 -2
- package/dist/dom.d.ts +2 -2
- package/dist/dom.js +1 -1
- package/dist/dom.mjs +1 -1
- package/dist/{editor-CJ2KuRh5.d.ts → editor-BKoo9SPL.d.ts} +189 -19
- package/dist/{editor-B6pchGYk.mjs → editor-CvWpD5mu.mjs} +313 -13
- package/dist/{editor-YQwdWHBb.d.mts → editor-Dl7c0q5A.d.mts} +189 -19
- package/dist/{editor-BHHU_Nvz.js → editor-F8ckj9X1.js} +313 -13
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{model-DIzZmeyf.mjs → model-B2UWgViT.mjs} +69 -17
- package/dist/{model-DqGqV1H4.js → model-CJ1Ctq14.js} +69 -17
- package/dist/presets.d.mts +2 -2
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +2 -2
- package/dist/presets.mjs +1 -1
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/react.mjs +2 -2
- package/package.json +25 -4
|
@@ -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
|
|
503
|
-
* can hand them straight to
|
|
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
|
-
*
|
|
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
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
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
|
-
*
|
|
645
|
-
*
|
|
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
|
|
807
|
-
*
|
|
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"]):
|
|
811
|
-
kind: "
|
|
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-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
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
|
-
*
|
|
1089
|
-
*
|
|
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,
|