@grida/svg-editor 1.0.0-alpha.15 → 1.0.0-alpha.16
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 +36 -0
- package/dist/{dom-CK6GlgFF.d.mts → dom-98AUOfsP.d.mts} +44 -2
- package/dist/{dom-CsKXTaNw.d.ts → dom-BO2-E9oK.d.ts} +44 -2
- package/dist/{dom-DILY80j7.mjs → dom-DOvcMvl4.mjs} +67 -2
- package/dist/{dom-Dee6FtgZ.js → dom-U6ae5fQF.js} +72 -1
- 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-F8ckj9X1.js → editor-C6Lj1In-.js} +416 -865
- package/dist/{editor-BKoo9SPL.d.ts → editor-CYoGJ3Hf.d.ts} +311 -5
- package/dist/{editor-Dl7c0q5A.d.mts → editor-D2eQe8lB.d.mts} +311 -5
- package/dist/{editor-CvWpD5mu.mjs → editor-DKQOIKuU.mjs} +415 -865
- 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-CJ1Ctq14.js → model-D0nU_EkL.js} +1176 -62
- package/dist/{model-B2UWgViT.mjs → model-L3t9ixT_.mjs} +1171 -63
- 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 +20 -3
- package/dist/react.d.ts +20 -3
- package/dist/react.js +25 -2
- package/dist/react.mjs +25 -3
- package/package.json +8 -5
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
const require_model = require("./model-
|
|
1
|
+
const require_model = require("./model-D0nU_EkL.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,26 @@ 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.ungroup", () => {
|
|
88
|
+
if (editor.state.mode !== "select") return false;
|
|
89
|
+
if (editor.state.selection.length !== 1) return false;
|
|
90
|
+
return editor.commands.ungroup();
|
|
91
|
+
});
|
|
88
92
|
reg.register("selection.resize_to", (args) => {
|
|
89
93
|
if (editor.state.mode !== "select") return false;
|
|
90
94
|
if (editor.state.selection.length === 0) return false;
|
|
91
95
|
const target = args;
|
|
92
96
|
return editor.commands.resize_to(target);
|
|
93
97
|
});
|
|
98
|
+
reg.register("selection.nudge_resize", (args) => {
|
|
99
|
+
if (editor.state.mode !== "select") return false;
|
|
100
|
+
if (editor.state.selection.length === 0) return false;
|
|
101
|
+
const { dw, dh } = args;
|
|
102
|
+
return editor.commands.resize_by({
|
|
103
|
+
dw,
|
|
104
|
+
dh
|
|
105
|
+
});
|
|
106
|
+
});
|
|
94
107
|
reg.register("selection.rotate", (args) => {
|
|
95
108
|
if (editor.state.mode !== "select") return false;
|
|
96
109
|
if (editor.state.selection.length === 0) return false;
|
|
@@ -310,7 +323,8 @@ function compareEntries(a, b) {
|
|
|
310
323
|
* Same key, multiple meanings? Add multiple rows. The chain semantics
|
|
311
324
|
* (handler returns `false` when not applicable) handle the rest.
|
|
312
325
|
*/
|
|
313
|
-
const NUDGE_MEANINGFUL = _grida_keybinding.M.Shift;
|
|
326
|
+
const NUDGE_MEANINGFUL = _grida_keybinding.M.Shift | _grida_keybinding.M.Ctrl;
|
|
327
|
+
const RESIZE_MEANINGFUL = _grida_keybinding.M.Shift | _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt;
|
|
314
328
|
const DEFAULT_BINDINGS = [
|
|
315
329
|
{
|
|
316
330
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyZ, _grida_keybinding.M.CtrlCmd),
|
|
@@ -340,6 +354,10 @@ const DEFAULT_BINDINGS = [
|
|
|
340
354
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd),
|
|
341
355
|
command: "selection.group"
|
|
342
356
|
},
|
|
357
|
+
{
|
|
358
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd | _grida_keybinding.M.Shift),
|
|
359
|
+
command: "selection.ungroup"
|
|
360
|
+
},
|
|
343
361
|
{
|
|
344
362
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyA, _grida_keybinding.M.CtrlCmd),
|
|
345
363
|
command: "selection.all"
|
|
@@ -460,6 +478,70 @@ const DEFAULT_BINDINGS = [
|
|
|
460
478
|
dy: 10
|
|
461
479
|
}
|
|
462
480
|
},
|
|
481
|
+
{
|
|
482
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
|
|
483
|
+
command: "selection.nudge_resize",
|
|
484
|
+
args: {
|
|
485
|
+
dw: 1,
|
|
486
|
+
dh: 0
|
|
487
|
+
}
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
|
|
491
|
+
command: "selection.nudge_resize",
|
|
492
|
+
args: {
|
|
493
|
+
dw: -1,
|
|
494
|
+
dh: 0
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
|
|
499
|
+
command: "selection.nudge_resize",
|
|
500
|
+
args: {
|
|
501
|
+
dw: 0,
|
|
502
|
+
dh: 1
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt, RESIZE_MEANINGFUL),
|
|
507
|
+
command: "selection.nudge_resize",
|
|
508
|
+
args: {
|
|
509
|
+
dw: 0,
|
|
510
|
+
dh: -1
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.RightArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
|
|
515
|
+
command: "selection.nudge_resize",
|
|
516
|
+
args: {
|
|
517
|
+
dw: 10,
|
|
518
|
+
dh: 0
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.LeftArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
|
|
523
|
+
command: "selection.nudge_resize",
|
|
524
|
+
args: {
|
|
525
|
+
dw: -10,
|
|
526
|
+
dh: 0
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.DownArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
|
|
531
|
+
command: "selection.nudge_resize",
|
|
532
|
+
args: {
|
|
533
|
+
dw: 0,
|
|
534
|
+
dh: 10
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.UpArrow, _grida_keybinding.M.Ctrl | _grida_keybinding.M.Alt | _grida_keybinding.M.Shift, RESIZE_MEANINGFUL),
|
|
539
|
+
command: "selection.nudge_resize",
|
|
540
|
+
args: {
|
|
541
|
+
dw: 0,
|
|
542
|
+
dh: -10
|
|
543
|
+
}
|
|
544
|
+
},
|
|
463
545
|
{
|
|
464
546
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyV),
|
|
465
547
|
command: TOOL_SET,
|
|
@@ -756,825 +838,6 @@ function create_defs(doc) {
|
|
|
756
838
|
return { gradients: new GradientsRegistry(doc) };
|
|
757
839
|
}
|
|
758
840
|
//#endregion
|
|
759
|
-
//#region src/core/document.ts
|
|
760
|
-
/** The native vector tags `retype_to_path` can re-type, keyed by tag → the
|
|
761
|
-
* native geometry attributes it consumes (so no orphaned geometry attr
|
|
762
|
-
* survives on the resulting `<path>`). Covers the geometry primitives
|
|
763
|
-
* (rect / circle / ellipse — always re-typed) and the vertex tags (line /
|
|
764
|
-
* polyline / polygon — re-typed only when an edit escapes their native
|
|
765
|
-
* form). */
|
|
766
|
-
const RETYPABLE_GEOMETRY_ATTRS = {
|
|
767
|
-
line: new Set([
|
|
768
|
-
"x1",
|
|
769
|
-
"y1",
|
|
770
|
-
"x2",
|
|
771
|
-
"y2"
|
|
772
|
-
]),
|
|
773
|
-
polyline: new Set(["points"]),
|
|
774
|
-
polygon: new Set(["points"]),
|
|
775
|
-
rect: new Set([
|
|
776
|
-
"x",
|
|
777
|
-
"y",
|
|
778
|
-
"width",
|
|
779
|
-
"height",
|
|
780
|
-
"rx",
|
|
781
|
-
"ry"
|
|
782
|
-
]),
|
|
783
|
-
circle: new Set([
|
|
784
|
-
"cx",
|
|
785
|
-
"cy",
|
|
786
|
-
"r"
|
|
787
|
-
]),
|
|
788
|
-
ellipse: new Set([
|
|
789
|
-
"cx",
|
|
790
|
-
"cy",
|
|
791
|
-
"rx",
|
|
792
|
-
"ry"
|
|
793
|
-
])
|
|
794
|
-
};
|
|
795
|
-
/**
|
|
796
|
-
* Parse a single SVG length attribute as a plain user-unit number. Returns
|
|
797
|
-
* `null` for absent, non-finite, or unit/percentage values (`50%`, `5px`,
|
|
798
|
-
* `5em`) — those are an out-of-scope geometry gap, and refusing them here
|
|
799
|
-
* means the editor never offers a promotion it cannot perform faithfully.
|
|
800
|
-
*/
|
|
801
|
-
function parse_user_unit(raw) {
|
|
802
|
-
if (raw === null) return null;
|
|
803
|
-
const s = raw.trim();
|
|
804
|
-
if (s === "") return null;
|
|
805
|
-
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
|
|
806
|
-
const n = Number(s);
|
|
807
|
-
return Number.isFinite(n) ? n : null;
|
|
808
|
-
}
|
|
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.
|
|
814
|
-
*
|
|
815
|
-
* Includes text-shaping attributes (font-*) because they re-shape glyph
|
|
816
|
-
* runs and change `<text>` bbox.
|
|
817
|
-
*/
|
|
818
|
-
const GEOMETRY_ATTRS = new Set([
|
|
819
|
-
"x",
|
|
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
|
-
}
|
|
1103
|
-
/**
|
|
1104
|
-
* Whether `id` can be opened in the flat-string text editor.
|
|
1105
|
-
*
|
|
1106
|
-
* v1 contract: the editor only operates on a *single flat text run*. That
|
|
1107
|
-
* means the target must be a `<text>` or `<tspan>` whose direct children
|
|
1108
|
-
* are all text nodes (or it has no children). A `<text>` containing a
|
|
1109
|
-
* `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
|
|
1110
|
-
* content from the editor's view, and a flat-text write would leave the
|
|
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).
|
|
1151
|
-
*/
|
|
1152
|
-
/**
|
|
1153
|
-
* Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
|
|
1154
|
-
* endpoints). An **absent** attribute takes the SVG default (`0`); a
|
|
1155
|
-
* **present** attribute that is not a plain user-unit number (`%`, `px`,
|
|
1156
|
-
* `em`, …) is out of scope and yields `null` so the caller refuses the
|
|
1157
|
-
* element — the same gate required attrs (width / radius) already apply.
|
|
1158
|
-
*
|
|
1159
|
-
* The absent-vs-present distinction is the point: a bare `?? 0` would
|
|
1160
|
-
* silently coerce an authored `x1="5px"` to `0`, then the first native
|
|
1161
|
-
* writeback would overwrite that authored value. Refusing keeps the
|
|
1162
|
-
* editor from misrepresenting geometry it cannot read faithfully.
|
|
1163
|
-
*/
|
|
1164
|
-
optional_user_unit_coord(id, name) {
|
|
1165
|
-
const raw = this.get_attr(id, name);
|
|
1166
|
-
if (raw === null) return 0;
|
|
1167
|
-
return parse_user_unit(raw);
|
|
1168
|
-
}
|
|
1169
|
-
is_vector_edit_target(id) {
|
|
1170
|
-
const n = this.nodes.get(id);
|
|
1171
|
-
if (!n || n.kind !== "element") return null;
|
|
1172
|
-
if (RETYPABLE_GEOMETRY_ATTRS[n.local] && n.attrs.some((a) => a.prefix === null && a.ns === null && a.local === "d")) return null;
|
|
1173
|
-
switch (n.local) {
|
|
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
|
-
}
|
|
1271
|
-
/**
|
|
1272
|
-
* Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
|
|
1273
|
-
* `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
|
|
1274
|
-
* its native geometry attributes and setting `d`. A structural mutation:
|
|
1275
|
-
* this layer executes the re-type; it does not decide when one is
|
|
1276
|
-
* warranted.
|
|
1277
|
-
*
|
|
1278
|
-
* Idempotent: returns `null` if `id` is not currently one of those tags
|
|
1279
|
-
* (so it is safe to call repeatedly — once re-typed, e.g. already a
|
|
1280
|
-
* `<path>`, further calls are no-ops). Otherwise mutates the node and
|
|
1281
|
-
* returns an opaque {@link RetypeRecord} reversal token.
|
|
1282
|
-
*
|
|
1283
|
-
* Identity, children, `self_closing`, non-geometry attributes, and all
|
|
1284
|
-
* source trivia are preserved unchanged — only the tag and the geometry
|
|
1285
|
-
* attributes move. Pass the token to {@link revert_retype} to restore
|
|
1286
|
-
* the original primitive byte-for-byte.
|
|
1287
|
-
*
|
|
1288
|
-
* (see test/svg-editor-vector-promote-to-path.md)
|
|
1289
|
-
*/
|
|
1290
|
-
retype_to_path(id, d) {
|
|
1291
|
-
const n = this.nodes.get(id);
|
|
1292
|
-
if (!n || n.kind !== "element") return null;
|
|
1293
|
-
const geom = RETYPABLE_GEOMETRY_ATTRS[n.local];
|
|
1294
|
-
if (!geom) return null;
|
|
1295
|
-
const prev_local = n.local;
|
|
1296
|
-
const prev_raw_tag = n.raw_tag;
|
|
1297
|
-
const removed = [];
|
|
1298
|
-
for (let i = n.attrs.length - 1; i >= 0; i--) {
|
|
1299
|
-
const a = n.attrs[i];
|
|
1300
|
-
if (a.prefix === null && a.ns === null && geom.has(a.local)) {
|
|
1301
|
-
removed.push({
|
|
1302
|
-
index: i,
|
|
1303
|
-
token: a
|
|
1304
|
-
});
|
|
1305
|
-
n.attrs.splice(i, 1);
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
removed.reverse();
|
|
1309
|
-
n.local = "path";
|
|
1310
|
-
n.raw_tag = n.prefix ? `${n.prefix}:path` : "path";
|
|
1311
|
-
n.attrs.push({
|
|
1312
|
-
raw_name: "d",
|
|
1313
|
-
prefix: null,
|
|
1314
|
-
local: "d",
|
|
1315
|
-
ns: null,
|
|
1316
|
-
value: d,
|
|
1317
|
-
pre: " ",
|
|
1318
|
-
eq_trivia: "",
|
|
1319
|
-
quote: "\""
|
|
1320
|
-
});
|
|
1321
|
-
let added_fill_none = false;
|
|
1322
|
-
if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
|
|
1323
|
-
n.attrs.push({
|
|
1324
|
-
raw_name: "fill",
|
|
1325
|
-
prefix: null,
|
|
1326
|
-
local: "fill",
|
|
1327
|
-
ns: null,
|
|
1328
|
-
value: "none",
|
|
1329
|
-
pre: " ",
|
|
1330
|
-
eq_trivia: "",
|
|
1331
|
-
quote: "\""
|
|
1332
|
-
});
|
|
1333
|
-
added_fill_none = true;
|
|
1334
|
-
}
|
|
1335
|
-
this._structure_version++;
|
|
1336
|
-
this._geometry_version++;
|
|
1337
|
-
this.emit();
|
|
1338
|
-
return {
|
|
1339
|
-
prev_local,
|
|
1340
|
-
prev_raw_tag,
|
|
1341
|
-
removed,
|
|
1342
|
-
added_fill_none
|
|
1343
|
-
};
|
|
1344
|
-
}
|
|
1345
|
-
/**
|
|
1346
|
-
* Reverse a {@link retype_to_path}: restore the original tag, remove the
|
|
1347
|
-
* `d` attribute the promotion added, and splice the captured geometry
|
|
1348
|
-
* attribute tokens back at their original positions (preserving their
|
|
1349
|
-
* trivia, so a later `serialize()` is byte-equal to the pre-promotion
|
|
1350
|
-
* source).
|
|
1351
|
-
*/
|
|
1352
|
-
revert_retype(id, token) {
|
|
1353
|
-
const n = this.nodes.get(id);
|
|
1354
|
-
if (!n || n.kind !== "element") return;
|
|
1355
|
-
for (let i = n.attrs.length - 1; i >= 0; i--) {
|
|
1356
|
-
const a = n.attrs[i];
|
|
1357
|
-
if (a.prefix === null && a.ns === null && a.local === "d") {
|
|
1358
|
-
n.attrs.splice(i, 1);
|
|
1359
|
-
break;
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
if (token.added_fill_none) for (let i = n.attrs.length - 1; i >= 0; i--) {
|
|
1363
|
-
const a = n.attrs[i];
|
|
1364
|
-
if (a.prefix === null && a.ns === null && a.local === "fill") {
|
|
1365
|
-
n.attrs.splice(i, 1);
|
|
1366
|
-
break;
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
n.local = token.prev_local;
|
|
1370
|
-
n.raw_tag = token.prev_raw_tag;
|
|
1371
|
-
for (const { index, token: t } of token.removed) n.attrs.splice(index, 0, t);
|
|
1372
|
-
this._structure_version++;
|
|
1373
|
-
this._geometry_version++;
|
|
1374
|
-
this.emit();
|
|
1375
|
-
}
|
|
1376
|
-
/**
|
|
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;
|
|
1407
|
-
}
|
|
1408
|
-
text_of(id) {
|
|
1409
|
-
const n = this.nodes.get(id);
|
|
1410
|
-
if (!n || n.kind !== "element") return "";
|
|
1411
|
-
let out = "";
|
|
1412
|
-
for (const c of n.children) {
|
|
1413
|
-
const cn = this.nodes.get(c);
|
|
1414
|
-
if (cn?.kind === "text") out += cn.value;
|
|
1415
|
-
}
|
|
1416
|
-
return out;
|
|
1417
|
-
}
|
|
1418
|
-
/** Replace all direct text children with a single text node carrying `value`. */
|
|
1419
|
-
set_text(id, value) {
|
|
1420
|
-
const n = this.nodes.get(id);
|
|
1421
|
-
if (!n || n.kind !== "element") return;
|
|
1422
|
-
n.children = n.children.filter((c) => this.nodes.get(c)?.kind !== "text");
|
|
1423
|
-
if (value !== "") {
|
|
1424
|
-
const text_id = `t${Math.random().toString(36).slice(2, 10)}`;
|
|
1425
|
-
const text_node = {
|
|
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);
|
|
1433
|
-
}
|
|
1434
|
-
this._structure_version++;
|
|
1435
|
-
this._geometry_version++;
|
|
1436
|
-
this.emit();
|
|
1437
|
-
}
|
|
1438
|
-
insert(id, parent, before) {
|
|
1439
|
-
const node = this.nodes.get(id);
|
|
1440
|
-
const parent_node = this.nodes.get(parent);
|
|
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
|
-
}
|
|
1448
|
-
}
|
|
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
|
-
return out;
|
|
1498
|
-
}
|
|
1499
|
-
/**
|
|
1500
|
-
* Serialize a single element's subtree as an SVG **fragment**, using the
|
|
1501
|
-
* same trivia-preserving rules as {@link serialize} (attribute order,
|
|
1502
|
-
* quote style, whitespace, comments — emitted exactly as authored).
|
|
1503
|
-
*
|
|
1504
|
-
* This is NOT {@link serialize} scoped to a node — it is a deliberately
|
|
1505
|
-
* weaker output (sdk-design D3, asymmetric outputs stay separate):
|
|
1506
|
-
*
|
|
1507
|
-
* - `serialize()` emits the whole document and carries the P1
|
|
1508
|
-
* whole-document round-trip guarantee.
|
|
1509
|
-
* - `serialize_node()` emits a fragment and does NOT. Namespace
|
|
1510
|
-
* declarations that live on an ancestor (`xmlns:xlink` and friends,
|
|
1511
|
-
* normally on the root `<svg>`) are NOT inlined — a node using
|
|
1512
|
-
* `xlink:href` serializes without `xmlns:xlink`. The fragment is the
|
|
1513
|
-
* element's markup as authored, not a standalone parseable document.
|
|
1514
|
-
*
|
|
1515
|
-
* Throws on an unknown id, a non-element node, or a node detached from
|
|
1516
|
-
* the live tree: the contract is "the markup for a selected element,"
|
|
1517
|
-
* selections are always live elements, and a string return of `""` for a
|
|
1518
|
-
* bad id would hide consumer bugs. The detached case matters because
|
|
1519
|
-
* `remove()` keeps the node in the id map for undo — a stale id from a
|
|
1520
|
-
* removed node would otherwise serialize content no longer in the
|
|
1521
|
-
* document, silently feeding a consumer deleted markup.
|
|
1522
|
-
*/
|
|
1523
|
-
serialize_node(id) {
|
|
1524
|
-
const n = this.nodes.get(id);
|
|
1525
|
-
if (!n) throw new Error(`serialize_node: unknown node id ${JSON.stringify(id)}`);
|
|
1526
|
-
if (n.kind !== "element") throw new Error(`serialize_node: node ${JSON.stringify(id)} is a ${n.kind} node, not an element`);
|
|
1527
|
-
if (!this.contains(this.root, id)) throw new Error(`serialize_node: node ${JSON.stringify(id)} is detached from the current document`);
|
|
1528
|
-
return this.emit_node(n);
|
|
1529
|
-
}
|
|
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
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
emit_attr(a) {
|
|
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
|
-
}
|
|
1577
|
-
//#endregion
|
|
1578
841
|
//#region src/core/align.ts
|
|
1579
842
|
/**
|
|
1580
843
|
* Compute per-member translation deltas to align `members` against `target`.
|
|
@@ -1782,7 +1045,7 @@ const DISPLAY_LABEL_MAX_LEN = 40;
|
|
|
1782
1045
|
* to `SvgEditor` so the published `.d.ts` doesn't advertise internals.
|
|
1783
1046
|
*/
|
|
1784
1047
|
function _create_svg_editor_internal(opts) {
|
|
1785
|
-
const doc = new SvgDocument(opts.svg);
|
|
1048
|
+
const doc = new require_model.SvgDocument(opts.svg);
|
|
1786
1049
|
const history = new _grida_history.HistoryImpl();
|
|
1787
1050
|
const defs = create_defs(doc);
|
|
1788
1051
|
let selection = [];
|
|
@@ -1845,12 +1108,22 @@ function _create_svg_editor_internal(opts) {
|
|
|
1845
1108
|
const notify_translate_commit = () => {
|
|
1846
1109
|
for (const cb of translate_commit_listeners) cb();
|
|
1847
1110
|
};
|
|
1848
|
-
|
|
1849
|
-
|
|
1111
|
+
/**
|
|
1112
|
+
* Fan out the geometry channel iff the doc's `geometry_version` has
|
|
1113
|
+
* moved since we last fired. Shared by the `doc.on_change` handler
|
|
1114
|
+
* (mutation-driven bumps) and the surface-driven `bump_geometry` seam
|
|
1115
|
+
* (font-load reflow). Idempotent against a stale version — never
|
|
1116
|
+
* double-fires for the same value.
|
|
1117
|
+
*/
|
|
1118
|
+
function fire_geometry_listeners_if_advanced() {
|
|
1850
1119
|
if (doc.geometry_version !== last_emitted_geometry_version) {
|
|
1851
1120
|
last_emitted_geometry_version = doc.geometry_version;
|
|
1852
1121
|
for (const cb of geometry_listeners) cb();
|
|
1853
1122
|
}
|
|
1123
|
+
}
|
|
1124
|
+
doc.on_change(() => {
|
|
1125
|
+
doc_version++;
|
|
1126
|
+
fire_geometry_listeners_if_advanced();
|
|
1854
1127
|
});
|
|
1855
1128
|
function subscribe(fn) {
|
|
1856
1129
|
listeners.add(fn);
|
|
@@ -2150,66 +1423,79 @@ function _create_svg_editor_internal(opts) {
|
|
|
2150
1423
|
if (do_translate_oneshot(delta, require_model.translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
|
|
2151
1424
|
}
|
|
2152
1425
|
/**
|
|
2153
|
-
*
|
|
2154
|
-
*
|
|
2155
|
-
*
|
|
2156
|
-
*
|
|
2157
|
-
* atomic step rather than a preview session.
|
|
2158
|
-
*
|
|
2159
|
-
* The function does its own geometry lookup via the
|
|
2160
|
-
* `geometry_provider` registered by the DOM surface. When no surface
|
|
2161
|
-
* is attached, the call is a no-op (returns `false`). Members whose
|
|
2162
|
-
* tag is not resizable are silently filtered.
|
|
1426
|
+
* Gate + capture for a resize gesture. Returns the resizable members (with
|
|
1427
|
+
* captured baseline / pre-transform / bbox), or `null` if the gesture can't
|
|
1428
|
+
* run: no geometry provider, empty selection, or — in `all_or_nothing` mode
|
|
1429
|
+
* — any member fails the gate.
|
|
2163
1430
|
*
|
|
2164
|
-
*
|
|
2165
|
-
*
|
|
2166
|
-
*
|
|
2167
|
-
* `
|
|
1431
|
+
* `mode`:
|
|
1432
|
+
* - `"skip"` — drop members failing the `is_resizable_node` gate
|
|
1433
|
+
* (tag + transform class) or lacking a bbox; resize the rest. Used by the
|
|
1434
|
+
* inspector `resize_to` (set-bbox) path.
|
|
1435
|
+
* - `"all_or_nothing"` — refuse the WHOLE gesture (return `null`) if ANY
|
|
1436
|
+
* member fails. Used by keyboard `resize_by` (nudge), matching the resize
|
|
1437
|
+
* HUD, whose handle-drag is rejected when any member is unsafe.
|
|
2168
1438
|
*/
|
|
2169
|
-
function
|
|
2170
|
-
|
|
2171
|
-
if (
|
|
2172
|
-
if (!geometry_provider) return false;
|
|
1439
|
+
function collect_resize_members(ids, mode) {
|
|
1440
|
+
if (ids.length === 0) return null;
|
|
1441
|
+
if (!geometry_provider) return null;
|
|
2173
1442
|
const members = [];
|
|
2174
1443
|
for (const id of ids) {
|
|
2175
|
-
if (!require_model.resize_pipeline.intent.
|
|
1444
|
+
if (!require_model.resize_pipeline.intent.is_resizable_node(doc, id)) {
|
|
1445
|
+
if (mode === "all_or_nothing") return null;
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
2176
1448
|
const bbox = geometry_provider.bounds_of(id);
|
|
2177
|
-
if (!bbox)
|
|
1449
|
+
if (!bbox) {
|
|
1450
|
+
if (mode === "all_or_nothing") return null;
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
2178
1453
|
members.push({
|
|
2179
1454
|
id,
|
|
2180
1455
|
rz: require_model.resize_pipeline.intent.capture_baseline(doc, id, bbox),
|
|
2181
|
-
tx_pre: require_model.translate_pipeline.intent.capture_baseline(doc, id),
|
|
2182
1456
|
transform_pre: doc.get_attr(id, "transform"),
|
|
2183
1457
|
bbox
|
|
2184
1458
|
});
|
|
2185
1459
|
}
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
1460
|
+
return members.length === 0 ? null : members;
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Apply a resize to each member, optionally followed by a uniform group
|
|
1464
|
+
* translate, as ONE atomic history step. `op` resolves each member's scale
|
|
1465
|
+
* factors + scale origin; `group_translate` is the post-scale envelope shift
|
|
1466
|
+
* (group resize only — `null` for per-element). Callers guarantee `members`
|
|
1467
|
+
* is non-empty. Returns `true` when a history step was pushed; `false` when
|
|
1468
|
+
* the gesture is geometrically identity (no member scales and no group
|
|
1469
|
+
* translate) so undo isn't polluted with an empty step. NOTE: a per-tag
|
|
1470
|
+
* constraint that collapses a non-1 factor to identity *inside* the handler
|
|
1471
|
+
* (e.g. `<circle>` uniform `min` on a single-axis nudge) is not detected
|
|
1472
|
+
* here — the op-level factor is still ≠ 1, so that case still pushes a step.
|
|
1473
|
+
*/
|
|
1474
|
+
function commit_resize(members, op, group_translate, label) {
|
|
1475
|
+
const ops = members.map((m) => ({
|
|
1476
|
+
m,
|
|
1477
|
+
...op(m)
|
|
1478
|
+
}));
|
|
1479
|
+
const scales = ops.some(({ sx, sy }) => sx !== 1 || sy !== 1);
|
|
1480
|
+
const translates = !!group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0);
|
|
1481
|
+
if (!scales && !translates) return false;
|
|
2196
1482
|
const apply = () => {
|
|
2197
|
-
for (const m of
|
|
2198
|
-
if (dx !== 0 || dy !== 0) for (const m of members) {
|
|
1483
|
+
for (const { m, sx, sy, origin } of ops) require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
|
|
1484
|
+
if (group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0)) for (const m of members) {
|
|
2199
1485
|
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);
|
|
1486
|
+
require_model.translate_pipeline.intent.apply(doc, m.id, tx_after, group_translate.dx, group_translate.dy);
|
|
2201
1487
|
}
|
|
2202
1488
|
emit();
|
|
2203
1489
|
};
|
|
2204
1490
|
const revert = () => {
|
|
2205
|
-
for (const m of
|
|
1491
|
+
for (const { m, origin } of ops) {
|
|
2206
1492
|
require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
|
|
2207
1493
|
doc.set_attr(m.id, "transform", m.transform_pre);
|
|
2208
1494
|
}
|
|
2209
1495
|
emit();
|
|
2210
1496
|
};
|
|
2211
1497
|
apply();
|
|
2212
|
-
history.atomic(
|
|
1498
|
+
history.atomic(label, (tx) => {
|
|
2213
1499
|
tx.push({
|
|
2214
1500
|
providerId: PROVIDER_ID,
|
|
2215
1501
|
apply,
|
|
@@ -2218,6 +1504,73 @@ function _create_svg_editor_internal(opts) {
|
|
|
2218
1504
|
});
|
|
2219
1505
|
return true;
|
|
2220
1506
|
}
|
|
1507
|
+
/**
|
|
1508
|
+
* One-shot multi-member resize to an explicit target rect. Mirrors a
|
|
1509
|
+
* drag-resize gesture in mechanics — capture per-member baselines,
|
|
1510
|
+
* scale around the union's NW corner, translate the result so the
|
|
1511
|
+
* union NW lands at the requested position — but as a single
|
|
1512
|
+
* atomic step rather than a preview session. This is the GROUP path:
|
|
1513
|
+
* the whole selection is treated as one envelope.
|
|
1514
|
+
*
|
|
1515
|
+
* The function does its own geometry lookup via the
|
|
1516
|
+
* `geometry_provider` registered by the DOM surface. When no surface
|
|
1517
|
+
* is attached, the call is a no-op (returns `false`). Members that fail
|
|
1518
|
+
* the `is_resizable_node` gate — an unresizable tag (e.g. `<g>`) OR a
|
|
1519
|
+
* non-trivially-transformed element — are silently skipped (see
|
|
1520
|
+
* `collect_resize_members`).
|
|
1521
|
+
*
|
|
1522
|
+
* Revert restores the captured `transform` attribute and all
|
|
1523
|
+
* geometry attrs the apply step wrote — so a `<rect>` with an
|
|
1524
|
+
* existing `transform` round-trips cleanly. See `apply_translate`'s
|
|
1525
|
+
* `viaTransform` arm for why this matters.
|
|
1526
|
+
*/
|
|
1527
|
+
function resize_to(target, opts) {
|
|
1528
|
+
const members = collect_resize_members(opts?.ids ?? selection, "skip");
|
|
1529
|
+
if (!members) return false;
|
|
1530
|
+
const union = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
|
|
1531
|
+
const sx = union.width === 0 ? 1 : target.width / union.width;
|
|
1532
|
+
const sy = union.height === 0 ? 1 : target.height / union.height;
|
|
1533
|
+
const origin = {
|
|
1534
|
+
x: union.x,
|
|
1535
|
+
y: union.y
|
|
1536
|
+
};
|
|
1537
|
+
return commit_resize(members, () => ({
|
|
1538
|
+
sx,
|
|
1539
|
+
sy,
|
|
1540
|
+
origin
|
|
1541
|
+
}), {
|
|
1542
|
+
dx: target.x - union.x,
|
|
1543
|
+
dy: target.y - union.y
|
|
1544
|
+
}, opts?.label ?? "resize-to");
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Resize by a `{dw, dh}` delta — the core verb behind keyboard nudge-resize
|
|
1548
|
+
* (`Ctrl+Alt+Arrow`). This is the PER-ELEMENT path: each selected member
|
|
1549
|
+
* grows/shrinks by the delta around ITS OWN NW corner, so members keep their
|
|
1550
|
+
* positions relative to one another. This deliberately differs from
|
|
1551
|
+
* {@link resize_to} (the group/envelope path): a HUD group-resize scales the
|
|
1552
|
+
* whole selection around the shared union origin, translating off-origin
|
|
1553
|
+
* members — correct for a drag handle, wrong for a keyboard nudge, whose UX
|
|
1554
|
+
* is "apply the delta to each".
|
|
1555
|
+
*
|
|
1556
|
+
* ALL-OR-NOTHING gate (`collect_resize_members("all_or_nothing")`): refuses
|
|
1557
|
+
* (returns `false`, no history step) on empty selection, no geometry
|
|
1558
|
+
* provider, or any member failing the `is_resizable_node` gate — matching
|
|
1559
|
+
* the resize HUD rather than `resize_to`'s per-member skip.
|
|
1560
|
+
*/
|
|
1561
|
+
function resize_by(delta, opts) {
|
|
1562
|
+
const members = collect_resize_members(opts?.ids ?? selection, "all_or_nothing");
|
|
1563
|
+
if (!members) return false;
|
|
1564
|
+
const axis = (size, d) => size === 0 ? 1 : Math.max(0, size + d) / size;
|
|
1565
|
+
return commit_resize(members, (m) => ({
|
|
1566
|
+
sx: axis(m.bbox.width, delta.dw),
|
|
1567
|
+
sy: axis(m.bbox.height, delta.dh),
|
|
1568
|
+
origin: {
|
|
1569
|
+
x: m.bbox.x,
|
|
1570
|
+
y: m.bbox.y
|
|
1571
|
+
}
|
|
1572
|
+
}), null, "nudge-resize");
|
|
1573
|
+
}
|
|
2221
1574
|
/** Shared helper: compute a default rotation pivot from the live
|
|
2222
1575
|
* geometry_provider when the caller omitted one. Falls back to (0,0)
|
|
2223
1576
|
* if no surface is attached. */
|
|
@@ -2299,6 +1652,70 @@ function _create_svg_editor_internal(opts) {
|
|
|
2299
1652
|
});
|
|
2300
1653
|
return true;
|
|
2301
1654
|
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Relative affine compose about a pivot. See the `Commands.transform`
|
|
1657
|
+
* doc for the full contract. This function owns ONLY the pivot/effective-
|
|
1658
|
+
* matrix computation (which needs `geometry_provider`); the parse→fold→
|
|
1659
|
+
* emit round-trip is delegated per-member to the pure
|
|
1660
|
+
* `transform.apply_affine` helper.
|
|
1661
|
+
*/
|
|
1662
|
+
function apply_transform(matrix, opts) {
|
|
1663
|
+
const ids = opts?.ids ?? selection;
|
|
1664
|
+
if (ids.length === 0) return false;
|
|
1665
|
+
if (!geometry_provider) return false;
|
|
1666
|
+
for (const id of ids) if (require_model.rotate_pipeline.intent.is_transformable(doc, id).kind === "refuse") return false;
|
|
1667
|
+
const pivot = opts?.pivot ?? default_rotate_pivot(ids);
|
|
1668
|
+
const [a, b, c, d, e, f] = matrix;
|
|
1669
|
+
const requested = [[
|
|
1670
|
+
a,
|
|
1671
|
+
c,
|
|
1672
|
+
e
|
|
1673
|
+
], [
|
|
1674
|
+
b,
|
|
1675
|
+
d,
|
|
1676
|
+
f
|
|
1677
|
+
]];
|
|
1678
|
+
const t_pivot = [[
|
|
1679
|
+
1,
|
|
1680
|
+
0,
|
|
1681
|
+
pivot.x
|
|
1682
|
+
], [
|
|
1683
|
+
0,
|
|
1684
|
+
1,
|
|
1685
|
+
pivot.y
|
|
1686
|
+
]];
|
|
1687
|
+
const t_neg_pivot = [[
|
|
1688
|
+
1,
|
|
1689
|
+
0,
|
|
1690
|
+
-pivot.x
|
|
1691
|
+
], [
|
|
1692
|
+
0,
|
|
1693
|
+
1,
|
|
1694
|
+
-pivot.y
|
|
1695
|
+
]];
|
|
1696
|
+
const effective = _grida_cmath.default.transform.multiply(_grida_cmath.default.transform.multiply(t_pivot, requested), t_neg_pivot);
|
|
1697
|
+
const members = ids.map((id) => ({
|
|
1698
|
+
id,
|
|
1699
|
+
transform_pre: doc.get_attr(id, "transform")
|
|
1700
|
+
}));
|
|
1701
|
+
const apply = () => {
|
|
1702
|
+
for (const m of members) doc.set_attr(m.id, "transform", require_model.transform.apply_affine(m.transform_pre, effective));
|
|
1703
|
+
emit();
|
|
1704
|
+
};
|
|
1705
|
+
const revert = () => {
|
|
1706
|
+
for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
|
|
1707
|
+
emit();
|
|
1708
|
+
};
|
|
1709
|
+
apply();
|
|
1710
|
+
history.atomic("transform", (tx) => {
|
|
1711
|
+
tx.push({
|
|
1712
|
+
providerId: PROVIDER_ID,
|
|
1713
|
+
apply,
|
|
1714
|
+
revert
|
|
1715
|
+
});
|
|
1716
|
+
});
|
|
1717
|
+
return true;
|
|
1718
|
+
}
|
|
2302
1719
|
function flatten_transform(opts) {
|
|
2303
1720
|
const ids = opts?.ids ?? selection;
|
|
2304
1721
|
if (ids.length === 0) return false;
|
|
@@ -2550,6 +1967,47 @@ function _create_svg_editor_internal(opts) {
|
|
|
2550
1967
|
});
|
|
2551
1968
|
return true;
|
|
2552
1969
|
}
|
|
1970
|
+
function ungroup(opts) {
|
|
1971
|
+
let target;
|
|
1972
|
+
if (opts?.id !== void 0) target = opts.id;
|
|
1973
|
+
else {
|
|
1974
|
+
if (selection.length !== 1) return false;
|
|
1975
|
+
target = selection[0];
|
|
1976
|
+
}
|
|
1977
|
+
const plan = require_model.group.plan_ungroup(doc, target);
|
|
1978
|
+
if (!plan) return false;
|
|
1979
|
+
const group_id = plan.group_id;
|
|
1980
|
+
const group_next_sibling = doc.next_element_sibling_of(group_id);
|
|
1981
|
+
const original_child_transforms = /* @__PURE__ */ new Map();
|
|
1982
|
+
for (const child of plan.children) original_child_transforms.set(child, doc.get_attr(child, "transform"));
|
|
1983
|
+
const group_ops = plan.group_transform === null ? [] : require_model.transform.parse(plan.group_transform) ?? [];
|
|
1984
|
+
const original_selection = selection;
|
|
1985
|
+
const apply = () => {
|
|
1986
|
+
if (group_ops.length > 0) for (const child of plan.children) {
|
|
1987
|
+
const child_ops = require_model.transform.parse(doc.get_attr(child, "transform")) ?? [];
|
|
1988
|
+
const next = require_model.transform.emit([...group_ops, ...child_ops]);
|
|
1989
|
+
doc.set_attr(child, "transform", next === "" ? null : next);
|
|
1990
|
+
}
|
|
1991
|
+
for (const child of plan.children) doc.insert(child, plan.parent, group_id);
|
|
1992
|
+
doc.remove(group_id);
|
|
1993
|
+
set_selection(plan.children);
|
|
1994
|
+
};
|
|
1995
|
+
const revert = () => {
|
|
1996
|
+
doc.insert(group_id, plan.parent, group_next_sibling);
|
|
1997
|
+
for (const child of plan.children) doc.insert(child, group_id, null);
|
|
1998
|
+
if (group_ops.length > 0) for (const child of plan.children) doc.set_attr(child, "transform", original_child_transforms.get(child) ?? null);
|
|
1999
|
+
set_selection(original_selection);
|
|
2000
|
+
};
|
|
2001
|
+
apply();
|
|
2002
|
+
history.atomic("ungroup", (tx) => {
|
|
2003
|
+
tx.push({
|
|
2004
|
+
providerId: PROVIDER_ID,
|
|
2005
|
+
apply,
|
|
2006
|
+
revert
|
|
2007
|
+
});
|
|
2008
|
+
});
|
|
2009
|
+
return true;
|
|
2010
|
+
}
|
|
2553
2011
|
/**
|
|
2554
2012
|
* Atomic one-shot insertion. Used by paste, programmatic RPC, and the
|
|
2555
2013
|
* click-no-drag commit path inside the insertion gesture driver. One
|
|
@@ -2559,11 +2017,20 @@ function _create_svg_editor_internal(opts) {
|
|
|
2559
2017
|
* win. `opts.parent` defaults to root; `opts.index` (insert-before
|
|
2560
2018
|
* sibling index) defaults to append; `opts.select` defaults to `true`.
|
|
2561
2019
|
*/
|
|
2020
|
+
/**
|
|
2021
|
+
* Resolve an optional `index` (position in `parent`'s element-children
|
|
2022
|
+
* list to insert AT — anything at or after it shifts; out-of-range or
|
|
2023
|
+
* `undefined` appends) to an insert-before anchor. Shared by `insert`,
|
|
2024
|
+
* `insert_fragment`, and `insert_preview`.
|
|
2025
|
+
*/
|
|
2026
|
+
function resolve_insert_before(parent, index) {
|
|
2027
|
+
if (index === void 0) return null;
|
|
2028
|
+
return doc.element_children_of(parent)[index] ?? null;
|
|
2029
|
+
}
|
|
2562
2030
|
function insert(tag, attrs, opts) {
|
|
2563
2031
|
const parent = opts?.parent ?? doc.root;
|
|
2564
2032
|
const select_after = opts?.select !== false;
|
|
2565
|
-
|
|
2566
|
-
if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
|
|
2033
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2567
2034
|
const id = doc.create_element(tag);
|
|
2568
2035
|
const merged_attrs = {
|
|
2569
2036
|
...default_paint_attrs_for(tag),
|
|
@@ -2591,6 +2058,56 @@ function _create_svg_editor_internal(opts) {
|
|
|
2591
2058
|
return id;
|
|
2592
2059
|
}
|
|
2593
2060
|
/**
|
|
2061
|
+
* Atomic fragment insertion — contract in {@link Commands.insert_fragment}.
|
|
2062
|
+
* Parses + adopts via `doc.create_fragment` (subtrees registered but
|
|
2063
|
+
* detached, like `create_element` — history.redo finds them via
|
|
2064
|
+
* closure), computes the namespace hoist plan, then brackets inserts +
|
|
2065
|
+
* hoisted declarations + selection in ONE history step.
|
|
2066
|
+
*/
|
|
2067
|
+
function insert_fragment(svg, opts) {
|
|
2068
|
+
const parent = opts?.parent ?? doc.root;
|
|
2069
|
+
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`);
|
|
2070
|
+
const select_after = opts?.select !== false;
|
|
2071
|
+
const { roots, xmlns } = doc.create_fragment(svg);
|
|
2072
|
+
if (roots.length === 0) return [];
|
|
2073
|
+
const known_uri = new Map([["xlink", _grida_svg_parser.XLINK_NS]]);
|
|
2074
|
+
for (const d of xmlns) known_uri.set(d.prefix, d.uri);
|
|
2075
|
+
const hoist = [];
|
|
2076
|
+
const considered = /* @__PURE__ */ new Set();
|
|
2077
|
+
for (const id of roots) for (const prefix of doc.undeclared_ns_prefixes(id)) {
|
|
2078
|
+
if (considered.has(prefix)) continue;
|
|
2079
|
+
considered.add(prefix);
|
|
2080
|
+
if (doc.get_attr(doc.root, prefix, _grida_svg_parser.XMLNS_NS) !== null) continue;
|
|
2081
|
+
const uri = known_uri.get(prefix);
|
|
2082
|
+
if (uri === void 0) continue;
|
|
2083
|
+
hoist.push({
|
|
2084
|
+
prefix,
|
|
2085
|
+
uri
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2089
|
+
const previous_selection = selection;
|
|
2090
|
+
const apply = () => {
|
|
2091
|
+
for (const { prefix, uri } of hoist) doc.declare_xmlns(prefix, uri);
|
|
2092
|
+
for (const id of roots) doc.insert(id, parent, insert_before);
|
|
2093
|
+
if (select_after) set_selection(roots);
|
|
2094
|
+
};
|
|
2095
|
+
const revert = () => {
|
|
2096
|
+
for (let i = roots.length - 1; i >= 0; i--) doc.remove(roots[i]);
|
|
2097
|
+
for (const { prefix } of hoist) doc.set_attr(doc.root, prefix, null, _grida_svg_parser.XMLNS_NS);
|
|
2098
|
+
if (select_after) set_selection(previous_selection);
|
|
2099
|
+
};
|
|
2100
|
+
apply();
|
|
2101
|
+
history.atomic("insert fragment", (tx) => {
|
|
2102
|
+
tx.push({
|
|
2103
|
+
providerId: PROVIDER_ID,
|
|
2104
|
+
apply,
|
|
2105
|
+
revert
|
|
2106
|
+
});
|
|
2107
|
+
});
|
|
2108
|
+
return roots;
|
|
2109
|
+
}
|
|
2110
|
+
/**
|
|
2594
2111
|
* Preview-bracketed insertion. Used by the pointer-driven drag gesture
|
|
2595
2112
|
* in the DOM surface. Per-frame attr writes call `update(attrs)`; one
|
|
2596
2113
|
* undo step on `commit()`; clean rollback on `discard()`.
|
|
@@ -2601,8 +2118,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
2601
2118
|
*/
|
|
2602
2119
|
function insert_preview(tag, initial, opts) {
|
|
2603
2120
|
const parent = opts?.parent ?? doc.root;
|
|
2604
|
-
|
|
2605
|
-
if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
|
|
2121
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2606
2122
|
const id = doc.create_element(tag);
|
|
2607
2123
|
const previous_selection = selection;
|
|
2608
2124
|
const live_attrs = {
|
|
@@ -2760,6 +2276,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
2760
2276
|
current_surface_hover = id;
|
|
2761
2277
|
notify_surface_hover();
|
|
2762
2278
|
}
|
|
2279
|
+
const pick_listeners = /* @__PURE__ */ new Set();
|
|
2280
|
+
function notify_pick(e) {
|
|
2281
|
+
for (const cb of pick_listeners) cb(e);
|
|
2282
|
+
}
|
|
2763
2283
|
function enter_content_edit(target) {
|
|
2764
2284
|
const id = target ?? (selection.length === 1 ? selection[0] : null);
|
|
2765
2285
|
if (!id) return false;
|
|
@@ -2805,14 +2325,18 @@ function _create_svg_editor_internal(opts) {
|
|
|
2805
2325
|
translate,
|
|
2806
2326
|
nudge,
|
|
2807
2327
|
resize_to,
|
|
2328
|
+
resize_by,
|
|
2808
2329
|
rotate,
|
|
2809
2330
|
rotate_to,
|
|
2331
|
+
transform: apply_transform,
|
|
2810
2332
|
flatten_transform,
|
|
2811
2333
|
align,
|
|
2812
2334
|
reorder,
|
|
2813
2335
|
remove,
|
|
2814
2336
|
group: group$1,
|
|
2337
|
+
ungroup,
|
|
2815
2338
|
insert,
|
|
2339
|
+
insert_fragment,
|
|
2816
2340
|
insert_preview,
|
|
2817
2341
|
set_text,
|
|
2818
2342
|
load_svg,
|
|
@@ -2858,6 +2382,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
2858
2382
|
function dispose() {
|
|
2859
2383
|
detach();
|
|
2860
2384
|
listeners.clear();
|
|
2385
|
+
surface_hover_listeners.clear();
|
|
2386
|
+
geometry_listeners.clear();
|
|
2387
|
+
translate_commit_listeners.clear();
|
|
2388
|
+
pick_listeners.clear();
|
|
2861
2389
|
}
|
|
2862
2390
|
function set_style(partial) {
|
|
2863
2391
|
style = {
|
|
@@ -2949,6 +2477,22 @@ function _create_svg_editor_internal(opts) {
|
|
|
2949
2477
|
};
|
|
2950
2478
|
},
|
|
2951
2479
|
/**
|
|
2480
|
+
* Subscribe to pick (tap) outcomes — a discrete click on the canvas,
|
|
2481
|
+
* reporting the document-space point and the node under it (`null` for
|
|
2482
|
+
* empty canvas), plus the button and modifier snapshot. Fires once per
|
|
2483
|
+
* tap, after the editor's own selection handling. Observe-only: a pick
|
|
2484
|
+
* cannot alter selection, and the channel does NOT bump `state.version`.
|
|
2485
|
+
* See {@link PickEvent}.
|
|
2486
|
+
*
|
|
2487
|
+
* @unstable
|
|
2488
|
+
*/
|
|
2489
|
+
subscribe_pick(cb) {
|
|
2490
|
+
pick_listeners.add(cb);
|
|
2491
|
+
return () => {
|
|
2492
|
+
pick_listeners.delete(cb);
|
|
2493
|
+
};
|
|
2494
|
+
},
|
|
2495
|
+
/**
|
|
2952
2496
|
* Subscribe to bounds-affecting changes. Fires when any document
|
|
2953
2497
|
* mutation advances `state.geometry_version` — drag, resize, text
|
|
2954
2498
|
* edit, structural insert/remove. Skips presentation-only writes
|
|
@@ -3019,11 +2563,18 @@ function _create_svg_editor_internal(opts) {
|
|
|
3019
2563
|
push_surface_hover(id) {
|
|
3020
2564
|
_set_current_surface_hover(id);
|
|
3021
2565
|
},
|
|
2566
|
+
push_pick(e) {
|
|
2567
|
+
notify_pick(e);
|
|
2568
|
+
},
|
|
3022
2569
|
set_computed_resolver(fn) {
|
|
3023
2570
|
computed_resolver = fn;
|
|
3024
2571
|
},
|
|
3025
2572
|
set_geometry(p) {
|
|
3026
2573
|
geometry_provider = p;
|
|
2574
|
+
},
|
|
2575
|
+
bump_geometry() {
|
|
2576
|
+
doc.bump_geometry();
|
|
2577
|
+
fire_geometry_listeners_if_advanced();
|
|
3027
2578
|
}
|
|
3028
2579
|
},
|
|
3029
2580
|
keymap
|