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