@grida/svg-editor 1.0.0-alpha.14 → 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 +59 -0
- package/dist/{dom-Dz_V6q0Y.d.mts → dom-98AUOfsP.d.mts} +44 -2
- package/dist/{dom-D4dy6kq5.d.ts → dom-BO2-E9oK.d.ts} +44 -2
- package/dist/{dom-DSjfCllZ.mjs → dom-DOvcMvl4.mjs} +295 -176
- package/dist/{dom-BuD8TKmL.js → dom-U6ae5fQF.js} +300 -175
- 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-BHHU_Nvz.js → editor-C6Lj1In-.js} +433 -582
- package/dist/{editor-CJ2KuRh5.d.ts → editor-CYoGJ3Hf.d.ts} +500 -24
- package/dist/{editor-YQwdWHBb.d.mts → editor-D2eQe8lB.d.mts} +500 -24
- package/dist/{editor-B6pchGYk.mjs → editor-DKQOIKuU.mjs} +432 -582
- 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-DqGqV1H4.js → model-D0nU_EkL.js} +1245 -79
- package/dist/{model-DIzZmeyf.mjs → model-L3t9ixT_.mjs} +1240 -80
- 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 +29 -5
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { _ as
|
|
1
|
+
import { _ as XLINK_NS, a as paint, b as is_text_input_focused, g as SvgDocument, h as transform, i as TOOL_CURSOR, m as group, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline, v as XMLNS_NS, y as array_shallow_equal } from "./model-L3t9ixT_.mjs";
|
|
2
2
|
import { HistoryImpl } from "@grida/history";
|
|
3
3
|
import { KeyCode, M, chunkKey, eventToChunk, getKeyboardOS, kb, keybindingsToKeyCodes } from "@grida/keybinding";
|
|
4
4
|
import cmath from "@grida/cmath";
|
|
5
|
-
import { XLINK_NS, encode_attr_value, encode_text, parse_svg } from "@grida/svg/parser";
|
|
6
|
-
import { svg_parse } from "@grida/svg/parse";
|
|
7
5
|
//#region src/commands/registry.ts
|
|
8
6
|
var CommandRegistry = class {
|
|
9
7
|
constructor() {
|
|
@@ -84,12 +82,26 @@ function registerDefaultCommands(reg, editor) {
|
|
|
84
82
|
if (editor.state.selection.length === 0) return false;
|
|
85
83
|
return editor.commands.group();
|
|
86
84
|
});
|
|
85
|
+
reg.register("selection.ungroup", () => {
|
|
86
|
+
if (editor.state.mode !== "select") return false;
|
|
87
|
+
if (editor.state.selection.length !== 1) return false;
|
|
88
|
+
return editor.commands.ungroup();
|
|
89
|
+
});
|
|
87
90
|
reg.register("selection.resize_to", (args) => {
|
|
88
91
|
if (editor.state.mode !== "select") return false;
|
|
89
92
|
if (editor.state.selection.length === 0) return false;
|
|
90
93
|
const target = args;
|
|
91
94
|
return editor.commands.resize_to(target);
|
|
92
95
|
});
|
|
96
|
+
reg.register("selection.nudge_resize", (args) => {
|
|
97
|
+
if (editor.state.mode !== "select") return false;
|
|
98
|
+
if (editor.state.selection.length === 0) return false;
|
|
99
|
+
const { dw, dh } = args;
|
|
100
|
+
return editor.commands.resize_by({
|
|
101
|
+
dw,
|
|
102
|
+
dh
|
|
103
|
+
});
|
|
104
|
+
});
|
|
93
105
|
reg.register("selection.rotate", (args) => {
|
|
94
106
|
if (editor.state.mode !== "select") return false;
|
|
95
107
|
if (editor.state.selection.length === 0) return false;
|
|
@@ -309,7 +321,8 @@ function compareEntries(a, b) {
|
|
|
309
321
|
* Same key, multiple meanings? Add multiple rows. The chain semantics
|
|
310
322
|
* (handler returns `false` when not applicable) handle the rest.
|
|
311
323
|
*/
|
|
312
|
-
const NUDGE_MEANINGFUL = M.Shift;
|
|
324
|
+
const NUDGE_MEANINGFUL = M.Shift | M.Ctrl;
|
|
325
|
+
const RESIZE_MEANINGFUL = M.Shift | M.Ctrl | M.Alt;
|
|
313
326
|
const DEFAULT_BINDINGS = [
|
|
314
327
|
{
|
|
315
328
|
keybinding: kb(KeyCode.KeyZ, M.CtrlCmd),
|
|
@@ -339,6 +352,10 @@ const DEFAULT_BINDINGS = [
|
|
|
339
352
|
keybinding: kb(KeyCode.KeyG, M.CtrlCmd),
|
|
340
353
|
command: "selection.group"
|
|
341
354
|
},
|
|
355
|
+
{
|
|
356
|
+
keybinding: kb(KeyCode.KeyG, M.CtrlCmd | M.Shift),
|
|
357
|
+
command: "selection.ungroup"
|
|
358
|
+
},
|
|
342
359
|
{
|
|
343
360
|
keybinding: kb(KeyCode.KeyA, M.CtrlCmd),
|
|
344
361
|
command: "selection.all"
|
|
@@ -459,6 +476,70 @@ const DEFAULT_BINDINGS = [
|
|
|
459
476
|
dy: 10
|
|
460
477
|
}
|
|
461
478
|
},
|
|
479
|
+
{
|
|
480
|
+
keybinding: kb(KeyCode.RightArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
|
|
481
|
+
command: "selection.nudge_resize",
|
|
482
|
+
args: {
|
|
483
|
+
dw: 1,
|
|
484
|
+
dh: 0
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
keybinding: kb(KeyCode.LeftArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
|
|
489
|
+
command: "selection.nudge_resize",
|
|
490
|
+
args: {
|
|
491
|
+
dw: -1,
|
|
492
|
+
dh: 0
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
keybinding: kb(KeyCode.DownArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
|
|
497
|
+
command: "selection.nudge_resize",
|
|
498
|
+
args: {
|
|
499
|
+
dw: 0,
|
|
500
|
+
dh: 1
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
keybinding: kb(KeyCode.UpArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
|
|
505
|
+
command: "selection.nudge_resize",
|
|
506
|
+
args: {
|
|
507
|
+
dw: 0,
|
|
508
|
+
dh: -1
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
{
|
|
512
|
+
keybinding: kb(KeyCode.RightArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
|
|
513
|
+
command: "selection.nudge_resize",
|
|
514
|
+
args: {
|
|
515
|
+
dw: 10,
|
|
516
|
+
dh: 0
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
keybinding: kb(KeyCode.LeftArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
|
|
521
|
+
command: "selection.nudge_resize",
|
|
522
|
+
args: {
|
|
523
|
+
dw: -10,
|
|
524
|
+
dh: 0
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
keybinding: kb(KeyCode.DownArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
|
|
529
|
+
command: "selection.nudge_resize",
|
|
530
|
+
args: {
|
|
531
|
+
dw: 0,
|
|
532
|
+
dh: 10
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
keybinding: kb(KeyCode.UpArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
|
|
537
|
+
command: "selection.nudge_resize",
|
|
538
|
+
args: {
|
|
539
|
+
dw: 0,
|
|
540
|
+
dh: -10
|
|
541
|
+
}
|
|
542
|
+
},
|
|
462
543
|
{
|
|
463
544
|
keybinding: kb(KeyCode.KeyV),
|
|
464
545
|
command: TOOL_SET,
|
|
@@ -755,541 +836,6 @@ function create_defs(doc) {
|
|
|
755
836
|
return { gradients: new GradientsRegistry(doc) };
|
|
756
837
|
}
|
|
757
838
|
//#endregion
|
|
758
|
-
//#region src/core/document.ts
|
|
759
|
-
/**
|
|
760
|
-
* Attribute names whose writes can shift a node's rendered bounds.
|
|
761
|
-
* Membership drives `_geometry_version` bumps in `set_attr`. Only
|
|
762
|
-
* non-namespaced attribute names — namespaced writes (xlink:href, etc.)
|
|
763
|
-
* never bump because they're references, not geometry.
|
|
764
|
-
*
|
|
765
|
-
* Includes text-shaping attributes (font-*) because they re-shape glyph
|
|
766
|
-
* runs and change `<text>` bbox.
|
|
767
|
-
*/
|
|
768
|
-
const GEOMETRY_ATTRS = new Set([
|
|
769
|
-
"x",
|
|
770
|
-
"y",
|
|
771
|
-
"x1",
|
|
772
|
-
"y1",
|
|
773
|
-
"x2",
|
|
774
|
-
"y2",
|
|
775
|
-
"cx",
|
|
776
|
-
"cy",
|
|
777
|
-
"width",
|
|
778
|
-
"height",
|
|
779
|
-
"r",
|
|
780
|
-
"rx",
|
|
781
|
-
"ry",
|
|
782
|
-
"points",
|
|
783
|
-
"d",
|
|
784
|
-
"transform",
|
|
785
|
-
"viewBox",
|
|
786
|
-
"font-size",
|
|
787
|
-
"font-family",
|
|
788
|
-
"font-weight",
|
|
789
|
-
"font-style",
|
|
790
|
-
"text-anchor",
|
|
791
|
-
"dx",
|
|
792
|
-
"dy",
|
|
793
|
-
"rotate",
|
|
794
|
-
"textLength",
|
|
795
|
-
"lengthAdjust",
|
|
796
|
-
"pathLength",
|
|
797
|
-
"marker-start",
|
|
798
|
-
"marker-mid",
|
|
799
|
-
"marker-end"
|
|
800
|
-
]);
|
|
801
|
-
/** `transform:` CSS property at the start of a declaration list or after `;`. */
|
|
802
|
-
const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
|
|
803
|
-
var SvgDocument = class SvgDocument {
|
|
804
|
-
constructor(svg) {
|
|
805
|
-
this.listeners = /* @__PURE__ */ new Set();
|
|
806
|
-
this._structure_version = 0;
|
|
807
|
-
this._geometry_version = 0;
|
|
808
|
-
if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
|
|
809
|
-
this.source = svg;
|
|
810
|
-
const parsed = parse_svg(svg);
|
|
811
|
-
this.original = parsed;
|
|
812
|
-
this.nodes = parsed.nodes;
|
|
813
|
-
this.prolog = parsed.prolog;
|
|
814
|
-
this.epilog = parsed.epilog;
|
|
815
|
-
this.root = parsed.root;
|
|
816
|
-
}
|
|
817
|
-
static parse(svg) {
|
|
818
|
-
return new SvgDocument(svg);
|
|
819
|
-
}
|
|
820
|
-
/** Reload from the original parse, discarding all edits. */
|
|
821
|
-
reset_to_original() {
|
|
822
|
-
const parsed = parse_svg(this.source);
|
|
823
|
-
this.original = parsed;
|
|
824
|
-
this.nodes = parsed.nodes;
|
|
825
|
-
this.prolog = parsed.prolog;
|
|
826
|
-
this.epilog = parsed.epilog;
|
|
827
|
-
this.root = parsed.root;
|
|
828
|
-
this._structure_version++;
|
|
829
|
-
this._geometry_version++;
|
|
830
|
-
this.emit();
|
|
831
|
-
}
|
|
832
|
-
/** Replace document with new svg source (clears edits + history-owned state). */
|
|
833
|
-
load(svg) {
|
|
834
|
-
if (typeof svg !== "string") throw new TypeError(`SvgDocument.load(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
|
|
835
|
-
this.source = svg;
|
|
836
|
-
const parsed = parse_svg(svg);
|
|
837
|
-
this.original = parsed;
|
|
838
|
-
this.nodes = parsed.nodes;
|
|
839
|
-
this.prolog = parsed.prolog;
|
|
840
|
-
this.epilog = parsed.epilog;
|
|
841
|
-
this.root = parsed.root;
|
|
842
|
-
this._structure_version++;
|
|
843
|
-
this._geometry_version++;
|
|
844
|
-
this.emit();
|
|
845
|
-
}
|
|
846
|
-
on_change(fn) {
|
|
847
|
-
this.listeners.add(fn);
|
|
848
|
-
return () => this.listeners.delete(fn);
|
|
849
|
-
}
|
|
850
|
-
/** See `_structure_version` for what this counter signals. */
|
|
851
|
-
get structure_version() {
|
|
852
|
-
return this._structure_version;
|
|
853
|
-
}
|
|
854
|
-
/** See `_geometry_version` for what this counter signals. */
|
|
855
|
-
get geometry_version() {
|
|
856
|
-
return this._geometry_version;
|
|
857
|
-
}
|
|
858
|
-
emit() {
|
|
859
|
-
for (const fn of this.listeners) fn();
|
|
860
|
-
}
|
|
861
|
-
/** Notify subscribers — for callers that mutate directly via setAttr/etc. */
|
|
862
|
-
notify() {
|
|
863
|
-
this.emit();
|
|
864
|
-
}
|
|
865
|
-
get(id) {
|
|
866
|
-
return this.nodes.get(id) ?? null;
|
|
867
|
-
}
|
|
868
|
-
is_element(id) {
|
|
869
|
-
return this.nodes.get(id)?.kind === "element";
|
|
870
|
-
}
|
|
871
|
-
parent_of(id) {
|
|
872
|
-
return this.nodes.get(id)?.parent ?? null;
|
|
873
|
-
}
|
|
874
|
-
children_of(id) {
|
|
875
|
-
const n = this.nodes.get(id);
|
|
876
|
-
if (!n || n.kind !== "element") return [];
|
|
877
|
-
return n.children;
|
|
878
|
-
}
|
|
879
|
-
/** Element children only — text/comment/cdata filtered out. */
|
|
880
|
-
element_children_of(id) {
|
|
881
|
-
return this.children_of(id).filter((c) => this.is_element(c));
|
|
882
|
-
}
|
|
883
|
-
next_sibling_of(id) {
|
|
884
|
-
const parent = this.parent_of(id);
|
|
885
|
-
if (parent === null) return null;
|
|
886
|
-
const siblings = this.children_of(parent);
|
|
887
|
-
const i = siblings.indexOf(id);
|
|
888
|
-
return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
|
|
889
|
-
}
|
|
890
|
-
next_element_sibling_of(id) {
|
|
891
|
-
const parent = this.parent_of(id);
|
|
892
|
-
if (parent === null) return null;
|
|
893
|
-
const siblings = this.element_children_of(parent);
|
|
894
|
-
const i = siblings.indexOf(id);
|
|
895
|
-
return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
|
|
896
|
-
}
|
|
897
|
-
tag_of(id) {
|
|
898
|
-
const n = this.nodes.get(id);
|
|
899
|
-
return n && n.kind === "element" ? n.local : "";
|
|
900
|
-
}
|
|
901
|
-
contains(ancestor, descendant) {
|
|
902
|
-
if (ancestor === descendant) return true;
|
|
903
|
-
let cur = this.parent_of(descendant);
|
|
904
|
-
while (cur !== null) {
|
|
905
|
-
if (cur === ancestor) return true;
|
|
906
|
-
cur = this.parent_of(cur);
|
|
907
|
-
}
|
|
908
|
-
return false;
|
|
909
|
-
}
|
|
910
|
-
/**
|
|
911
|
-
* Filter a selection down to its **subtree roots** — drop any id whose
|
|
912
|
-
* ancestor is also in the input set.
|
|
913
|
-
*
|
|
914
|
-
* Mirrors `pruneNestedNodes` in the main canvas editor's query module
|
|
915
|
-
* ([editor/grida-canvas/query/index.ts:138](../../../../editor/grida-canvas/query/index.ts)) and shares its UX motivation:
|
|
916
|
-
* when a parent and a descendant are both selected, only the parent
|
|
917
|
-
* should drive multi-node mutations — otherwise the descendant
|
|
918
|
-
* accumulates the transform twice (once via the parent's `transform`,
|
|
919
|
-
* once via its own attribute write). Required for `commands.remove`
|
|
920
|
-
* (avoids re-attaching detached descendants on undo) and any multi-
|
|
921
|
-
* member translate path (avoids 2× drift for the Bar-chart marquee
|
|
922
|
-
* case).
|
|
923
|
-
*
|
|
924
|
-
* Order: preserves the input order for retained ids. Duplicates in
|
|
925
|
-
* the input are not deduplicated — callers are responsible (the
|
|
926
|
-
* editor's `commands.select` already dedupes).
|
|
927
|
-
*
|
|
928
|
-
* Performance: `O(n × depth)`. Builds a `Set` over the input once,
|
|
929
|
-
* then walks each id's ancestor chain at most once. The main editor's
|
|
930
|
-
* version is `O(n² × depth)` (per-pair `isAncestor`) — fine at typical
|
|
931
|
-
* selection sizes (a few dozen), worth winning here for free since
|
|
932
|
-
* `parent_of` is `O(1)` on our parent-map.
|
|
933
|
-
*/
|
|
934
|
-
prune_nested_nodes(ids) {
|
|
935
|
-
if (ids.length <= 1) return [...ids];
|
|
936
|
-
const set = new Set(ids);
|
|
937
|
-
const out = [];
|
|
938
|
-
for (const id of ids) {
|
|
939
|
-
let nested = false;
|
|
940
|
-
let cur = this.parent_of(id);
|
|
941
|
-
while (cur !== null) {
|
|
942
|
-
if (set.has(cur)) {
|
|
943
|
-
nested = true;
|
|
944
|
-
break;
|
|
945
|
-
}
|
|
946
|
-
cur = this.parent_of(cur);
|
|
947
|
-
}
|
|
948
|
-
if (!nested) out.push(id);
|
|
949
|
-
}
|
|
950
|
-
return out;
|
|
951
|
-
}
|
|
952
|
-
all_nodes() {
|
|
953
|
-
const out = [];
|
|
954
|
-
const walk = (id) => {
|
|
955
|
-
out.push(id);
|
|
956
|
-
const c = this.children_of(id);
|
|
957
|
-
for (const ch of c) walk(ch);
|
|
958
|
-
};
|
|
959
|
-
walk(this.root);
|
|
960
|
-
return out;
|
|
961
|
-
}
|
|
962
|
-
all_elements() {
|
|
963
|
-
return this.all_nodes().filter((id) => this.is_element(id));
|
|
964
|
-
}
|
|
965
|
-
find_by_tag(ancestor, tag) {
|
|
966
|
-
const out = [];
|
|
967
|
-
const walk = (id) => {
|
|
968
|
-
if (id !== ancestor && this.is_element(id) && this.tag_of(id) === tag) out.push(id);
|
|
969
|
-
for (const c of this.children_of(id)) walk(c);
|
|
970
|
-
};
|
|
971
|
-
walk(ancestor);
|
|
972
|
-
return out;
|
|
973
|
-
}
|
|
974
|
-
/** Read attribute by local name, optionally namespace-filtered. */
|
|
975
|
-
get_attr(id, name, ns = null) {
|
|
976
|
-
const n = this.nodes.get(id);
|
|
977
|
-
if (!n || n.kind !== "element") return null;
|
|
978
|
-
for (const a of n.attrs) if (a.local === name && (ns === null || a.ns === ns)) return a.value;
|
|
979
|
-
return null;
|
|
980
|
-
}
|
|
981
|
-
/**
|
|
982
|
-
* Set / remove an attribute. If the attribute exists, it is mutated in place
|
|
983
|
-
* (preserving source position). If it doesn't, it's appended.
|
|
984
|
-
*/
|
|
985
|
-
set_attr(id, name, value, ns = null) {
|
|
986
|
-
const n = this.nodes.get(id);
|
|
987
|
-
if (!n || n.kind !== "element") return;
|
|
988
|
-
const structural = name === "id";
|
|
989
|
-
const geometry = ns === null && GEOMETRY_ATTRS.has(name);
|
|
990
|
-
for (let i = 0; i < n.attrs.length; i++) {
|
|
991
|
-
const a = n.attrs[i];
|
|
992
|
-
if (a.local === name && (ns === null || a.ns === ns)) {
|
|
993
|
-
if (value === null) n.attrs.splice(i, 1);
|
|
994
|
-
else a.value = value;
|
|
995
|
-
if (structural) this._structure_version++;
|
|
996
|
-
if (geometry) this._geometry_version++;
|
|
997
|
-
this.emit();
|
|
998
|
-
return;
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
if (value !== null) {
|
|
1002
|
-
const prefix = ns === XLINK_NS ? "xlink" : null;
|
|
1003
|
-
n.attrs.push({
|
|
1004
|
-
raw_name: prefix ? `${prefix}:${name}` : name,
|
|
1005
|
-
prefix,
|
|
1006
|
-
local: name,
|
|
1007
|
-
ns,
|
|
1008
|
-
value,
|
|
1009
|
-
pre: " ",
|
|
1010
|
-
eq_trivia: "",
|
|
1011
|
-
quote: "\""
|
|
1012
|
-
});
|
|
1013
|
-
if (structural) this._structure_version++;
|
|
1014
|
-
if (geometry) this._geometry_version++;
|
|
1015
|
-
this.emit();
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
attributes_of(id) {
|
|
1019
|
-
const n = this.nodes.get(id);
|
|
1020
|
-
if (!n || n.kind !== "element") return [];
|
|
1021
|
-
return n.attrs.map((a) => ({
|
|
1022
|
-
name: a.local,
|
|
1023
|
-
ns: a.ns,
|
|
1024
|
-
value: a.value
|
|
1025
|
-
}));
|
|
1026
|
-
}
|
|
1027
|
-
get_style(id, property) {
|
|
1028
|
-
const style = this.get_attr(id, "style");
|
|
1029
|
-
if (!style) return null;
|
|
1030
|
-
const decls = parse_inline_style(style);
|
|
1031
|
-
for (const d of decls) if (d.property === property) return d.value;
|
|
1032
|
-
return null;
|
|
1033
|
-
}
|
|
1034
|
-
set_style(id, property, value) {
|
|
1035
|
-
const decls = parse_inline_style(this.get_attr(id, "style") ?? "");
|
|
1036
|
-
const idx = decls.findIndex((d) => d.property === property);
|
|
1037
|
-
if (value === null) {
|
|
1038
|
-
if (idx === -1) return;
|
|
1039
|
-
decls.splice(idx, 1);
|
|
1040
|
-
} else if (idx === -1) decls.push({
|
|
1041
|
-
property,
|
|
1042
|
-
value
|
|
1043
|
-
});
|
|
1044
|
-
else decls[idx].value = value;
|
|
1045
|
-
const next = decls.map((d) => `${d.property}: ${d.value}`).join("; ");
|
|
1046
|
-
this.set_attr(id, "style", next === "" ? null : next);
|
|
1047
|
-
}
|
|
1048
|
-
get_all_styles(id) {
|
|
1049
|
-
const style = this.get_attr(id, "style");
|
|
1050
|
-
if (!style) return [];
|
|
1051
|
-
return parse_inline_style(style);
|
|
1052
|
-
}
|
|
1053
|
-
/**
|
|
1054
|
-
* Whether `id` can be opened in the flat-string text editor.
|
|
1055
|
-
*
|
|
1056
|
-
* v1 contract: the editor only operates on a *single flat text run*. That
|
|
1057
|
-
* means the target must be a `<text>` or `<tspan>` whose direct children
|
|
1058
|
-
* are all text nodes (or it has no children). A `<text>` containing a
|
|
1059
|
-
* `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
|
|
1060
|
-
* content from the editor's view, and a flat-text write would leave the
|
|
1061
|
-
* tspan dangling. Tspan-as-target is fine and well-defined when it's a
|
|
1062
|
-
* leaf; only the host decides whether to route double-click to a tspan
|
|
1063
|
-
* or its parent text.
|
|
1064
|
-
*/
|
|
1065
|
-
is_text_edit_target(id) {
|
|
1066
|
-
const n = this.nodes.get(id);
|
|
1067
|
-
if (!n || n.kind !== "element") return false;
|
|
1068
|
-
if (n.local !== "text" && n.local !== "tspan") return false;
|
|
1069
|
-
for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
|
|
1070
|
-
return true;
|
|
1071
|
-
}
|
|
1072
|
-
/**
|
|
1073
|
-
* Returns a tag-discriminated snapshot of the authored geometry attrs
|
|
1074
|
-
* if this node is eligible for vector (vertex) editing — else `null`.
|
|
1075
|
-
*
|
|
1076
|
-
* v1 eligibility:
|
|
1077
|
-
* - `<path>` — requires non-empty `d`.
|
|
1078
|
-
* - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
|
|
1079
|
-
* - `<polygon>` — same as polyline.
|
|
1080
|
-
*
|
|
1081
|
-
* Deliberately rejects `<line>` in v1: the only useful vertex-edit
|
|
1082
|
-
* gestures on a `<line>` are (a) introducing a new vertex (which would
|
|
1083
|
-
* have to promote it to `<polyline>`) and (b) bending it with a tangent
|
|
1084
|
-
* (which would have to promote it to `<path>`). Both promotions are
|
|
1085
|
-
* out of scope for v1, so opening a `<line>` in vector-edit mode would
|
|
1086
|
-
* advertise capabilities that don't work.
|
|
1087
|
-
*
|
|
1088
|
-
* Also rejects `<rect>`, `<circle>`, `<ellipse>`, `<image>`, `<use>` —
|
|
1089
|
-
* those would force the same promotion-to-`<path>` machinery (trivia
|
|
1090
|
-
* transfer, cross-cutting attr carry, DOM-element swap, history-bracket
|
|
1091
|
-
* changes) that v1 keeps out of scope.
|
|
1092
|
-
*/
|
|
1093
|
-
is_vector_edit_target(id) {
|
|
1094
|
-
const n = this.nodes.get(id);
|
|
1095
|
-
if (!n || n.kind !== "element") return null;
|
|
1096
|
-
switch (n.local) {
|
|
1097
|
-
case "path": {
|
|
1098
|
-
const d = this.get_attr(id, "d");
|
|
1099
|
-
if (d === null || d.trim().length === 0) return null;
|
|
1100
|
-
return {
|
|
1101
|
-
kind: "path",
|
|
1102
|
-
d
|
|
1103
|
-
};
|
|
1104
|
-
}
|
|
1105
|
-
case "polyline":
|
|
1106
|
-
case "polygon": {
|
|
1107
|
-
const raw = this.get_attr(id, "points") ?? "";
|
|
1108
|
-
const parsed = svg_parse.parse_points(raw);
|
|
1109
|
-
if (parsed.length < 2) return null;
|
|
1110
|
-
const points = parsed.map((p) => [p.x, p.y]);
|
|
1111
|
-
return n.local === "polyline" ? {
|
|
1112
|
-
kind: "polyline",
|
|
1113
|
-
points
|
|
1114
|
-
} : {
|
|
1115
|
-
kind: "polygon",
|
|
1116
|
-
points
|
|
1117
|
-
};
|
|
1118
|
-
}
|
|
1119
|
-
default: return null;
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
/**
|
|
1123
|
-
* True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
|
|
1124
|
-
* per-glyph attribute (which conflicts with element-level rotation).
|
|
1125
|
-
*/
|
|
1126
|
-
has_glyph_rotate(id) {
|
|
1127
|
-
const tag = this.tag_of(id);
|
|
1128
|
-
if (tag !== "text" && tag !== "tspan") return false;
|
|
1129
|
-
const value = this.get_attr(id, "rotate");
|
|
1130
|
-
if (value === null) return false;
|
|
1131
|
-
return value.trim() !== "";
|
|
1132
|
-
}
|
|
1133
|
-
/**
|
|
1134
|
-
* True iff this element's inline `style=""` declares a `transform:`
|
|
1135
|
-
* CSS property (which would shadow the editor's `transform=` writes).
|
|
1136
|
-
*/
|
|
1137
|
-
has_inline_css_transform(id) {
|
|
1138
|
-
const style = this.get_attr(id, "style");
|
|
1139
|
-
if (!style) return false;
|
|
1140
|
-
return CSS_TRANSFORM_PROPERTY.test(style);
|
|
1141
|
-
}
|
|
1142
|
-
/**
|
|
1143
|
-
* True iff this element has a direct `<animateTransform>` child
|
|
1144
|
-
* (which produces a time-varying transform invisible to attribute writes).
|
|
1145
|
-
* Only direct children are checked — nested cases attach to the nearer ancestor.
|
|
1146
|
-
*/
|
|
1147
|
-
has_animate_transform_child(id) {
|
|
1148
|
-
for (const c of this.children_of(id)) {
|
|
1149
|
-
const n = this.nodes.get(c);
|
|
1150
|
-
if (n?.kind === "element" && n.local === "animateTransform") return true;
|
|
1151
|
-
}
|
|
1152
|
-
return false;
|
|
1153
|
-
}
|
|
1154
|
-
text_of(id) {
|
|
1155
|
-
const n = this.nodes.get(id);
|
|
1156
|
-
if (!n || n.kind !== "element") return "";
|
|
1157
|
-
let out = "";
|
|
1158
|
-
for (const c of n.children) {
|
|
1159
|
-
const cn = this.nodes.get(c);
|
|
1160
|
-
if (cn?.kind === "text") out += cn.value;
|
|
1161
|
-
}
|
|
1162
|
-
return out;
|
|
1163
|
-
}
|
|
1164
|
-
/** Replace all direct text children with a single text node carrying `value`. */
|
|
1165
|
-
set_text(id, value) {
|
|
1166
|
-
const n = this.nodes.get(id);
|
|
1167
|
-
if (!n || n.kind !== "element") return;
|
|
1168
|
-
n.children = n.children.filter((c) => this.nodes.get(c)?.kind !== "text");
|
|
1169
|
-
if (value !== "") {
|
|
1170
|
-
const text_id = `t${Math.random().toString(36).slice(2, 10)}`;
|
|
1171
|
-
const text_node = {
|
|
1172
|
-
kind: "text",
|
|
1173
|
-
id: text_id,
|
|
1174
|
-
parent: id,
|
|
1175
|
-
value
|
|
1176
|
-
};
|
|
1177
|
-
this.nodes.set(text_id, text_node);
|
|
1178
|
-
n.children.push(text_id);
|
|
1179
|
-
}
|
|
1180
|
-
this._structure_version++;
|
|
1181
|
-
this._geometry_version++;
|
|
1182
|
-
this.emit();
|
|
1183
|
-
}
|
|
1184
|
-
insert(id, parent, before) {
|
|
1185
|
-
const node = this.nodes.get(id);
|
|
1186
|
-
const parent_node = this.nodes.get(parent);
|
|
1187
|
-
if (!node || !parent_node || parent_node.kind !== "element") return;
|
|
1188
|
-
if (node.parent !== null) {
|
|
1189
|
-
const old_parent = this.nodes.get(node.parent);
|
|
1190
|
-
if (old_parent && old_parent.kind === "element") {
|
|
1191
|
-
const i = old_parent.children.indexOf(id);
|
|
1192
|
-
if (i >= 0) old_parent.children.splice(i, 1);
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
const ix = before === null ? -1 : parent_node.children.indexOf(before);
|
|
1196
|
-
if (ix < 0) parent_node.children.push(id);
|
|
1197
|
-
else parent_node.children.splice(ix, 0, id);
|
|
1198
|
-
node.parent = parent;
|
|
1199
|
-
this._structure_version++;
|
|
1200
|
-
this._geometry_version++;
|
|
1201
|
-
this.emit();
|
|
1202
|
-
}
|
|
1203
|
-
remove(id) {
|
|
1204
|
-
const n = this.nodes.get(id);
|
|
1205
|
-
if (!n || n.parent === null) return;
|
|
1206
|
-
const parent = this.nodes.get(n.parent);
|
|
1207
|
-
if (!parent || parent.kind !== "element") return;
|
|
1208
|
-
const i = parent.children.indexOf(id);
|
|
1209
|
-
if (i >= 0) parent.children.splice(i, 1);
|
|
1210
|
-
n.parent = null;
|
|
1211
|
-
this._structure_version++;
|
|
1212
|
-
this._geometry_version++;
|
|
1213
|
-
this.emit();
|
|
1214
|
-
}
|
|
1215
|
-
/** Create a new element node and register it (not yet inserted). */
|
|
1216
|
-
create_element(local, opts) {
|
|
1217
|
-
const id = `e${Math.random().toString(36).slice(2, 10)}`;
|
|
1218
|
-
const prefix = opts?.prefix ?? null;
|
|
1219
|
-
const ns = opts?.ns ?? null;
|
|
1220
|
-
const node = {
|
|
1221
|
-
kind: "element",
|
|
1222
|
-
id,
|
|
1223
|
-
parent: null,
|
|
1224
|
-
raw_tag: prefix ? `${prefix}:${local}` : local,
|
|
1225
|
-
prefix,
|
|
1226
|
-
local,
|
|
1227
|
-
ns,
|
|
1228
|
-
attrs: [],
|
|
1229
|
-
children: [],
|
|
1230
|
-
self_closing: false,
|
|
1231
|
-
open_tag_trailing: "",
|
|
1232
|
-
close_tag_leading: "",
|
|
1233
|
-
close_tag_trailing: ""
|
|
1234
|
-
};
|
|
1235
|
-
this.nodes.set(id, node);
|
|
1236
|
-
return id;
|
|
1237
|
-
}
|
|
1238
|
-
serialize() {
|
|
1239
|
-
let out = "";
|
|
1240
|
-
for (const p of this.prolog) out += this.emit_node(p);
|
|
1241
|
-
out += this.emit_node(this.nodes.get(this.root));
|
|
1242
|
-
for (const e of this.epilog) out += this.emit_node(e);
|
|
1243
|
-
return out;
|
|
1244
|
-
}
|
|
1245
|
-
emit_node(n) {
|
|
1246
|
-
switch (n.kind) {
|
|
1247
|
-
case "text": return encode_text(n.value);
|
|
1248
|
-
case "comment": return `<!--${n.value}-->`;
|
|
1249
|
-
case "cdata": return `<![CDATA[${n.value}]]>`;
|
|
1250
|
-
case "pi": {
|
|
1251
|
-
const pi = n;
|
|
1252
|
-
return `<?${pi.target}${pi.value ? " " + pi.value : ""}?>`;
|
|
1253
|
-
}
|
|
1254
|
-
case "doctype": return `<!DOCTYPE${n.value}>`;
|
|
1255
|
-
case "element": {
|
|
1256
|
-
const e = n;
|
|
1257
|
-
let s = `<${e.raw_tag}`;
|
|
1258
|
-
for (const a of e.attrs) s += this.emit_attr(a);
|
|
1259
|
-
if (e.children.length === 0 && e.self_closing) {
|
|
1260
|
-
s += `${e.open_tag_trailing}/>`;
|
|
1261
|
-
return s;
|
|
1262
|
-
}
|
|
1263
|
-
s += `${e.open_tag_trailing}>`;
|
|
1264
|
-
for (const cid of e.children) {
|
|
1265
|
-
const cn = this.nodes.get(cid);
|
|
1266
|
-
if (cn) s += this.emit_node(cn);
|
|
1267
|
-
}
|
|
1268
|
-
s += `</${e.close_tag_leading}${e.raw_tag}${e.close_tag_trailing}>`;
|
|
1269
|
-
return s;
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
emit_attr(a) {
|
|
1274
|
-
return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${encode_attr_value(a.value, a.quote)}${a.quote}`;
|
|
1275
|
-
}
|
|
1276
|
-
};
|
|
1277
|
-
function parse_inline_style(s) {
|
|
1278
|
-
const out = [];
|
|
1279
|
-
const decls = s.split(";");
|
|
1280
|
-
for (const decl of decls) {
|
|
1281
|
-
const colon = decl.indexOf(":");
|
|
1282
|
-
if (colon === -1) continue;
|
|
1283
|
-
const property = decl.slice(0, colon).trim();
|
|
1284
|
-
const value = decl.slice(colon + 1).trim();
|
|
1285
|
-
if (property) out.push({
|
|
1286
|
-
property,
|
|
1287
|
-
value
|
|
1288
|
-
});
|
|
1289
|
-
}
|
|
1290
|
-
return out;
|
|
1291
|
-
}
|
|
1292
|
-
//#endregion
|
|
1293
839
|
//#region src/core/align.ts
|
|
1294
840
|
/**
|
|
1295
841
|
* Compute per-member translation deltas to align `members` against `target`.
|
|
@@ -1560,12 +1106,22 @@ function _create_svg_editor_internal(opts) {
|
|
|
1560
1106
|
const notify_translate_commit = () => {
|
|
1561
1107
|
for (const cb of translate_commit_listeners) cb();
|
|
1562
1108
|
};
|
|
1563
|
-
|
|
1564
|
-
|
|
1109
|
+
/**
|
|
1110
|
+
* Fan out the geometry channel iff the doc's `geometry_version` has
|
|
1111
|
+
* moved since we last fired. Shared by the `doc.on_change` handler
|
|
1112
|
+
* (mutation-driven bumps) and the surface-driven `bump_geometry` seam
|
|
1113
|
+
* (font-load reflow). Idempotent against a stale version — never
|
|
1114
|
+
* double-fires for the same value.
|
|
1115
|
+
*/
|
|
1116
|
+
function fire_geometry_listeners_if_advanced() {
|
|
1565
1117
|
if (doc.geometry_version !== last_emitted_geometry_version) {
|
|
1566
1118
|
last_emitted_geometry_version = doc.geometry_version;
|
|
1567
1119
|
for (const cb of geometry_listeners) cb();
|
|
1568
1120
|
}
|
|
1121
|
+
}
|
|
1122
|
+
doc.on_change(() => {
|
|
1123
|
+
doc_version++;
|
|
1124
|
+
fire_geometry_listeners_if_advanced();
|
|
1569
1125
|
});
|
|
1570
1126
|
function subscribe(fn) {
|
|
1571
1127
|
listeners.add(fn);
|
|
@@ -1845,7 +1401,8 @@ function _create_svg_editor_internal(opts) {
|
|
|
1845
1401
|
snap_threshold_px: style.snap_threshold_px
|
|
1846
1402
|
},
|
|
1847
1403
|
emit,
|
|
1848
|
-
stages
|
|
1404
|
+
stages,
|
|
1405
|
+
project: (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d
|
|
1849
1406
|
});
|
|
1850
1407
|
apply();
|
|
1851
1408
|
history.atomic(label, (tx) => {
|
|
@@ -1864,66 +1421,79 @@ function _create_svg_editor_internal(opts) {
|
|
|
1864
1421
|
if (do_translate_oneshot(delta, translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
|
|
1865
1422
|
}
|
|
1866
1423
|
/**
|
|
1867
|
-
*
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
1870
|
-
*
|
|
1871
|
-
* atomic step rather than a preview session.
|
|
1424
|
+
* Gate + capture for a resize gesture. Returns the resizable members (with
|
|
1425
|
+
* captured baseline / pre-transform / bbox), or `null` if the gesture can't
|
|
1426
|
+
* run: no geometry provider, empty selection, or — in `all_or_nothing` mode
|
|
1427
|
+
* — any member fails the gate.
|
|
1872
1428
|
*
|
|
1873
|
-
*
|
|
1874
|
-
* `
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
1878
|
-
*
|
|
1879
|
-
*
|
|
1880
|
-
* existing `transform` round-trips cleanly. See `apply_translate`'s
|
|
1881
|
-
* `viaTransform` arm for why this matters.
|
|
1429
|
+
* `mode`:
|
|
1430
|
+
* - `"skip"` — drop members failing the `is_resizable_node` gate
|
|
1431
|
+
* (tag + transform class) or lacking a bbox; resize the rest. Used by the
|
|
1432
|
+
* inspector `resize_to` (set-bbox) path.
|
|
1433
|
+
* - `"all_or_nothing"` — refuse the WHOLE gesture (return `null`) if ANY
|
|
1434
|
+
* member fails. Used by keyboard `resize_by` (nudge), matching the resize
|
|
1435
|
+
* HUD, whose handle-drag is rejected when any member is unsafe.
|
|
1882
1436
|
*/
|
|
1883
|
-
function
|
|
1884
|
-
|
|
1885
|
-
if (
|
|
1886
|
-
if (!geometry_provider) return false;
|
|
1437
|
+
function collect_resize_members(ids, mode) {
|
|
1438
|
+
if (ids.length === 0) return null;
|
|
1439
|
+
if (!geometry_provider) return null;
|
|
1887
1440
|
const members = [];
|
|
1888
1441
|
for (const id of ids) {
|
|
1889
|
-
if (!resize_pipeline.intent.
|
|
1442
|
+
if (!resize_pipeline.intent.is_resizable_node(doc, id)) {
|
|
1443
|
+
if (mode === "all_or_nothing") return null;
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1890
1446
|
const bbox = geometry_provider.bounds_of(id);
|
|
1891
|
-
if (!bbox)
|
|
1447
|
+
if (!bbox) {
|
|
1448
|
+
if (mode === "all_or_nothing") return null;
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1892
1451
|
members.push({
|
|
1893
1452
|
id,
|
|
1894
1453
|
rz: resize_pipeline.intent.capture_baseline(doc, id, bbox),
|
|
1895
|
-
tx_pre: translate_pipeline.intent.capture_baseline(doc, id),
|
|
1896
1454
|
transform_pre: doc.get_attr(id, "transform"),
|
|
1897
1455
|
bbox
|
|
1898
1456
|
});
|
|
1899
1457
|
}
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1458
|
+
return members.length === 0 ? null : members;
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Apply a resize to each member, optionally followed by a uniform group
|
|
1462
|
+
* translate, as ONE atomic history step. `op` resolves each member's scale
|
|
1463
|
+
* factors + scale origin; `group_translate` is the post-scale envelope shift
|
|
1464
|
+
* (group resize only — `null` for per-element). Callers guarantee `members`
|
|
1465
|
+
* is non-empty. Returns `true` when a history step was pushed; `false` when
|
|
1466
|
+
* the gesture is geometrically identity (no member scales and no group
|
|
1467
|
+
* translate) so undo isn't polluted with an empty step. NOTE: a per-tag
|
|
1468
|
+
* constraint that collapses a non-1 factor to identity *inside* the handler
|
|
1469
|
+
* (e.g. `<circle>` uniform `min` on a single-axis nudge) is not detected
|
|
1470
|
+
* here — the op-level factor is still ≠ 1, so that case still pushes a step.
|
|
1471
|
+
*/
|
|
1472
|
+
function commit_resize(members, op, group_translate, label) {
|
|
1473
|
+
const ops = members.map((m) => ({
|
|
1474
|
+
m,
|
|
1475
|
+
...op(m)
|
|
1476
|
+
}));
|
|
1477
|
+
const scales = ops.some(({ sx, sy }) => sx !== 1 || sy !== 1);
|
|
1478
|
+
const translates = !!group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0);
|
|
1479
|
+
if (!scales && !translates) return false;
|
|
1910
1480
|
const apply = () => {
|
|
1911
|
-
for (const m of
|
|
1912
|
-
if (dx !== 0 || dy !== 0) for (const m of members) {
|
|
1481
|
+
for (const { m, sx, sy, origin } of ops) resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
|
|
1482
|
+
if (group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0)) for (const m of members) {
|
|
1913
1483
|
const tx_after = translate_pipeline.intent.capture_baseline(doc, m.id);
|
|
1914
|
-
translate_pipeline.intent.apply(doc, m.id, tx_after, dx, dy);
|
|
1484
|
+
translate_pipeline.intent.apply(doc, m.id, tx_after, group_translate.dx, group_translate.dy);
|
|
1915
1485
|
}
|
|
1916
1486
|
emit();
|
|
1917
1487
|
};
|
|
1918
1488
|
const revert = () => {
|
|
1919
|
-
for (const m of
|
|
1489
|
+
for (const { m, origin } of ops) {
|
|
1920
1490
|
resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
|
|
1921
1491
|
doc.set_attr(m.id, "transform", m.transform_pre);
|
|
1922
1492
|
}
|
|
1923
1493
|
emit();
|
|
1924
1494
|
};
|
|
1925
1495
|
apply();
|
|
1926
|
-
history.atomic(
|
|
1496
|
+
history.atomic(label, (tx) => {
|
|
1927
1497
|
tx.push({
|
|
1928
1498
|
providerId: PROVIDER_ID,
|
|
1929
1499
|
apply,
|
|
@@ -1932,6 +1502,73 @@ function _create_svg_editor_internal(opts) {
|
|
|
1932
1502
|
});
|
|
1933
1503
|
return true;
|
|
1934
1504
|
}
|
|
1505
|
+
/**
|
|
1506
|
+
* One-shot multi-member resize to an explicit target rect. Mirrors a
|
|
1507
|
+
* drag-resize gesture in mechanics — capture per-member baselines,
|
|
1508
|
+
* scale around the union's NW corner, translate the result so the
|
|
1509
|
+
* union NW lands at the requested position — but as a single
|
|
1510
|
+
* atomic step rather than a preview session. This is the GROUP path:
|
|
1511
|
+
* the whole selection is treated as one envelope.
|
|
1512
|
+
*
|
|
1513
|
+
* The function does its own geometry lookup via the
|
|
1514
|
+
* `geometry_provider` registered by the DOM surface. When no surface
|
|
1515
|
+
* is attached, the call is a no-op (returns `false`). Members that fail
|
|
1516
|
+
* the `is_resizable_node` gate — an unresizable tag (e.g. `<g>`) OR a
|
|
1517
|
+
* non-trivially-transformed element — are silently skipped (see
|
|
1518
|
+
* `collect_resize_members`).
|
|
1519
|
+
*
|
|
1520
|
+
* Revert restores the captured `transform` attribute and all
|
|
1521
|
+
* geometry attrs the apply step wrote — so a `<rect>` with an
|
|
1522
|
+
* existing `transform` round-trips cleanly. See `apply_translate`'s
|
|
1523
|
+
* `viaTransform` arm for why this matters.
|
|
1524
|
+
*/
|
|
1525
|
+
function resize_to(target, opts) {
|
|
1526
|
+
const members = collect_resize_members(opts?.ids ?? selection, "skip");
|
|
1527
|
+
if (!members) return false;
|
|
1528
|
+
const union = cmath.rect.union(members.map((m) => m.bbox));
|
|
1529
|
+
const sx = union.width === 0 ? 1 : target.width / union.width;
|
|
1530
|
+
const sy = union.height === 0 ? 1 : target.height / union.height;
|
|
1531
|
+
const origin = {
|
|
1532
|
+
x: union.x,
|
|
1533
|
+
y: union.y
|
|
1534
|
+
};
|
|
1535
|
+
return commit_resize(members, () => ({
|
|
1536
|
+
sx,
|
|
1537
|
+
sy,
|
|
1538
|
+
origin
|
|
1539
|
+
}), {
|
|
1540
|
+
dx: target.x - union.x,
|
|
1541
|
+
dy: target.y - union.y
|
|
1542
|
+
}, opts?.label ?? "resize-to");
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Resize by a `{dw, dh}` delta — the core verb behind keyboard nudge-resize
|
|
1546
|
+
* (`Ctrl+Alt+Arrow`). This is the PER-ELEMENT path: each selected member
|
|
1547
|
+
* grows/shrinks by the delta around ITS OWN NW corner, so members keep their
|
|
1548
|
+
* positions relative to one another. This deliberately differs from
|
|
1549
|
+
* {@link resize_to} (the group/envelope path): a HUD group-resize scales the
|
|
1550
|
+
* whole selection around the shared union origin, translating off-origin
|
|
1551
|
+
* members — correct for a drag handle, wrong for a keyboard nudge, whose UX
|
|
1552
|
+
* is "apply the delta to each".
|
|
1553
|
+
*
|
|
1554
|
+
* ALL-OR-NOTHING gate (`collect_resize_members("all_or_nothing")`): refuses
|
|
1555
|
+
* (returns `false`, no history step) on empty selection, no geometry
|
|
1556
|
+
* provider, or any member failing the `is_resizable_node` gate — matching
|
|
1557
|
+
* the resize HUD rather than `resize_to`'s per-member skip.
|
|
1558
|
+
*/
|
|
1559
|
+
function resize_by(delta, opts) {
|
|
1560
|
+
const members = collect_resize_members(opts?.ids ?? selection, "all_or_nothing");
|
|
1561
|
+
if (!members) return false;
|
|
1562
|
+
const axis = (size, d) => size === 0 ? 1 : Math.max(0, size + d) / size;
|
|
1563
|
+
return commit_resize(members, (m) => ({
|
|
1564
|
+
sx: axis(m.bbox.width, delta.dw),
|
|
1565
|
+
sy: axis(m.bbox.height, delta.dh),
|
|
1566
|
+
origin: {
|
|
1567
|
+
x: m.bbox.x,
|
|
1568
|
+
y: m.bbox.y
|
|
1569
|
+
}
|
|
1570
|
+
}), null, "nudge-resize");
|
|
1571
|
+
}
|
|
1935
1572
|
/** Shared helper: compute a default rotation pivot from the live
|
|
1936
1573
|
* geometry_provider when the caller omitted one. Falls back to (0,0)
|
|
1937
1574
|
* if no surface is attached. */
|
|
@@ -2013,6 +1650,70 @@ function _create_svg_editor_internal(opts) {
|
|
|
2013
1650
|
});
|
|
2014
1651
|
return true;
|
|
2015
1652
|
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Relative affine compose about a pivot. See the `Commands.transform`
|
|
1655
|
+
* doc for the full contract. This function owns ONLY the pivot/effective-
|
|
1656
|
+
* matrix computation (which needs `geometry_provider`); the parse→fold→
|
|
1657
|
+
* emit round-trip is delegated per-member to the pure
|
|
1658
|
+
* `transform.apply_affine` helper.
|
|
1659
|
+
*/
|
|
1660
|
+
function apply_transform(matrix, opts) {
|
|
1661
|
+
const ids = opts?.ids ?? selection;
|
|
1662
|
+
if (ids.length === 0) return false;
|
|
1663
|
+
if (!geometry_provider) return false;
|
|
1664
|
+
for (const id of ids) if (rotate_pipeline.intent.is_transformable(doc, id).kind === "refuse") return false;
|
|
1665
|
+
const pivot = opts?.pivot ?? default_rotate_pivot(ids);
|
|
1666
|
+
const [a, b, c, d, e, f] = matrix;
|
|
1667
|
+
const requested = [[
|
|
1668
|
+
a,
|
|
1669
|
+
c,
|
|
1670
|
+
e
|
|
1671
|
+
], [
|
|
1672
|
+
b,
|
|
1673
|
+
d,
|
|
1674
|
+
f
|
|
1675
|
+
]];
|
|
1676
|
+
const t_pivot = [[
|
|
1677
|
+
1,
|
|
1678
|
+
0,
|
|
1679
|
+
pivot.x
|
|
1680
|
+
], [
|
|
1681
|
+
0,
|
|
1682
|
+
1,
|
|
1683
|
+
pivot.y
|
|
1684
|
+
]];
|
|
1685
|
+
const t_neg_pivot = [[
|
|
1686
|
+
1,
|
|
1687
|
+
0,
|
|
1688
|
+
-pivot.x
|
|
1689
|
+
], [
|
|
1690
|
+
0,
|
|
1691
|
+
1,
|
|
1692
|
+
-pivot.y
|
|
1693
|
+
]];
|
|
1694
|
+
const effective = cmath.transform.multiply(cmath.transform.multiply(t_pivot, requested), t_neg_pivot);
|
|
1695
|
+
const members = ids.map((id) => ({
|
|
1696
|
+
id,
|
|
1697
|
+
transform_pre: doc.get_attr(id, "transform")
|
|
1698
|
+
}));
|
|
1699
|
+
const apply = () => {
|
|
1700
|
+
for (const m of members) doc.set_attr(m.id, "transform", transform.apply_affine(m.transform_pre, effective));
|
|
1701
|
+
emit();
|
|
1702
|
+
};
|
|
1703
|
+
const revert = () => {
|
|
1704
|
+
for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
|
|
1705
|
+
emit();
|
|
1706
|
+
};
|
|
1707
|
+
apply();
|
|
1708
|
+
history.atomic("transform", (tx) => {
|
|
1709
|
+
tx.push({
|
|
1710
|
+
providerId: PROVIDER_ID,
|
|
1711
|
+
apply,
|
|
1712
|
+
revert
|
|
1713
|
+
});
|
|
1714
|
+
});
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
2016
1717
|
function flatten_transform(opts) {
|
|
2017
1718
|
const ids = opts?.ids ?? selection;
|
|
2018
1719
|
if (ids.length === 0) return false;
|
|
@@ -2264,6 +1965,47 @@ function _create_svg_editor_internal(opts) {
|
|
|
2264
1965
|
});
|
|
2265
1966
|
return true;
|
|
2266
1967
|
}
|
|
1968
|
+
function ungroup(opts) {
|
|
1969
|
+
let target;
|
|
1970
|
+
if (opts?.id !== void 0) target = opts.id;
|
|
1971
|
+
else {
|
|
1972
|
+
if (selection.length !== 1) return false;
|
|
1973
|
+
target = selection[0];
|
|
1974
|
+
}
|
|
1975
|
+
const plan = group.plan_ungroup(doc, target);
|
|
1976
|
+
if (!plan) return false;
|
|
1977
|
+
const group_id = plan.group_id;
|
|
1978
|
+
const group_next_sibling = doc.next_element_sibling_of(group_id);
|
|
1979
|
+
const original_child_transforms = /* @__PURE__ */ new Map();
|
|
1980
|
+
for (const child of plan.children) original_child_transforms.set(child, doc.get_attr(child, "transform"));
|
|
1981
|
+
const group_ops = plan.group_transform === null ? [] : transform.parse(plan.group_transform) ?? [];
|
|
1982
|
+
const original_selection = selection;
|
|
1983
|
+
const apply = () => {
|
|
1984
|
+
if (group_ops.length > 0) for (const child of plan.children) {
|
|
1985
|
+
const child_ops = transform.parse(doc.get_attr(child, "transform")) ?? [];
|
|
1986
|
+
const next = transform.emit([...group_ops, ...child_ops]);
|
|
1987
|
+
doc.set_attr(child, "transform", next === "" ? null : next);
|
|
1988
|
+
}
|
|
1989
|
+
for (const child of plan.children) doc.insert(child, plan.parent, group_id);
|
|
1990
|
+
doc.remove(group_id);
|
|
1991
|
+
set_selection(plan.children);
|
|
1992
|
+
};
|
|
1993
|
+
const revert = () => {
|
|
1994
|
+
doc.insert(group_id, plan.parent, group_next_sibling);
|
|
1995
|
+
for (const child of plan.children) doc.insert(child, group_id, null);
|
|
1996
|
+
if (group_ops.length > 0) for (const child of plan.children) doc.set_attr(child, "transform", original_child_transforms.get(child) ?? null);
|
|
1997
|
+
set_selection(original_selection);
|
|
1998
|
+
};
|
|
1999
|
+
apply();
|
|
2000
|
+
history.atomic("ungroup", (tx) => {
|
|
2001
|
+
tx.push({
|
|
2002
|
+
providerId: PROVIDER_ID,
|
|
2003
|
+
apply,
|
|
2004
|
+
revert
|
|
2005
|
+
});
|
|
2006
|
+
});
|
|
2007
|
+
return true;
|
|
2008
|
+
}
|
|
2267
2009
|
/**
|
|
2268
2010
|
* Atomic one-shot insertion. Used by paste, programmatic RPC, and the
|
|
2269
2011
|
* click-no-drag commit path inside the insertion gesture driver. One
|
|
@@ -2273,11 +2015,20 @@ function _create_svg_editor_internal(opts) {
|
|
|
2273
2015
|
* win. `opts.parent` defaults to root; `opts.index` (insert-before
|
|
2274
2016
|
* sibling index) defaults to append; `opts.select` defaults to `true`.
|
|
2275
2017
|
*/
|
|
2018
|
+
/**
|
|
2019
|
+
* Resolve an optional `index` (position in `parent`'s element-children
|
|
2020
|
+
* list to insert AT — anything at or after it shifts; out-of-range or
|
|
2021
|
+
* `undefined` appends) to an insert-before anchor. Shared by `insert`,
|
|
2022
|
+
* `insert_fragment`, and `insert_preview`.
|
|
2023
|
+
*/
|
|
2024
|
+
function resolve_insert_before(parent, index) {
|
|
2025
|
+
if (index === void 0) return null;
|
|
2026
|
+
return doc.element_children_of(parent)[index] ?? null;
|
|
2027
|
+
}
|
|
2276
2028
|
function insert(tag, attrs, opts) {
|
|
2277
2029
|
const parent = opts?.parent ?? doc.root;
|
|
2278
2030
|
const select_after = opts?.select !== false;
|
|
2279
|
-
|
|
2280
|
-
if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
|
|
2031
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2281
2032
|
const id = doc.create_element(tag);
|
|
2282
2033
|
const merged_attrs = {
|
|
2283
2034
|
...default_paint_attrs_for(tag),
|
|
@@ -2305,6 +2056,56 @@ function _create_svg_editor_internal(opts) {
|
|
|
2305
2056
|
return id;
|
|
2306
2057
|
}
|
|
2307
2058
|
/**
|
|
2059
|
+
* Atomic fragment insertion — contract in {@link Commands.insert_fragment}.
|
|
2060
|
+
* Parses + adopts via `doc.create_fragment` (subtrees registered but
|
|
2061
|
+
* detached, like `create_element` — history.redo finds them via
|
|
2062
|
+
* closure), computes the namespace hoist plan, then brackets inserts +
|
|
2063
|
+
* hoisted declarations + selection in ONE history step.
|
|
2064
|
+
*/
|
|
2065
|
+
function insert_fragment(svg, opts) {
|
|
2066
|
+
const parent = opts?.parent ?? doc.root;
|
|
2067
|
+
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`);
|
|
2068
|
+
const select_after = opts?.select !== false;
|
|
2069
|
+
const { roots, xmlns } = doc.create_fragment(svg);
|
|
2070
|
+
if (roots.length === 0) return [];
|
|
2071
|
+
const known_uri = new Map([["xlink", XLINK_NS]]);
|
|
2072
|
+
for (const d of xmlns) known_uri.set(d.prefix, d.uri);
|
|
2073
|
+
const hoist = [];
|
|
2074
|
+
const considered = /* @__PURE__ */ new Set();
|
|
2075
|
+
for (const id of roots) for (const prefix of doc.undeclared_ns_prefixes(id)) {
|
|
2076
|
+
if (considered.has(prefix)) continue;
|
|
2077
|
+
considered.add(prefix);
|
|
2078
|
+
if (doc.get_attr(doc.root, prefix, XMLNS_NS) !== null) continue;
|
|
2079
|
+
const uri = known_uri.get(prefix);
|
|
2080
|
+
if (uri === void 0) continue;
|
|
2081
|
+
hoist.push({
|
|
2082
|
+
prefix,
|
|
2083
|
+
uri
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2087
|
+
const previous_selection = selection;
|
|
2088
|
+
const apply = () => {
|
|
2089
|
+
for (const { prefix, uri } of hoist) doc.declare_xmlns(prefix, uri);
|
|
2090
|
+
for (const id of roots) doc.insert(id, parent, insert_before);
|
|
2091
|
+
if (select_after) set_selection(roots);
|
|
2092
|
+
};
|
|
2093
|
+
const revert = () => {
|
|
2094
|
+
for (let i = roots.length - 1; i >= 0; i--) doc.remove(roots[i]);
|
|
2095
|
+
for (const { prefix } of hoist) doc.set_attr(doc.root, prefix, null, XMLNS_NS);
|
|
2096
|
+
if (select_after) set_selection(previous_selection);
|
|
2097
|
+
};
|
|
2098
|
+
apply();
|
|
2099
|
+
history.atomic("insert fragment", (tx) => {
|
|
2100
|
+
tx.push({
|
|
2101
|
+
providerId: PROVIDER_ID,
|
|
2102
|
+
apply,
|
|
2103
|
+
revert
|
|
2104
|
+
});
|
|
2105
|
+
});
|
|
2106
|
+
return roots;
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2308
2109
|
* Preview-bracketed insertion. Used by the pointer-driven drag gesture
|
|
2309
2110
|
* in the DOM surface. Per-frame attr writes call `update(attrs)`; one
|
|
2310
2111
|
* undo step on `commit()`; clean rollback on `discard()`.
|
|
@@ -2315,8 +2116,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
2315
2116
|
*/
|
|
2316
2117
|
function insert_preview(tag, initial, opts) {
|
|
2317
2118
|
const parent = opts?.parent ?? doc.root;
|
|
2318
|
-
|
|
2319
|
-
if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
|
|
2119
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2320
2120
|
const id = doc.create_element(tag);
|
|
2321
2121
|
const previous_selection = selection;
|
|
2322
2122
|
const live_attrs = {
|
|
@@ -2474,6 +2274,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
2474
2274
|
current_surface_hover = id;
|
|
2475
2275
|
notify_surface_hover();
|
|
2476
2276
|
}
|
|
2277
|
+
const pick_listeners = /* @__PURE__ */ new Set();
|
|
2278
|
+
function notify_pick(e) {
|
|
2279
|
+
for (const cb of pick_listeners) cb(e);
|
|
2280
|
+
}
|
|
2477
2281
|
function enter_content_edit(target) {
|
|
2478
2282
|
const id = target ?? (selection.length === 1 ? selection[0] : null);
|
|
2479
2283
|
if (!id) return false;
|
|
@@ -2519,14 +2323,18 @@ function _create_svg_editor_internal(opts) {
|
|
|
2519
2323
|
translate,
|
|
2520
2324
|
nudge,
|
|
2521
2325
|
resize_to,
|
|
2326
|
+
resize_by,
|
|
2522
2327
|
rotate,
|
|
2523
2328
|
rotate_to,
|
|
2329
|
+
transform: apply_transform,
|
|
2524
2330
|
flatten_transform,
|
|
2525
2331
|
align,
|
|
2526
2332
|
reorder,
|
|
2527
2333
|
remove,
|
|
2528
2334
|
group: group$1,
|
|
2335
|
+
ungroup,
|
|
2529
2336
|
insert,
|
|
2337
|
+
insert_fragment,
|
|
2530
2338
|
insert_preview,
|
|
2531
2339
|
set_text,
|
|
2532
2340
|
load_svg,
|
|
@@ -2572,6 +2380,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
2572
2380
|
function dispose() {
|
|
2573
2381
|
detach();
|
|
2574
2382
|
listeners.clear();
|
|
2383
|
+
surface_hover_listeners.clear();
|
|
2384
|
+
geometry_listeners.clear();
|
|
2385
|
+
translate_commit_listeners.clear();
|
|
2386
|
+
pick_listeners.clear();
|
|
2575
2387
|
}
|
|
2576
2388
|
function set_style(partial) {
|
|
2577
2389
|
style = {
|
|
@@ -2663,6 +2475,22 @@ function _create_svg_editor_internal(opts) {
|
|
|
2663
2475
|
};
|
|
2664
2476
|
},
|
|
2665
2477
|
/**
|
|
2478
|
+
* Subscribe to pick (tap) outcomes — a discrete click on the canvas,
|
|
2479
|
+
* reporting the document-space point and the node under it (`null` for
|
|
2480
|
+
* empty canvas), plus the button and modifier snapshot. Fires once per
|
|
2481
|
+
* tap, after the editor's own selection handling. Observe-only: a pick
|
|
2482
|
+
* cannot alter selection, and the channel does NOT bump `state.version`.
|
|
2483
|
+
* See {@link PickEvent}.
|
|
2484
|
+
*
|
|
2485
|
+
* @unstable
|
|
2486
|
+
*/
|
|
2487
|
+
subscribe_pick(cb) {
|
|
2488
|
+
pick_listeners.add(cb);
|
|
2489
|
+
return () => {
|
|
2490
|
+
pick_listeners.delete(cb);
|
|
2491
|
+
};
|
|
2492
|
+
},
|
|
2493
|
+
/**
|
|
2666
2494
|
* Subscribe to bounds-affecting changes. Fires when any document
|
|
2667
2495
|
* mutation advances `state.geometry_version` — drag, resize, text
|
|
2668
2496
|
* edit, structural insert/remove. Skips presentation-only writes
|
|
@@ -2691,6 +2519,21 @@ function _create_svg_editor_internal(opts) {
|
|
|
2691
2519
|
set_style,
|
|
2692
2520
|
load,
|
|
2693
2521
|
serialize,
|
|
2522
|
+
/**
|
|
2523
|
+
* Serialize a single element's subtree as an SVG **fragment**, using the
|
|
2524
|
+
* same trivia-preserving rules as {@link serialize} — for handing "the
|
|
2525
|
+
* markup of the element the user selected" to a downstream consumer
|
|
2526
|
+
* (e.g. an AI agent) without re-serializing the whole document.
|
|
2527
|
+
*
|
|
2528
|
+
* Fragment, not document (see `SvgDocument.serialize_node`): it does NOT
|
|
2529
|
+
* carry `serialize()`'s whole-document round-trip guarantee. Namespace
|
|
2530
|
+
* declarations on an ancestor (`xmlns:xlink`, normally on the root
|
|
2531
|
+
* `<svg>`) are NOT inlined — a node using `xlink:href` serializes without
|
|
2532
|
+
* `xmlns:xlink`. Throws on an unknown id or a non-element node.
|
|
2533
|
+
*/
|
|
2534
|
+
serialize_node(id) {
|
|
2535
|
+
return doc.serialize_node(id);
|
|
2536
|
+
},
|
|
2694
2537
|
reset,
|
|
2695
2538
|
attach,
|
|
2696
2539
|
detach,
|
|
@@ -2718,11 +2561,18 @@ function _create_svg_editor_internal(opts) {
|
|
|
2718
2561
|
push_surface_hover(id) {
|
|
2719
2562
|
_set_current_surface_hover(id);
|
|
2720
2563
|
},
|
|
2564
|
+
push_pick(e) {
|
|
2565
|
+
notify_pick(e);
|
|
2566
|
+
},
|
|
2721
2567
|
set_computed_resolver(fn) {
|
|
2722
2568
|
computed_resolver = fn;
|
|
2723
2569
|
},
|
|
2724
2570
|
set_geometry(p) {
|
|
2725
2571
|
geometry_provider = p;
|
|
2572
|
+
},
|
|
2573
|
+
bump_geometry() {
|
|
2574
|
+
doc.bump_geometry();
|
|
2575
|
+
fire_geometry_listeners_if_advanced();
|
|
2726
2576
|
}
|
|
2727
2577
|
},
|
|
2728
2578
|
keymap
|