@grida/svg-editor 1.0.0-alpha.15 → 1.0.0-alpha.17
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 +69 -0
- package/dist/{dom-CsKXTaNw.d.ts → dom-BMzX1CXZ.d.ts} +56 -2
- package/dist/{dom-DILY80j7.mjs → dom-Bjj9xySE.mjs} +171 -13
- package/dist/{dom-Dee6FtgZ.js → dom-CaByuo6C.js} +176 -12
- package/dist/{dom-CK6GlgFF.d.mts → dom-TctdgRnn.d.mts} +56 -2
- package/dist/dom.d.mts +3 -3
- package/dist/dom.d.ts +3 -3
- package/dist/dom.js +2 -1
- package/dist/dom.mjs +2 -2
- package/dist/{editor-CvWpD5mu.mjs → editor-BLsELHSZ.mjs} +769 -866
- package/dist/{editor-BKoo9SPL.d.ts → editor-BSxTUsW_.d.ts} +553 -5
- package/dist/{editor-Dl7c0q5A.d.mts → editor-KqpIW1qm.d.mts} +553 -5
- package/dist/{editor-F8ckj9X1.js → editor-N9af0JD2.js} +769 -866
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{model-B2UWgViT.mjs → model-DMaN5GnH.mjs} +1442 -72
- package/dist/{model-CJ1Ctq14.js → model-GpysNbOv.js} +1459 -71
- 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 +28 -4
- package/dist/react.d.ts +28 -4
- package/dist/react.js +29 -4
- package/dist/react.mjs +29 -5
- package/package.json +9 -6
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
const require_model = require("./model-
|
|
1
|
+
const require_model = require("./model-GpysNbOv.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");
|
|
5
5
|
_grida_cmath = require_model.__toESM(_grida_cmath);
|
|
6
6
|
let _grida_svg_parser = require("@grida/svg/parser");
|
|
7
|
-
let _grida_svg_parse = require("@grida/svg/parse");
|
|
8
7
|
//#region src/commands/registry.ts
|
|
9
8
|
var CommandRegistry = class {
|
|
10
9
|
constructor() {
|
|
@@ -85,12 +84,31 @@ function registerDefaultCommands(reg, editor) {
|
|
|
85
84
|
if (editor.state.selection.length === 0) return false;
|
|
86
85
|
return editor.commands.group();
|
|
87
86
|
});
|
|
87
|
+
reg.register("selection.duplicate", () => {
|
|
88
|
+
if (editor.state.mode !== "select") return false;
|
|
89
|
+
if (editor.state.selection.length === 0) return false;
|
|
90
|
+
return editor.commands.duplicate().length > 0;
|
|
91
|
+
});
|
|
92
|
+
reg.register("selection.ungroup", () => {
|
|
93
|
+
if (editor.state.mode !== "select") return false;
|
|
94
|
+
if (editor.state.selection.length !== 1) return false;
|
|
95
|
+
return editor.commands.ungroup();
|
|
96
|
+
});
|
|
88
97
|
reg.register("selection.resize_to", (args) => {
|
|
89
98
|
if (editor.state.mode !== "select") return false;
|
|
90
99
|
if (editor.state.selection.length === 0) return false;
|
|
91
100
|
const target = args;
|
|
92
101
|
return editor.commands.resize_to(target);
|
|
93
102
|
});
|
|
103
|
+
reg.register("selection.nudge_resize", (args) => {
|
|
104
|
+
if (editor.state.mode !== "select") return false;
|
|
105
|
+
if (editor.state.selection.length === 0) return false;
|
|
106
|
+
const { dw, dh } = args;
|
|
107
|
+
return editor.commands.resize_by({
|
|
108
|
+
dw,
|
|
109
|
+
dh
|
|
110
|
+
});
|
|
111
|
+
});
|
|
94
112
|
reg.register("selection.rotate", (args) => {
|
|
95
113
|
if (editor.state.mode !== "select") return false;
|
|
96
114
|
if (editor.state.selection.length === 0) return false;
|
|
@@ -120,6 +138,31 @@ function registerDefaultCommands(reg, editor) {
|
|
|
120
138
|
if (editor.state.mode !== "select") return false;
|
|
121
139
|
return editor.commands.align(args);
|
|
122
140
|
});
|
|
141
|
+
reg.register("clipboard.copy", () => {
|
|
142
|
+
if (editor.state.mode !== "select") return false;
|
|
143
|
+
if (editor.state.selection.length === 0) return false;
|
|
144
|
+
return editor.commands.copy() !== null;
|
|
145
|
+
});
|
|
146
|
+
reg.register("clipboard.cut", () => {
|
|
147
|
+
if (editor.state.mode !== "select") return false;
|
|
148
|
+
if (editor.state.selection.length === 0) return false;
|
|
149
|
+
return editor.commands.cut() !== null;
|
|
150
|
+
});
|
|
151
|
+
reg.register("clipboard.paste", (args) => {
|
|
152
|
+
if (editor.state.mode !== "select") return false;
|
|
153
|
+
const text = args?.text;
|
|
154
|
+
if (typeof text === "string") return editor.commands.paste(text).length > 0;
|
|
155
|
+
const provider = editor.providers.clipboard;
|
|
156
|
+
if (provider) {
|
|
157
|
+
provider.read().then((text) => {
|
|
158
|
+
if (text) editor.commands.paste(text);
|
|
159
|
+
}).catch((err) => {
|
|
160
|
+
console.warn("[svg-editor] clipboard provider read failed:", err);
|
|
161
|
+
});
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return editor.commands.paste().length > 0;
|
|
165
|
+
});
|
|
123
166
|
reg.register("content.enter", () => editor.enter_content_edit());
|
|
124
167
|
reg.register("hierarchy.enter", () => {
|
|
125
168
|
if (editor.state.selection.length !== 1) return false;
|
|
@@ -310,7 +353,8 @@ function compareEntries(a, b) {
|
|
|
310
353
|
* Same key, multiple meanings? Add multiple rows. The chain semantics
|
|
311
354
|
* (handler returns `false` when not applicable) handle the rest.
|
|
312
355
|
*/
|
|
313
|
-
const NUDGE_MEANINGFUL = _grida_keybinding.M.Shift;
|
|
356
|
+
const NUDGE_MEANINGFUL = _grida_keybinding.M.Shift | _grida_keybinding.M.Ctrl;
|
|
357
|
+
const RESIZE_MEANINGFUL = _grida_keybinding.M.Shift | _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt;
|
|
314
358
|
const DEFAULT_BINDINGS = [
|
|
315
359
|
{
|
|
316
360
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyZ, _grida_keybinding.M.CtrlCmd),
|
|
@@ -340,6 +384,14 @@ const DEFAULT_BINDINGS = [
|
|
|
340
384
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd),
|
|
341
385
|
command: "selection.group"
|
|
342
386
|
},
|
|
387
|
+
{
|
|
388
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd | _grida_keybinding.M.Shift),
|
|
389
|
+
command: "selection.ungroup"
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyD, _grida_keybinding.M.CtrlCmd),
|
|
393
|
+
command: "selection.duplicate"
|
|
394
|
+
},
|
|
343
395
|
{
|
|
344
396
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyA, _grida_keybinding.M.CtrlCmd),
|
|
345
397
|
command: "selection.all"
|
|
@@ -460,6 +512,70 @@ const DEFAULT_BINDINGS = [
|
|
|
460
512
|
dy: 10
|
|
461
513
|
}
|
|
462
514
|
},
|
|
515
|
+
{
|
|
516
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
|
|
517
|
+
command: "selection.nudge_resize",
|
|
518
|
+
args: {
|
|
519
|
+
dw: 1,
|
|
520
|
+
dh: 0
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
|
|
525
|
+
command: "selection.nudge_resize",
|
|
526
|
+
args: {
|
|
527
|
+
dw: -1,
|
|
528
|
+
dh: 0
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
|
|
533
|
+
command: "selection.nudge_resize",
|
|
534
|
+
args: {
|
|
535
|
+
dw: 0,
|
|
536
|
+
dh: 1
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
|
|
541
|
+
command: "selection.nudge_resize",
|
|
542
|
+
args: {
|
|
543
|
+
dw: 0,
|
|
544
|
+
dh: -1
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
|
|
549
|
+
command: "selection.nudge_resize",
|
|
550
|
+
args: {
|
|
551
|
+
dw: 10,
|
|
552
|
+
dh: 0
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
|
|
557
|
+
command: "selection.nudge_resize",
|
|
558
|
+
args: {
|
|
559
|
+
dw: -10,
|
|
560
|
+
dh: 0
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
|
|
565
|
+
command: "selection.nudge_resize",
|
|
566
|
+
args: {
|
|
567
|
+
dw: 0,
|
|
568
|
+
dh: 10
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
|
|
573
|
+
command: "selection.nudge_resize",
|
|
574
|
+
args: {
|
|
575
|
+
dw: 0,
|
|
576
|
+
dh: -10
|
|
577
|
+
}
|
|
578
|
+
},
|
|
463
579
|
{
|
|
464
580
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyV),
|
|
465
581
|
command: TOOL_SET,
|
|
@@ -756,824 +872,175 @@ function create_defs(doc) {
|
|
|
756
872
|
return { gradients: new GradientsRegistry(doc) };
|
|
757
873
|
}
|
|
758
874
|
//#endregion
|
|
759
|
-
//#region src/core/
|
|
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
|
-
};
|
|
875
|
+
//#region src/core/clipboard.ts
|
|
795
876
|
/**
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
|
|
801
|
-
function
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
}
|
|
809
|
-
/**
|
|
810
|
-
* Attribute names whose writes can shift a node's rendered bounds.
|
|
811
|
-
* Membership drives `_geometry_version` bumps in `set_attr`. Only
|
|
812
|
-
* non-namespaced attribute names — namespaced writes (xlink:href, etc.)
|
|
813
|
-
* never bump because they're references, not geometry.
|
|
877
|
+
* Clipboard payload extraction — selection → standalone SVG document.
|
|
878
|
+
*
|
|
879
|
+
* Implements the copy side of the clipboard FRD
|
|
880
|
+
* ([docs/wg/feat-svg-editor/clipboard.md](../../../../docs/wg/feat-svg-editor/clipboard.md)):
|
|
881
|
+
* the payload is a standalone, namespace-well-formed SVG document — not a
|
|
882
|
+
* private envelope. Assembly is a pure function of (document, selection):
|
|
883
|
+
* no geometry, no environment, no randomness (FRD R6 — the same selection
|
|
884
|
+
* yields the same bytes headless or surface-attached).
|
|
814
885
|
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
886
|
+
* What the payload carries, and what it deliberately does not
|
|
887
|
+
* (FRD §Extraction — five context kinds):
|
|
888
|
+
*
|
|
889
|
+
* 1. Referenced resources — CARRIED. The outbound `url(#…)` / `href`
|
|
890
|
+
* closure is walked from the closed carrier list below and emitted
|
|
891
|
+
* verbatim in one `<defs>` block.
|
|
892
|
+
* 2. Namespace declarations — CARRIED. Prefixes a subtree borrows from
|
|
893
|
+
* ancestor scope are declared on the payload shell (an undeclared
|
|
894
|
+
* prefix is a well-formedness error, so this includes the deliberate
|
|
895
|
+
* well-known-table repair for e.g. `xlink`).
|
|
896
|
+
* 3. Ancestor transforms — NOT carried (verbatim policy).
|
|
897
|
+
* 4. Inherited presentation / cascade — NOT carried (verbatim policy).
|
|
898
|
+
* 5. Viewport — NOT carried (no `viewBox`, no sizing on the shell).
|
|
899
|
+
*
|
|
900
|
+
* This module is the **payload extraction** operation only. The sibling
|
|
901
|
+
* operation the FRD names — in-document subtree CLONE (duplicate /
|
|
902
|
+
* clone-drag), which must NOT carry the closure — lives in `./subtree`.
|
|
903
|
+
* The two share exactly selection normalization (`subtree.normalize_roots`)
|
|
904
|
+
* and verbatim subtree serialization (`doc.serialize_node`).
|
|
817
905
|
*/
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
"y",
|
|
821
|
-
"x1",
|
|
822
|
-
"y1",
|
|
823
|
-
"x2",
|
|
824
|
-
"y2",
|
|
825
|
-
"cx",
|
|
826
|
-
"cy",
|
|
827
|
-
"width",
|
|
828
|
-
"height",
|
|
829
|
-
"r",
|
|
830
|
-
"rx",
|
|
831
|
-
"ry",
|
|
832
|
-
"points",
|
|
833
|
-
"d",
|
|
834
|
-
"transform",
|
|
835
|
-
"viewBox",
|
|
836
|
-
"font-size",
|
|
837
|
-
"font-family",
|
|
838
|
-
"font-weight",
|
|
839
|
-
"font-style",
|
|
840
|
-
"text-anchor",
|
|
841
|
-
"dx",
|
|
842
|
-
"dy",
|
|
843
|
-
"rotate",
|
|
844
|
-
"textLength",
|
|
845
|
-
"lengthAdjust",
|
|
846
|
-
"pathLength",
|
|
847
|
-
"marker-start",
|
|
848
|
-
"marker-mid",
|
|
849
|
-
"marker-end"
|
|
850
|
-
]);
|
|
851
|
-
/** `transform:` CSS property at the start of a declaration list or after `;`. */
|
|
852
|
-
const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
|
|
853
|
-
var SvgDocument = class SvgDocument {
|
|
854
|
-
constructor(svg) {
|
|
855
|
-
this.listeners = /* @__PURE__ */ new Set();
|
|
856
|
-
this._structure_version = 0;
|
|
857
|
-
this._geometry_version = 0;
|
|
858
|
-
if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
|
|
859
|
-
this.source = svg;
|
|
860
|
-
const parsed = (0, _grida_svg_parser.parse_svg)(svg);
|
|
861
|
-
this.original = parsed;
|
|
862
|
-
this.nodes = parsed.nodes;
|
|
863
|
-
this.prolog = parsed.prolog;
|
|
864
|
-
this.epilog = parsed.epilog;
|
|
865
|
-
this.root = parsed.root;
|
|
866
|
-
}
|
|
867
|
-
static parse(svg) {
|
|
868
|
-
return new SvgDocument(svg);
|
|
869
|
-
}
|
|
870
|
-
/** Reload from the original parse, discarding all edits. */
|
|
871
|
-
reset_to_original() {
|
|
872
|
-
const parsed = (0, _grida_svg_parser.parse_svg)(this.source);
|
|
873
|
-
this.original = parsed;
|
|
874
|
-
this.nodes = parsed.nodes;
|
|
875
|
-
this.prolog = parsed.prolog;
|
|
876
|
-
this.epilog = parsed.epilog;
|
|
877
|
-
this.root = parsed.root;
|
|
878
|
-
this._structure_version++;
|
|
879
|
-
this._geometry_version++;
|
|
880
|
-
this.emit();
|
|
881
|
-
}
|
|
882
|
-
/** Replace document with new svg source (clears edits + history-owned state). */
|
|
883
|
-
load(svg) {
|
|
884
|
-
if (typeof svg !== "string") throw new TypeError(`SvgDocument.load(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
|
|
885
|
-
this.source = svg;
|
|
886
|
-
const parsed = (0, _grida_svg_parser.parse_svg)(svg);
|
|
887
|
-
this.original = parsed;
|
|
888
|
-
this.nodes = parsed.nodes;
|
|
889
|
-
this.prolog = parsed.prolog;
|
|
890
|
-
this.epilog = parsed.epilog;
|
|
891
|
-
this.root = parsed.root;
|
|
892
|
-
this._structure_version++;
|
|
893
|
-
this._geometry_version++;
|
|
894
|
-
this.emit();
|
|
895
|
-
}
|
|
896
|
-
on_change(fn) {
|
|
897
|
-
this.listeners.add(fn);
|
|
898
|
-
return () => this.listeners.delete(fn);
|
|
899
|
-
}
|
|
900
|
-
/** See `_structure_version` for what this counter signals. */
|
|
901
|
-
get structure_version() {
|
|
902
|
-
return this._structure_version;
|
|
903
|
-
}
|
|
904
|
-
/** See `_geometry_version` for what this counter signals. */
|
|
905
|
-
get geometry_version() {
|
|
906
|
-
return this._geometry_version;
|
|
907
|
-
}
|
|
908
|
-
emit() {
|
|
909
|
-
for (const fn of this.listeners) fn();
|
|
910
|
-
}
|
|
911
|
-
/** Notify subscribers — for callers that mutate directly via setAttr/etc. */
|
|
912
|
-
notify() {
|
|
913
|
-
this.emit();
|
|
914
|
-
}
|
|
915
|
-
get(id) {
|
|
916
|
-
return this.nodes.get(id) ?? null;
|
|
917
|
-
}
|
|
918
|
-
is_element(id) {
|
|
919
|
-
return this.nodes.get(id)?.kind === "element";
|
|
920
|
-
}
|
|
921
|
-
parent_of(id) {
|
|
922
|
-
return this.nodes.get(id)?.parent ?? null;
|
|
923
|
-
}
|
|
924
|
-
children_of(id) {
|
|
925
|
-
const n = this.nodes.get(id);
|
|
926
|
-
if (!n || n.kind !== "element") return [];
|
|
927
|
-
return n.children;
|
|
928
|
-
}
|
|
929
|
-
/** Element children only — text/comment/cdata filtered out. */
|
|
930
|
-
element_children_of(id) {
|
|
931
|
-
return this.children_of(id).filter((c) => this.is_element(c));
|
|
932
|
-
}
|
|
933
|
-
next_sibling_of(id) {
|
|
934
|
-
const parent = this.parent_of(id);
|
|
935
|
-
if (parent === null) return null;
|
|
936
|
-
const siblings = this.children_of(parent);
|
|
937
|
-
const i = siblings.indexOf(id);
|
|
938
|
-
return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
|
|
939
|
-
}
|
|
940
|
-
next_element_sibling_of(id) {
|
|
941
|
-
const parent = this.parent_of(id);
|
|
942
|
-
if (parent === null) return null;
|
|
943
|
-
const siblings = this.element_children_of(parent);
|
|
944
|
-
const i = siblings.indexOf(id);
|
|
945
|
-
return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
|
|
946
|
-
}
|
|
947
|
-
tag_of(id) {
|
|
948
|
-
const n = this.nodes.get(id);
|
|
949
|
-
return n && n.kind === "element" ? n.local : "";
|
|
950
|
-
}
|
|
951
|
-
contains(ancestor, descendant) {
|
|
952
|
-
if (ancestor === descendant) return true;
|
|
953
|
-
let cur = this.parent_of(descendant);
|
|
954
|
-
while (cur !== null) {
|
|
955
|
-
if (cur === ancestor) return true;
|
|
956
|
-
cur = this.parent_of(cur);
|
|
957
|
-
}
|
|
958
|
-
return false;
|
|
959
|
-
}
|
|
960
|
-
/**
|
|
961
|
-
* Filter a selection down to its **subtree roots** — drop any id whose
|
|
962
|
-
* ancestor is also in the input set.
|
|
963
|
-
*
|
|
964
|
-
* Mirrors `pruneNestedNodes` in the main canvas editor's query module
|
|
965
|
-
* ([editor/grida-canvas/query/index.ts:138](../../../../editor/grida-canvas/query/index.ts)) and shares its UX motivation:
|
|
966
|
-
* when a parent and a descendant are both selected, only the parent
|
|
967
|
-
* should drive multi-node mutations — otherwise the descendant
|
|
968
|
-
* accumulates the transform twice (once via the parent's `transform`,
|
|
969
|
-
* once via its own attribute write). Required for `commands.remove`
|
|
970
|
-
* (avoids re-attaching detached descendants on undo) and any multi-
|
|
971
|
-
* member translate path (avoids 2× drift for the Bar-chart marquee
|
|
972
|
-
* case).
|
|
973
|
-
*
|
|
974
|
-
* Order: preserves the input order for retained ids. Duplicates in
|
|
975
|
-
* the input are not deduplicated — callers are responsible (the
|
|
976
|
-
* editor's `commands.select` already dedupes).
|
|
977
|
-
*
|
|
978
|
-
* Performance: `O(n × depth)`. Builds a `Set` over the input once,
|
|
979
|
-
* then walks each id's ancestor chain at most once. The main editor's
|
|
980
|
-
* version is `O(n² × depth)` (per-pair `isAncestor`) — fine at typical
|
|
981
|
-
* selection sizes (a few dozen), worth winning here for free since
|
|
982
|
-
* `parent_of` is `O(1)` on our parent-map.
|
|
983
|
-
*/
|
|
984
|
-
prune_nested_nodes(ids) {
|
|
985
|
-
if (ids.length <= 1) return [...ids];
|
|
986
|
-
const set = new Set(ids);
|
|
987
|
-
const out = [];
|
|
988
|
-
for (const id of ids) {
|
|
989
|
-
let nested = false;
|
|
990
|
-
let cur = this.parent_of(id);
|
|
991
|
-
while (cur !== null) {
|
|
992
|
-
if (set.has(cur)) {
|
|
993
|
-
nested = true;
|
|
994
|
-
break;
|
|
995
|
-
}
|
|
996
|
-
cur = this.parent_of(cur);
|
|
997
|
-
}
|
|
998
|
-
if (!nested) out.push(id);
|
|
999
|
-
}
|
|
1000
|
-
return out;
|
|
1001
|
-
}
|
|
1002
|
-
all_nodes() {
|
|
1003
|
-
const out = [];
|
|
1004
|
-
const walk = (id) => {
|
|
1005
|
-
out.push(id);
|
|
1006
|
-
const c = this.children_of(id);
|
|
1007
|
-
for (const ch of c) walk(ch);
|
|
1008
|
-
};
|
|
1009
|
-
walk(this.root);
|
|
1010
|
-
return out;
|
|
1011
|
-
}
|
|
1012
|
-
all_elements() {
|
|
1013
|
-
return this.all_nodes().filter((id) => this.is_element(id));
|
|
1014
|
-
}
|
|
1015
|
-
find_by_tag(ancestor, tag) {
|
|
1016
|
-
const out = [];
|
|
1017
|
-
const walk = (id) => {
|
|
1018
|
-
if (id !== ancestor && this.is_element(id) && this.tag_of(id) === tag) out.push(id);
|
|
1019
|
-
for (const c of this.children_of(id)) walk(c);
|
|
1020
|
-
};
|
|
1021
|
-
walk(ancestor);
|
|
1022
|
-
return out;
|
|
1023
|
-
}
|
|
1024
|
-
/** Read attribute by local name, optionally namespace-filtered. */
|
|
1025
|
-
get_attr(id, name, ns = null) {
|
|
1026
|
-
const n = this.nodes.get(id);
|
|
1027
|
-
if (!n || n.kind !== "element") return null;
|
|
1028
|
-
for (const a of n.attrs) if (a.local === name && (ns === null || a.ns === ns)) return a.value;
|
|
1029
|
-
return null;
|
|
1030
|
-
}
|
|
1031
|
-
/**
|
|
1032
|
-
* Set / remove an attribute. If the attribute exists, it is mutated in place
|
|
1033
|
-
* (preserving source position). If it doesn't, it's appended.
|
|
1034
|
-
*/
|
|
1035
|
-
set_attr(id, name, value, ns = null) {
|
|
1036
|
-
const n = this.nodes.get(id);
|
|
1037
|
-
if (!n || n.kind !== "element") return;
|
|
1038
|
-
const structural = name === "id";
|
|
1039
|
-
const geometry = ns === null && GEOMETRY_ATTRS.has(name);
|
|
1040
|
-
for (let i = 0; i < n.attrs.length; i++) {
|
|
1041
|
-
const a = n.attrs[i];
|
|
1042
|
-
if (a.local === name && (ns === null || a.ns === ns)) {
|
|
1043
|
-
if (value === null) n.attrs.splice(i, 1);
|
|
1044
|
-
else a.value = value;
|
|
1045
|
-
if (structural) this._structure_version++;
|
|
1046
|
-
if (geometry) this._geometry_version++;
|
|
1047
|
-
this.emit();
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
if (value !== null) {
|
|
1052
|
-
const prefix = ns === _grida_svg_parser.XLINK_NS ? "xlink" : null;
|
|
1053
|
-
n.attrs.push({
|
|
1054
|
-
raw_name: prefix ? `${prefix}:${name}` : name,
|
|
1055
|
-
prefix,
|
|
1056
|
-
local: name,
|
|
1057
|
-
ns,
|
|
1058
|
-
value,
|
|
1059
|
-
pre: " ",
|
|
1060
|
-
eq_trivia: "",
|
|
1061
|
-
quote: "\""
|
|
1062
|
-
});
|
|
1063
|
-
if (structural) this._structure_version++;
|
|
1064
|
-
if (geometry) this._geometry_version++;
|
|
1065
|
-
this.emit();
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
attributes_of(id) {
|
|
1069
|
-
const n = this.nodes.get(id);
|
|
1070
|
-
if (!n || n.kind !== "element") return [];
|
|
1071
|
-
return n.attrs.map((a) => ({
|
|
1072
|
-
name: a.local,
|
|
1073
|
-
ns: a.ns,
|
|
1074
|
-
value: a.value
|
|
1075
|
-
}));
|
|
1076
|
-
}
|
|
1077
|
-
get_style(id, property) {
|
|
1078
|
-
const style = this.get_attr(id, "style");
|
|
1079
|
-
if (!style) return null;
|
|
1080
|
-
const decls = parse_inline_style(style);
|
|
1081
|
-
for (const d of decls) if (d.property === property) return d.value;
|
|
1082
|
-
return null;
|
|
1083
|
-
}
|
|
1084
|
-
set_style(id, property, value) {
|
|
1085
|
-
const decls = parse_inline_style(this.get_attr(id, "style") ?? "");
|
|
1086
|
-
const idx = decls.findIndex((d) => d.property === property);
|
|
1087
|
-
if (value === null) {
|
|
1088
|
-
if (idx === -1) return;
|
|
1089
|
-
decls.splice(idx, 1);
|
|
1090
|
-
} else if (idx === -1) decls.push({
|
|
1091
|
-
property,
|
|
1092
|
-
value
|
|
1093
|
-
});
|
|
1094
|
-
else decls[idx].value = value;
|
|
1095
|
-
const next = decls.map((d) => `${d.property}: ${d.value}`).join("; ");
|
|
1096
|
-
this.set_attr(id, "style", next === "" ? null : next);
|
|
1097
|
-
}
|
|
1098
|
-
get_all_styles(id) {
|
|
1099
|
-
const style = this.get_attr(id, "style");
|
|
1100
|
-
if (!style) return [];
|
|
1101
|
-
return parse_inline_style(style);
|
|
1102
|
-
}
|
|
906
|
+
let clipboard;
|
|
907
|
+
(function(_clipboard) {
|
|
1103
908
|
/**
|
|
1104
|
-
*
|
|
909
|
+
* Presentation carriers that may hold `url(#…)` references, read both as
|
|
910
|
+
* a presentation attribute and as an inline `style=""` declaration.
|
|
1105
911
|
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
* tspan dangling. Tspan-as-target is fine and well-defined when it's a
|
|
1112
|
-
* leaf; only the host decides whether to route double-click to a tspan
|
|
1113
|
-
* or its parent text.
|
|
1114
|
-
*/
|
|
1115
|
-
is_text_edit_target(id) {
|
|
1116
|
-
const n = this.nodes.get(id);
|
|
1117
|
-
if (!n || n.kind !== "element") return false;
|
|
1118
|
-
if (n.local !== "text" && n.local !== "tspan") return false;
|
|
1119
|
-
for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
|
|
1120
|
-
return true;
|
|
1121
|
-
}
|
|
1122
|
-
/**
|
|
1123
|
-
* Returns a tag-discriminated snapshot of the authored geometry attrs
|
|
1124
|
-
* if this node is eligible for vector (vertex) editing — else `null`.
|
|
1125
|
-
*
|
|
1126
|
-
* Eligibility:
|
|
1127
|
-
* - `<path>` — requires non-empty `d`.
|
|
1128
|
-
* - `<line>` — requires two distinct finite user-unit endpoints.
|
|
1129
|
-
* - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
|
|
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`.
|
|
1144
|
-
*
|
|
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.
|
|
1148
|
-
*
|
|
1149
|
-
* Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
|
|
1150
|
-
* editable outline).
|
|
912
|
+
* CLOSED LIST — extending it is a spec change to the FRD's §Extraction 1
|
|
913
|
+
* carrier list, not a bug fix. Deliberately NOT walked in v1 (documented
|
|
914
|
+
* degradations): `<style>` element rules (CSS parsing is the deferred
|
|
915
|
+
* cascade capability), SMIL timing/value references, `cursor`, SVG 2
|
|
916
|
+
* text-layout properties.
|
|
1151
917
|
*/
|
|
918
|
+
const URL_REF_PROPS = [
|
|
919
|
+
"fill",
|
|
920
|
+
"stroke",
|
|
921
|
+
"filter",
|
|
922
|
+
"clip-path",
|
|
923
|
+
"mask",
|
|
924
|
+
"marker-start",
|
|
925
|
+
"marker-mid",
|
|
926
|
+
"marker-end",
|
|
927
|
+
"marker"
|
|
928
|
+
];
|
|
929
|
+
/** Set view of {@link URL_REF_PROPS} for membership tests over parsed
|
|
930
|
+
* style declarations. */
|
|
931
|
+
const URL_REF_PROP_SET = new Set(URL_REF_PROPS);
|
|
1152
932
|
/**
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
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.
|
|
933
|
+
* Elements whose `href` / `xlink:href` is a same-document resource
|
|
934
|
+
* reference the closure follows. CLOSED LIST — `<a href>` is navigation,
|
|
935
|
+
* `<image href>` is content, SMIL `href` is an animation target; none of
|
|
936
|
+
* them are walked.
|
|
1163
937
|
*/
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
case "path": {
|
|
1175
|
-
const d = this.get_attr(id, "d");
|
|
1176
|
-
if (d === null || d.trim().length === 0) return null;
|
|
1177
|
-
return {
|
|
1178
|
-
kind: "path",
|
|
1179
|
-
d
|
|
1180
|
-
};
|
|
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
|
-
}
|
|
1197
|
-
case "polyline":
|
|
1198
|
-
case "polygon": {
|
|
1199
|
-
const raw = this.get_attr(id, "points") ?? "";
|
|
1200
|
-
const parsed = _grida_svg_parse.svg_parse.parse_points(raw);
|
|
1201
|
-
if (parsed.length < 2) return null;
|
|
1202
|
-
const points = parsed.map((p) => [p.x, p.y]);
|
|
1203
|
-
return n.local === "polyline" ? {
|
|
1204
|
-
kind: "polyline",
|
|
1205
|
-
points
|
|
1206
|
-
} : {
|
|
1207
|
-
kind: "polygon",
|
|
1208
|
-
points
|
|
1209
|
-
};
|
|
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
|
-
}
|
|
1268
|
-
default: return null;
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
938
|
+
const HREF_TAGS = new Set([
|
|
939
|
+
"use",
|
|
940
|
+
"textPath",
|
|
941
|
+
"mpath",
|
|
942
|
+
"feImage",
|
|
943
|
+
"pattern",
|
|
944
|
+
"linearGradient",
|
|
945
|
+
"radialGradient",
|
|
946
|
+
"filter"
|
|
947
|
+
]);
|
|
1271
948
|
/**
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
*
|
|
1275
|
-
*
|
|
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)
|
|
949
|
+
* `url(#id)` extractor. Global — a single value can carry several
|
|
950
|
+
* references (`filter: url(#a) blur(2px) url(#b)` is a legal filter
|
|
951
|
+
* function list). Same quoting tolerance as the defs registry's
|
|
952
|
+
* ref-counting pattern.
|
|
1289
953
|
*/
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
const
|
|
1294
|
-
if (
|
|
1295
|
-
const
|
|
1296
|
-
const
|
|
1297
|
-
const
|
|
1298
|
-
|
|
1299
|
-
const
|
|
1300
|
-
if (
|
|
1301
|
-
removed.push({
|
|
1302
|
-
index: i,
|
|
1303
|
-
token: a
|
|
1304
|
-
});
|
|
1305
|
-
n.attrs.splice(i, 1);
|
|
1306
|
-
}
|
|
954
|
+
const URL_REF_RE = /url\(\s*["']?#([^"')\s]+)["']?\s*\)/g;
|
|
955
|
+
function extract_payload(doc, selection) {
|
|
956
|
+
const order = require_model.subtree.by_document_order(doc);
|
|
957
|
+
const roots = require_model.subtree.normalize_roots(doc, selection, order);
|
|
958
|
+
if (roots.length === 0) return null;
|
|
959
|
+
const closure = collect_reference_closure(doc, roots);
|
|
960
|
+
const shell_ns = /* @__PURE__ */ new Map();
|
|
961
|
+
for (const member of [...closure, ...roots].sort(order)) for (const prefix of doc.undeclared_ns_prefixes(member)) {
|
|
962
|
+
if (shell_ns.has(prefix)) continue;
|
|
963
|
+
const uri = resolve_prefix(doc, member, prefix);
|
|
964
|
+
if (uri !== null) shell_ns.set(prefix, uri);
|
|
1307
965
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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;
|
|
966
|
+
let shell = `<svg xmlns="${_grida_svg_parser.SVG_NS}"`;
|
|
967
|
+
for (const [prefix, uri] of shell_ns) shell += ` xmlns:${prefix}="${(0, _grida_svg_parser.encode_attr_value)(uri, "\"")}"`;
|
|
968
|
+
shell += ">";
|
|
969
|
+
const defs_block = closure.length > 0 ? `<defs>${closure.map((id) => doc.serialize_node(id)).join("")}</defs>` : "";
|
|
970
|
+
const content = roots.map((id) => doc.serialize_node(id)).join("");
|
|
971
|
+
return `${shell}${defs_block}${content}</svg>`;
|
|
972
|
+
}
|
|
973
|
+
_clipboard.extract_payload = extract_payload;
|
|
974
|
+
function collect_reference_closure(doc, roots) {
|
|
975
|
+
const id_map = /* @__PURE__ */ new Map();
|
|
976
|
+
for (const el of doc.all_elements()) {
|
|
977
|
+
const id_attr = doc.get_attr(el, "id");
|
|
978
|
+
if (id_attr !== null && !id_map.has(id_attr)) id_map.set(id_attr, el);
|
|
1334
979
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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;
|
|
980
|
+
const in_forest = (target) => roots.some((r) => doc.contains(r, target));
|
|
981
|
+
const collected = /* @__PURE__ */ new Set();
|
|
982
|
+
const pending = [...roots];
|
|
983
|
+
while (pending.length > 0) {
|
|
984
|
+
const subtree = pending.pop();
|
|
985
|
+
for (const el of elements_of_subtree(doc, subtree)) for (const ref of refs_of(doc, el)) {
|
|
986
|
+
const target = id_map.get(ref);
|
|
987
|
+
if (target === void 0) continue;
|
|
988
|
+
if (in_forest(target)) continue;
|
|
989
|
+
if (collected.has(target)) continue;
|
|
990
|
+
collected.add(target);
|
|
991
|
+
pending.push(target);
|
|
1367
992
|
}
|
|
1368
993
|
}
|
|
1369
|
-
|
|
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
|
-
/**
|
|
1377
|
-
* True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
|
|
1378
|
-
* per-glyph attribute (which conflicts with element-level rotation).
|
|
1379
|
-
*/
|
|
1380
|
-
has_glyph_rotate(id) {
|
|
1381
|
-
const tag = this.tag_of(id);
|
|
1382
|
-
if (tag !== "text" && tag !== "tspan") return false;
|
|
1383
|
-
const value = this.get_attr(id, "rotate");
|
|
1384
|
-
if (value === null) return false;
|
|
1385
|
-
return value.trim() !== "";
|
|
1386
|
-
}
|
|
1387
|
-
/**
|
|
1388
|
-
* True iff this element's inline `style=""` declares a `transform:`
|
|
1389
|
-
* CSS property (which would shadow the editor's `transform=` writes).
|
|
1390
|
-
*/
|
|
1391
|
-
has_inline_css_transform(id) {
|
|
1392
|
-
const style = this.get_attr(id, "style");
|
|
1393
|
-
if (!style) return false;
|
|
1394
|
-
return CSS_TRANSFORM_PROPERTY.test(style);
|
|
1395
|
-
}
|
|
1396
|
-
/**
|
|
1397
|
-
* True iff this element has a direct `<animateTransform>` child
|
|
1398
|
-
* (which produces a time-varying transform invisible to attribute writes).
|
|
1399
|
-
* Only direct children are checked — nested cases attach to the nearer ancestor.
|
|
1400
|
-
*/
|
|
1401
|
-
has_animate_transform_child(id) {
|
|
1402
|
-
for (const c of this.children_of(id)) {
|
|
1403
|
-
const n = this.nodes.get(c);
|
|
1404
|
-
if (n?.kind === "element" && n.local === "animateTransform") return true;
|
|
1405
|
-
}
|
|
1406
|
-
return false;
|
|
994
|
+
return doc.prune_nested_nodes([...collected]).sort(require_model.subtree.by_document_order(doc));
|
|
1407
995
|
}
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
996
|
+
_clipboard.collect_reference_closure = collect_reference_closure;
|
|
997
|
+
/** Preorder element walk of `root`'s subtree, root included. */
|
|
998
|
+
function elements_of_subtree(doc, root) {
|
|
999
|
+
const out = [];
|
|
1000
|
+
const walk = (id) => {
|
|
1001
|
+
if (!doc.is_element(id)) return;
|
|
1002
|
+
out.push(id);
|
|
1003
|
+
for (const c of doc.children_of(id)) walk(c);
|
|
1004
|
+
};
|
|
1005
|
+
walk(root);
|
|
1416
1006
|
return out;
|
|
1417
1007
|
}
|
|
1418
|
-
/**
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
const
|
|
1426
|
-
kind: "text",
|
|
1427
|
-
id: text_id,
|
|
1428
|
-
parent: id,
|
|
1429
|
-
value
|
|
1430
|
-
};
|
|
1431
|
-
this.nodes.set(text_id, text_node);
|
|
1432
|
-
n.children.push(text_id);
|
|
1008
|
+
/** Same-document reference ids carried by one element, per the closed
|
|
1009
|
+
* carrier list. */
|
|
1010
|
+
function refs_of(doc, id) {
|
|
1011
|
+
const out = [];
|
|
1012
|
+
for (const prop of URL_REF_PROPS) {
|
|
1013
|
+
const value = doc.get_attr(id, prop);
|
|
1014
|
+
if (!value) continue;
|
|
1015
|
+
for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
|
|
1433
1016
|
}
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
if (!node || !parent_node || parent_node.kind !== "element") return;
|
|
1442
|
-
if (node.parent !== null) {
|
|
1443
|
-
const old_parent = this.nodes.get(node.parent);
|
|
1444
|
-
if (old_parent && old_parent.kind === "element") {
|
|
1445
|
-
const i = old_parent.children.indexOf(id);
|
|
1446
|
-
if (i >= 0) old_parent.children.splice(i, 1);
|
|
1447
|
-
}
|
|
1017
|
+
for (const { property, value } of doc.get_all_styles(id)) {
|
|
1018
|
+
if (!URL_REF_PROP_SET.has(property)) continue;
|
|
1019
|
+
for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
|
|
1020
|
+
}
|
|
1021
|
+
if (HREF_TAGS.has(doc.tag_of(id))) {
|
|
1022
|
+
const href = doc.get_attr(id, "href");
|
|
1023
|
+
if (href !== null && href.startsWith("#") && href.length > 1) out.push(href.slice(1));
|
|
1448
1024
|
}
|
|
1449
|
-
const ix = before === null ? -1 : parent_node.children.indexOf(before);
|
|
1450
|
-
if (ix < 0) parent_node.children.push(id);
|
|
1451
|
-
else parent_node.children.splice(ix, 0, id);
|
|
1452
|
-
node.parent = parent;
|
|
1453
|
-
this._structure_version++;
|
|
1454
|
-
this._geometry_version++;
|
|
1455
|
-
this.emit();
|
|
1456
|
-
}
|
|
1457
|
-
remove(id) {
|
|
1458
|
-
const n = this.nodes.get(id);
|
|
1459
|
-
if (!n || n.parent === null) return;
|
|
1460
|
-
const parent = this.nodes.get(n.parent);
|
|
1461
|
-
if (!parent || parent.kind !== "element") return;
|
|
1462
|
-
const i = parent.children.indexOf(id);
|
|
1463
|
-
if (i >= 0) parent.children.splice(i, 1);
|
|
1464
|
-
n.parent = null;
|
|
1465
|
-
this._structure_version++;
|
|
1466
|
-
this._geometry_version++;
|
|
1467
|
-
this.emit();
|
|
1468
|
-
}
|
|
1469
|
-
/** Create a new element node and register it (not yet inserted). */
|
|
1470
|
-
create_element(local, opts) {
|
|
1471
|
-
const id = `e${Math.random().toString(36).slice(2, 10)}`;
|
|
1472
|
-
const prefix = opts?.prefix ?? null;
|
|
1473
|
-
const ns = opts?.ns ?? null;
|
|
1474
|
-
const node = {
|
|
1475
|
-
kind: "element",
|
|
1476
|
-
id,
|
|
1477
|
-
parent: null,
|
|
1478
|
-
raw_tag: prefix ? `${prefix}:${local}` : local,
|
|
1479
|
-
prefix,
|
|
1480
|
-
local,
|
|
1481
|
-
ns,
|
|
1482
|
-
attrs: [],
|
|
1483
|
-
children: [],
|
|
1484
|
-
self_closing: false,
|
|
1485
|
-
open_tag_trailing: "",
|
|
1486
|
-
close_tag_leading: "",
|
|
1487
|
-
close_tag_trailing: ""
|
|
1488
|
-
};
|
|
1489
|
-
this.nodes.set(id, node);
|
|
1490
|
-
return id;
|
|
1491
|
-
}
|
|
1492
|
-
serialize() {
|
|
1493
|
-
let out = "";
|
|
1494
|
-
for (const p of this.prolog) out += this.emit_node(p);
|
|
1495
|
-
out += this.emit_node(this.nodes.get(this.root));
|
|
1496
|
-
for (const e of this.epilog) out += this.emit_node(e);
|
|
1497
1025
|
return out;
|
|
1498
1026
|
}
|
|
1499
1027
|
/**
|
|
1500
|
-
*
|
|
1501
|
-
*
|
|
1502
|
-
*
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
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.
|
|
1028
|
+
* Resolve a prefix a member's subtree borrows from ancestor scope:
|
|
1029
|
+
* nearest ancestor `xmlns:<prefix>` declaration wins (correct XML
|
|
1030
|
+
* scoping), falling back to the well-known table — the deliberate
|
|
1031
|
+
* repair that keeps the payload namespace-well-formed even when the
|
|
1032
|
+
* source never declared the prefix (FRD §Extraction 2).
|
|
1522
1033
|
*/
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
}
|
|
1530
|
-
emit_node(n) {
|
|
1531
|
-
switch (n.kind) {
|
|
1532
|
-
case "text": return (0, _grida_svg_parser.encode_text)(n.value);
|
|
1533
|
-
case "comment": return `<!--${n.value}-->`;
|
|
1534
|
-
case "cdata": return `<![CDATA[${n.value}]]>`;
|
|
1535
|
-
case "pi": {
|
|
1536
|
-
const pi = n;
|
|
1537
|
-
return `<?${pi.target}${pi.value ? " " + pi.value : ""}?>`;
|
|
1538
|
-
}
|
|
1539
|
-
case "doctype": return `<!DOCTYPE${n.value}>`;
|
|
1540
|
-
case "element": {
|
|
1541
|
-
const e = n;
|
|
1542
|
-
let s = `<${e.raw_tag}`;
|
|
1543
|
-
for (const a of e.attrs) s += this.emit_attr(a);
|
|
1544
|
-
if (e.children.length === 0 && e.self_closing) {
|
|
1545
|
-
s += `${e.open_tag_trailing}/>`;
|
|
1546
|
-
return s;
|
|
1547
|
-
}
|
|
1548
|
-
s += `${e.open_tag_trailing}>`;
|
|
1549
|
-
for (const cid of e.children) {
|
|
1550
|
-
const cn = this.nodes.get(cid);
|
|
1551
|
-
if (cn) s += this.emit_node(cn);
|
|
1552
|
-
}
|
|
1553
|
-
s += `</${e.close_tag_leading}${e.raw_tag}${e.close_tag_trailing}>`;
|
|
1554
|
-
return s;
|
|
1555
|
-
}
|
|
1034
|
+
function resolve_prefix(doc, member, prefix) {
|
|
1035
|
+
let cur = doc.parent_of(member);
|
|
1036
|
+
while (cur !== null) {
|
|
1037
|
+
const uri = doc.get_attr(cur, prefix, _grida_svg_parser.XMLNS_NS);
|
|
1038
|
+
if (uri !== null) return uri;
|
|
1039
|
+
cur = doc.parent_of(cur);
|
|
1556
1040
|
}
|
|
1041
|
+
return require_model.WELL_KNOWN_NS_PREFIXES.get(prefix) ?? null;
|
|
1557
1042
|
}
|
|
1558
|
-
|
|
1559
|
-
return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${(0, _grida_svg_parser.encode_attr_value)(a.value, a.quote)}${a.quote}`;
|
|
1560
|
-
}
|
|
1561
|
-
};
|
|
1562
|
-
function parse_inline_style(s) {
|
|
1563
|
-
const out = [];
|
|
1564
|
-
const decls = s.split(";");
|
|
1565
|
-
for (const decl of decls) {
|
|
1566
|
-
const colon = decl.indexOf(":");
|
|
1567
|
-
if (colon === -1) continue;
|
|
1568
|
-
const property = decl.slice(0, colon).trim();
|
|
1569
|
-
const value = decl.slice(colon + 1).trim();
|
|
1570
|
-
if (property) out.push({
|
|
1571
|
-
property,
|
|
1572
|
-
value
|
|
1573
|
-
});
|
|
1574
|
-
}
|
|
1575
|
-
return out;
|
|
1576
|
-
}
|
|
1043
|
+
})(clipboard || (clipboard = {}));
|
|
1577
1044
|
//#endregion
|
|
1578
1045
|
//#region src/core/align.ts
|
|
1579
1046
|
/**
|
|
@@ -1782,7 +1249,7 @@ const DISPLAY_LABEL_MAX_LEN = 40;
|
|
|
1782
1249
|
* to `SvgEditor` so the published `.d.ts` doesn't advertise internals.
|
|
1783
1250
|
*/
|
|
1784
1251
|
function _create_svg_editor_internal(opts) {
|
|
1785
|
-
const doc = new SvgDocument(opts.svg);
|
|
1252
|
+
const doc = new require_model.SvgDocument(opts.svg);
|
|
1786
1253
|
const history = new _grida_history.HistoryImpl();
|
|
1787
1254
|
const defs = create_defs(doc);
|
|
1788
1255
|
let selection = [];
|
|
@@ -1790,10 +1257,11 @@ function _create_svg_editor_internal(opts) {
|
|
|
1790
1257
|
let mode = "select";
|
|
1791
1258
|
let tool = require_model.TOOL_CURSOR;
|
|
1792
1259
|
let version = 0;
|
|
1793
|
-
/**
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1260
|
+
/** `doc.revision` at the last load()/reset(); compared to derive `dirty`.
|
|
1261
|
+
* The doc's own total mutation counter is the single edit-version
|
|
1262
|
+
* source — `content_version`, `dirty`, and the typed-read memo caches
|
|
1263
|
+
* all derive from it (no editor-side shadow counter to drift). */
|
|
1264
|
+
let baseline_revision = doc.revision;
|
|
1797
1265
|
/**
|
|
1798
1266
|
* Bumps once per `editor.load(svg)` call. The constructor's initial parse
|
|
1799
1267
|
* does NOT count — it's the "factory" state. Hosts subscribe via
|
|
@@ -1806,6 +1274,23 @@ function _create_svg_editor_internal(opts) {
|
|
|
1806
1274
|
...opts.style
|
|
1807
1275
|
};
|
|
1808
1276
|
const providers = opts.providers ?? {};
|
|
1277
|
+
/**
|
|
1278
|
+
* In-memory clipboard buffer — the transport floor (FRD R1: the buffer
|
|
1279
|
+
* write cannot fail; external channels are best-effort on top). NOT part
|
|
1280
|
+
* of `EditorState` and NOT history-managed: it survives `load()` /
|
|
1281
|
+
* `reset()` / undo, like the OS clipboard it mirrors.
|
|
1282
|
+
*/
|
|
1283
|
+
let clipboard_buffer = null;
|
|
1284
|
+
/**
|
|
1285
|
+
* The last committed duplication — read by the NEXT `duplicate()` to
|
|
1286
|
+
* repeat the user's translate delta (gridaco/grida#825; spec
|
|
1287
|
+
* §Repeating offset). Session state like `clipboard_buffer`: not in
|
|
1288
|
+
* `EditorState`, not history-managed (undo/redo replay never re-arms
|
|
1289
|
+
* it — only a user-initiated ⌘D or cloned-drag commit does). Staleness
|
|
1290
|
+
* is caught at use by `subtree.repeat_delta`; the only eager clears are
|
|
1291
|
+
* `load()` / `reset()`, where every NodeId dies wholesale.
|
|
1292
|
+
*/
|
|
1293
|
+
let active_duplication = null;
|
|
1809
1294
|
const listeners = /* @__PURE__ */ new Set();
|
|
1810
1295
|
let attached_surface = null;
|
|
1811
1296
|
/**
|
|
@@ -1821,11 +1306,11 @@ function _create_svg_editor_internal(opts) {
|
|
|
1821
1306
|
scope,
|
|
1822
1307
|
mode,
|
|
1823
1308
|
tool,
|
|
1824
|
-
dirty:
|
|
1309
|
+
dirty: doc.revision !== baseline_revision,
|
|
1825
1310
|
can_undo: history.stack.canUndo,
|
|
1826
1311
|
can_redo: history.stack.canRedo,
|
|
1827
1312
|
version,
|
|
1828
|
-
content_version:
|
|
1313
|
+
content_version: doc.revision,
|
|
1829
1314
|
structure_version: doc.structure_version,
|
|
1830
1315
|
geometry_version: doc.geometry_version,
|
|
1831
1316
|
load_version
|
|
@@ -1845,12 +1330,21 @@ function _create_svg_editor_internal(opts) {
|
|
|
1845
1330
|
const notify_translate_commit = () => {
|
|
1846
1331
|
for (const cb of translate_commit_listeners) cb();
|
|
1847
1332
|
};
|
|
1848
|
-
|
|
1849
|
-
|
|
1333
|
+
/**
|
|
1334
|
+
* Fan out the geometry channel iff the doc's `geometry_version` has
|
|
1335
|
+
* moved since we last fired. Shared by the `doc.on_change` handler
|
|
1336
|
+
* (mutation-driven bumps) and the surface-driven `bump_geometry` seam
|
|
1337
|
+
* (font-load reflow). Idempotent against a stale version — never
|
|
1338
|
+
* double-fires for the same value.
|
|
1339
|
+
*/
|
|
1340
|
+
function fire_geometry_listeners_if_advanced() {
|
|
1850
1341
|
if (doc.geometry_version !== last_emitted_geometry_version) {
|
|
1851
1342
|
last_emitted_geometry_version = doc.geometry_version;
|
|
1852
1343
|
for (const cb of geometry_listeners) cb();
|
|
1853
1344
|
}
|
|
1345
|
+
}
|
|
1346
|
+
doc.on_change(() => {
|
|
1347
|
+
fire_geometry_listeners_if_advanced();
|
|
1854
1348
|
});
|
|
1855
1349
|
function subscribe(fn) {
|
|
1856
1350
|
listeners.add(fn);
|
|
@@ -1974,14 +1468,14 @@ function _create_svg_editor_internal(opts) {
|
|
|
1974
1468
|
function node_property_cached(id, name) {
|
|
1975
1469
|
const key = `${id}${name}`;
|
|
1976
1470
|
const cached = property_cache.get(key);
|
|
1977
|
-
if (cached && cached.
|
|
1471
|
+
if (cached && cached.revision === doc.revision) return cached.value;
|
|
1978
1472
|
const next = properties.read(doc, id, name);
|
|
1979
1473
|
if (cached && properties.value_equals(cached.value, next)) {
|
|
1980
|
-
cached.
|
|
1474
|
+
cached.revision = doc.revision;
|
|
1981
1475
|
return cached.value;
|
|
1982
1476
|
}
|
|
1983
1477
|
property_cache.set(key, {
|
|
1984
|
-
|
|
1478
|
+
revision: doc.revision,
|
|
1985
1479
|
value: next
|
|
1986
1480
|
});
|
|
1987
1481
|
return next;
|
|
@@ -1989,7 +1483,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
1989
1483
|
function node_properties(id, names) {
|
|
1990
1484
|
const key = `${id}${names.join("")}`;
|
|
1991
1485
|
const cached = properties_cache.get(key);
|
|
1992
|
-
if (cached && cached.
|
|
1486
|
+
if (cached && cached.revision === doc.revision) return cached.value;
|
|
1993
1487
|
const next = {};
|
|
1994
1488
|
let changed = !cached;
|
|
1995
1489
|
for (const name of names) {
|
|
@@ -1998,12 +1492,12 @@ function _create_svg_editor_internal(opts) {
|
|
|
1998
1492
|
if (cached && cached.value[name] !== v) changed = true;
|
|
1999
1493
|
}
|
|
2000
1494
|
if (cached && !changed) {
|
|
2001
|
-
cached.
|
|
1495
|
+
cached.revision = doc.revision;
|
|
2002
1496
|
return cached.value;
|
|
2003
1497
|
}
|
|
2004
1498
|
const frozen = Object.freeze(next);
|
|
2005
1499
|
properties_cache.set(key, {
|
|
2006
|
-
|
|
1500
|
+
revision: doc.revision,
|
|
2007
1501
|
value: frozen
|
|
2008
1502
|
});
|
|
2009
1503
|
return frozen;
|
|
@@ -2011,7 +1505,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
2011
1505
|
function node_paint(id, channel) {
|
|
2012
1506
|
const key = `${id}${channel}`;
|
|
2013
1507
|
const cached = paint_cache.get(key);
|
|
2014
|
-
if (cached && cached.
|
|
1508
|
+
if (cached && cached.revision === doc.revision) return cached.value;
|
|
2015
1509
|
const { declared, provenance } = properties.resolve_declared(doc, id, channel);
|
|
2016
1510
|
const next = {
|
|
2017
1511
|
declared,
|
|
@@ -2019,11 +1513,11 @@ function _create_svg_editor_internal(opts) {
|
|
|
2019
1513
|
provenance
|
|
2020
1514
|
};
|
|
2021
1515
|
if (cached && require_model.paint.value_equals(cached.value, next)) {
|
|
2022
|
-
cached.
|
|
1516
|
+
cached.revision = doc.revision;
|
|
2023
1517
|
return cached.value;
|
|
2024
1518
|
}
|
|
2025
1519
|
paint_cache.set(key, {
|
|
2026
|
-
|
|
1520
|
+
revision: doc.revision,
|
|
2027
1521
|
value: next
|
|
2028
1522
|
});
|
|
2029
1523
|
return next;
|
|
@@ -2112,6 +1606,13 @@ function _create_svg_editor_internal(opts) {
|
|
|
2112
1606
|
});
|
|
2113
1607
|
return { gradient_id };
|
|
2114
1608
|
}
|
|
1609
|
+
/** World→local delta projection shared by every one-shot translate
|
|
1610
|
+
* writer (translate / nudge via `prepare_rpc`, align). Re-expresses a
|
|
1611
|
+
* world-space delta in the frame the target's position attributes are
|
|
1612
|
+
* written in — nested-viewport / transformed-ancestor correctness.
|
|
1613
|
+
* Identity for flat docs and DOM-less hosts (no provider, or a
|
|
1614
|
+
* provider without a layout engine). */
|
|
1615
|
+
const project_world_delta = (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d;
|
|
2115
1616
|
/** Shared one-shot translate runner. `stages` selects semantics — see
|
|
2116
1617
|
* `core/translate-pipeline/README.md`'s "Stage lists per entry point". */
|
|
2117
1618
|
function do_translate_oneshot(delta, stages, label) {
|
|
@@ -2131,7 +1632,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
2131
1632
|
},
|
|
2132
1633
|
emit,
|
|
2133
1634
|
stages,
|
|
2134
|
-
project:
|
|
1635
|
+
project: project_world_delta
|
|
2135
1636
|
});
|
|
2136
1637
|
apply();
|
|
2137
1638
|
history.atomic(label, (tx) => {
|
|
@@ -2150,66 +1651,79 @@ function _create_svg_editor_internal(opts) {
|
|
|
2150
1651
|
if (do_translate_oneshot(delta, require_model.translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
|
|
2151
1652
|
}
|
|
2152
1653
|
/**
|
|
2153
|
-
*
|
|
2154
|
-
*
|
|
2155
|
-
*
|
|
2156
|
-
*
|
|
2157
|
-
* atomic step rather than a preview session.
|
|
1654
|
+
* Gate + capture for a resize gesture. Returns the resizable members (with
|
|
1655
|
+
* captured baseline / pre-transform / bbox), or `null` if the gesture can't
|
|
1656
|
+
* run: no geometry provider, empty selection, or — in `all_or_nothing` mode
|
|
1657
|
+
* — any member fails the gate.
|
|
2158
1658
|
*
|
|
2159
|
-
*
|
|
2160
|
-
* `
|
|
2161
|
-
*
|
|
2162
|
-
*
|
|
2163
|
-
*
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
2166
|
-
* existing `transform` round-trips cleanly. See `apply_translate`'s
|
|
2167
|
-
* `viaTransform` arm for why this matters.
|
|
1659
|
+
* `mode`:
|
|
1660
|
+
* - `"skip"` — drop members failing the `is_resizable_node` gate
|
|
1661
|
+
* (tag + transform class) or lacking a bbox; resize the rest. Used by the
|
|
1662
|
+
* inspector `resize_to` (set-bbox) path.
|
|
1663
|
+
* - `"all_or_nothing"` — refuse the WHOLE gesture (return `null`) if ANY
|
|
1664
|
+
* member fails. Used by keyboard `resize_by` (nudge), matching the resize
|
|
1665
|
+
* HUD, whose handle-drag is rejected when any member is unsafe.
|
|
2168
1666
|
*/
|
|
2169
|
-
function
|
|
2170
|
-
|
|
2171
|
-
if (
|
|
2172
|
-
if (!geometry_provider) return false;
|
|
1667
|
+
function collect_resize_members(ids, mode) {
|
|
1668
|
+
if (ids.length === 0) return null;
|
|
1669
|
+
if (!geometry_provider) return null;
|
|
2173
1670
|
const members = [];
|
|
2174
1671
|
for (const id of ids) {
|
|
2175
|
-
if (!require_model.resize_pipeline.intent.
|
|
1672
|
+
if (!require_model.resize_pipeline.intent.is_resizable_node(doc, id)) {
|
|
1673
|
+
if (mode === "all_or_nothing") return null;
|
|
1674
|
+
continue;
|
|
1675
|
+
}
|
|
2176
1676
|
const bbox = geometry_provider.bounds_of(id);
|
|
2177
|
-
if (!bbox)
|
|
1677
|
+
if (!bbox) {
|
|
1678
|
+
if (mode === "all_or_nothing") return null;
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
2178
1681
|
members.push({
|
|
2179
1682
|
id,
|
|
2180
1683
|
rz: require_model.resize_pipeline.intent.capture_baseline(doc, id, bbox),
|
|
2181
|
-
tx_pre: require_model.translate_pipeline.intent.capture_baseline(doc, id),
|
|
2182
1684
|
transform_pre: doc.get_attr(id, "transform"),
|
|
2183
1685
|
bbox
|
|
2184
1686
|
});
|
|
2185
1687
|
}
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
1688
|
+
return members.length === 0 ? null : members;
|
|
1689
|
+
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Apply a resize to each member, optionally followed by a uniform group
|
|
1692
|
+
* translate, as ONE atomic history step. `op` resolves each member's scale
|
|
1693
|
+
* factors + scale origin; `group_translate` is the post-scale envelope shift
|
|
1694
|
+
* (group resize only — `null` for per-element). Callers guarantee `members`
|
|
1695
|
+
* is non-empty. Returns `true` when a history step was pushed; `false` when
|
|
1696
|
+
* the gesture is geometrically identity (no member scales and no group
|
|
1697
|
+
* translate) so undo isn't polluted with an empty step. NOTE: a per-tag
|
|
1698
|
+
* constraint that collapses a non-1 factor to identity *inside* the handler
|
|
1699
|
+
* (e.g. `<circle>` uniform `min` on a single-axis nudge) is not detected
|
|
1700
|
+
* here — the op-level factor is still ≠ 1, so that case still pushes a step.
|
|
1701
|
+
*/
|
|
1702
|
+
function commit_resize(members, op, group_translate, label) {
|
|
1703
|
+
const ops = members.map((m) => ({
|
|
1704
|
+
m,
|
|
1705
|
+
...op(m)
|
|
1706
|
+
}));
|
|
1707
|
+
const scales = ops.some(({ sx, sy }) => sx !== 1 || sy !== 1);
|
|
1708
|
+
const translates = !!group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0);
|
|
1709
|
+
if (!scales && !translates) return false;
|
|
2196
1710
|
const apply = () => {
|
|
2197
|
-
for (const m of
|
|
2198
|
-
if (dx !== 0 || dy !== 0) for (const m of members) {
|
|
1711
|
+
for (const { m, sx, sy, origin } of ops) require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
|
|
1712
|
+
if (group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0)) for (const m of members) {
|
|
2199
1713
|
const tx_after = require_model.translate_pipeline.intent.capture_baseline(doc, m.id);
|
|
2200
|
-
require_model.translate_pipeline.intent.apply(doc, m.id, tx_after, dx, dy);
|
|
1714
|
+
require_model.translate_pipeline.intent.apply(doc, m.id, tx_after, group_translate.dx, group_translate.dy);
|
|
2201
1715
|
}
|
|
2202
1716
|
emit();
|
|
2203
1717
|
};
|
|
2204
1718
|
const revert = () => {
|
|
2205
|
-
for (const m of
|
|
1719
|
+
for (const { m, origin } of ops) {
|
|
2206
1720
|
require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
|
|
2207
1721
|
doc.set_attr(m.id, "transform", m.transform_pre);
|
|
2208
1722
|
}
|
|
2209
1723
|
emit();
|
|
2210
1724
|
};
|
|
2211
1725
|
apply();
|
|
2212
|
-
history.atomic(
|
|
1726
|
+
history.atomic(label, (tx) => {
|
|
2213
1727
|
tx.push({
|
|
2214
1728
|
providerId: PROVIDER_ID,
|
|
2215
1729
|
apply,
|
|
@@ -2218,6 +1732,73 @@ function _create_svg_editor_internal(opts) {
|
|
|
2218
1732
|
});
|
|
2219
1733
|
return true;
|
|
2220
1734
|
}
|
|
1735
|
+
/**
|
|
1736
|
+
* One-shot multi-member resize to an explicit target rect. Mirrors a
|
|
1737
|
+
* drag-resize gesture in mechanics — capture per-member baselines,
|
|
1738
|
+
* scale around the union's NW corner, translate the result so the
|
|
1739
|
+
* union NW lands at the requested position — but as a single
|
|
1740
|
+
* atomic step rather than a preview session. This is the GROUP path:
|
|
1741
|
+
* the whole selection is treated as one envelope.
|
|
1742
|
+
*
|
|
1743
|
+
* The function does its own geometry lookup via the
|
|
1744
|
+
* `geometry_provider` registered by the DOM surface. When no surface
|
|
1745
|
+
* is attached, the call is a no-op (returns `false`). Members that fail
|
|
1746
|
+
* the `is_resizable_node` gate — an unresizable tag (e.g. `<g>`) OR a
|
|
1747
|
+
* non-trivially-transformed element — are silently skipped (see
|
|
1748
|
+
* `collect_resize_members`).
|
|
1749
|
+
*
|
|
1750
|
+
* Revert restores the captured `transform` attribute and all
|
|
1751
|
+
* geometry attrs the apply step wrote — so a `<rect>` with an
|
|
1752
|
+
* existing `transform` round-trips cleanly. See `apply_translate`'s
|
|
1753
|
+
* `viaTransform` arm for why this matters.
|
|
1754
|
+
*/
|
|
1755
|
+
function resize_to(target, opts) {
|
|
1756
|
+
const members = collect_resize_members(opts?.ids ?? selection, "skip");
|
|
1757
|
+
if (!members) return false;
|
|
1758
|
+
const union = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
|
|
1759
|
+
const sx = union.width === 0 ? 1 : target.width / union.width;
|
|
1760
|
+
const sy = union.height === 0 ? 1 : target.height / union.height;
|
|
1761
|
+
const origin = {
|
|
1762
|
+
x: union.x,
|
|
1763
|
+
y: union.y
|
|
1764
|
+
};
|
|
1765
|
+
return commit_resize(members, () => ({
|
|
1766
|
+
sx,
|
|
1767
|
+
sy,
|
|
1768
|
+
origin
|
|
1769
|
+
}), {
|
|
1770
|
+
dx: target.x - union.x,
|
|
1771
|
+
dy: target.y - union.y
|
|
1772
|
+
}, opts?.label ?? "resize-to");
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Resize by a `{dw, dh}` delta — the core verb behind keyboard nudge-resize
|
|
1776
|
+
* (`Ctrl+Alt+Arrow`). This is the PER-ELEMENT path: each selected member
|
|
1777
|
+
* grows/shrinks by the delta around ITS OWN NW corner, so members keep their
|
|
1778
|
+
* positions relative to one another. This deliberately differs from
|
|
1779
|
+
* {@link resize_to} (the group/envelope path): a HUD group-resize scales the
|
|
1780
|
+
* whole selection around the shared union origin, translating off-origin
|
|
1781
|
+
* members — correct for a drag handle, wrong for a keyboard nudge, whose UX
|
|
1782
|
+
* is "apply the delta to each".
|
|
1783
|
+
*
|
|
1784
|
+
* ALL-OR-NOTHING gate (`collect_resize_members("all_or_nothing")`): refuses
|
|
1785
|
+
* (returns `false`, no history step) on empty selection, no geometry
|
|
1786
|
+
* provider, or any member failing the `is_resizable_node` gate — matching
|
|
1787
|
+
* the resize HUD rather than `resize_to`'s per-member skip.
|
|
1788
|
+
*/
|
|
1789
|
+
function resize_by(delta, opts) {
|
|
1790
|
+
const members = collect_resize_members(opts?.ids ?? selection, "all_or_nothing");
|
|
1791
|
+
if (!members) return false;
|
|
1792
|
+
const axis = (size, d) => size === 0 ? 1 : Math.max(0, size + d) / size;
|
|
1793
|
+
return commit_resize(members, (m) => ({
|
|
1794
|
+
sx: axis(m.bbox.width, delta.dw),
|
|
1795
|
+
sy: axis(m.bbox.height, delta.dh),
|
|
1796
|
+
origin: {
|
|
1797
|
+
x: m.bbox.x,
|
|
1798
|
+
y: m.bbox.y
|
|
1799
|
+
}
|
|
1800
|
+
}), null, "nudge-resize");
|
|
1801
|
+
}
|
|
2221
1802
|
/** Shared helper: compute a default rotation pivot from the live
|
|
2222
1803
|
* geometry_provider when the caller omitted one. Falls back to (0,0)
|
|
2223
1804
|
* if no surface is attached. */
|
|
@@ -2299,6 +1880,70 @@ function _create_svg_editor_internal(opts) {
|
|
|
2299
1880
|
});
|
|
2300
1881
|
return true;
|
|
2301
1882
|
}
|
|
1883
|
+
/**
|
|
1884
|
+
* Relative affine compose about a pivot. See the `Commands.transform`
|
|
1885
|
+
* doc for the full contract. This function owns ONLY the pivot/effective-
|
|
1886
|
+
* matrix computation (which needs `geometry_provider`); the parse→fold→
|
|
1887
|
+
* emit round-trip is delegated per-member to the pure
|
|
1888
|
+
* `transform.apply_affine` helper.
|
|
1889
|
+
*/
|
|
1890
|
+
function apply_transform(matrix, opts) {
|
|
1891
|
+
const ids = opts?.ids ?? selection;
|
|
1892
|
+
if (ids.length === 0) return false;
|
|
1893
|
+
if (!geometry_provider) return false;
|
|
1894
|
+
for (const id of ids) if (require_model.rotate_pipeline.intent.is_transformable(doc, id).kind === "refuse") return false;
|
|
1895
|
+
const pivot = opts?.pivot ?? default_rotate_pivot(ids);
|
|
1896
|
+
const [a, b, c, d, e, f] = matrix;
|
|
1897
|
+
const requested = [[
|
|
1898
|
+
a,
|
|
1899
|
+
c,
|
|
1900
|
+
e
|
|
1901
|
+
], [
|
|
1902
|
+
b,
|
|
1903
|
+
d,
|
|
1904
|
+
f
|
|
1905
|
+
]];
|
|
1906
|
+
const t_pivot = [[
|
|
1907
|
+
1,
|
|
1908
|
+
0,
|
|
1909
|
+
pivot.x
|
|
1910
|
+
], [
|
|
1911
|
+
0,
|
|
1912
|
+
1,
|
|
1913
|
+
pivot.y
|
|
1914
|
+
]];
|
|
1915
|
+
const t_neg_pivot = [[
|
|
1916
|
+
1,
|
|
1917
|
+
0,
|
|
1918
|
+
-pivot.x
|
|
1919
|
+
], [
|
|
1920
|
+
0,
|
|
1921
|
+
1,
|
|
1922
|
+
-pivot.y
|
|
1923
|
+
]];
|
|
1924
|
+
const effective = _grida_cmath.default.transform.multiply(_grida_cmath.default.transform.multiply(t_pivot, requested), t_neg_pivot);
|
|
1925
|
+
const members = ids.map((id) => ({
|
|
1926
|
+
id,
|
|
1927
|
+
transform_pre: doc.get_attr(id, "transform")
|
|
1928
|
+
}));
|
|
1929
|
+
const apply = () => {
|
|
1930
|
+
for (const m of members) doc.set_attr(m.id, "transform", require_model.transform.apply_affine(m.transform_pre, effective));
|
|
1931
|
+
emit();
|
|
1932
|
+
};
|
|
1933
|
+
const revert = () => {
|
|
1934
|
+
for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
|
|
1935
|
+
emit();
|
|
1936
|
+
};
|
|
1937
|
+
apply();
|
|
1938
|
+
history.atomic("transform", (tx) => {
|
|
1939
|
+
tx.push({
|
|
1940
|
+
providerId: PROVIDER_ID,
|
|
1941
|
+
apply,
|
|
1942
|
+
revert
|
|
1943
|
+
});
|
|
1944
|
+
});
|
|
1945
|
+
return true;
|
|
1946
|
+
}
|
|
2302
1947
|
function flatten_transform(opts) {
|
|
2303
1948
|
const ids = opts?.ids ?? selection;
|
|
2304
1949
|
if (ids.length === 0) return false;
|
|
@@ -2351,7 +1996,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
2351
1996
|
* center of a reference rect. Same mechanics as `resize_to`: per-member
|
|
2352
1997
|
* translate baselines (so `<g>`, transformed, and natively-attributed
|
|
2353
1998
|
* nodes all write the cleanest in-place representation), one atomic
|
|
2354
|
-
* history step.
|
|
1999
|
+
* history step. Deltas are computed in world space and re-expressed in
|
|
2000
|
+
* each member's local frame before writing (`world_delta_to_local`),
|
|
2001
|
+
* so members under scaled/rotated ancestors land exactly and a repeat
|
|
2002
|
+
* invocation is a no-op.
|
|
2355
2003
|
*
|
|
2356
2004
|
* Reference rect is selection-size dependent:
|
|
2357
2005
|
* - multi-selection: union of member bboxes
|
|
@@ -2387,8 +2035,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
2387
2035
|
if (!parent_bbox) return false;
|
|
2388
2036
|
target = parent_bbox;
|
|
2389
2037
|
} else target = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
|
|
2390
|
-
const
|
|
2391
|
-
if (
|
|
2038
|
+
const world_deltas = compute_align_deltas(members, target, direction);
|
|
2039
|
+
if (world_deltas.size === 0) return false;
|
|
2040
|
+
const deltas = /* @__PURE__ */ new Map();
|
|
2041
|
+
for (const [id, d] of world_deltas) deltas.set(id, project_world_delta(id, d));
|
|
2392
2042
|
const apply = () => {
|
|
2393
2043
|
for (const m of members) {
|
|
2394
2044
|
const d = deltas.get(m.id);
|
|
@@ -2490,13 +2140,19 @@ function _create_svg_editor_internal(opts) {
|
|
|
2490
2140
|
});
|
|
2491
2141
|
}
|
|
2492
2142
|
function remove() {
|
|
2143
|
+
remove_selection("remove");
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Shared deletion body for `remove` and `cut` — identical
|
|
2147
|
+
* capture/revert semantics, differing only in the history label
|
|
2148
|
+
* (`verb`), so undo attribution names the gesture that caused the
|
|
2149
|
+
* deletion.
|
|
2150
|
+
*/
|
|
2151
|
+
function remove_selection(verb) {
|
|
2493
2152
|
if (selection.length === 0) return;
|
|
2494
2153
|
const filtered = doc.prune_nested_nodes(selection).filter((id) => doc.parent_of(id) !== null);
|
|
2495
2154
|
if (filtered.length === 0) return;
|
|
2496
|
-
const
|
|
2497
|
-
const index_of = /* @__PURE__ */ new Map();
|
|
2498
|
-
for (let i = 0; i < doc_order.length; i++) index_of.set(doc_order[i], i);
|
|
2499
|
-
const captures = [...filtered].sort((a, b) => (index_of.get(a) ?? 0) - (index_of.get(b) ?? 0)).map((id) => ({
|
|
2155
|
+
const captures = [...filtered].sort(require_model.subtree.by_document_order(doc)).map((id) => ({
|
|
2500
2156
|
id,
|
|
2501
2157
|
parent: doc.parent_of(id),
|
|
2502
2158
|
next_sibling: doc.next_element_sibling_of(id)
|
|
@@ -2514,7 +2170,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
2514
2170
|
set_selection(old_selection);
|
|
2515
2171
|
};
|
|
2516
2172
|
apply();
|
|
2517
|
-
history.atomic(captures.length === 1 ?
|
|
2173
|
+
history.atomic(captures.length === 1 ? verb : `${verb} ${captures.length}`, (tx) => {
|
|
2518
2174
|
tx.push({
|
|
2519
2175
|
providerId: PROVIDER_ID,
|
|
2520
2176
|
apply,
|
|
@@ -2550,6 +2206,47 @@ function _create_svg_editor_internal(opts) {
|
|
|
2550
2206
|
});
|
|
2551
2207
|
return true;
|
|
2552
2208
|
}
|
|
2209
|
+
function ungroup(opts) {
|
|
2210
|
+
let target;
|
|
2211
|
+
if (opts?.id !== void 0) target = opts.id;
|
|
2212
|
+
else {
|
|
2213
|
+
if (selection.length !== 1) return false;
|
|
2214
|
+
target = selection[0];
|
|
2215
|
+
}
|
|
2216
|
+
const plan = require_model.group.plan_ungroup(doc, target);
|
|
2217
|
+
if (!plan) return false;
|
|
2218
|
+
const group_id = plan.group_id;
|
|
2219
|
+
const group_next_sibling = doc.next_element_sibling_of(group_id);
|
|
2220
|
+
const original_child_transforms = /* @__PURE__ */ new Map();
|
|
2221
|
+
for (const child of plan.children) original_child_transforms.set(child, doc.get_attr(child, "transform"));
|
|
2222
|
+
const group_ops = plan.group_transform === null ? [] : require_model.transform.parse(plan.group_transform) ?? [];
|
|
2223
|
+
const original_selection = selection;
|
|
2224
|
+
const apply = () => {
|
|
2225
|
+
if (group_ops.length > 0) for (const child of plan.children) {
|
|
2226
|
+
const child_ops = require_model.transform.parse(doc.get_attr(child, "transform")) ?? [];
|
|
2227
|
+
const next = require_model.transform.emit([...group_ops, ...child_ops]);
|
|
2228
|
+
doc.set_attr(child, "transform", next === "" ? null : next);
|
|
2229
|
+
}
|
|
2230
|
+
for (const child of plan.children) doc.insert(child, plan.parent, group_id);
|
|
2231
|
+
doc.remove(group_id);
|
|
2232
|
+
set_selection(plan.children);
|
|
2233
|
+
};
|
|
2234
|
+
const revert = () => {
|
|
2235
|
+
doc.insert(group_id, plan.parent, group_next_sibling);
|
|
2236
|
+
for (const child of plan.children) doc.insert(child, group_id, null);
|
|
2237
|
+
if (group_ops.length > 0) for (const child of plan.children) doc.set_attr(child, "transform", original_child_transforms.get(child) ?? null);
|
|
2238
|
+
set_selection(original_selection);
|
|
2239
|
+
};
|
|
2240
|
+
apply();
|
|
2241
|
+
history.atomic("ungroup", (tx) => {
|
|
2242
|
+
tx.push({
|
|
2243
|
+
providerId: PROVIDER_ID,
|
|
2244
|
+
apply,
|
|
2245
|
+
revert
|
|
2246
|
+
});
|
|
2247
|
+
});
|
|
2248
|
+
return true;
|
|
2249
|
+
}
|
|
2553
2250
|
/**
|
|
2554
2251
|
* Atomic one-shot insertion. Used by paste, programmatic RPC, and the
|
|
2555
2252
|
* click-no-drag commit path inside the insertion gesture driver. One
|
|
@@ -2559,11 +2256,20 @@ function _create_svg_editor_internal(opts) {
|
|
|
2559
2256
|
* win. `opts.parent` defaults to root; `opts.index` (insert-before
|
|
2560
2257
|
* sibling index) defaults to append; `opts.select` defaults to `true`.
|
|
2561
2258
|
*/
|
|
2259
|
+
/**
|
|
2260
|
+
* Resolve an optional `index` (position in `parent`'s element-children
|
|
2261
|
+
* list to insert AT — anything at or after it shifts; out-of-range or
|
|
2262
|
+
* `undefined` appends) to an insert-before anchor. Shared by `insert`,
|
|
2263
|
+
* `insert_fragment`, and `insert_preview`.
|
|
2264
|
+
*/
|
|
2265
|
+
function resolve_insert_before(parent, index) {
|
|
2266
|
+
if (index === void 0) return null;
|
|
2267
|
+
return doc.element_children_of(parent)[index] ?? null;
|
|
2268
|
+
}
|
|
2562
2269
|
function insert(tag, attrs, opts) {
|
|
2563
2270
|
const parent = opts?.parent ?? doc.root;
|
|
2564
2271
|
const select_after = opts?.select !== false;
|
|
2565
|
-
|
|
2566
|
-
if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
|
|
2272
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2567
2273
|
const id = doc.create_element(tag);
|
|
2568
2274
|
const merged_attrs = {
|
|
2569
2275
|
...default_paint_attrs_for(tag),
|
|
@@ -2591,6 +2297,153 @@ function _create_svg_editor_internal(opts) {
|
|
|
2591
2297
|
return id;
|
|
2592
2298
|
}
|
|
2593
2299
|
/**
|
|
2300
|
+
* Atomic fragment insertion — contract in {@link Commands.insert_fragment}.
|
|
2301
|
+
* Parses + adopts via `doc.create_fragment` (subtrees registered but
|
|
2302
|
+
* detached, like `create_element` — history.redo finds them via
|
|
2303
|
+
* closure), computes the namespace hoist plan, then brackets inserts +
|
|
2304
|
+
* hoisted declarations + selection in ONE history step.
|
|
2305
|
+
*/
|
|
2306
|
+
function insert_fragment(svg, opts) {
|
|
2307
|
+
return insert_fragment_impl(svg, opts, "insert fragment");
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Label-bearing body shared by `insert_fragment` and `paste` — same
|
|
2311
|
+
* atomic insertion, differing only in history attribution (undo for a
|
|
2312
|
+
* paste gesture should read "paste", not "insert fragment").
|
|
2313
|
+
*/
|
|
2314
|
+
function insert_fragment_impl(svg, opts, label) {
|
|
2315
|
+
const parent = opts?.parent ?? doc.root;
|
|
2316
|
+
if (!doc.is_element(parent) || !doc.contains(doc.root, parent)) throw new Error(`insert_fragment: parent ${JSON.stringify(parent)} is not an element in the current document`);
|
|
2317
|
+
const select_after = opts?.select !== false;
|
|
2318
|
+
const { roots, xmlns } = doc.create_fragment(svg);
|
|
2319
|
+
if (roots.length === 0) return [];
|
|
2320
|
+
const known_uri = new Map(require_model.WELL_KNOWN_NS_PREFIXES);
|
|
2321
|
+
for (const d of xmlns) known_uri.set(d.prefix, d.uri);
|
|
2322
|
+
const hoist = [];
|
|
2323
|
+
const considered = /* @__PURE__ */ new Set();
|
|
2324
|
+
for (const id of roots) for (const prefix of doc.undeclared_ns_prefixes(id)) {
|
|
2325
|
+
if (considered.has(prefix)) continue;
|
|
2326
|
+
considered.add(prefix);
|
|
2327
|
+
if (doc.get_attr(doc.root, prefix, _grida_svg_parser.XMLNS_NS) !== null) continue;
|
|
2328
|
+
const uri = known_uri.get(prefix);
|
|
2329
|
+
if (uri === void 0) continue;
|
|
2330
|
+
hoist.push({
|
|
2331
|
+
prefix,
|
|
2332
|
+
uri
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2336
|
+
const previous_selection = selection;
|
|
2337
|
+
const apply = () => {
|
|
2338
|
+
for (const { prefix, uri } of hoist) doc.declare_xmlns(prefix, uri);
|
|
2339
|
+
for (const id of roots) doc.insert(id, parent, insert_before);
|
|
2340
|
+
if (select_after) set_selection(roots);
|
|
2341
|
+
};
|
|
2342
|
+
const revert = () => {
|
|
2343
|
+
for (let i = roots.length - 1; i >= 0; i--) doc.remove(roots[i]);
|
|
2344
|
+
for (const { prefix } of hoist) doc.set_attr(doc.root, prefix, null, _grida_svg_parser.XMLNS_NS);
|
|
2345
|
+
if (select_after) set_selection(previous_selection);
|
|
2346
|
+
};
|
|
2347
|
+
apply();
|
|
2348
|
+
history.atomic(label, (tx) => {
|
|
2349
|
+
tx.push({
|
|
2350
|
+
providerId: PROVIDER_ID,
|
|
2351
|
+
apply,
|
|
2352
|
+
revert
|
|
2353
|
+
});
|
|
2354
|
+
});
|
|
2355
|
+
return roots;
|
|
2356
|
+
}
|
|
2357
|
+
function copy_impl(deliver_external) {
|
|
2358
|
+
const payload = clipboard.extract_payload(doc, selection);
|
|
2359
|
+
if (payload === null) return null;
|
|
2360
|
+
clipboard_buffer = payload;
|
|
2361
|
+
if (deliver_external && providers.clipboard) providers.clipboard.write(payload).catch((err) => {
|
|
2362
|
+
console.warn("[svg-editor] clipboard provider write failed:", err);
|
|
2363
|
+
});
|
|
2364
|
+
return payload;
|
|
2365
|
+
}
|
|
2366
|
+
function copy() {
|
|
2367
|
+
return copy_impl(true);
|
|
2368
|
+
}
|
|
2369
|
+
function cut_impl(deliver_external) {
|
|
2370
|
+
const payload = copy_impl(deliver_external);
|
|
2371
|
+
if (payload === null) return null;
|
|
2372
|
+
remove_selection("cut");
|
|
2373
|
+
return payload;
|
|
2374
|
+
}
|
|
2375
|
+
function cut() {
|
|
2376
|
+
return cut_impl(true);
|
|
2377
|
+
}
|
|
2378
|
+
function paste(text) {
|
|
2379
|
+
if (text !== void 0 && typeof text !== "string") throw new TypeError(`paste(text) requires a string when provided, got ${text === null ? "null" : typeof text}`);
|
|
2380
|
+
const source = text ?? clipboard_buffer;
|
|
2381
|
+
if (source === null) return [];
|
|
2382
|
+
try {
|
|
2383
|
+
return insert_fragment_impl(source, void 0, "paste");
|
|
2384
|
+
} catch (err) {
|
|
2385
|
+
if (err instanceof TypeError) throw err;
|
|
2386
|
+
return [];
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* Duplicate over `subtree.clone_plan` — see the `Commands` doc. Same
|
|
2391
|
+
* atomic shape as `insert_fragment_impl`: closures own the
|
|
2392
|
+
* insert/remove pair so redo re-inserts the same NodeIds.
|
|
2393
|
+
*
|
|
2394
|
+
* Repeating offset (gridaco/grida#825, spec §Repeating offset): when
|
|
2395
|
+
* the targets are exactly the previous duplication's clones and
|
|
2396
|
+
* geometry witnesses a rigid translate between that record's origins
|
|
2397
|
+
* and clones, the fresh clones land displaced by the same delta. The
|
|
2398
|
+
* offset rides the translate pipeline INSIDE the same atomic step —
|
|
2399
|
+
* one undo removes copy + offset together. Clone baselines are
|
|
2400
|
+
* key-swapped from the origins (a clone is a verbatim copy at rest —
|
|
2401
|
+
* the orchestrator's `enter_clone` trick), so nothing reads the
|
|
2402
|
+
* detached clones. Any failed precondition degrades to plain
|
|
2403
|
+
* duplicate-in-place; never an error.
|
|
2404
|
+
*/
|
|
2405
|
+
function duplicate() {
|
|
2406
|
+
const plan = require_model.subtree.clone_plan(doc, selection);
|
|
2407
|
+
if (plan.length === 0) return [];
|
|
2408
|
+
const clones = plan.map((p) => p.clone);
|
|
2409
|
+
const origins = plan.map((p) => p.origin);
|
|
2410
|
+
const previous_selection = selection;
|
|
2411
|
+
const delta = require_model.subtree.repeat_delta(active_duplication, origins, (id) => geometry_provider ? geometry_provider.bounds_of(id) : null);
|
|
2412
|
+
let offset_plan = null;
|
|
2413
|
+
if (delta) {
|
|
2414
|
+
const baselines = /* @__PURE__ */ new Map();
|
|
2415
|
+
for (const p of plan) baselines.set(p.clone, require_model.translate_pipeline.intent.capture_baseline(doc, p.origin));
|
|
2416
|
+
offset_plan = {
|
|
2417
|
+
ids: clones,
|
|
2418
|
+
baselines,
|
|
2419
|
+
delta
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
const apply = () => {
|
|
2423
|
+
require_model.subtree.insert_plan(doc, plan);
|
|
2424
|
+
if (offset_plan) require_model.translate_pipeline.apply(doc, offset_plan, project_world_delta);
|
|
2425
|
+
set_selection(clones);
|
|
2426
|
+
};
|
|
2427
|
+
const revert = () => {
|
|
2428
|
+
if (offset_plan) require_model.translate_pipeline.revert(doc, offset_plan);
|
|
2429
|
+
require_model.subtree.remove_plan(doc, plan);
|
|
2430
|
+
set_selection(previous_selection);
|
|
2431
|
+
};
|
|
2432
|
+
apply();
|
|
2433
|
+
history.atomic("duplicate", (tx) => {
|
|
2434
|
+
tx.push({
|
|
2435
|
+
providerId: PROVIDER_ID,
|
|
2436
|
+
apply,
|
|
2437
|
+
revert
|
|
2438
|
+
});
|
|
2439
|
+
});
|
|
2440
|
+
active_duplication = {
|
|
2441
|
+
origins,
|
|
2442
|
+
clones
|
|
2443
|
+
};
|
|
2444
|
+
return clones;
|
|
2445
|
+
}
|
|
2446
|
+
/**
|
|
2594
2447
|
* Preview-bracketed insertion. Used by the pointer-driven drag gesture
|
|
2595
2448
|
* in the DOM surface. Per-frame attr writes call `update(attrs)`; one
|
|
2596
2449
|
* undo step on `commit()`; clean rollback on `discard()`.
|
|
@@ -2601,8 +2454,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
2601
2454
|
*/
|
|
2602
2455
|
function insert_preview(tag, initial, opts) {
|
|
2603
2456
|
const parent = opts?.parent ?? doc.root;
|
|
2604
|
-
|
|
2605
|
-
if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
|
|
2457
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2606
2458
|
const id = doc.create_element(tag);
|
|
2607
2459
|
const previous_selection = selection;
|
|
2608
2460
|
const live_attrs = {
|
|
@@ -2760,6 +2612,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
2760
2612
|
current_surface_hover = id;
|
|
2761
2613
|
notify_surface_hover();
|
|
2762
2614
|
}
|
|
2615
|
+
const pick_listeners = /* @__PURE__ */ new Set();
|
|
2616
|
+
function notify_pick(e) {
|
|
2617
|
+
for (const cb of pick_listeners) cb(e);
|
|
2618
|
+
}
|
|
2763
2619
|
function enter_content_edit(target) {
|
|
2764
2620
|
const id = target ?? (selection.length === 1 ? selection[0] : null);
|
|
2765
2621
|
if (!id) return false;
|
|
@@ -2774,7 +2630,8 @@ function _create_svg_editor_internal(opts) {
|
|
|
2774
2630
|
mode = "select";
|
|
2775
2631
|
tool = require_model.TOOL_CURSOR;
|
|
2776
2632
|
history.clear();
|
|
2777
|
-
|
|
2633
|
+
active_duplication = null;
|
|
2634
|
+
baseline_revision = doc.revision;
|
|
2778
2635
|
load_version++;
|
|
2779
2636
|
emit();
|
|
2780
2637
|
}
|
|
@@ -2805,14 +2662,22 @@ function _create_svg_editor_internal(opts) {
|
|
|
2805
2662
|
translate,
|
|
2806
2663
|
nudge,
|
|
2807
2664
|
resize_to,
|
|
2665
|
+
resize_by,
|
|
2808
2666
|
rotate,
|
|
2809
2667
|
rotate_to,
|
|
2668
|
+
transform: apply_transform,
|
|
2810
2669
|
flatten_transform,
|
|
2811
2670
|
align,
|
|
2812
2671
|
reorder,
|
|
2813
2672
|
remove,
|
|
2673
|
+
copy,
|
|
2674
|
+
cut,
|
|
2675
|
+
paste,
|
|
2676
|
+
duplicate,
|
|
2814
2677
|
group: group$1,
|
|
2678
|
+
ungroup,
|
|
2815
2679
|
insert,
|
|
2680
|
+
insert_fragment,
|
|
2816
2681
|
insert_preview,
|
|
2817
2682
|
set_text,
|
|
2818
2683
|
load_svg,
|
|
@@ -2836,7 +2701,8 @@ function _create_svg_editor_internal(opts) {
|
|
|
2836
2701
|
scope = null;
|
|
2837
2702
|
mode = "select";
|
|
2838
2703
|
tool = require_model.TOOL_CURSOR;
|
|
2839
|
-
|
|
2704
|
+
active_duplication = null;
|
|
2705
|
+
baseline_revision = doc.revision;
|
|
2840
2706
|
emit();
|
|
2841
2707
|
}
|
|
2842
2708
|
function attach(surface) {
|
|
@@ -2858,6 +2724,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
2858
2724
|
function dispose() {
|
|
2859
2725
|
detach();
|
|
2860
2726
|
listeners.clear();
|
|
2727
|
+
surface_hover_listeners.clear();
|
|
2728
|
+
geometry_listeners.clear();
|
|
2729
|
+
translate_commit_listeners.clear();
|
|
2730
|
+
pick_listeners.clear();
|
|
2861
2731
|
}
|
|
2862
2732
|
function set_style(partial) {
|
|
2863
2733
|
style = {
|
|
@@ -2949,6 +2819,22 @@ function _create_svg_editor_internal(opts) {
|
|
|
2949
2819
|
};
|
|
2950
2820
|
},
|
|
2951
2821
|
/**
|
|
2822
|
+
* Subscribe to pick (tap) outcomes — a discrete click on the canvas,
|
|
2823
|
+
* reporting the document-space point and the node under it (`null` for
|
|
2824
|
+
* empty canvas), plus the button and modifier snapshot. Fires once per
|
|
2825
|
+
* tap, after the editor's own selection handling. Observe-only: a pick
|
|
2826
|
+
* cannot alter selection, and the channel does NOT bump `state.version`.
|
|
2827
|
+
* See {@link PickEvent}.
|
|
2828
|
+
*
|
|
2829
|
+
* @unstable
|
|
2830
|
+
*/
|
|
2831
|
+
subscribe_pick(cb) {
|
|
2832
|
+
pick_listeners.add(cb);
|
|
2833
|
+
return () => {
|
|
2834
|
+
pick_listeners.delete(cb);
|
|
2835
|
+
};
|
|
2836
|
+
},
|
|
2837
|
+
/**
|
|
2952
2838
|
* Subscribe to bounds-affecting changes. Fires when any document
|
|
2953
2839
|
* mutation advances `state.geometry_version` — drag, resize, text
|
|
2954
2840
|
* edit, structural insert/remove. Skips presentation-only writes
|
|
@@ -2999,7 +2885,14 @@ function _create_svg_editor_internal(opts) {
|
|
|
2999
2885
|
providers,
|
|
3000
2886
|
_internal: {
|
|
3001
2887
|
doc,
|
|
3002
|
-
history: {
|
|
2888
|
+
history: {
|
|
2889
|
+
preview: (label) => history.preview(label),
|
|
2890
|
+
undo_label: () => history.stack.undoLabel
|
|
2891
|
+
},
|
|
2892
|
+
clipboard: {
|
|
2893
|
+
copy: () => copy_impl(false),
|
|
2894
|
+
cut: () => cut_impl(false)
|
|
2895
|
+
},
|
|
3003
2896
|
insert_text_preview,
|
|
3004
2897
|
emit,
|
|
3005
2898
|
subscribe_translate_commit(cb) {
|
|
@@ -3009,6 +2902,9 @@ function _create_svg_editor_internal(opts) {
|
|
|
3009
2902
|
};
|
|
3010
2903
|
},
|
|
3011
2904
|
notify_translate_commit,
|
|
2905
|
+
seed_duplication(record) {
|
|
2906
|
+
active_duplication = record;
|
|
2907
|
+
},
|
|
3012
2908
|
set_content_edit_driver(fn) {
|
|
3013
2909
|
content_edit_driver = fn;
|
|
3014
2910
|
},
|
|
@@ -3019,11 +2915,18 @@ function _create_svg_editor_internal(opts) {
|
|
|
3019
2915
|
push_surface_hover(id) {
|
|
3020
2916
|
_set_current_surface_hover(id);
|
|
3021
2917
|
},
|
|
2918
|
+
push_pick(e) {
|
|
2919
|
+
notify_pick(e);
|
|
2920
|
+
},
|
|
3022
2921
|
set_computed_resolver(fn) {
|
|
3023
2922
|
computed_resolver = fn;
|
|
3024
2923
|
},
|
|
3025
2924
|
set_geometry(p) {
|
|
3026
2925
|
geometry_provider = p;
|
|
2926
|
+
},
|
|
2927
|
+
bump_geometry() {
|
|
2928
|
+
doc.bump_geometry();
|
|
2929
|
+
fire_geometry_listeners_if_advanced();
|
|
3027
2930
|
}
|
|
3028
2931
|
},
|
|
3029
2932
|
keymap
|