@grida/svg-editor 1.0.0-alpha.16 → 1.0.0-alpha.18
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 +33 -0
- package/dist/{dom-BO2-E9oK.d.ts → dom-BMzX1CXZ.d.ts} +13 -1
- package/dist/{dom-U6ae5fQF.js → dom-DKQ4Vt3z.js} +105 -12
- package/dist/{dom-DOvcMvl4.mjs → dom-OP-kmK8k.mjs} +105 -12
- package/dist/{dom-98AUOfsP.d.mts → dom-TctdgRnn.d.mts} +13 -1
- package/dist/dom.d.mts +2 -2
- package/dist/dom.d.ts +2 -2
- package/dist/dom.js +1 -1
- package/dist/dom.mjs +1 -1
- package/dist/{editor-CYoGJ3Hf.d.ts → editor-BSxTUsW_.d.ts} +246 -4
- package/dist/{editor-C6Lj1In-.js → editor-Be6UrMeV.js} +384 -36
- package/dist/{editor-DKQOIKuU.mjs → editor-BkCbYCz2.mjs} +385 -36
- package/dist/{editor-D2eQe8lB.d.mts → editor-KqpIW1qm.d.mts} +246 -4
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/index.mjs +2 -2
- package/dist/{model-D0nU_EkL.js → model-BLhMJZKJ.js} +373 -27
- package/dist/{model-L3t9ixT_.mjs → model-DU0GOMwM.mjs} +362 -28
- 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 +10 -3
- package/dist/react.d.ts +10 -3
- package/dist/react.js +6 -4
- package/dist/react.mjs +6 -4
- package/package.json +5 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const require_model = require("./model-
|
|
1
|
+
const require_model = require("./model-BLhMJZKJ.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");
|
|
@@ -84,6 +84,11 @@ function registerDefaultCommands(reg, editor) {
|
|
|
84
84
|
if (editor.state.selection.length === 0) return false;
|
|
85
85
|
return editor.commands.group();
|
|
86
86
|
});
|
|
87
|
+
reg.register("selection.duplicate", () => {
|
|
88
|
+
if (editor.state.mode !== "select") return false;
|
|
89
|
+
if (editor.state.selection.length === 0) return false;
|
|
90
|
+
return editor.commands.duplicate().length > 0;
|
|
91
|
+
});
|
|
87
92
|
reg.register("selection.ungroup", () => {
|
|
88
93
|
if (editor.state.mode !== "select") return false;
|
|
89
94
|
if (editor.state.selection.length !== 1) return false;
|
|
@@ -133,6 +138,31 @@ function registerDefaultCommands(reg, editor) {
|
|
|
133
138
|
if (editor.state.mode !== "select") return false;
|
|
134
139
|
return editor.commands.align(args);
|
|
135
140
|
});
|
|
141
|
+
reg.register("clipboard.copy", () => {
|
|
142
|
+
if (editor.state.mode !== "select") return false;
|
|
143
|
+
if (editor.state.selection.length === 0) return false;
|
|
144
|
+
return editor.commands.copy() !== null;
|
|
145
|
+
});
|
|
146
|
+
reg.register("clipboard.cut", () => {
|
|
147
|
+
if (editor.state.mode !== "select") return false;
|
|
148
|
+
if (editor.state.selection.length === 0) return false;
|
|
149
|
+
return editor.commands.cut() !== null;
|
|
150
|
+
});
|
|
151
|
+
reg.register("clipboard.paste", (args) => {
|
|
152
|
+
if (editor.state.mode !== "select") return false;
|
|
153
|
+
const text = args?.text;
|
|
154
|
+
if (typeof text === "string") return editor.commands.paste(text).length > 0;
|
|
155
|
+
const provider = editor.providers.clipboard;
|
|
156
|
+
if (provider) {
|
|
157
|
+
provider.read().then((text) => {
|
|
158
|
+
if (text) editor.commands.paste(text);
|
|
159
|
+
}).catch((err) => {
|
|
160
|
+
console.warn("[svg-editor] clipboard provider read failed:", err);
|
|
161
|
+
});
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
return editor.commands.paste().length > 0;
|
|
165
|
+
});
|
|
136
166
|
reg.register("content.enter", () => editor.enter_content_edit());
|
|
137
167
|
reg.register("hierarchy.enter", () => {
|
|
138
168
|
if (editor.state.selection.length !== 1) return false;
|
|
@@ -358,6 +388,10 @@ const DEFAULT_BINDINGS = [
|
|
|
358
388
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyG, _grida_keybinding.M.CtrlCmd | _grida_keybinding.M.Shift),
|
|
359
389
|
command: "selection.ungroup"
|
|
360
390
|
},
|
|
391
|
+
{
|
|
392
|
+
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyD, _grida_keybinding.M.CtrlCmd),
|
|
393
|
+
command: "selection.duplicate"
|
|
394
|
+
},
|
|
361
395
|
{
|
|
362
396
|
keybinding: (0, _grida_keybinding.kb)(_grida_keybinding.KeyCode.KeyA, _grida_keybinding.M.CtrlCmd),
|
|
363
397
|
command: "selection.all"
|
|
@@ -838,6 +872,176 @@ function create_defs(doc) {
|
|
|
838
872
|
return { gradients: new GradientsRegistry(doc) };
|
|
839
873
|
}
|
|
840
874
|
//#endregion
|
|
875
|
+
//#region src/core/clipboard.ts
|
|
876
|
+
/**
|
|
877
|
+
* Clipboard payload extraction — selection → standalone SVG document.
|
|
878
|
+
*
|
|
879
|
+
* Implements the copy side of the clipboard FRD
|
|
880
|
+
* ([docs/wg/feat-svg-editor/clipboard.md](../../../../docs/wg/feat-svg-editor/clipboard.md)):
|
|
881
|
+
* the payload is a standalone, namespace-well-formed SVG document — not a
|
|
882
|
+
* private envelope. Assembly is a pure function of (document, selection):
|
|
883
|
+
* no geometry, no environment, no randomness (FRD R6 — the same selection
|
|
884
|
+
* yields the same bytes headless or surface-attached).
|
|
885
|
+
*
|
|
886
|
+
* What the payload carries, and what it deliberately does not
|
|
887
|
+
* (FRD §Extraction — five context kinds):
|
|
888
|
+
*
|
|
889
|
+
* 1. Referenced resources — CARRIED. The outbound `url(#…)` / `href`
|
|
890
|
+
* closure is walked from the closed carrier list below and emitted
|
|
891
|
+
* verbatim in one `<defs>` block.
|
|
892
|
+
* 2. Namespace declarations — CARRIED. Prefixes a subtree borrows from
|
|
893
|
+
* ancestor scope are declared on the payload shell (an undeclared
|
|
894
|
+
* prefix is a well-formedness error, so this includes the deliberate
|
|
895
|
+
* well-known-table repair for e.g. `xlink`).
|
|
896
|
+
* 3. Ancestor transforms — NOT carried (verbatim policy).
|
|
897
|
+
* 4. Inherited presentation / cascade — NOT carried (verbatim policy).
|
|
898
|
+
* 5. Viewport — NOT carried (no `viewBox`, no sizing on the shell).
|
|
899
|
+
*
|
|
900
|
+
* This module is the **payload extraction** operation only. The sibling
|
|
901
|
+
* operation the FRD names — in-document subtree CLONE (duplicate /
|
|
902
|
+
* clone-drag), which must NOT carry the closure — lives in `./subtree`.
|
|
903
|
+
* The two share exactly selection normalization (`subtree.normalize_roots`)
|
|
904
|
+
* and verbatim subtree serialization (`doc.serialize_node`).
|
|
905
|
+
*/
|
|
906
|
+
let clipboard;
|
|
907
|
+
(function(_clipboard) {
|
|
908
|
+
/**
|
|
909
|
+
* Presentation carriers that may hold `url(#…)` references, read both as
|
|
910
|
+
* a presentation attribute and as an inline `style=""` declaration.
|
|
911
|
+
*
|
|
912
|
+
* CLOSED LIST — extending it is a spec change to the FRD's §Extraction 1
|
|
913
|
+
* carrier list, not a bug fix. Deliberately NOT walked in v1 (documented
|
|
914
|
+
* degradations): `<style>` element rules (CSS parsing is the deferred
|
|
915
|
+
* cascade capability), SMIL timing/value references, `cursor`, SVG 2
|
|
916
|
+
* text-layout properties.
|
|
917
|
+
*/
|
|
918
|
+
const URL_REF_PROPS = [
|
|
919
|
+
"fill",
|
|
920
|
+
"stroke",
|
|
921
|
+
"filter",
|
|
922
|
+
"clip-path",
|
|
923
|
+
"mask",
|
|
924
|
+
"marker-start",
|
|
925
|
+
"marker-mid",
|
|
926
|
+
"marker-end",
|
|
927
|
+
"marker"
|
|
928
|
+
];
|
|
929
|
+
/** Set view of {@link URL_REF_PROPS} for membership tests over parsed
|
|
930
|
+
* style declarations. */
|
|
931
|
+
const URL_REF_PROP_SET = new Set(URL_REF_PROPS);
|
|
932
|
+
/**
|
|
933
|
+
* Elements whose `href` / `xlink:href` is a same-document resource
|
|
934
|
+
* reference the closure follows. CLOSED LIST — `<a href>` is navigation,
|
|
935
|
+
* `<image href>` is content, SMIL `href` is an animation target; none of
|
|
936
|
+
* them are walked.
|
|
937
|
+
*/
|
|
938
|
+
const HREF_TAGS = new Set([
|
|
939
|
+
"use",
|
|
940
|
+
"textPath",
|
|
941
|
+
"mpath",
|
|
942
|
+
"feImage",
|
|
943
|
+
"pattern",
|
|
944
|
+
"linearGradient",
|
|
945
|
+
"radialGradient",
|
|
946
|
+
"filter"
|
|
947
|
+
]);
|
|
948
|
+
/**
|
|
949
|
+
* `url(#id)` extractor. Global — a single value can carry several
|
|
950
|
+
* references (`filter: url(#a) blur(2px) url(#b)` is a legal filter
|
|
951
|
+
* function list). Same quoting tolerance as the defs registry's
|
|
952
|
+
* ref-counting pattern.
|
|
953
|
+
*/
|
|
954
|
+
const URL_REF_RE = /url\(\s*["']?#([^"')\s]+)["']?\s*\)/g;
|
|
955
|
+
function extract_payload(doc, selection) {
|
|
956
|
+
const order = require_model.subtree.by_document_order(doc);
|
|
957
|
+
const roots = require_model.subtree.normalize_roots(doc, selection, order);
|
|
958
|
+
if (roots.length === 0) return null;
|
|
959
|
+
const closure = collect_reference_closure(doc, roots);
|
|
960
|
+
const shell_ns = /* @__PURE__ */ new Map();
|
|
961
|
+
for (const member of [...closure, ...roots].sort(order)) for (const prefix of doc.undeclared_ns_prefixes(member)) {
|
|
962
|
+
if (shell_ns.has(prefix)) continue;
|
|
963
|
+
const uri = resolve_prefix(doc, member, prefix);
|
|
964
|
+
if (uri !== null) shell_ns.set(prefix, uri);
|
|
965
|
+
}
|
|
966
|
+
let shell = `<svg xmlns="${_grida_svg_parser.SVG_NS}"`;
|
|
967
|
+
for (const [prefix, uri] of shell_ns) shell += ` xmlns:${prefix}="${(0, _grida_svg_parser.encode_attr_value)(uri, "\"")}"`;
|
|
968
|
+
shell += ">";
|
|
969
|
+
const defs_block = closure.length > 0 ? `<defs>${closure.map((id) => doc.serialize_node(id)).join("")}</defs>` : "";
|
|
970
|
+
const content = roots.map((id) => doc.serialize_node(id)).join("");
|
|
971
|
+
return `${shell}${defs_block}${content}</svg>`;
|
|
972
|
+
}
|
|
973
|
+
_clipboard.extract_payload = extract_payload;
|
|
974
|
+
function collect_reference_closure(doc, roots) {
|
|
975
|
+
const id_map = /* @__PURE__ */ new Map();
|
|
976
|
+
for (const el of doc.all_elements()) {
|
|
977
|
+
const id_attr = doc.get_attr(el, "id");
|
|
978
|
+
if (id_attr !== null && !id_map.has(id_attr)) id_map.set(id_attr, el);
|
|
979
|
+
}
|
|
980
|
+
const in_forest = (target) => roots.some((r) => doc.contains(r, target));
|
|
981
|
+
const collected = /* @__PURE__ */ new Set();
|
|
982
|
+
const pending = [...roots];
|
|
983
|
+
while (pending.length > 0) {
|
|
984
|
+
const subtree = pending.pop();
|
|
985
|
+
for (const el of elements_of_subtree(doc, subtree)) for (const ref of refs_of(doc, el)) {
|
|
986
|
+
const target = id_map.get(ref);
|
|
987
|
+
if (target === void 0) continue;
|
|
988
|
+
if (in_forest(target)) continue;
|
|
989
|
+
if (collected.has(target)) continue;
|
|
990
|
+
collected.add(target);
|
|
991
|
+
pending.push(target);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return doc.prune_nested_nodes([...collected]).sort(require_model.subtree.by_document_order(doc));
|
|
995
|
+
}
|
|
996
|
+
_clipboard.collect_reference_closure = collect_reference_closure;
|
|
997
|
+
/** Preorder element walk of `root`'s subtree, root included. */
|
|
998
|
+
function elements_of_subtree(doc, root) {
|
|
999
|
+
const out = [];
|
|
1000
|
+
const walk = (id) => {
|
|
1001
|
+
if (!doc.is_element(id)) return;
|
|
1002
|
+
out.push(id);
|
|
1003
|
+
for (const c of doc.children_of(id)) walk(c);
|
|
1004
|
+
};
|
|
1005
|
+
walk(root);
|
|
1006
|
+
return out;
|
|
1007
|
+
}
|
|
1008
|
+
/** Same-document reference ids carried by one element, per the closed
|
|
1009
|
+
* carrier list. */
|
|
1010
|
+
function refs_of(doc, id) {
|
|
1011
|
+
const out = [];
|
|
1012
|
+
for (const prop of URL_REF_PROPS) {
|
|
1013
|
+
const value = doc.get_attr(id, prop);
|
|
1014
|
+
if (!value) continue;
|
|
1015
|
+
for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
|
|
1016
|
+
}
|
|
1017
|
+
for (const { property, value } of doc.get_all_styles(id)) {
|
|
1018
|
+
if (!URL_REF_PROP_SET.has(property)) continue;
|
|
1019
|
+
for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
|
|
1020
|
+
}
|
|
1021
|
+
if (HREF_TAGS.has(doc.tag_of(id))) {
|
|
1022
|
+
const href = doc.get_attr(id, "href");
|
|
1023
|
+
if (href !== null && href.startsWith("#") && href.length > 1) out.push(href.slice(1));
|
|
1024
|
+
}
|
|
1025
|
+
return out;
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Resolve a prefix a member's subtree borrows from ancestor scope:
|
|
1029
|
+
* nearest ancestor `xmlns:<prefix>` declaration wins (correct XML
|
|
1030
|
+
* scoping), falling back to the well-known table — the deliberate
|
|
1031
|
+
* repair that keeps the payload namespace-well-formed even when the
|
|
1032
|
+
* source never declared the prefix (FRD §Extraction 2).
|
|
1033
|
+
*/
|
|
1034
|
+
function resolve_prefix(doc, member, prefix) {
|
|
1035
|
+
let cur = doc.parent_of(member);
|
|
1036
|
+
while (cur !== null) {
|
|
1037
|
+
const uri = doc.get_attr(cur, prefix, _grida_svg_parser.XMLNS_NS);
|
|
1038
|
+
if (uri !== null) return uri;
|
|
1039
|
+
cur = doc.parent_of(cur);
|
|
1040
|
+
}
|
|
1041
|
+
return require_model.WELL_KNOWN_NS_PREFIXES.get(prefix) ?? null;
|
|
1042
|
+
}
|
|
1043
|
+
})(clipboard || (clipboard = {}));
|
|
1044
|
+
//#endregion
|
|
841
1045
|
//#region src/core/align.ts
|
|
842
1046
|
/**
|
|
843
1047
|
* Compute per-member translation deltas to align `members` against `target`.
|
|
@@ -1053,10 +1257,11 @@ function _create_svg_editor_internal(opts) {
|
|
|
1053
1257
|
let mode = "select";
|
|
1054
1258
|
let tool = require_model.TOOL_CURSOR;
|
|
1055
1259
|
let version = 0;
|
|
1056
|
-
/**
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1260
|
+
/** `doc.revision` at the last load()/reset(); compared to derive `dirty`.
|
|
1261
|
+
* The doc's own total mutation counter is the single edit-version
|
|
1262
|
+
* source — `content_version`, `dirty`, and the typed-read memo caches
|
|
1263
|
+
* all derive from it (no editor-side shadow counter to drift). */
|
|
1264
|
+
let baseline_revision = doc.revision;
|
|
1060
1265
|
/**
|
|
1061
1266
|
* Bumps once per `editor.load(svg)` call. The constructor's initial parse
|
|
1062
1267
|
* does NOT count — it's the "factory" state. Hosts subscribe via
|
|
@@ -1069,6 +1274,23 @@ function _create_svg_editor_internal(opts) {
|
|
|
1069
1274
|
...opts.style
|
|
1070
1275
|
};
|
|
1071
1276
|
const providers = opts.providers ?? {};
|
|
1277
|
+
/**
|
|
1278
|
+
* In-memory clipboard buffer — the transport floor (FRD R1: the buffer
|
|
1279
|
+
* write cannot fail; external channels are best-effort on top). NOT part
|
|
1280
|
+
* of `EditorState` and NOT history-managed: it survives `load()` /
|
|
1281
|
+
* `reset()` / undo, like the OS clipboard it mirrors.
|
|
1282
|
+
*/
|
|
1283
|
+
let clipboard_buffer = null;
|
|
1284
|
+
/**
|
|
1285
|
+
* The last committed duplication — read by the NEXT `duplicate()` to
|
|
1286
|
+
* repeat the user's translate delta (gridaco/grida#825; spec
|
|
1287
|
+
* §Repeating offset). Session state like `clipboard_buffer`: not in
|
|
1288
|
+
* `EditorState`, not history-managed (undo/redo replay never re-arms
|
|
1289
|
+
* it — only a user-initiated ⌘D or cloned-drag commit does). Staleness
|
|
1290
|
+
* is caught at use by `subtree.repeat_delta`; the only eager clears are
|
|
1291
|
+
* `load()` / `reset()`, where every NodeId dies wholesale.
|
|
1292
|
+
*/
|
|
1293
|
+
let active_duplication = null;
|
|
1072
1294
|
const listeners = /* @__PURE__ */ new Set();
|
|
1073
1295
|
let attached_surface = null;
|
|
1074
1296
|
/**
|
|
@@ -1084,11 +1306,11 @@ function _create_svg_editor_internal(opts) {
|
|
|
1084
1306
|
scope,
|
|
1085
1307
|
mode,
|
|
1086
1308
|
tool,
|
|
1087
|
-
dirty:
|
|
1309
|
+
dirty: doc.revision !== baseline_revision,
|
|
1088
1310
|
can_undo: history.stack.canUndo,
|
|
1089
1311
|
can_redo: history.stack.canRedo,
|
|
1090
1312
|
version,
|
|
1091
|
-
content_version:
|
|
1313
|
+
content_version: doc.revision,
|
|
1092
1314
|
structure_version: doc.structure_version,
|
|
1093
1315
|
geometry_version: doc.geometry_version,
|
|
1094
1316
|
load_version
|
|
@@ -1122,7 +1344,6 @@ function _create_svg_editor_internal(opts) {
|
|
|
1122
1344
|
}
|
|
1123
1345
|
}
|
|
1124
1346
|
doc.on_change(() => {
|
|
1125
|
-
doc_version++;
|
|
1126
1347
|
fire_geometry_listeners_if_advanced();
|
|
1127
1348
|
});
|
|
1128
1349
|
function subscribe(fn) {
|
|
@@ -1247,14 +1468,14 @@ function _create_svg_editor_internal(opts) {
|
|
|
1247
1468
|
function node_property_cached(id, name) {
|
|
1248
1469
|
const key = `${id}${name}`;
|
|
1249
1470
|
const cached = property_cache.get(key);
|
|
1250
|
-
if (cached && cached.
|
|
1471
|
+
if (cached && cached.revision === doc.revision) return cached.value;
|
|
1251
1472
|
const next = properties.read(doc, id, name);
|
|
1252
1473
|
if (cached && properties.value_equals(cached.value, next)) {
|
|
1253
|
-
cached.
|
|
1474
|
+
cached.revision = doc.revision;
|
|
1254
1475
|
return cached.value;
|
|
1255
1476
|
}
|
|
1256
1477
|
property_cache.set(key, {
|
|
1257
|
-
|
|
1478
|
+
revision: doc.revision,
|
|
1258
1479
|
value: next
|
|
1259
1480
|
});
|
|
1260
1481
|
return next;
|
|
@@ -1262,7 +1483,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
1262
1483
|
function node_properties(id, names) {
|
|
1263
1484
|
const key = `${id}${names.join("")}`;
|
|
1264
1485
|
const cached = properties_cache.get(key);
|
|
1265
|
-
if (cached && cached.
|
|
1486
|
+
if (cached && cached.revision === doc.revision) return cached.value;
|
|
1266
1487
|
const next = {};
|
|
1267
1488
|
let changed = !cached;
|
|
1268
1489
|
for (const name of names) {
|
|
@@ -1271,12 +1492,12 @@ function _create_svg_editor_internal(opts) {
|
|
|
1271
1492
|
if (cached && cached.value[name] !== v) changed = true;
|
|
1272
1493
|
}
|
|
1273
1494
|
if (cached && !changed) {
|
|
1274
|
-
cached.
|
|
1495
|
+
cached.revision = doc.revision;
|
|
1275
1496
|
return cached.value;
|
|
1276
1497
|
}
|
|
1277
1498
|
const frozen = Object.freeze(next);
|
|
1278
1499
|
properties_cache.set(key, {
|
|
1279
|
-
|
|
1500
|
+
revision: doc.revision,
|
|
1280
1501
|
value: frozen
|
|
1281
1502
|
});
|
|
1282
1503
|
return frozen;
|
|
@@ -1284,7 +1505,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
1284
1505
|
function node_paint(id, channel) {
|
|
1285
1506
|
const key = `${id}${channel}`;
|
|
1286
1507
|
const cached = paint_cache.get(key);
|
|
1287
|
-
if (cached && cached.
|
|
1508
|
+
if (cached && cached.revision === doc.revision) return cached.value;
|
|
1288
1509
|
const { declared, provenance } = properties.resolve_declared(doc, id, channel);
|
|
1289
1510
|
const next = {
|
|
1290
1511
|
declared,
|
|
@@ -1292,11 +1513,11 @@ function _create_svg_editor_internal(opts) {
|
|
|
1292
1513
|
provenance
|
|
1293
1514
|
};
|
|
1294
1515
|
if (cached && require_model.paint.value_equals(cached.value, next)) {
|
|
1295
|
-
cached.
|
|
1516
|
+
cached.revision = doc.revision;
|
|
1296
1517
|
return cached.value;
|
|
1297
1518
|
}
|
|
1298
1519
|
paint_cache.set(key, {
|
|
1299
|
-
|
|
1520
|
+
revision: doc.revision,
|
|
1300
1521
|
value: next
|
|
1301
1522
|
});
|
|
1302
1523
|
return next;
|
|
@@ -1385,6 +1606,13 @@ function _create_svg_editor_internal(opts) {
|
|
|
1385
1606
|
});
|
|
1386
1607
|
return { gradient_id };
|
|
1387
1608
|
}
|
|
1609
|
+
/** World→local delta projection shared by every one-shot translate
|
|
1610
|
+
* writer (translate / nudge via `prepare_rpc`, align). Re-expresses a
|
|
1611
|
+
* world-space delta in the frame the target's position attributes are
|
|
1612
|
+
* written in — nested-viewport / transformed-ancestor correctness.
|
|
1613
|
+
* Identity for flat docs and DOM-less hosts (no provider, or a
|
|
1614
|
+
* provider without a layout engine). */
|
|
1615
|
+
const project_world_delta = (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d;
|
|
1388
1616
|
/** Shared one-shot translate runner. `stages` selects semantics — see
|
|
1389
1617
|
* `core/translate-pipeline/README.md`'s "Stage lists per entry point". */
|
|
1390
1618
|
function do_translate_oneshot(delta, stages, label) {
|
|
@@ -1404,7 +1632,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
1404
1632
|
},
|
|
1405
1633
|
emit,
|
|
1406
1634
|
stages,
|
|
1407
|
-
project:
|
|
1635
|
+
project: project_world_delta
|
|
1408
1636
|
});
|
|
1409
1637
|
apply();
|
|
1410
1638
|
history.atomic(label, (tx) => {
|
|
@@ -1453,7 +1681,6 @@ function _create_svg_editor_internal(opts) {
|
|
|
1453
1681
|
members.push({
|
|
1454
1682
|
id,
|
|
1455
1683
|
rz: require_model.resize_pipeline.intent.capture_baseline(doc, id, bbox),
|
|
1456
|
-
transform_pre: doc.get_attr(id, "transform"),
|
|
1457
1684
|
bbox
|
|
1458
1685
|
});
|
|
1459
1686
|
}
|
|
@@ -1488,10 +1715,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
1488
1715
|
emit();
|
|
1489
1716
|
};
|
|
1490
1717
|
const revert = () => {
|
|
1491
|
-
for (const { m
|
|
1492
|
-
require_model.resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
|
|
1493
|
-
doc.set_attr(m.id, "transform", m.transform_pre);
|
|
1494
|
-
}
|
|
1718
|
+
for (const { m } of ops) require_model.resize_pipeline.intent.restore(doc, m.id, m.rz);
|
|
1495
1719
|
emit();
|
|
1496
1720
|
};
|
|
1497
1721
|
apply();
|
|
@@ -1768,7 +1992,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
1768
1992
|
* center of a reference rect. Same mechanics as `resize_to`: per-member
|
|
1769
1993
|
* translate baselines (so `<g>`, transformed, and natively-attributed
|
|
1770
1994
|
* nodes all write the cleanest in-place representation), one atomic
|
|
1771
|
-
* history step.
|
|
1995
|
+
* history step. Deltas are computed in world space and re-expressed in
|
|
1996
|
+
* each member's local frame before writing (`world_delta_to_local`),
|
|
1997
|
+
* so members under scaled/rotated ancestors land exactly and a repeat
|
|
1998
|
+
* invocation is a no-op.
|
|
1772
1999
|
*
|
|
1773
2000
|
* Reference rect is selection-size dependent:
|
|
1774
2001
|
* - multi-selection: union of member bboxes
|
|
@@ -1804,8 +2031,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
1804
2031
|
if (!parent_bbox) return false;
|
|
1805
2032
|
target = parent_bbox;
|
|
1806
2033
|
} else target = _grida_cmath.default.rect.union(members.map((m) => m.bbox));
|
|
1807
|
-
const
|
|
1808
|
-
if (
|
|
2034
|
+
const world_deltas = compute_align_deltas(members, target, direction);
|
|
2035
|
+
if (world_deltas.size === 0) return false;
|
|
2036
|
+
const deltas = /* @__PURE__ */ new Map();
|
|
2037
|
+
for (const [id, d] of world_deltas) deltas.set(id, project_world_delta(id, d));
|
|
1809
2038
|
const apply = () => {
|
|
1810
2039
|
for (const m of members) {
|
|
1811
2040
|
const d = deltas.get(m.id);
|
|
@@ -1907,13 +2136,19 @@ function _create_svg_editor_internal(opts) {
|
|
|
1907
2136
|
});
|
|
1908
2137
|
}
|
|
1909
2138
|
function remove() {
|
|
2139
|
+
remove_selection("remove");
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Shared deletion body for `remove` and `cut` — identical
|
|
2143
|
+
* capture/revert semantics, differing only in the history label
|
|
2144
|
+
* (`verb`), so undo attribution names the gesture that caused the
|
|
2145
|
+
* deletion.
|
|
2146
|
+
*/
|
|
2147
|
+
function remove_selection(verb) {
|
|
1910
2148
|
if (selection.length === 0) return;
|
|
1911
2149
|
const filtered = doc.prune_nested_nodes(selection).filter((id) => doc.parent_of(id) !== null);
|
|
1912
2150
|
if (filtered.length === 0) return;
|
|
1913
|
-
const
|
|
1914
|
-
const index_of = /* @__PURE__ */ new Map();
|
|
1915
|
-
for (let i = 0; i < doc_order.length; i++) index_of.set(doc_order[i], i);
|
|
1916
|
-
const captures = [...filtered].sort((a, b) => (index_of.get(a) ?? 0) - (index_of.get(b) ?? 0)).map((id) => ({
|
|
2151
|
+
const captures = [...filtered].sort(require_model.subtree.by_document_order(doc)).map((id) => ({
|
|
1917
2152
|
id,
|
|
1918
2153
|
parent: doc.parent_of(id),
|
|
1919
2154
|
next_sibling: doc.next_element_sibling_of(id)
|
|
@@ -1931,7 +2166,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
1931
2166
|
set_selection(old_selection);
|
|
1932
2167
|
};
|
|
1933
2168
|
apply();
|
|
1934
|
-
history.atomic(captures.length === 1 ?
|
|
2169
|
+
history.atomic(captures.length === 1 ? verb : `${verb} ${captures.length}`, (tx) => {
|
|
1935
2170
|
tx.push({
|
|
1936
2171
|
providerId: PROVIDER_ID,
|
|
1937
2172
|
apply,
|
|
@@ -2065,12 +2300,20 @@ function _create_svg_editor_internal(opts) {
|
|
|
2065
2300
|
* hoisted declarations + selection in ONE history step.
|
|
2066
2301
|
*/
|
|
2067
2302
|
function insert_fragment(svg, opts) {
|
|
2303
|
+
return insert_fragment_impl(svg, opts, "insert fragment");
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Label-bearing body shared by `insert_fragment` and `paste` — same
|
|
2307
|
+
* atomic insertion, differing only in history attribution (undo for a
|
|
2308
|
+
* paste gesture should read "paste", not "insert fragment").
|
|
2309
|
+
*/
|
|
2310
|
+
function insert_fragment_impl(svg, opts, label) {
|
|
2068
2311
|
const parent = opts?.parent ?? doc.root;
|
|
2069
2312
|
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
2313
|
const select_after = opts?.select !== false;
|
|
2071
2314
|
const { roots, xmlns } = doc.create_fragment(svg);
|
|
2072
2315
|
if (roots.length === 0) return [];
|
|
2073
|
-
const known_uri = new Map(
|
|
2316
|
+
const known_uri = new Map(require_model.WELL_KNOWN_NS_PREFIXES);
|
|
2074
2317
|
for (const d of xmlns) known_uri.set(d.prefix, d.uri);
|
|
2075
2318
|
const hoist = [];
|
|
2076
2319
|
const considered = /* @__PURE__ */ new Set();
|
|
@@ -2098,7 +2341,7 @@ function _create_svg_editor_internal(opts) {
|
|
|
2098
2341
|
if (select_after) set_selection(previous_selection);
|
|
2099
2342
|
};
|
|
2100
2343
|
apply();
|
|
2101
|
-
history.atomic(
|
|
2344
|
+
history.atomic(label, (tx) => {
|
|
2102
2345
|
tx.push({
|
|
2103
2346
|
providerId: PROVIDER_ID,
|
|
2104
2347
|
apply,
|
|
@@ -2107,6 +2350,95 @@ function _create_svg_editor_internal(opts) {
|
|
|
2107
2350
|
});
|
|
2108
2351
|
return roots;
|
|
2109
2352
|
}
|
|
2353
|
+
function copy_impl(deliver_external) {
|
|
2354
|
+
const payload = clipboard.extract_payload(doc, selection);
|
|
2355
|
+
if (payload === null) return null;
|
|
2356
|
+
clipboard_buffer = payload;
|
|
2357
|
+
if (deliver_external && providers.clipboard) providers.clipboard.write(payload).catch((err) => {
|
|
2358
|
+
console.warn("[svg-editor] clipboard provider write failed:", err);
|
|
2359
|
+
});
|
|
2360
|
+
return payload;
|
|
2361
|
+
}
|
|
2362
|
+
function copy() {
|
|
2363
|
+
return copy_impl(true);
|
|
2364
|
+
}
|
|
2365
|
+
function cut_impl(deliver_external) {
|
|
2366
|
+
const payload = copy_impl(deliver_external);
|
|
2367
|
+
if (payload === null) return null;
|
|
2368
|
+
remove_selection("cut");
|
|
2369
|
+
return payload;
|
|
2370
|
+
}
|
|
2371
|
+
function cut() {
|
|
2372
|
+
return cut_impl(true);
|
|
2373
|
+
}
|
|
2374
|
+
function paste(text) {
|
|
2375
|
+
if (text !== void 0 && typeof text !== "string") throw new TypeError(`paste(text) requires a string when provided, got ${text === null ? "null" : typeof text}`);
|
|
2376
|
+
const source = text ?? clipboard_buffer;
|
|
2377
|
+
if (source === null) return [];
|
|
2378
|
+
try {
|
|
2379
|
+
return insert_fragment_impl(source, void 0, "paste");
|
|
2380
|
+
} catch (err) {
|
|
2381
|
+
if (err instanceof TypeError) throw err;
|
|
2382
|
+
return [];
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* Duplicate over `subtree.clone_plan` — see the `Commands` doc. Same
|
|
2387
|
+
* atomic shape as `insert_fragment_impl`: closures own the
|
|
2388
|
+
* insert/remove pair so redo re-inserts the same NodeIds.
|
|
2389
|
+
*
|
|
2390
|
+
* Repeating offset (gridaco/grida#825, spec §Repeating offset): when
|
|
2391
|
+
* the targets are exactly the previous duplication's clones and
|
|
2392
|
+
* geometry witnesses a rigid translate between that record's origins
|
|
2393
|
+
* and clones, the fresh clones land displaced by the same delta. The
|
|
2394
|
+
* offset rides the translate pipeline INSIDE the same atomic step —
|
|
2395
|
+
* one undo removes copy + offset together. Clone baselines are
|
|
2396
|
+
* key-swapped from the origins (a clone is a verbatim copy at rest —
|
|
2397
|
+
* the orchestrator's `enter_clone` trick), so nothing reads the
|
|
2398
|
+
* detached clones. Any failed precondition degrades to plain
|
|
2399
|
+
* duplicate-in-place; never an error.
|
|
2400
|
+
*/
|
|
2401
|
+
function duplicate() {
|
|
2402
|
+
const plan = require_model.subtree.clone_plan(doc, selection);
|
|
2403
|
+
if (plan.length === 0) return [];
|
|
2404
|
+
const clones = plan.map((p) => p.clone);
|
|
2405
|
+
const origins = plan.map((p) => p.origin);
|
|
2406
|
+
const previous_selection = selection;
|
|
2407
|
+
const delta = require_model.subtree.repeat_delta(active_duplication, origins, (id) => geometry_provider ? geometry_provider.bounds_of(id) : null);
|
|
2408
|
+
let offset_plan = null;
|
|
2409
|
+
if (delta) {
|
|
2410
|
+
const baselines = /* @__PURE__ */ new Map();
|
|
2411
|
+
for (const p of plan) baselines.set(p.clone, require_model.translate_pipeline.intent.capture_baseline(doc, p.origin));
|
|
2412
|
+
offset_plan = {
|
|
2413
|
+
ids: clones,
|
|
2414
|
+
baselines,
|
|
2415
|
+
delta
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
const apply = () => {
|
|
2419
|
+
require_model.subtree.insert_plan(doc, plan);
|
|
2420
|
+
if (offset_plan) require_model.translate_pipeline.apply(doc, offset_plan, project_world_delta);
|
|
2421
|
+
set_selection(clones);
|
|
2422
|
+
};
|
|
2423
|
+
const revert = () => {
|
|
2424
|
+
if (offset_plan) require_model.translate_pipeline.revert(doc, offset_plan);
|
|
2425
|
+
require_model.subtree.remove_plan(doc, plan);
|
|
2426
|
+
set_selection(previous_selection);
|
|
2427
|
+
};
|
|
2428
|
+
apply();
|
|
2429
|
+
history.atomic("duplicate", (tx) => {
|
|
2430
|
+
tx.push({
|
|
2431
|
+
providerId: PROVIDER_ID,
|
|
2432
|
+
apply,
|
|
2433
|
+
revert
|
|
2434
|
+
});
|
|
2435
|
+
});
|
|
2436
|
+
active_duplication = {
|
|
2437
|
+
origins,
|
|
2438
|
+
clones
|
|
2439
|
+
};
|
|
2440
|
+
return clones;
|
|
2441
|
+
}
|
|
2110
2442
|
/**
|
|
2111
2443
|
* Preview-bracketed insertion. Used by the pointer-driven drag gesture
|
|
2112
2444
|
* in the DOM surface. Per-frame attr writes call `update(attrs)`; one
|
|
@@ -2294,7 +2626,8 @@ function _create_svg_editor_internal(opts) {
|
|
|
2294
2626
|
mode = "select";
|
|
2295
2627
|
tool = require_model.TOOL_CURSOR;
|
|
2296
2628
|
history.clear();
|
|
2297
|
-
|
|
2629
|
+
active_duplication = null;
|
|
2630
|
+
baseline_revision = doc.revision;
|
|
2298
2631
|
load_version++;
|
|
2299
2632
|
emit();
|
|
2300
2633
|
}
|
|
@@ -2333,6 +2666,10 @@ function _create_svg_editor_internal(opts) {
|
|
|
2333
2666
|
align,
|
|
2334
2667
|
reorder,
|
|
2335
2668
|
remove,
|
|
2669
|
+
copy,
|
|
2670
|
+
cut,
|
|
2671
|
+
paste,
|
|
2672
|
+
duplicate,
|
|
2336
2673
|
group: group$1,
|
|
2337
2674
|
ungroup,
|
|
2338
2675
|
insert,
|
|
@@ -2360,7 +2697,8 @@ function _create_svg_editor_internal(opts) {
|
|
|
2360
2697
|
scope = null;
|
|
2361
2698
|
mode = "select";
|
|
2362
2699
|
tool = require_model.TOOL_CURSOR;
|
|
2363
|
-
|
|
2700
|
+
active_duplication = null;
|
|
2701
|
+
baseline_revision = doc.revision;
|
|
2364
2702
|
emit();
|
|
2365
2703
|
}
|
|
2366
2704
|
function attach(surface) {
|
|
@@ -2543,7 +2881,14 @@ function _create_svg_editor_internal(opts) {
|
|
|
2543
2881
|
providers,
|
|
2544
2882
|
_internal: {
|
|
2545
2883
|
doc,
|
|
2546
|
-
history: {
|
|
2884
|
+
history: {
|
|
2885
|
+
preview: (label) => history.preview(label),
|
|
2886
|
+
undo_label: () => history.stack.undoLabel
|
|
2887
|
+
},
|
|
2888
|
+
clipboard: {
|
|
2889
|
+
copy: () => copy_impl(false),
|
|
2890
|
+
cut: () => cut_impl(false)
|
|
2891
|
+
},
|
|
2547
2892
|
insert_text_preview,
|
|
2548
2893
|
emit,
|
|
2549
2894
|
subscribe_translate_commit(cb) {
|
|
@@ -2553,6 +2898,9 @@ function _create_svg_editor_internal(opts) {
|
|
|
2553
2898
|
};
|
|
2554
2899
|
},
|
|
2555
2900
|
notify_translate_commit,
|
|
2901
|
+
seed_duplication(record) {
|
|
2902
|
+
active_duplication = record;
|
|
2903
|
+
},
|
|
2556
2904
|
set_content_edit_driver(fn) {
|
|
2557
2905
|
content_edit_driver = fn;
|
|
2558
2906
|
},
|