@grida/svg-editor 1.0.0-alpha.13 → 1.0.0-alpha.15
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 +111 -42
- package/dist/dom-CK6GlgFF.d.mts +114 -0
- package/dist/dom-CsKXTaNw.d.ts +112 -0
- package/dist/{dom-BlMk07oX.mjs → dom-DILY80j7.mjs} +1622 -619
- package/dist/{dom-Cvm9Towu.js → dom-Dee6FtgZ.js} +1648 -626
- package/dist/dom.d.mts +3 -3
- package/dist/dom.d.ts +3 -3
- package/dist/dom.js +4 -1
- package/dist/dom.mjs +2 -2
- package/dist/{editor-Bd4-VCEJ.d.ts → editor-BKoo9SPL.d.ts} +643 -25
- package/dist/{editor-DtuRIs-Q.mjs → editor-CvWpD5mu.mjs} +820 -322
- package/dist/{editor-BH03X8cX.d.mts → editor-Dl7c0q5A.d.mts} +643 -25
- package/dist/{editor-CdyC3uAe.js → editor-F8ckj9X1.js} +828 -330
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -4
- package/dist/index.mjs +3 -3
- package/dist/model-B2UWgViT.mjs +3729 -0
- package/dist/model-CJ1Ctq14.js +3875 -0
- package/dist/presets.d.mts +2 -2
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +3 -3
- package/dist/presets.mjs +2 -2
- package/dist/react.d.mts +18 -6
- package/dist/react.d.ts +18 -6
- package/dist/react.js +24 -3
- package/dist/react.mjs +24 -3
- package/package.json +30 -8
- package/dist/dom-DCX-a8Kr.d.ts +0 -57
- package/dist/dom-DgB4f-TE.d.mts +0 -59
- package/dist/insertions-BJ-6o6o5.js +0 -2399
- package/dist/insertions-Okcuo-Ck.mjs +0 -2176
- /package/dist/{chunk-CfYAbeIz.mjs → chunk-D7D4PA-g.mjs} +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { _ as is_text_input_focused, a as paint, g as array_shallow_equal, h as group, i as TOOL_CURSOR, m as transform, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline } from "./model-B2UWgViT.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
5
|
import { XLINK_NS, encode_attr_value, encode_text, parse_svg } from "@grida/svg/parser";
|
|
6
|
+
import { svg_parse } from "@grida/svg/parse";
|
|
6
7
|
//#region src/commands/registry.ts
|
|
7
8
|
var CommandRegistry = class {
|
|
8
9
|
constructor() {
|
|
@@ -118,6 +119,7 @@ function registerDefaultCommands(reg, editor) {
|
|
|
118
119
|
if (editor.state.mode !== "select") return false;
|
|
119
120
|
return editor.commands.align(args);
|
|
120
121
|
});
|
|
122
|
+
reg.register("content.enter", () => editor.enter_content_edit());
|
|
121
123
|
reg.register("hierarchy.enter", () => {
|
|
122
124
|
if (editor.state.selection.length !== 1) return false;
|
|
123
125
|
const id = editor.state.selection[0];
|
|
@@ -142,8 +144,10 @@ function registerDefaultCommands(reg, editor) {
|
|
|
142
144
|
return true;
|
|
143
145
|
});
|
|
144
146
|
reg.register(TOOL_SET, (args) => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
const next = args;
|
|
148
|
+
const required_mode = next.type === "lasso" || next.type === "bend" ? "edit-content" : next.type === "insert" || next.type === "insert-text" ? "select" : null;
|
|
149
|
+
if (required_mode !== null && editor.state.mode !== required_mode) return false;
|
|
150
|
+
editor.set_tool(next);
|
|
147
151
|
return true;
|
|
148
152
|
});
|
|
149
153
|
}
|
|
@@ -163,12 +167,6 @@ function registerDefaultCommands(reg, editor) {
|
|
|
163
167
|
* measurement). That stays on the HUD modifiers channel. The keymap
|
|
164
168
|
* only sees Mod+D-shape chords.
|
|
165
169
|
*/
|
|
166
|
-
/** Modifiers that, when held, allow a binding to fire even inside a text input. */
|
|
167
|
-
const TEXT_INPUT_SAFE_MODS = new Set([
|
|
168
|
-
KeyCode.Meta,
|
|
169
|
-
KeyCode.Ctrl,
|
|
170
|
-
KeyCode.Alt
|
|
171
|
-
]);
|
|
172
170
|
var Keymap = class {
|
|
173
171
|
constructor(commands, platformGetter = getKeyboardOS) {
|
|
174
172
|
this.commands = commands;
|
|
@@ -234,15 +232,16 @@ var Keymap = class {
|
|
|
234
232
|
* bar even when the binding's handler rejects.
|
|
235
233
|
*
|
|
236
234
|
* Pure read; runs no handlers, no side effects. Honors the same
|
|
237
|
-
*
|
|
238
|
-
* keystroke isn't "claimed"
|
|
235
|
+
* form-element focus guard `dispatch` uses, so a typing user's
|
|
236
|
+
* keystroke isn't "claimed" — and the browser's native text-editing
|
|
237
|
+
* default (Cmd+A select all, Cmd+Z undo, etc.) wins.
|
|
239
238
|
*/
|
|
240
239
|
claims(event) {
|
|
241
240
|
const chunk = eventToChunk(event);
|
|
242
241
|
if (chunk.keys.length === 0) return false;
|
|
243
242
|
const list = this.buckets.get(chunkKey(chunk));
|
|
244
243
|
if (!list || list.length === 0) return false;
|
|
245
|
-
if (is_text_input_focused()
|
|
244
|
+
if (is_text_input_focused()) return list.some(({ binding }) => binding.allowInFormElement === true);
|
|
246
245
|
return true;
|
|
247
246
|
}
|
|
248
247
|
/**
|
|
@@ -250,6 +249,12 @@ var Keymap = class {
|
|
|
250
249
|
* order. Returns `true` on the first handler that consumes; returns
|
|
251
250
|
* `false` if nothing matched or all matches fell through.
|
|
252
251
|
*
|
|
252
|
+
* **Form-element focus guard.** When a text input is focused
|
|
253
|
+
* (`<input>`, `<textarea>`, contentEditable), bindings are suppressed
|
|
254
|
+
* by default so the platform's native shortcuts (Cmd+A, Cmd+Z, Cmd+C,
|
|
255
|
+
* arrow nav, …) are preserved. A binding can opt out of this guard
|
|
256
|
+
* with `allowInFormElement: true` — see `KeymapBinding`.
|
|
257
|
+
*
|
|
253
258
|
* `dispatch` is browser-agnostic: it does NOT call `preventDefault()`
|
|
254
259
|
* or touch the event in any way. The host decides what to do with the
|
|
255
260
|
* platform default — typically `if (keymap.claims(e)) e.preventDefault()`,
|
|
@@ -264,7 +269,7 @@ var Keymap = class {
|
|
|
264
269
|
if (!list || list.length === 0) return false;
|
|
265
270
|
const text_focused = is_text_input_focused();
|
|
266
271
|
for (const { binding } of list) {
|
|
267
|
-
if (text_focused &&
|
|
272
|
+
if (text_focused && binding.allowInFormElement !== true) continue;
|
|
268
273
|
if (this.commands.invoke(binding.command, binding.args)) return true;
|
|
269
274
|
}
|
|
270
275
|
return false;
|
|
@@ -284,10 +289,6 @@ var Keymap = class {
|
|
|
284
289
|
}
|
|
285
290
|
return out;
|
|
286
291
|
}
|
|
287
|
-
has_safe_mod(mods) {
|
|
288
|
-
for (const m of mods) if (TEXT_INPUT_SAFE_MODS.has(m)) return true;
|
|
289
|
-
return false;
|
|
290
|
-
}
|
|
291
292
|
};
|
|
292
293
|
function compareEntries(a, b) {
|
|
293
294
|
const pa = a.binding.priority ?? 0;
|
|
@@ -382,6 +383,10 @@ const DEFAULT_BINDINGS = [
|
|
|
382
383
|
command: "selection.align",
|
|
383
384
|
args: "vertical_centers"
|
|
384
385
|
},
|
|
386
|
+
{
|
|
387
|
+
keybinding: kb(KeyCode.Enter),
|
|
388
|
+
command: "content.enter"
|
|
389
|
+
},
|
|
385
390
|
{
|
|
386
391
|
keybinding: kb(KeyCode.Enter),
|
|
387
392
|
command: "hierarchy.enter"
|
|
@@ -483,6 +488,16 @@ const DEFAULT_BINDINGS = [
|
|
|
483
488
|
tag: "line"
|
|
484
489
|
}
|
|
485
490
|
},
|
|
491
|
+
{
|
|
492
|
+
keybinding: kb(KeyCode.KeyT),
|
|
493
|
+
command: TOOL_SET,
|
|
494
|
+
args: { type: "insert-text" }
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
keybinding: kb(KeyCode.KeyQ),
|
|
498
|
+
command: TOOL_SET,
|
|
499
|
+
args: { type: "lasso" }
|
|
500
|
+
},
|
|
486
501
|
{
|
|
487
502
|
keybinding: kb(KeyCode.BracketRight),
|
|
488
503
|
command: "reorder",
|
|
@@ -509,14 +524,6 @@ function applyDefaultBindings(keymap) {
|
|
|
509
524
|
for (const b of DEFAULT_BINDINGS) keymap.bind(b);
|
|
510
525
|
}
|
|
511
526
|
//#endregion
|
|
512
|
-
//#region src/util/equal.ts
|
|
513
|
-
function array_shallow_equal(a, b) {
|
|
514
|
-
if (a === b) return true;
|
|
515
|
-
if (a.length !== b.length) return false;
|
|
516
|
-
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
517
|
-
return true;
|
|
518
|
-
}
|
|
519
|
-
//#endregion
|
|
520
527
|
//#region src/core/defs.ts
|
|
521
528
|
var GradientsRegistry = class {
|
|
522
529
|
constructor(doc) {
|
|
@@ -749,6 +756,55 @@ function create_defs(doc) {
|
|
|
749
756
|
}
|
|
750
757
|
//#endregion
|
|
751
758
|
//#region src/core/document.ts
|
|
759
|
+
/** The native vector tags `retype_to_path` can re-type, keyed by tag → the
|
|
760
|
+
* native geometry attributes it consumes (so no orphaned geometry attr
|
|
761
|
+
* survives on the resulting `<path>`). Covers the geometry primitives
|
|
762
|
+
* (rect / circle / ellipse — always re-typed) and the vertex tags (line /
|
|
763
|
+
* polyline / polygon — re-typed only when an edit escapes their native
|
|
764
|
+
* form). */
|
|
765
|
+
const RETYPABLE_GEOMETRY_ATTRS = {
|
|
766
|
+
line: new Set([
|
|
767
|
+
"x1",
|
|
768
|
+
"y1",
|
|
769
|
+
"x2",
|
|
770
|
+
"y2"
|
|
771
|
+
]),
|
|
772
|
+
polyline: new Set(["points"]),
|
|
773
|
+
polygon: new Set(["points"]),
|
|
774
|
+
rect: new Set([
|
|
775
|
+
"x",
|
|
776
|
+
"y",
|
|
777
|
+
"width",
|
|
778
|
+
"height",
|
|
779
|
+
"rx",
|
|
780
|
+
"ry"
|
|
781
|
+
]),
|
|
782
|
+
circle: new Set([
|
|
783
|
+
"cx",
|
|
784
|
+
"cy",
|
|
785
|
+
"r"
|
|
786
|
+
]),
|
|
787
|
+
ellipse: new Set([
|
|
788
|
+
"cx",
|
|
789
|
+
"cy",
|
|
790
|
+
"rx",
|
|
791
|
+
"ry"
|
|
792
|
+
])
|
|
793
|
+
};
|
|
794
|
+
/**
|
|
795
|
+
* Parse a single SVG length attribute as a plain user-unit number. Returns
|
|
796
|
+
* `null` for absent, non-finite, or unit/percentage values (`50%`, `5px`,
|
|
797
|
+
* `5em`) — those are an out-of-scope geometry gap, and refusing them here
|
|
798
|
+
* means the editor never offers a promotion it cannot perform faithfully.
|
|
799
|
+
*/
|
|
800
|
+
function parse_user_unit(raw) {
|
|
801
|
+
if (raw === null) return null;
|
|
802
|
+
const s = raw.trim();
|
|
803
|
+
if (s === "") return null;
|
|
804
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return null;
|
|
805
|
+
const n = Number(s);
|
|
806
|
+
return Number.isFinite(n) ? n : null;
|
|
807
|
+
}
|
|
752
808
|
/**
|
|
753
809
|
* Attribute names whose writes can shift a node's rendered bounds.
|
|
754
810
|
* Membership drives `_geometry_version` bumps in `set_attr`. Only
|
|
@@ -791,11 +847,14 @@ const GEOMETRY_ATTRS = new Set([
|
|
|
791
847
|
"marker-mid",
|
|
792
848
|
"marker-end"
|
|
793
849
|
]);
|
|
850
|
+
/** `transform:` CSS property at the start of a declaration list or after `;`. */
|
|
851
|
+
const CSS_TRANSFORM_PROPERTY = /(?:^|;)\s*transform\s*:/i;
|
|
794
852
|
var SvgDocument = class SvgDocument {
|
|
795
853
|
constructor(svg) {
|
|
796
854
|
this.listeners = /* @__PURE__ */ new Set();
|
|
797
855
|
this._structure_version = 0;
|
|
798
856
|
this._geometry_version = 0;
|
|
857
|
+
if (typeof svg !== "string") throw new TypeError(`new SvgDocument(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
|
|
799
858
|
this.source = svg;
|
|
800
859
|
const parsed = parse_svg(svg);
|
|
801
860
|
this.original = parsed;
|
|
@@ -821,6 +880,7 @@ var SvgDocument = class SvgDocument {
|
|
|
821
880
|
}
|
|
822
881
|
/** Replace document with new svg source (clears edits + history-owned state). */
|
|
823
882
|
load(svg) {
|
|
883
|
+
if (typeof svg !== "string") throw new TypeError(`SvgDocument.load(svg) requires a string source, got ${svg === null ? "null" : typeof svg}`);
|
|
824
884
|
this.source = svg;
|
|
825
885
|
const parsed = parse_svg(svg);
|
|
826
886
|
this.original = parsed;
|
|
@@ -1058,6 +1118,292 @@ var SvgDocument = class SvgDocument {
|
|
|
1058
1118
|
for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
|
|
1059
1119
|
return true;
|
|
1060
1120
|
}
|
|
1121
|
+
/**
|
|
1122
|
+
* Returns a tag-discriminated snapshot of the authored geometry attrs
|
|
1123
|
+
* if this node is eligible for vector (vertex) editing — else `null`.
|
|
1124
|
+
*
|
|
1125
|
+
* Eligibility:
|
|
1126
|
+
* - `<path>` — requires non-empty `d`.
|
|
1127
|
+
* - `<line>` — requires two distinct finite user-unit endpoints.
|
|
1128
|
+
* - `<polyline>` — requires `points` parseable to ≥ 2 vertices.
|
|
1129
|
+
* - `<polygon>` — same as polyline.
|
|
1130
|
+
* - `<rect>` — requires finite user-unit `width`/`height` > 0.
|
|
1131
|
+
* - `<circle>` — requires finite user-unit `r` > 0.
|
|
1132
|
+
* - `<ellipse>` — requires finite user-unit `rx`/`ry` > 0.
|
|
1133
|
+
*
|
|
1134
|
+
* The vertex tags (`line` / `polyline` / `polygon`) write edits back to
|
|
1135
|
+
* their native attributes while the geometry stays expressible there; an
|
|
1136
|
+
* edit that escapes the native form (a curve, or a topology change that
|
|
1137
|
+
* leaves the canonical chain) re-types the element to `<path>`. The
|
|
1138
|
+
* geometry primitives (`rect` / `circle` / `ellipse`) have no native
|
|
1139
|
+
* vector form, so any vector edit re-types them. In all cases the native
|
|
1140
|
+
* tag is preserved byte-for-byte until the first re-typing edit commits
|
|
1141
|
+
* (see `retype_to_path`). Design:
|
|
1142
|
+
* `docs/wg/feat-svg-editor/promote-to-path.md`.
|
|
1143
|
+
*
|
|
1144
|
+
* Geometry that is not a plain user-unit number (`%`, `px`, `em`, …) is
|
|
1145
|
+
* an out-of-scope gap, so such an element returns `null` rather than
|
|
1146
|
+
* advertising an edit the editor cannot perform faithfully.
|
|
1147
|
+
*
|
|
1148
|
+
* Rejects `<image>` / `<use>` (raster / reference bounding boxes, no
|
|
1149
|
+
* editable outline).
|
|
1150
|
+
*/
|
|
1151
|
+
/**
|
|
1152
|
+
* Parse an optional SVG geometry coordinate (`x`/`y`, `cx`/`cy`, the line
|
|
1153
|
+
* endpoints). An **absent** attribute takes the SVG default (`0`); a
|
|
1154
|
+
* **present** attribute that is not a plain user-unit number (`%`, `px`,
|
|
1155
|
+
* `em`, …) is out of scope and yields `null` so the caller refuses the
|
|
1156
|
+
* element — the same gate required attrs (width / radius) already apply.
|
|
1157
|
+
*
|
|
1158
|
+
* The absent-vs-present distinction is the point: a bare `?? 0` would
|
|
1159
|
+
* silently coerce an authored `x1="5px"` to `0`, then the first native
|
|
1160
|
+
* writeback would overwrite that authored value. Refusing keeps the
|
|
1161
|
+
* editor from misrepresenting geometry it cannot read faithfully.
|
|
1162
|
+
*/
|
|
1163
|
+
optional_user_unit_coord(id, name) {
|
|
1164
|
+
const raw = this.get_attr(id, name);
|
|
1165
|
+
if (raw === null) return 0;
|
|
1166
|
+
return parse_user_unit(raw);
|
|
1167
|
+
}
|
|
1168
|
+
is_vector_edit_target(id) {
|
|
1169
|
+
const n = this.nodes.get(id);
|
|
1170
|
+
if (!n || n.kind !== "element") return null;
|
|
1171
|
+
if (RETYPABLE_GEOMETRY_ATTRS[n.local] && n.attrs.some((a) => a.prefix === null && a.ns === null && a.local === "d")) return null;
|
|
1172
|
+
switch (n.local) {
|
|
1173
|
+
case "path": {
|
|
1174
|
+
const d = this.get_attr(id, "d");
|
|
1175
|
+
if (d === null || d.trim().length === 0) return null;
|
|
1176
|
+
return {
|
|
1177
|
+
kind: "path",
|
|
1178
|
+
d
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
case "line": {
|
|
1182
|
+
const x1 = this.optional_user_unit_coord(id, "x1");
|
|
1183
|
+
const y1 = this.optional_user_unit_coord(id, "y1");
|
|
1184
|
+
const x2 = this.optional_user_unit_coord(id, "x2");
|
|
1185
|
+
const y2 = this.optional_user_unit_coord(id, "y2");
|
|
1186
|
+
if (x1 === null || y1 === null || x2 === null || y2 === null) return null;
|
|
1187
|
+
if (x1 === x2 && y1 === y2) return null;
|
|
1188
|
+
return {
|
|
1189
|
+
kind: "line",
|
|
1190
|
+
x1,
|
|
1191
|
+
y1,
|
|
1192
|
+
x2,
|
|
1193
|
+
y2
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
case "polyline":
|
|
1197
|
+
case "polygon": {
|
|
1198
|
+
const raw = this.get_attr(id, "points") ?? "";
|
|
1199
|
+
const parsed = svg_parse.parse_points(raw);
|
|
1200
|
+
if (parsed.length < 2) return null;
|
|
1201
|
+
const points = parsed.map((p) => [p.x, p.y]);
|
|
1202
|
+
return n.local === "polyline" ? {
|
|
1203
|
+
kind: "polyline",
|
|
1204
|
+
points
|
|
1205
|
+
} : {
|
|
1206
|
+
kind: "polygon",
|
|
1207
|
+
points
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
case "rect": {
|
|
1211
|
+
const x = this.optional_user_unit_coord(id, "x");
|
|
1212
|
+
const y = this.optional_user_unit_coord(id, "y");
|
|
1213
|
+
if (x === null || y === null) return null;
|
|
1214
|
+
const width = parse_user_unit(this.get_attr(id, "width"));
|
|
1215
|
+
const height = parse_user_unit(this.get_attr(id, "height"));
|
|
1216
|
+
if (width === null || height === null) return null;
|
|
1217
|
+
if (width <= 0 || height <= 0) return null;
|
|
1218
|
+
const rx_attr = this.get_attr(id, "rx");
|
|
1219
|
+
const ry_attr = this.get_attr(id, "ry");
|
|
1220
|
+
const rx_parsed = rx_attr === null ? null : parse_user_unit(rx_attr);
|
|
1221
|
+
const ry_parsed = ry_attr === null ? null : parse_user_unit(ry_attr);
|
|
1222
|
+
if (rx_attr !== null && rx_parsed === null) return null;
|
|
1223
|
+
if (ry_attr !== null && ry_parsed === null) return null;
|
|
1224
|
+
let rx = rx_parsed ?? ry_parsed ?? 0;
|
|
1225
|
+
let ry = ry_parsed ?? rx_parsed ?? 0;
|
|
1226
|
+
rx = Math.max(0, Math.min(rx, width / 2));
|
|
1227
|
+
ry = Math.max(0, Math.min(ry, height / 2));
|
|
1228
|
+
return {
|
|
1229
|
+
kind: "rect",
|
|
1230
|
+
x,
|
|
1231
|
+
y,
|
|
1232
|
+
width,
|
|
1233
|
+
height,
|
|
1234
|
+
rx,
|
|
1235
|
+
ry
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
case "circle": {
|
|
1239
|
+
const cx = this.optional_user_unit_coord(id, "cx");
|
|
1240
|
+
const cy = this.optional_user_unit_coord(id, "cy");
|
|
1241
|
+
if (cx === null || cy === null) return null;
|
|
1242
|
+
const r = parse_user_unit(this.get_attr(id, "r"));
|
|
1243
|
+
if (r === null || r <= 0) return null;
|
|
1244
|
+
return {
|
|
1245
|
+
kind: "circle",
|
|
1246
|
+
cx,
|
|
1247
|
+
cy,
|
|
1248
|
+
r
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
case "ellipse": {
|
|
1252
|
+
const cx = this.optional_user_unit_coord(id, "cx");
|
|
1253
|
+
const cy = this.optional_user_unit_coord(id, "cy");
|
|
1254
|
+
if (cx === null || cy === null) return null;
|
|
1255
|
+
const rx = parse_user_unit(this.get_attr(id, "rx"));
|
|
1256
|
+
const ry = parse_user_unit(this.get_attr(id, "ry"));
|
|
1257
|
+
if (rx === null || ry === null) return null;
|
|
1258
|
+
if (rx <= 0 || ry <= 0) return null;
|
|
1259
|
+
return {
|
|
1260
|
+
kind: "ellipse",
|
|
1261
|
+
cx,
|
|
1262
|
+
cy,
|
|
1263
|
+
rx,
|
|
1264
|
+
ry
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
default: return null;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Re-type a native vector element (`<line>` / `<polyline>` / `<polygon>` /
|
|
1272
|
+
* `<rect>` / `<circle>` / `<ellipse>`) into a `<path>` in place, consuming
|
|
1273
|
+
* its native geometry attributes and setting `d`. A structural mutation:
|
|
1274
|
+
* this layer executes the re-type; it does not decide when one is
|
|
1275
|
+
* warranted.
|
|
1276
|
+
*
|
|
1277
|
+
* Idempotent: returns `null` if `id` is not currently one of those tags
|
|
1278
|
+
* (so it is safe to call repeatedly — once re-typed, e.g. already a
|
|
1279
|
+
* `<path>`, further calls are no-ops). Otherwise mutates the node and
|
|
1280
|
+
* returns an opaque {@link RetypeRecord} reversal token.
|
|
1281
|
+
*
|
|
1282
|
+
* Identity, children, `self_closing`, non-geometry attributes, and all
|
|
1283
|
+
* source trivia are preserved unchanged — only the tag and the geometry
|
|
1284
|
+
* attributes move. Pass the token to {@link revert_retype} to restore
|
|
1285
|
+
* the original primitive byte-for-byte.
|
|
1286
|
+
*
|
|
1287
|
+
* (see test/svg-editor-vector-promote-to-path.md)
|
|
1288
|
+
*/
|
|
1289
|
+
retype_to_path(id, d) {
|
|
1290
|
+
const n = this.nodes.get(id);
|
|
1291
|
+
if (!n || n.kind !== "element") return null;
|
|
1292
|
+
const geom = RETYPABLE_GEOMETRY_ATTRS[n.local];
|
|
1293
|
+
if (!geom) return null;
|
|
1294
|
+
const prev_local = n.local;
|
|
1295
|
+
const prev_raw_tag = n.raw_tag;
|
|
1296
|
+
const removed = [];
|
|
1297
|
+
for (let i = n.attrs.length - 1; i >= 0; i--) {
|
|
1298
|
+
const a = n.attrs[i];
|
|
1299
|
+
if (a.prefix === null && a.ns === null && geom.has(a.local)) {
|
|
1300
|
+
removed.push({
|
|
1301
|
+
index: i,
|
|
1302
|
+
token: a
|
|
1303
|
+
});
|
|
1304
|
+
n.attrs.splice(i, 1);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
removed.reverse();
|
|
1308
|
+
n.local = "path";
|
|
1309
|
+
n.raw_tag = n.prefix ? `${n.prefix}:path` : "path";
|
|
1310
|
+
n.attrs.push({
|
|
1311
|
+
raw_name: "d",
|
|
1312
|
+
prefix: null,
|
|
1313
|
+
local: "d",
|
|
1314
|
+
ns: null,
|
|
1315
|
+
value: d,
|
|
1316
|
+
pre: " ",
|
|
1317
|
+
eq_trivia: "",
|
|
1318
|
+
quote: "\""
|
|
1319
|
+
});
|
|
1320
|
+
let added_fill_none = false;
|
|
1321
|
+
if (prev_local === "line" && this.get_attr(id, "fill") === null && this.get_style(id, "fill") === null) {
|
|
1322
|
+
n.attrs.push({
|
|
1323
|
+
raw_name: "fill",
|
|
1324
|
+
prefix: null,
|
|
1325
|
+
local: "fill",
|
|
1326
|
+
ns: null,
|
|
1327
|
+
value: "none",
|
|
1328
|
+
pre: " ",
|
|
1329
|
+
eq_trivia: "",
|
|
1330
|
+
quote: "\""
|
|
1331
|
+
});
|
|
1332
|
+
added_fill_none = true;
|
|
1333
|
+
}
|
|
1334
|
+
this._structure_version++;
|
|
1335
|
+
this._geometry_version++;
|
|
1336
|
+
this.emit();
|
|
1337
|
+
return {
|
|
1338
|
+
prev_local,
|
|
1339
|
+
prev_raw_tag,
|
|
1340
|
+
removed,
|
|
1341
|
+
added_fill_none
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Reverse a {@link retype_to_path}: restore the original tag, remove the
|
|
1346
|
+
* `d` attribute the promotion added, and splice the captured geometry
|
|
1347
|
+
* attribute tokens back at their original positions (preserving their
|
|
1348
|
+
* trivia, so a later `serialize()` is byte-equal to the pre-promotion
|
|
1349
|
+
* source).
|
|
1350
|
+
*/
|
|
1351
|
+
revert_retype(id, token) {
|
|
1352
|
+
const n = this.nodes.get(id);
|
|
1353
|
+
if (!n || n.kind !== "element") return;
|
|
1354
|
+
for (let i = n.attrs.length - 1; i >= 0; i--) {
|
|
1355
|
+
const a = n.attrs[i];
|
|
1356
|
+
if (a.prefix === null && a.ns === null && a.local === "d") {
|
|
1357
|
+
n.attrs.splice(i, 1);
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (token.added_fill_none) for (let i = n.attrs.length - 1; i >= 0; i--) {
|
|
1362
|
+
const a = n.attrs[i];
|
|
1363
|
+
if (a.prefix === null && a.ns === null && a.local === "fill") {
|
|
1364
|
+
n.attrs.splice(i, 1);
|
|
1365
|
+
break;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
n.local = token.prev_local;
|
|
1369
|
+
n.raw_tag = token.prev_raw_tag;
|
|
1370
|
+
for (const { index, token: t } of token.removed) n.attrs.splice(index, 0, t);
|
|
1371
|
+
this._structure_version++;
|
|
1372
|
+
this._geometry_version++;
|
|
1373
|
+
this.emit();
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* True iff this `<text>` / `<tspan>` carries a non-empty `rotate=""`
|
|
1377
|
+
* per-glyph attribute (which conflicts with element-level rotation).
|
|
1378
|
+
*/
|
|
1379
|
+
has_glyph_rotate(id) {
|
|
1380
|
+
const tag = this.tag_of(id);
|
|
1381
|
+
if (tag !== "text" && tag !== "tspan") return false;
|
|
1382
|
+
const value = this.get_attr(id, "rotate");
|
|
1383
|
+
if (value === null) return false;
|
|
1384
|
+
return value.trim() !== "";
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* True iff this element's inline `style=""` declares a `transform:`
|
|
1388
|
+
* CSS property (which would shadow the editor's `transform=` writes).
|
|
1389
|
+
*/
|
|
1390
|
+
has_inline_css_transform(id) {
|
|
1391
|
+
const style = this.get_attr(id, "style");
|
|
1392
|
+
if (!style) return false;
|
|
1393
|
+
return CSS_TRANSFORM_PROPERTY.test(style);
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* True iff this element has a direct `<animateTransform>` child
|
|
1397
|
+
* (which produces a time-varying transform invisible to attribute writes).
|
|
1398
|
+
* Only direct children are checked — nested cases attach to the nearer ancestor.
|
|
1399
|
+
*/
|
|
1400
|
+
has_animate_transform_child(id) {
|
|
1401
|
+
for (const c of this.children_of(id)) {
|
|
1402
|
+
const n = this.nodes.get(c);
|
|
1403
|
+
if (n?.kind === "element" && n.local === "animateTransform") return true;
|
|
1404
|
+
}
|
|
1405
|
+
return false;
|
|
1406
|
+
}
|
|
1061
1407
|
text_of(id) {
|
|
1062
1408
|
const n = this.nodes.get(id);
|
|
1063
1409
|
if (!n || n.kind !== "element") return "";
|
|
@@ -1149,6 +1495,37 @@ var SvgDocument = class SvgDocument {
|
|
|
1149
1495
|
for (const e of this.epilog) out += this.emit_node(e);
|
|
1150
1496
|
return out;
|
|
1151
1497
|
}
|
|
1498
|
+
/**
|
|
1499
|
+
* Serialize a single element's subtree as an SVG **fragment**, using the
|
|
1500
|
+
* same trivia-preserving rules as {@link serialize} (attribute order,
|
|
1501
|
+
* quote style, whitespace, comments — emitted exactly as authored).
|
|
1502
|
+
*
|
|
1503
|
+
* This is NOT {@link serialize} scoped to a node — it is a deliberately
|
|
1504
|
+
* weaker output (sdk-design D3, asymmetric outputs stay separate):
|
|
1505
|
+
*
|
|
1506
|
+
* - `serialize()` emits the whole document and carries the P1
|
|
1507
|
+
* whole-document round-trip guarantee.
|
|
1508
|
+
* - `serialize_node()` emits a fragment and does NOT. Namespace
|
|
1509
|
+
* declarations that live on an ancestor (`xmlns:xlink` and friends,
|
|
1510
|
+
* normally on the root `<svg>`) are NOT inlined — a node using
|
|
1511
|
+
* `xlink:href` serializes without `xmlns:xlink`. The fragment is the
|
|
1512
|
+
* element's markup as authored, not a standalone parseable document.
|
|
1513
|
+
*
|
|
1514
|
+
* Throws on an unknown id, a non-element node, or a node detached from
|
|
1515
|
+
* the live tree: the contract is "the markup for a selected element,"
|
|
1516
|
+
* selections are always live elements, and a string return of `""` for a
|
|
1517
|
+
* bad id would hide consumer bugs. The detached case matters because
|
|
1518
|
+
* `remove()` keeps the node in the id map for undo — a stale id from a
|
|
1519
|
+
* removed node would otherwise serialize content no longer in the
|
|
1520
|
+
* document, silently feeding a consumer deleted markup.
|
|
1521
|
+
*/
|
|
1522
|
+
serialize_node(id) {
|
|
1523
|
+
const n = this.nodes.get(id);
|
|
1524
|
+
if (!n) throw new Error(`serialize_node: unknown node id ${JSON.stringify(id)}`);
|
|
1525
|
+
if (n.kind !== "element") throw new Error(`serialize_node: node ${JSON.stringify(id)} is a ${n.kind} node, not an element`);
|
|
1526
|
+
if (!this.contains(this.root, id)) throw new Error(`serialize_node: node ${JSON.stringify(id)} is detached from the current document`);
|
|
1527
|
+
return this.emit_node(n);
|
|
1528
|
+
}
|
|
1152
1529
|
emit_node(n) {
|
|
1153
1530
|
switch (n.kind) {
|
|
1154
1531
|
case "text": return encode_text(n.value);
|
|
@@ -1248,153 +1625,162 @@ function delta_for(bbox, target, direction) {
|
|
|
1248
1625
|
}
|
|
1249
1626
|
//#endregion
|
|
1250
1627
|
//#region src/core/properties.ts
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
* Resolve a property's declared value and its provenance for a single node.
|
|
1300
|
-
*
|
|
1301
|
-
* The cascade engine here covers what the README says is in scope:
|
|
1302
|
-
* presentation attributes + inline style + parent inheritance + initial.
|
|
1303
|
-
* `<style>` block matching is deferred.
|
|
1304
|
-
*/
|
|
1305
|
-
function resolve_declared(doc, id, property) {
|
|
1306
|
-
const inline = doc.get_style(id, property);
|
|
1307
|
-
if (inline !== null && inline !== "") return {
|
|
1308
|
-
declared: inline,
|
|
1309
|
-
provenance: {
|
|
1310
|
-
origin: "author",
|
|
1311
|
-
carrier: "inline_style"
|
|
1312
|
-
}
|
|
1628
|
+
let properties;
|
|
1629
|
+
(function(_properties) {
|
|
1630
|
+
/** SVG properties that inherit per SVG 2 §6 (subset; the common ones). */
|
|
1631
|
+
const INHERITED = new Set([
|
|
1632
|
+
"color",
|
|
1633
|
+
"cursor",
|
|
1634
|
+
"direction",
|
|
1635
|
+
"fill",
|
|
1636
|
+
"fill-opacity",
|
|
1637
|
+
"fill-rule",
|
|
1638
|
+
"font",
|
|
1639
|
+
"font-family",
|
|
1640
|
+
"font-size",
|
|
1641
|
+
"font-style",
|
|
1642
|
+
"font-variant",
|
|
1643
|
+
"font-weight",
|
|
1644
|
+
"letter-spacing",
|
|
1645
|
+
"marker",
|
|
1646
|
+
"marker-end",
|
|
1647
|
+
"marker-mid",
|
|
1648
|
+
"marker-start",
|
|
1649
|
+
"paint-order",
|
|
1650
|
+
"pointer-events",
|
|
1651
|
+
"shape-rendering",
|
|
1652
|
+
"stroke",
|
|
1653
|
+
"stroke-dasharray",
|
|
1654
|
+
"stroke-dashoffset",
|
|
1655
|
+
"stroke-linecap",
|
|
1656
|
+
"stroke-linejoin",
|
|
1657
|
+
"stroke-miterlimit",
|
|
1658
|
+
"stroke-opacity",
|
|
1659
|
+
"stroke-width",
|
|
1660
|
+
"text-anchor",
|
|
1661
|
+
"text-rendering",
|
|
1662
|
+
"visibility",
|
|
1663
|
+
"word-spacing",
|
|
1664
|
+
"writing-mode"
|
|
1665
|
+
]);
|
|
1666
|
+
/** Initial values for known properties (subset). */
|
|
1667
|
+
const INITIAL = {
|
|
1668
|
+
fill: "black",
|
|
1669
|
+
stroke: "none",
|
|
1670
|
+
"fill-opacity": "1",
|
|
1671
|
+
"stroke-opacity": "1",
|
|
1672
|
+
"stroke-width": "1",
|
|
1673
|
+
opacity: "1",
|
|
1674
|
+
visibility: "visible",
|
|
1675
|
+
display: "inline"
|
|
1313
1676
|
};
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
const
|
|
1324
|
-
if (
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1677
|
+
function resolve_declared(doc, id, property) {
|
|
1678
|
+
const inline = doc.get_style(id, property);
|
|
1679
|
+
if (inline !== null && inline !== "") return {
|
|
1680
|
+
declared: inline,
|
|
1681
|
+
provenance: {
|
|
1682
|
+
origin: "author",
|
|
1683
|
+
carrier: "inline_style"
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
const attr = doc.get_attr(id, property);
|
|
1687
|
+
if (attr !== null && attr !== "") return {
|
|
1688
|
+
declared: attr,
|
|
1689
|
+
provenance: {
|
|
1690
|
+
origin: "author",
|
|
1691
|
+
carrier: "presentation_attribute"
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
if (INHERITED.has(property)) {
|
|
1695
|
+
const parent = doc.parent_of(id);
|
|
1696
|
+
if (parent !== null && doc.is_element(parent)) {
|
|
1697
|
+
const r = resolve_declared(doc, parent, property);
|
|
1698
|
+
if (r.declared !== null) return {
|
|
1699
|
+
declared: r.declared,
|
|
1700
|
+
provenance: {
|
|
1701
|
+
origin: "author",
|
|
1702
|
+
carrier: "inherited"
|
|
1703
|
+
}
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1333
1706
|
}
|
|
1707
|
+
return {
|
|
1708
|
+
declared: INITIAL[property] ?? null,
|
|
1709
|
+
provenance: {
|
|
1710
|
+
origin: "user_agent",
|
|
1711
|
+
carrier: "defaulted"
|
|
1712
|
+
}
|
|
1713
|
+
};
|
|
1334
1714
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
case "height":
|
|
1364
|
-
case "cx":
|
|
1365
|
-
case "cy":
|
|
1366
|
-
case "r":
|
|
1367
|
-
case "rx":
|
|
1368
|
-
case "ry":
|
|
1369
|
-
case "font-size": {
|
|
1370
|
-
const n = parseFloat(trimmed);
|
|
1371
|
-
return Number.isFinite(n) ? n : trimmed;
|
|
1715
|
+
_properties.resolve_declared = resolve_declared;
|
|
1716
|
+
function compute_known(property, declared) {
|
|
1717
|
+
if (declared === null) return null;
|
|
1718
|
+
const trimmed = declared.trim();
|
|
1719
|
+
if (trimmed === "inherit" || trimmed === "initial" || trimmed === "unset" || trimmed === "revert" || trimmed === "revert-layer") return null;
|
|
1720
|
+
if (/^var\s*\(/i.test(trimmed)) return {
|
|
1721
|
+
error: "invalid_at_computed_value_time",
|
|
1722
|
+
reason: `var() substitution requires a cascade engine (not implemented)`
|
|
1723
|
+
};
|
|
1724
|
+
switch (property) {
|
|
1725
|
+
case "opacity":
|
|
1726
|
+
case "fill-opacity":
|
|
1727
|
+
case "stroke-opacity":
|
|
1728
|
+
case "stroke-width":
|
|
1729
|
+
case "x":
|
|
1730
|
+
case "y":
|
|
1731
|
+
case "width":
|
|
1732
|
+
case "height":
|
|
1733
|
+
case "cx":
|
|
1734
|
+
case "cy":
|
|
1735
|
+
case "r":
|
|
1736
|
+
case "rx":
|
|
1737
|
+
case "ry":
|
|
1738
|
+
case "font-size": {
|
|
1739
|
+
const n = parseFloat(trimmed);
|
|
1740
|
+
return Number.isFinite(n) ? n : trimmed;
|
|
1741
|
+
}
|
|
1742
|
+
default: return trimmed;
|
|
1372
1743
|
}
|
|
1373
|
-
default: return trimmed;
|
|
1374
1744
|
}
|
|
1375
|
-
|
|
1376
|
-
function
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1745
|
+
_properties.compute_known = compute_known;
|
|
1746
|
+
function read(doc, id, property) {
|
|
1747
|
+
const { declared, provenance } = resolve_declared(doc, id, property);
|
|
1748
|
+
return {
|
|
1749
|
+
declared,
|
|
1750
|
+
computed: compute_known(property, declared),
|
|
1751
|
+
provenance
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
_properties.read = read;
|
|
1755
|
+
function choose_write_carrier(doc, id, property) {
|
|
1756
|
+
const inline = doc.get_style(id, property);
|
|
1757
|
+
if (inline !== null && inline !== "") return "inline_style";
|
|
1758
|
+
return "presentation_attribute";
|
|
1759
|
+
}
|
|
1760
|
+
_properties.choose_write_carrier = choose_write_carrier;
|
|
1761
|
+
function value_equals(a, b) {
|
|
1762
|
+
if (a === b) return true;
|
|
1763
|
+
if (a.declared !== b.declared) return false;
|
|
1764
|
+
if (a.provenance.carrier !== b.provenance.carrier) return false;
|
|
1765
|
+
if (a.provenance.origin !== b.provenance.origin) return false;
|
|
1766
|
+
if (a.computed === b.computed) return true;
|
|
1767
|
+
if (a.computed && b.computed && typeof a.computed === "object" && typeof b.computed === "object" && "error" in a.computed && "error" in b.computed) return a.computed.error === b.computed.error && a.computed.reason === b.computed.reason;
|
|
1768
|
+
return false;
|
|
1769
|
+
}
|
|
1770
|
+
_properties.value_equals = value_equals;
|
|
1771
|
+
})(properties || (properties = {}));
|
|
1392
1772
|
//#endregion
|
|
1393
1773
|
//#region src/core/editor.ts
|
|
1394
1774
|
const PROVIDER_ID = "svg-editor";
|
|
1395
1775
|
/** Max characters in a synthesized display label before truncation. */
|
|
1396
1776
|
const DISPLAY_LABEL_MAX_LEN = 40;
|
|
1397
|
-
|
|
1777
|
+
/**
|
|
1778
|
+
* Wide internal factory — returns the full object including the
|
|
1779
|
+
* `_internal` / `keymap` surfaces in its inferred type. Stays private.
|
|
1780
|
+
* The public `createSvgEditor` below wraps this and narrows the return
|
|
1781
|
+
* to `SvgEditor` so the published `.d.ts` doesn't advertise internals.
|
|
1782
|
+
*/
|
|
1783
|
+
function _create_svg_editor_internal(opts) {
|
|
1398
1784
|
const doc = new SvgDocument(opts.svg);
|
|
1399
1785
|
const history = new HistoryImpl();
|
|
1400
1786
|
const defs = create_defs(doc);
|
|
@@ -1438,6 +1824,7 @@ function createSvgEditor(opts) {
|
|
|
1438
1824
|
can_undo: history.stack.canUndo,
|
|
1439
1825
|
can_redo: history.stack.canRedo,
|
|
1440
1826
|
version,
|
|
1827
|
+
content_version: doc_version,
|
|
1441
1828
|
structure_version: doc.structure_version,
|
|
1442
1829
|
geometry_version: doc.geometry_version,
|
|
1443
1830
|
load_version
|
|
@@ -1526,7 +1913,7 @@ function createSvgEditor(opts) {
|
|
|
1526
1913
|
}
|
|
1527
1914
|
function tools_equal(a, b) {
|
|
1528
1915
|
if (a.type !== b.type) return false;
|
|
1529
|
-
if (a.type === "cursor") return true;
|
|
1916
|
+
if (a.type === "cursor" || a.type === "lasso" || a.type === "bend" || a.type === "insert-text") return true;
|
|
1530
1917
|
return b.type === "insert" && a.tag === b.tag;
|
|
1531
1918
|
}
|
|
1532
1919
|
function set_tool(next) {
|
|
@@ -1587,8 +1974,8 @@ function createSvgEditor(opts) {
|
|
|
1587
1974
|
const key = `${id}${name}`;
|
|
1588
1975
|
const cached = property_cache.get(key);
|
|
1589
1976
|
if (cached && cached.doc_version === doc_version) return cached.value;
|
|
1590
|
-
const next =
|
|
1591
|
-
if (cached &&
|
|
1977
|
+
const next = properties.read(doc, id, name);
|
|
1978
|
+
if (cached && properties.value_equals(cached.value, next)) {
|
|
1592
1979
|
cached.doc_version = doc_version;
|
|
1593
1980
|
return cached.value;
|
|
1594
1981
|
}
|
|
@@ -1624,13 +2011,13 @@ function createSvgEditor(opts) {
|
|
|
1624
2011
|
const key = `${id}${channel}`;
|
|
1625
2012
|
const cached = paint_cache.get(key);
|
|
1626
2013
|
if (cached && cached.doc_version === doc_version) return cached.value;
|
|
1627
|
-
const { declared, provenance } = resolve_declared(doc, id, channel);
|
|
2014
|
+
const { declared, provenance } = properties.resolve_declared(doc, id, channel);
|
|
1628
2015
|
const next = {
|
|
1629
2016
|
declared,
|
|
1630
|
-
computed:
|
|
2017
|
+
computed: paint.parse(declared),
|
|
1631
2018
|
provenance
|
|
1632
2019
|
};
|
|
1633
|
-
if (cached &&
|
|
2020
|
+
if (cached && paint.value_equals(cached.value, next)) {
|
|
1634
2021
|
cached.doc_version = doc_version;
|
|
1635
2022
|
return cached.value;
|
|
1636
2023
|
}
|
|
@@ -1641,7 +2028,7 @@ function createSvgEditor(opts) {
|
|
|
1641
2028
|
return next;
|
|
1642
2029
|
}
|
|
1643
2030
|
function write_property(id, name, value) {
|
|
1644
|
-
if (choose_write_carrier(doc, id, name) === "inline_style") doc.set_style(id, name, value);
|
|
2031
|
+
if (properties.choose_write_carrier(doc, id, name) === "inline_style") doc.set_style(id, name, value);
|
|
1645
2032
|
else doc.set_attr(id, name, value);
|
|
1646
2033
|
}
|
|
1647
2034
|
function set_property(name, value) {
|
|
@@ -1704,14 +2091,14 @@ function createSvgEditor(opts) {
|
|
|
1704
2091
|
discard: () => preview.discard()
|
|
1705
2092
|
};
|
|
1706
2093
|
}
|
|
1707
|
-
function set_paint(channel,
|
|
2094
|
+
function set_paint(channel, p) {
|
|
1708
2095
|
if (selection.length === 0) return;
|
|
1709
|
-
set_property(channel,
|
|
2096
|
+
set_property(channel, paint.serialize(p));
|
|
1710
2097
|
}
|
|
1711
2098
|
function preview_paint(channel) {
|
|
1712
2099
|
const session = preview_property(channel);
|
|
1713
2100
|
return {
|
|
1714
|
-
update: (
|
|
2101
|
+
update: (p) => session.update(paint.serialize(p)),
|
|
1715
2102
|
commit: () => session.commit(),
|
|
1716
2103
|
discard: () => session.discard()
|
|
1717
2104
|
};
|
|
@@ -1729,7 +2116,7 @@ function createSvgEditor(opts) {
|
|
|
1729
2116
|
function do_translate_oneshot(delta, stages, label) {
|
|
1730
2117
|
if (selection.length === 0) return false;
|
|
1731
2118
|
if (delta.dx === 0 && delta.dy === 0) return false;
|
|
1732
|
-
const { apply, revert } =
|
|
2119
|
+
const { apply, revert } = translate_pipeline.prepare_rpc({
|
|
1733
2120
|
doc,
|
|
1734
2121
|
ids: selection,
|
|
1735
2122
|
delta: {
|
|
@@ -1742,7 +2129,8 @@ function createSvgEditor(opts) {
|
|
|
1742
2129
|
snap_threshold_px: style.snap_threshold_px
|
|
1743
2130
|
},
|
|
1744
2131
|
emit,
|
|
1745
|
-
stages
|
|
2132
|
+
stages,
|
|
2133
|
+
project: (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d
|
|
1746
2134
|
});
|
|
1747
2135
|
apply();
|
|
1748
2136
|
history.atomic(label, (tx) => {
|
|
@@ -1758,7 +2146,7 @@ function createSvgEditor(opts) {
|
|
|
1758
2146
|
if (do_translate_oneshot(delta, void 0, "translate")) notify_translate_commit();
|
|
1759
2147
|
}
|
|
1760
2148
|
function nudge(delta) {
|
|
1761
|
-
if (do_translate_oneshot(delta,
|
|
2149
|
+
if (do_translate_oneshot(delta, translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
|
|
1762
2150
|
}
|
|
1763
2151
|
/**
|
|
1764
2152
|
* One-shot multi-member resize to an explicit target rect. Mirrors a
|
|
@@ -1783,13 +2171,13 @@ function createSvgEditor(opts) {
|
|
|
1783
2171
|
if (!geometry_provider) return false;
|
|
1784
2172
|
const members = [];
|
|
1785
2173
|
for (const id of ids) {
|
|
1786
|
-
if (!is_resizable(doc.tag_of(id))) continue;
|
|
2174
|
+
if (!resize_pipeline.intent.is_resizable(doc.tag_of(id))) continue;
|
|
1787
2175
|
const bbox = geometry_provider.bounds_of(id);
|
|
1788
2176
|
if (!bbox) continue;
|
|
1789
2177
|
members.push({
|
|
1790
2178
|
id,
|
|
1791
|
-
rz:
|
|
1792
|
-
tx_pre:
|
|
2179
|
+
rz: resize_pipeline.intent.capture_baseline(doc, id, bbox),
|
|
2180
|
+
tx_pre: translate_pipeline.intent.capture_baseline(doc, id),
|
|
1793
2181
|
transform_pre: doc.get_attr(id, "transform"),
|
|
1794
2182
|
bbox
|
|
1795
2183
|
});
|
|
@@ -1805,16 +2193,16 @@ function createSvgEditor(opts) {
|
|
|
1805
2193
|
const dx = target.x - union.x;
|
|
1806
2194
|
const dy = target.y - union.y;
|
|
1807
2195
|
const apply = () => {
|
|
1808
|
-
for (const m of members)
|
|
2196
|
+
for (const m of members) resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
|
|
1809
2197
|
if (dx !== 0 || dy !== 0) for (const m of members) {
|
|
1810
|
-
const tx_after =
|
|
1811
|
-
|
|
2198
|
+
const tx_after = translate_pipeline.intent.capture_baseline(doc, m.id);
|
|
2199
|
+
translate_pipeline.intent.apply(doc, m.id, tx_after, dx, dy);
|
|
1812
2200
|
}
|
|
1813
2201
|
emit();
|
|
1814
2202
|
};
|
|
1815
2203
|
const revert = () => {
|
|
1816
2204
|
for (const m of members) {
|
|
1817
|
-
|
|
2205
|
+
resize_pipeline.intent.apply(doc, m.id, m.rz, 1, 1, origin);
|
|
1818
2206
|
doc.set_attr(m.id, "transform", m.transform_pre);
|
|
1819
2207
|
}
|
|
1820
2208
|
emit();
|
|
@@ -1855,10 +2243,11 @@ function createSvgEditor(opts) {
|
|
|
1855
2243
|
function rotate(angle, opts) {
|
|
1856
2244
|
const ids = opts?.ids ?? selection;
|
|
1857
2245
|
if (ids.length === 0) return false;
|
|
1858
|
-
const
|
|
2246
|
+
const pivot = opts?.pivot ?? default_rotate_pivot(ids);
|
|
2247
|
+
const prepared = rotate_pipeline.prepare_rpc({
|
|
1859
2248
|
doc,
|
|
1860
2249
|
ids,
|
|
1861
|
-
pivot
|
|
2250
|
+
pivot,
|
|
1862
2251
|
angle_radians: angle,
|
|
1863
2252
|
options: { angle_snap_step_radians: style.angle_snap_step_radians },
|
|
1864
2253
|
emit
|
|
@@ -1877,10 +2266,11 @@ function createSvgEditor(opts) {
|
|
|
1877
2266
|
function rotate_to(angle, opts) {
|
|
1878
2267
|
const ids = opts?.ids ?? selection;
|
|
1879
2268
|
if (ids.length === 0) return false;
|
|
1880
|
-
const
|
|
2269
|
+
const pivot = opts?.pivot ?? default_rotate_pivot(ids);
|
|
2270
|
+
const probe = rotate_pipeline.prepare_rpc({
|
|
1881
2271
|
doc,
|
|
1882
2272
|
ids,
|
|
1883
|
-
pivot
|
|
2273
|
+
pivot,
|
|
1884
2274
|
angle_radians: 0,
|
|
1885
2275
|
options: { angle_snap_step_radians: style.angle_snap_step_radians },
|
|
1886
2276
|
emit: () => {}
|
|
@@ -1890,12 +2280,12 @@ function createSvgEditor(opts) {
|
|
|
1890
2280
|
const apply = () => {
|
|
1891
2281
|
for (const m of probe.plan.members) {
|
|
1892
2282
|
const delta = angle - m.baseline.current_rotation_deg * DEG_TO_RAD;
|
|
1893
|
-
|
|
2283
|
+
rotate_pipeline.intent.apply(doc, m.id, m.baseline, delta);
|
|
1894
2284
|
}
|
|
1895
2285
|
emit();
|
|
1896
2286
|
};
|
|
1897
2287
|
const revert = () => {
|
|
1898
|
-
for (const m of probe.plan.members)
|
|
2288
|
+
for (const m of probe.plan.members) rotate_pipeline.intent.apply(doc, m.id, m.baseline, 0);
|
|
1899
2289
|
emit();
|
|
1900
2290
|
};
|
|
1901
2291
|
apply();
|
|
@@ -1915,7 +2305,7 @@ function createSvgEditor(opts) {
|
|
|
1915
2305
|
for (const id of ids) {
|
|
1916
2306
|
const pre = doc.get_attr(id, "transform");
|
|
1917
2307
|
if (pre === null) continue;
|
|
1918
|
-
const ops =
|
|
2308
|
+
const ops = transform.parse(pre);
|
|
1919
2309
|
if (ops === null) continue;
|
|
1920
2310
|
if (ops.length === 1 && ops[0].type === "matrix") continue;
|
|
1921
2311
|
members.push({
|
|
@@ -1925,104 +2315,11 @@ function createSvgEditor(opts) {
|
|
|
1925
2315
|
});
|
|
1926
2316
|
}
|
|
1927
2317
|
if (members.length === 0) return false;
|
|
1928
|
-
const IDENT = [
|
|
1929
|
-
1,
|
|
1930
|
-
0,
|
|
1931
|
-
0,
|
|
1932
|
-
1,
|
|
1933
|
-
0,
|
|
1934
|
-
0
|
|
1935
|
-
];
|
|
1936
|
-
function mul(m1, m2) {
|
|
1937
|
-
const [a1, b1, c1, d1, e1, f1] = m1;
|
|
1938
|
-
const [a2, b2, c2, d2, e2, f2] = m2;
|
|
1939
|
-
return [
|
|
1940
|
-
a1 * a2 + c1 * b2,
|
|
1941
|
-
b1 * a2 + d1 * b2,
|
|
1942
|
-
a1 * c2 + c1 * d2,
|
|
1943
|
-
b1 * c2 + d1 * d2,
|
|
1944
|
-
a1 * e2 + c1 * f2 + e1,
|
|
1945
|
-
b1 * e2 + d1 * f2 + f1
|
|
1946
|
-
];
|
|
1947
|
-
}
|
|
1948
|
-
function op_to_mat(op) {
|
|
1949
|
-
switch (op.type) {
|
|
1950
|
-
case "matrix": return [
|
|
1951
|
-
op.a,
|
|
1952
|
-
op.b,
|
|
1953
|
-
op.c,
|
|
1954
|
-
op.d,
|
|
1955
|
-
op.e,
|
|
1956
|
-
op.f
|
|
1957
|
-
];
|
|
1958
|
-
case "translate": return [
|
|
1959
|
-
1,
|
|
1960
|
-
0,
|
|
1961
|
-
0,
|
|
1962
|
-
1,
|
|
1963
|
-
op.tx,
|
|
1964
|
-
op.ty
|
|
1965
|
-
];
|
|
1966
|
-
case "rotate": {
|
|
1967
|
-
const rad = op.angle * Math.PI / 180;
|
|
1968
|
-
const c = Math.cos(rad);
|
|
1969
|
-
const s = Math.sin(rad);
|
|
1970
|
-
if (op.cx === 0 && op.cy === 0) return [
|
|
1971
|
-
c,
|
|
1972
|
-
s,
|
|
1973
|
-
-s,
|
|
1974
|
-
c,
|
|
1975
|
-
0,
|
|
1976
|
-
0
|
|
1977
|
-
];
|
|
1978
|
-
const e = op.cx - c * op.cx + s * op.cy;
|
|
1979
|
-
const f = op.cy - s * op.cx - c * op.cy;
|
|
1980
|
-
return [
|
|
1981
|
-
c,
|
|
1982
|
-
s,
|
|
1983
|
-
-s,
|
|
1984
|
-
c,
|
|
1985
|
-
e,
|
|
1986
|
-
f
|
|
1987
|
-
];
|
|
1988
|
-
}
|
|
1989
|
-
case "scale": return [
|
|
1990
|
-
op.sx,
|
|
1991
|
-
0,
|
|
1992
|
-
0,
|
|
1993
|
-
op.sy,
|
|
1994
|
-
0,
|
|
1995
|
-
0
|
|
1996
|
-
];
|
|
1997
|
-
case "skewX": {
|
|
1998
|
-
const rad = op.angle * Math.PI / 180;
|
|
1999
|
-
return [
|
|
2000
|
-
1,
|
|
2001
|
-
0,
|
|
2002
|
-
Math.tan(rad),
|
|
2003
|
-
1,
|
|
2004
|
-
0,
|
|
2005
|
-
0
|
|
2006
|
-
];
|
|
2007
|
-
}
|
|
2008
|
-
case "skewY": {
|
|
2009
|
-
const rad = op.angle * Math.PI / 180;
|
|
2010
|
-
return [
|
|
2011
|
-
1,
|
|
2012
|
-
Math.tan(rad),
|
|
2013
|
-
0,
|
|
2014
|
-
1,
|
|
2015
|
-
0,
|
|
2016
|
-
0
|
|
2017
|
-
];
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
2318
|
const apply = () => {
|
|
2022
2319
|
for (const m of members) {
|
|
2023
|
-
let mat =
|
|
2024
|
-
for (const op of m.ops) mat =
|
|
2025
|
-
doc.set_attr(m.id, "transform",
|
|
2320
|
+
let mat = FLATTEN_IDENT;
|
|
2321
|
+
for (const op of m.ops) mat = flatten_mul(mat, flatten_op_to_mat(op));
|
|
2322
|
+
doc.set_attr(m.id, "transform", transform.emit([{
|
|
2026
2323
|
type: "matrix",
|
|
2027
2324
|
a: mat[0],
|
|
2028
2325
|
b: mat[1],
|
|
@@ -2072,7 +2369,7 @@ function createSvgEditor(opts) {
|
|
|
2072
2369
|
for (const id of ids) {
|
|
2073
2370
|
const bbox = geometry_provider.bounds_of(id);
|
|
2074
2371
|
if (!bbox) continue;
|
|
2075
|
-
const baseline =
|
|
2372
|
+
const baseline = translate_pipeline.intent.capture_baseline(doc, id);
|
|
2076
2373
|
if (baseline.type === "unsupported") continue;
|
|
2077
2374
|
members.push({
|
|
2078
2375
|
id,
|
|
@@ -2094,12 +2391,12 @@ function createSvgEditor(opts) {
|
|
|
2094
2391
|
const apply = () => {
|
|
2095
2392
|
for (const m of members) {
|
|
2096
2393
|
const d = deltas.get(m.id);
|
|
2097
|
-
if (d)
|
|
2394
|
+
if (d) translate_pipeline.intent.apply(doc, m.id, m.baseline, d.x, d.y);
|
|
2098
2395
|
}
|
|
2099
2396
|
emit();
|
|
2100
2397
|
};
|
|
2101
2398
|
const revert = () => {
|
|
2102
|
-
for (const m of members) if (deltas.has(m.id))
|
|
2399
|
+
for (const m of members) if (deltas.has(m.id)) translate_pipeline.intent.apply(doc, m.id, m.baseline, 0, 0);
|
|
2103
2400
|
emit();
|
|
2104
2401
|
};
|
|
2105
2402
|
apply();
|
|
@@ -2224,8 +2521,8 @@ function createSvgEditor(opts) {
|
|
|
2224
2521
|
});
|
|
2225
2522
|
});
|
|
2226
2523
|
}
|
|
2227
|
-
function group() {
|
|
2228
|
-
const plan =
|
|
2524
|
+
function group$1() {
|
|
2525
|
+
const plan = group.plan(doc, selection);
|
|
2229
2526
|
if (!plan) return false;
|
|
2230
2527
|
const group_id = doc.create_element("g");
|
|
2231
2528
|
const original_selection = selection;
|
|
@@ -2353,12 +2650,70 @@ function createSvgEditor(opts) {
|
|
|
2353
2650
|
}
|
|
2354
2651
|
};
|
|
2355
2652
|
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Text-creation bracket for the click-to-place text tool. Creates an
|
|
2655
|
+
* empty `<text>` with `initial` attrs, opens a single history preview,
|
|
2656
|
+
* and selects it — the DOM surface then mounts inline content-edit on
|
|
2657
|
+
* it. The surface finalizes the returned session when content-edit
|
|
2658
|
+
* exits:
|
|
2659
|
+
*
|
|
2660
|
+
* - `commit()` — snapshots the live text content into the delta and
|
|
2661
|
+
* commits ONE undo step (create + text together). Redo replays both,
|
|
2662
|
+
* so a redone text insert keeps its content (a plain `insert_preview`
|
|
2663
|
+
* would lose it — text is not an attribute).
|
|
2664
|
+
* - `discard()` — rolls the creation back entirely: no node, no
|
|
2665
|
+
* committed history entry. This is the empty-equals-delete rule for a
|
|
2666
|
+
* freshly-placed node (design:
|
|
2667
|
+
* `docs/wg/feat-svg-editor/text-tool.md`).
|
|
2668
|
+
*
|
|
2669
|
+
* The node is inserted empty on open (so the caret has somewhere to
|
|
2670
|
+
* live); live edits mutate its text in place, and `commit()` reads the
|
|
2671
|
+
* final text back off the document.
|
|
2672
|
+
*/
|
|
2673
|
+
function insert_text_preview(initial, opts) {
|
|
2674
|
+
const parent = opts?.parent ?? doc.root;
|
|
2675
|
+
const id = doc.create_element("text");
|
|
2676
|
+
const previous_selection = selection;
|
|
2677
|
+
const attrs = { ...initial };
|
|
2678
|
+
let committed_text = "";
|
|
2679
|
+
const apply = () => {
|
|
2680
|
+
for (const name in attrs) doc.set_attr(id, name, attrs[name]);
|
|
2681
|
+
if (doc.parent_of(id) === null) doc.insert(id, parent, null);
|
|
2682
|
+
doc.set_text(id, committed_text);
|
|
2683
|
+
set_selection([id]);
|
|
2684
|
+
};
|
|
2685
|
+
const revert = () => {
|
|
2686
|
+
doc.remove(id);
|
|
2687
|
+
set_selection(previous_selection);
|
|
2688
|
+
};
|
|
2689
|
+
const preview = history.preview("insert text");
|
|
2690
|
+
let active = true;
|
|
2691
|
+
preview.set({
|
|
2692
|
+
providerId: PROVIDER_ID,
|
|
2693
|
+
apply,
|
|
2694
|
+
revert
|
|
2695
|
+
});
|
|
2696
|
+
return {
|
|
2697
|
+
id,
|
|
2698
|
+
commit() {
|
|
2699
|
+
if (!active) return;
|
|
2700
|
+
active = false;
|
|
2701
|
+
committed_text = doc.text_of(id);
|
|
2702
|
+
preview.commit();
|
|
2703
|
+
},
|
|
2704
|
+
discard() {
|
|
2705
|
+
if (!active) return;
|
|
2706
|
+
active = false;
|
|
2707
|
+
preview.discard();
|
|
2708
|
+
}
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2356
2711
|
/** Per-tag default paint attrs. Wrapped so callers don't need to depend
|
|
2357
2712
|
* on the InsertableTag type — `insert()` accepts arbitrary string tags
|
|
2358
2713
|
* (so `commands.insert("path", ...)` works for paste / RPC) but only
|
|
2359
2714
|
* the closed insertable set gets default paint. */
|
|
2360
2715
|
function default_paint_attrs_for(tag) {
|
|
2361
|
-
if (tag === "rect" || tag === "ellipse" || tag === "line") return default_paint_attrs(tag);
|
|
2716
|
+
if (tag === "rect" || tag === "ellipse" || tag === "line") return insertions.default_paint_attrs(tag);
|
|
2362
2717
|
return {};
|
|
2363
2718
|
}
|
|
2364
2719
|
function set_text(value) {
|
|
@@ -2407,7 +2762,7 @@ function createSvgEditor(opts) {
|
|
|
2407
2762
|
function enter_content_edit(target) {
|
|
2408
2763
|
const id = target ?? (selection.length === 1 ? selection[0] : null);
|
|
2409
2764
|
if (!id) return false;
|
|
2410
|
-
if (!doc.is_text_edit_target(id)) return false;
|
|
2765
|
+
if (!doc.is_text_edit_target(id) && doc.is_vector_edit_target(id) === null) return false;
|
|
2411
2766
|
if (!content_edit_driver) return false;
|
|
2412
2767
|
return content_edit_driver(id);
|
|
2413
2768
|
}
|
|
@@ -2455,7 +2810,7 @@ function createSvgEditor(opts) {
|
|
|
2455
2810
|
align,
|
|
2456
2811
|
reorder,
|
|
2457
2812
|
remove,
|
|
2458
|
-
group,
|
|
2813
|
+
group: group$1,
|
|
2459
2814
|
insert,
|
|
2460
2815
|
insert_preview,
|
|
2461
2816
|
set_text,
|
|
@@ -2511,6 +2866,10 @@ function createSvgEditor(opts) {
|
|
|
2511
2866
|
emit();
|
|
2512
2867
|
}
|
|
2513
2868
|
const public_editor = {
|
|
2869
|
+
/**
|
|
2870
|
+
* Low-level IR handle. Mutating directly bypasses history; prefer
|
|
2871
|
+
* `editor.commands` for app code.
|
|
2872
|
+
*/
|
|
2514
2873
|
document: doc,
|
|
2515
2874
|
get state() {
|
|
2516
2875
|
return snapshot();
|
|
@@ -2521,9 +2880,28 @@ function createSvgEditor(opts) {
|
|
|
2521
2880
|
node_paint,
|
|
2522
2881
|
dom_computed_property,
|
|
2523
2882
|
dom_computed_paint,
|
|
2883
|
+
/**
|
|
2884
|
+
* Enter content-edit mode on a `<text>` node. Returns `false` (no-op)
|
|
2885
|
+
* when no DOM surface is attached.
|
|
2886
|
+
*/
|
|
2524
2887
|
enter_content_edit,
|
|
2525
2888
|
defs,
|
|
2526
2889
|
commands,
|
|
2890
|
+
/**
|
|
2891
|
+
* Human-readable label for hierarchy panels. SVG has no native "name";
|
|
2892
|
+
* this is the package's single source of truth so panels don't reinvent
|
|
2893
|
+
* the rule.
|
|
2894
|
+
*
|
|
2895
|
+
* Rule:
|
|
2896
|
+
* - `<text>` → text content, whitespace-collapsed and truncated at
|
|
2897
|
+
* ~40 chars (falls back to `"text"` for empty content).
|
|
2898
|
+
* - Otherwise → tag name, suffixed with `#id` when the `id` attribute
|
|
2899
|
+
* is present (e.g. `"rect #sun"`).
|
|
2900
|
+
*
|
|
2901
|
+
* `opts.tagLabel` lets callers substitute a friendlier or localized
|
|
2902
|
+
* term for the raw tag (e.g. `"rect"` → `"Rectangle"`). Only invoked
|
|
2903
|
+
* on the non-text branch.
|
|
2904
|
+
*/
|
|
2527
2905
|
display_label(id, opts) {
|
|
2528
2906
|
const tag = doc.tag_of(id);
|
|
2529
2907
|
if (tag === "text") {
|
|
@@ -2538,30 +2916,59 @@ function createSvgEditor(opts) {
|
|
|
2538
2916
|
tree() {
|
|
2539
2917
|
return tree_snapshot();
|
|
2540
2918
|
},
|
|
2919
|
+
/**
|
|
2920
|
+
* The effective hover from the attached HUD surface — what's under the
|
|
2921
|
+
* pointer, OR whatever `set_surface_hover_override` last pushed. Used
|
|
2922
|
+
* by out-of-canvas UI (layers panel, breadcrumbs) to mirror the canvas
|
|
2923
|
+
* highlight. Returns `null` when nothing is hovered.
|
|
2924
|
+
*/
|
|
2541
2925
|
surface_hover() {
|
|
2542
2926
|
return current_surface_hover;
|
|
2543
2927
|
},
|
|
2928
|
+
/**
|
|
2929
|
+
* Push a hover override into the HUD surface — e.g. when the user
|
|
2930
|
+
* hovers a row in a layers panel. The HUD will render the override's
|
|
2931
|
+
* outline and (when applicable) drive measurement to that node.
|
|
2932
|
+
* Pass `null` to clear and let the pointer pick take over again.
|
|
2933
|
+
*/
|
|
2544
2934
|
set_surface_hover_override(id) {
|
|
2545
2935
|
if (surface_hover_override === id) return;
|
|
2546
2936
|
surface_hover_override = id;
|
|
2547
2937
|
if (surface_hover_override_driver) surface_hover_override_driver(id);
|
|
2548
2938
|
},
|
|
2939
|
+
/**
|
|
2940
|
+
* Subscribe to changes in the effective surface hover. Fires when the
|
|
2941
|
+
* HUD reports a new pointer pick AND when an override is set/cleared.
|
|
2942
|
+
* Cheap channel — does NOT bump `state.version`.
|
|
2943
|
+
*/
|
|
2549
2944
|
subscribe_surface_hover(cb) {
|
|
2550
2945
|
surface_hover_listeners.add(cb);
|
|
2551
2946
|
return () => {
|
|
2552
2947
|
surface_hover_listeners.delete(cb);
|
|
2553
2948
|
};
|
|
2554
2949
|
},
|
|
2950
|
+
/**
|
|
2951
|
+
* Subscribe to bounds-affecting changes. Fires when any document
|
|
2952
|
+
* mutation advances `state.geometry_version` — drag, resize, text
|
|
2953
|
+
* edit, structural insert/remove. Skips presentation-only writes
|
|
2954
|
+
* (fill, opacity, stroke-color).
|
|
2955
|
+
*/
|
|
2555
2956
|
subscribe_geometry(cb) {
|
|
2556
2957
|
geometry_listeners.add(cb);
|
|
2557
2958
|
return () => {
|
|
2558
2959
|
geometry_listeners.delete(cb);
|
|
2559
2960
|
};
|
|
2560
2961
|
},
|
|
2962
|
+
/**
|
|
2963
|
+
* World-space geometry queries. Non-null when a DOM surface is
|
|
2964
|
+
* attached; null otherwise (queries need a renderer to read bbox
|
|
2965
|
+
* from). Read-only — never mutates document state.
|
|
2966
|
+
*/
|
|
2561
2967
|
get geometry() {
|
|
2562
2968
|
return geometry_provider;
|
|
2563
2969
|
},
|
|
2564
2970
|
modes,
|
|
2971
|
+
/** Switch the active tool. No history entry; bumps `state.version`. */
|
|
2565
2972
|
set_tool,
|
|
2566
2973
|
get style() {
|
|
2567
2974
|
return style;
|
|
@@ -2569,6 +2976,21 @@ function createSvgEditor(opts) {
|
|
|
2569
2976
|
set_style,
|
|
2570
2977
|
load,
|
|
2571
2978
|
serialize,
|
|
2979
|
+
/**
|
|
2980
|
+
* Serialize a single element's subtree as an SVG **fragment**, using the
|
|
2981
|
+
* same trivia-preserving rules as {@link serialize} — for handing "the
|
|
2982
|
+
* markup of the element the user selected" to a downstream consumer
|
|
2983
|
+
* (e.g. an AI agent) without re-serializing the whole document.
|
|
2984
|
+
*
|
|
2985
|
+
* Fragment, not document (see `SvgDocument.serialize_node`): it does NOT
|
|
2986
|
+
* carry `serialize()`'s whole-document round-trip guarantee. Namespace
|
|
2987
|
+
* declarations on an ancestor (`xmlns:xlink`, normally on the root
|
|
2988
|
+
* `<svg>`) are NOT inlined — a node using `xlink:href` serializes without
|
|
2989
|
+
* `xmlns:xlink`. Throws on an unknown id or a non-element node.
|
|
2990
|
+
*/
|
|
2991
|
+
serialize_node(id) {
|
|
2992
|
+
return doc.serialize_node(id);
|
|
2993
|
+
},
|
|
2572
2994
|
reset,
|
|
2573
2995
|
attach,
|
|
2574
2996
|
detach,
|
|
@@ -2577,6 +2999,7 @@ function createSvgEditor(opts) {
|
|
|
2577
2999
|
_internal: {
|
|
2578
3000
|
doc,
|
|
2579
3001
|
history: { preview: (label) => history.preview(label) },
|
|
3002
|
+
insert_text_preview,
|
|
2580
3003
|
emit,
|
|
2581
3004
|
subscribe_translate_commit(cb) {
|
|
2582
3005
|
translate_commit_listeners.add(cb);
|
|
@@ -2608,37 +3031,112 @@ function createSvgEditor(opts) {
|
|
|
2608
3031
|
applyDefaultBindings(keymap);
|
|
2609
3032
|
return public_editor;
|
|
2610
3033
|
}
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
function
|
|
2619
|
-
if (
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
if (a.kind !== b.kind) return false;
|
|
2623
|
-
if (a.kind === "color" && b.kind === "color") {
|
|
2624
|
-
if (a.value.kind !== b.value.kind) return false;
|
|
2625
|
-
if (a.value.kind === "rgb" && b.value.kind === "rgb") return a.value.value === b.value.value;
|
|
2626
|
-
return true;
|
|
3034
|
+
/**
|
|
3035
|
+
* Construct a headless SVG editor. The returned object is the public
|
|
3036
|
+
* editor surface — observation (`state`, `subscribe`), commands
|
|
3037
|
+
* (`commands.*`), lifecycle (`attach` / `dispose`), and the typed-read
|
|
3038
|
+
* caches (`node_paint`, `node_properties`). Surfaces (DOM, headless)
|
|
3039
|
+
* attach later via `editor.attach(surface)`.
|
|
3040
|
+
*/
|
|
3041
|
+
function createSvgEditor(opts) {
|
|
3042
|
+
if (opts == null || typeof opts.svg !== "string") {
|
|
3043
|
+
const got = opts == null ? String(opts) : opts.svg === null ? "null" : typeof opts.svg;
|
|
3044
|
+
throw new TypeError(`createSvgEditor({ svg }) requires { svg: string }, got svg=${got}`);
|
|
2627
3045
|
}
|
|
2628
|
-
|
|
2629
|
-
if (a.kind === "none" && b.kind === "none") return true;
|
|
2630
|
-
if (a.kind === "context_fill" && b.kind === "context_fill") return true;
|
|
2631
|
-
if (a.kind === "context_stroke" && b.kind === "context_stroke") return true;
|
|
2632
|
-
return false;
|
|
3046
|
+
return _create_svg_editor_internal(opts);
|
|
2633
3047
|
}
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
3048
|
+
const FLATTEN_IDENT = [
|
|
3049
|
+
1,
|
|
3050
|
+
0,
|
|
3051
|
+
0,
|
|
3052
|
+
1,
|
|
3053
|
+
0,
|
|
3054
|
+
0
|
|
3055
|
+
];
|
|
3056
|
+
function flatten_mul(m1, m2) {
|
|
3057
|
+
const [a1, b1, c1, d1, e1, f1] = m1;
|
|
3058
|
+
const [a2, b2, c2, d2, e2, f2] = m2;
|
|
3059
|
+
return [
|
|
3060
|
+
a1 * a2 + c1 * b2,
|
|
3061
|
+
b1 * a2 + d1 * b2,
|
|
3062
|
+
a1 * c2 + c1 * d2,
|
|
3063
|
+
b1 * c2 + d1 * d2,
|
|
3064
|
+
a1 * e2 + c1 * f2 + e1,
|
|
3065
|
+
b1 * e2 + d1 * f2 + f1
|
|
3066
|
+
];
|
|
3067
|
+
}
|
|
3068
|
+
function flatten_op_to_mat(op) {
|
|
3069
|
+
switch (op.type) {
|
|
3070
|
+
case "matrix": return [
|
|
3071
|
+
op.a,
|
|
3072
|
+
op.b,
|
|
3073
|
+
op.c,
|
|
3074
|
+
op.d,
|
|
3075
|
+
op.e,
|
|
3076
|
+
op.f
|
|
3077
|
+
];
|
|
3078
|
+
case "translate": return [
|
|
3079
|
+
1,
|
|
3080
|
+
0,
|
|
3081
|
+
0,
|
|
3082
|
+
1,
|
|
3083
|
+
op.tx,
|
|
3084
|
+
op.ty
|
|
3085
|
+
];
|
|
3086
|
+
case "rotate": {
|
|
3087
|
+
const rad = op.angle * Math.PI / 180;
|
|
3088
|
+
const c = Math.cos(rad);
|
|
3089
|
+
const s = Math.sin(rad);
|
|
3090
|
+
if (op.cx === 0 && op.cy === 0) return [
|
|
3091
|
+
c,
|
|
3092
|
+
s,
|
|
3093
|
+
-s,
|
|
3094
|
+
c,
|
|
3095
|
+
0,
|
|
3096
|
+
0
|
|
3097
|
+
];
|
|
3098
|
+
const e = op.cx - c * op.cx + s * op.cy;
|
|
3099
|
+
const f = op.cy - s * op.cx - c * op.cy;
|
|
3100
|
+
return [
|
|
3101
|
+
c,
|
|
3102
|
+
s,
|
|
3103
|
+
-s,
|
|
3104
|
+
c,
|
|
3105
|
+
e,
|
|
3106
|
+
f
|
|
3107
|
+
];
|
|
3108
|
+
}
|
|
3109
|
+
case "scale": return [
|
|
3110
|
+
op.sx,
|
|
3111
|
+
0,
|
|
3112
|
+
0,
|
|
3113
|
+
op.sy,
|
|
3114
|
+
0,
|
|
3115
|
+
0
|
|
3116
|
+
];
|
|
3117
|
+
case "skewX": {
|
|
3118
|
+
const rad = op.angle * Math.PI / 180;
|
|
3119
|
+
return [
|
|
3120
|
+
1,
|
|
3121
|
+
0,
|
|
3122
|
+
Math.tan(rad),
|
|
3123
|
+
1,
|
|
3124
|
+
0,
|
|
3125
|
+
0
|
|
3126
|
+
];
|
|
3127
|
+
}
|
|
3128
|
+
case "skewY": {
|
|
3129
|
+
const rad = op.angle * Math.PI / 180;
|
|
3130
|
+
return [
|
|
3131
|
+
1,
|
|
3132
|
+
Math.tan(rad),
|
|
3133
|
+
0,
|
|
3134
|
+
1,
|
|
3135
|
+
0,
|
|
3136
|
+
0
|
|
3137
|
+
];
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
2642
3140
|
}
|
|
2643
3141
|
//#endregion
|
|
2644
3142
|
export { createSvgEditor as t };
|