@grida/svg-editor 1.0.0-alpha.1 → 1.0.0-alpha.12
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 +184 -185
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/dom-BlMk07oX.mjs +3515 -0
- package/dist/dom-Cvm9Towu.js +3545 -0
- package/dist/dom-DCX-a8Kr.d.ts +57 -0
- package/dist/dom-DgB4f-TE.d.mts +59 -0
- package/dist/dom.d.mts +3 -16
- package/dist/dom.d.ts +3 -16
- package/dist/dom.js +5 -1
- package/dist/dom.mjs +2 -2
- package/dist/editor-BH03X8cX.d.mts +1139 -0
- package/dist/editor-Bd4-VCEJ.d.ts +1139 -0
- package/dist/{editor-DQWUWrVZ.js → editor-CdyC3uAe.js} +1205 -388
- package/dist/{editor-B5z-gTML.mjs → editor-DtuRIs-Q.mjs} +1195 -372
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -2
- package/dist/index.mjs +3 -2
- package/dist/insertions-BJ-6o6o5.js +2399 -0
- package/dist/insertions-Okcuo-Ck.mjs +2176 -0
- package/dist/presets.d.mts +61 -0
- package/dist/presets.d.ts +61 -0
- package/dist/presets.js +61 -0
- package/dist/presets.mjs +55 -0
- package/dist/react.d.mts +94 -9
- package/dist/react.d.ts +94 -9
- package/dist/react.js +157 -19
- package/dist/react.mjs +147 -21
- package/package.json +11 -6
- package/dist/dom-CfP_ZURh.js +0 -963
- package/dist/dom-kA8NDuVh.mjs +0 -929
- package/dist/editor-CTtU2gu4.d.ts +0 -607
- package/dist/editor-JY7AQrR1.d.mts +0 -607
- package/dist/paint-DHq_3iwU.js +0 -509
- package/dist/paint-DuCg6Y-K.mjs +0 -461
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { A as is_text_input_focused, D as parse_transform_list, E as emit_transform_list, _ as apply_translate, a as DEFAULT_STYLE, c as serialize_paint, g as apply_rotate, h as apply_resize, k as plan_group, m as STAGES_NUDGE, o as TOOL_CURSOR, p as prepare_translate_rpc, r as default_paint_attrs, s as parse_paint, u as prepare_rotate_rpc, v as capture_resize_baseline, x as is_resizable, y as capture_translate_baseline } from "./insertions-Okcuo-Ck.mjs";
|
|
2
2
|
import { HistoryImpl } from "@grida/history";
|
|
3
3
|
import { KeyCode, M, chunkKey, eventToChunk, getKeyboardOS, kb, keybindingsToKeyCodes } from "@grida/keybinding";
|
|
4
|
+
import cmath from "@grida/cmath";
|
|
5
|
+
import { XLINK_NS, encode_attr_value, encode_text, parse_svg } from "@grida/svg/parser";
|
|
4
6
|
//#region src/commands/registry.ts
|
|
5
7
|
var CommandRegistry = class {
|
|
6
8
|
constructor() {
|
|
@@ -36,6 +38,25 @@ var CommandRegistry = class {
|
|
|
36
38
|
};
|
|
37
39
|
//#endregion
|
|
38
40
|
//#region src/commands/defaults.ts
|
|
41
|
+
/** Command id for `tool.set`. Bound to V/R/O/L in `keymap/defaults.ts`. */
|
|
42
|
+
const TOOL_SET = "tool.set";
|
|
43
|
+
/**
|
|
44
|
+
* The headless default `transform.nudge` handler. Exported so a host
|
|
45
|
+
* surface that overrides nudge (e.g. for faux-snap UX) can restore the
|
|
46
|
+
* default on teardown — the registry doesn't stack handlers, so a plain
|
|
47
|
+
* unregister leaves the slot empty.
|
|
48
|
+
*/
|
|
49
|
+
function default_nudge_handler(editor) {
|
|
50
|
+
return (args) => {
|
|
51
|
+
if (editor.state.selection.length === 0) return false;
|
|
52
|
+
const { dx, dy } = args;
|
|
53
|
+
editor.commands.nudge({
|
|
54
|
+
dx,
|
|
55
|
+
dy
|
|
56
|
+
});
|
|
57
|
+
return true;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
39
60
|
function registerDefaultCommands(reg, editor) {
|
|
40
61
|
reg.register("history.undo", () => {
|
|
41
62
|
if (!editor.state.can_undo) return false;
|
|
@@ -57,6 +78,46 @@ function registerDefaultCommands(reg, editor) {
|
|
|
57
78
|
editor.commands.remove();
|
|
58
79
|
return true;
|
|
59
80
|
});
|
|
81
|
+
reg.register("selection.group", () => {
|
|
82
|
+
if (editor.state.mode !== "select") return false;
|
|
83
|
+
if (editor.state.selection.length === 0) return false;
|
|
84
|
+
return editor.commands.group();
|
|
85
|
+
});
|
|
86
|
+
reg.register("selection.resize_to", (args) => {
|
|
87
|
+
if (editor.state.mode !== "select") return false;
|
|
88
|
+
if (editor.state.selection.length === 0) return false;
|
|
89
|
+
const target = args;
|
|
90
|
+
return editor.commands.resize_to(target);
|
|
91
|
+
});
|
|
92
|
+
reg.register("selection.rotate", (args) => {
|
|
93
|
+
if (editor.state.mode !== "select") return false;
|
|
94
|
+
if (editor.state.selection.length === 0) return false;
|
|
95
|
+
const a = args;
|
|
96
|
+
return editor.commands.rotate(a.angle, { pivot: a.pivot });
|
|
97
|
+
});
|
|
98
|
+
reg.register("selection.rotate_to", (args) => {
|
|
99
|
+
if (editor.state.mode !== "select") return false;
|
|
100
|
+
if (editor.state.selection.length === 0) return false;
|
|
101
|
+
const a = args;
|
|
102
|
+
return editor.commands.rotate_to(a.angle, { pivot: a.pivot });
|
|
103
|
+
});
|
|
104
|
+
reg.register("selection.flatten_transform", () => {
|
|
105
|
+
if (editor.state.mode !== "select") return false;
|
|
106
|
+
if (editor.state.selection.length === 0) return false;
|
|
107
|
+
return editor.commands.flatten_transform();
|
|
108
|
+
});
|
|
109
|
+
reg.register("selection.all", () => {
|
|
110
|
+
if (editor.state.mode !== "select") return false;
|
|
111
|
+
return editor.commands.select_all();
|
|
112
|
+
});
|
|
113
|
+
reg.register("selection.sibling", (args) => {
|
|
114
|
+
if (editor.state.mode !== "select") return false;
|
|
115
|
+
return editor.commands.select_sibling(args);
|
|
116
|
+
});
|
|
117
|
+
reg.register("selection.align", (args) => {
|
|
118
|
+
if (editor.state.mode !== "select") return false;
|
|
119
|
+
return editor.commands.align(args);
|
|
120
|
+
});
|
|
60
121
|
reg.register("hierarchy.enter", () => {
|
|
61
122
|
if (editor.state.selection.length !== 1) return false;
|
|
62
123
|
const id = editor.state.selection[0];
|
|
@@ -74,20 +135,17 @@ function registerDefaultCommands(reg, editor) {
|
|
|
74
135
|
editor.commands.select(node.parent);
|
|
75
136
|
return true;
|
|
76
137
|
});
|
|
77
|
-
reg.register("transform.nudge", (
|
|
78
|
-
if (editor.state.selection.length === 0) return false;
|
|
79
|
-
const { dx, dy } = args;
|
|
80
|
-
editor.commands.translate({
|
|
81
|
-
dx,
|
|
82
|
-
dy
|
|
83
|
-
});
|
|
84
|
-
return true;
|
|
85
|
-
});
|
|
138
|
+
reg.register("transform.nudge", default_nudge_handler(editor));
|
|
86
139
|
reg.register("reorder", (args) => {
|
|
87
140
|
if (editor.state.selection.length !== 1) return false;
|
|
88
141
|
editor.commands.reorder(args);
|
|
89
142
|
return true;
|
|
90
143
|
});
|
|
144
|
+
reg.register(TOOL_SET, (args) => {
|
|
145
|
+
if (editor.state.mode !== "select") return false;
|
|
146
|
+
editor.set_tool(args);
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
91
149
|
}
|
|
92
150
|
//#endregion
|
|
93
151
|
//#region src/keymap/keymap.ts
|
|
@@ -111,15 +169,6 @@ const TEXT_INPUT_SAFE_MODS = new Set([
|
|
|
111
169
|
KeyCode.Ctrl,
|
|
112
170
|
KeyCode.Alt
|
|
113
171
|
]);
|
|
114
|
-
function is_text_input_focused() {
|
|
115
|
-
if (typeof document === "undefined") return false;
|
|
116
|
-
const el = document.activeElement;
|
|
117
|
-
if (!el) return false;
|
|
118
|
-
const tag = el.tagName;
|
|
119
|
-
if (tag === "INPUT" || tag === "TEXTAREA") return true;
|
|
120
|
-
if (el.isContentEditable) return true;
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
172
|
var Keymap = class {
|
|
124
173
|
constructor(commands, platformGetter = getKeyboardOS) {
|
|
125
174
|
this.commands = commands;
|
|
@@ -177,10 +226,35 @@ var Keymap = class {
|
|
|
177
226
|
return Array.from(seen);
|
|
178
227
|
}
|
|
179
228
|
/**
|
|
229
|
+
* Does the keymap have a binding that matches this event's chord —
|
|
230
|
+
* regardless of whether any handler would consume it? Hosts use this
|
|
231
|
+
* to decide whether to swallow the platform's default action (e.g.
|
|
232
|
+
* `event.preventDefault()` in the browser), so that an advertised
|
|
233
|
+
* shortcut like `Cmd+G` doesn't fall through to the browser's find
|
|
234
|
+
* bar even when the binding's handler rejects.
|
|
235
|
+
*
|
|
236
|
+
* Pure read; runs no handlers, no side effects. Honors the same
|
|
237
|
+
* text-input-focused guard `dispatch` uses, so a typing user's
|
|
238
|
+
* keystroke isn't "claimed" by an unrelated unmodified key.
|
|
239
|
+
*/
|
|
240
|
+
claims(event) {
|
|
241
|
+
const chunk = eventToChunk(event);
|
|
242
|
+
if (chunk.keys.length === 0) return false;
|
|
243
|
+
const list = this.buckets.get(chunkKey(chunk));
|
|
244
|
+
if (!list || list.length === 0) return false;
|
|
245
|
+
if (is_text_input_focused() && !this.has_safe_mod(chunk.mods)) return false;
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
180
249
|
* Match the event against bound chunks, then run candidates in chain
|
|
181
|
-
* order. Returns `true`
|
|
182
|
-
*
|
|
183
|
-
*
|
|
250
|
+
* order. Returns `true` on the first handler that consumes; returns
|
|
251
|
+
* `false` if nothing matched or all matches fell through.
|
|
252
|
+
*
|
|
253
|
+
* `dispatch` is browser-agnostic: it does NOT call `preventDefault()`
|
|
254
|
+
* or touch the event in any way. The host decides what to do with the
|
|
255
|
+
* platform default — typically `if (keymap.claims(e)) e.preventDefault()`,
|
|
256
|
+
* which prevents the platform default for advertised shortcuts even
|
|
257
|
+
* when the chain rejects. See README → `editor.keymap`.
|
|
184
258
|
*/
|
|
185
259
|
dispatch(event) {
|
|
186
260
|
const chunk = eventToChunk(event);
|
|
@@ -191,10 +265,7 @@ var Keymap = class {
|
|
|
191
265
|
const text_focused = is_text_input_focused();
|
|
192
266
|
for (const { binding } of list) {
|
|
193
267
|
if (text_focused && !this.has_safe_mod(chunk.mods)) continue;
|
|
194
|
-
if (this.commands.invoke(binding.command, binding.args))
|
|
195
|
-
event.preventDefault();
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
268
|
+
if (this.commands.invoke(binding.command, binding.args)) return true;
|
|
198
269
|
}
|
|
199
270
|
return false;
|
|
200
271
|
}
|
|
@@ -237,6 +308,7 @@ function compareEntries(a, b) {
|
|
|
237
308
|
* Same key, multiple meanings? Add multiple rows. The chain semantics
|
|
238
309
|
* (handler returns `false` when not applicable) handle the rest.
|
|
239
310
|
*/
|
|
311
|
+
const NUDGE_MEANINGFUL = M.Shift;
|
|
240
312
|
const DEFAULT_BINDINGS = [
|
|
241
313
|
{
|
|
242
314
|
keybinding: kb(KeyCode.KeyZ, M.CtrlCmd),
|
|
@@ -262,6 +334,54 @@ const DEFAULT_BINDINGS = [
|
|
|
262
334
|
keybinding: kb(KeyCode.Delete),
|
|
263
335
|
command: "selection.remove"
|
|
264
336
|
},
|
|
337
|
+
{
|
|
338
|
+
keybinding: kb(KeyCode.KeyG, M.CtrlCmd),
|
|
339
|
+
command: "selection.group"
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
keybinding: kb(KeyCode.KeyA, M.CtrlCmd),
|
|
343
|
+
command: "selection.all"
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
keybinding: kb(KeyCode.Tab),
|
|
347
|
+
command: "selection.sibling",
|
|
348
|
+
args: "next"
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
keybinding: kb(KeyCode.Tab, M.Shift),
|
|
352
|
+
command: "selection.sibling",
|
|
353
|
+
args: "prev"
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
keybinding: kb(KeyCode.KeyA, M.Alt),
|
|
357
|
+
command: "selection.align",
|
|
358
|
+
args: "left"
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
keybinding: kb(KeyCode.KeyD, M.Alt),
|
|
362
|
+
command: "selection.align",
|
|
363
|
+
args: "right"
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
keybinding: kb(KeyCode.KeyW, M.Alt),
|
|
367
|
+
command: "selection.align",
|
|
368
|
+
args: "top"
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
keybinding: kb(KeyCode.KeyS, M.Alt),
|
|
372
|
+
command: "selection.align",
|
|
373
|
+
args: "bottom"
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
keybinding: kb(KeyCode.KeyH, M.Alt),
|
|
377
|
+
command: "selection.align",
|
|
378
|
+
args: "horizontal_centers"
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
keybinding: kb(KeyCode.KeyV, M.Alt),
|
|
382
|
+
command: "selection.align",
|
|
383
|
+
args: "vertical_centers"
|
|
384
|
+
},
|
|
265
385
|
{
|
|
266
386
|
keybinding: kb(KeyCode.Enter),
|
|
267
387
|
command: "hierarchy.enter"
|
|
@@ -271,7 +391,7 @@ const DEFAULT_BINDINGS = [
|
|
|
271
391
|
command: "hierarchy.exit"
|
|
272
392
|
},
|
|
273
393
|
{
|
|
274
|
-
keybinding: kb(KeyCode.LeftArrow),
|
|
394
|
+
keybinding: kb(KeyCode.LeftArrow, 0, NUDGE_MEANINGFUL),
|
|
275
395
|
command: "transform.nudge",
|
|
276
396
|
args: {
|
|
277
397
|
dx: -1,
|
|
@@ -279,7 +399,7 @@ const DEFAULT_BINDINGS = [
|
|
|
279
399
|
}
|
|
280
400
|
},
|
|
281
401
|
{
|
|
282
|
-
keybinding: kb(KeyCode.RightArrow),
|
|
402
|
+
keybinding: kb(KeyCode.RightArrow, 0, NUDGE_MEANINGFUL),
|
|
283
403
|
command: "transform.nudge",
|
|
284
404
|
args: {
|
|
285
405
|
dx: 1,
|
|
@@ -287,7 +407,7 @@ const DEFAULT_BINDINGS = [
|
|
|
287
407
|
}
|
|
288
408
|
},
|
|
289
409
|
{
|
|
290
|
-
keybinding: kb(KeyCode.UpArrow),
|
|
410
|
+
keybinding: kb(KeyCode.UpArrow, 0, NUDGE_MEANINGFUL),
|
|
291
411
|
command: "transform.nudge",
|
|
292
412
|
args: {
|
|
293
413
|
dx: 0,
|
|
@@ -295,7 +415,7 @@ const DEFAULT_BINDINGS = [
|
|
|
295
415
|
}
|
|
296
416
|
},
|
|
297
417
|
{
|
|
298
|
-
keybinding: kb(KeyCode.DownArrow),
|
|
418
|
+
keybinding: kb(KeyCode.DownArrow, 0, NUDGE_MEANINGFUL),
|
|
299
419
|
command: "transform.nudge",
|
|
300
420
|
args: {
|
|
301
421
|
dx: 0,
|
|
@@ -303,7 +423,7 @@ const DEFAULT_BINDINGS = [
|
|
|
303
423
|
}
|
|
304
424
|
},
|
|
305
425
|
{
|
|
306
|
-
keybinding: kb(KeyCode.LeftArrow, M.Shift),
|
|
426
|
+
keybinding: kb(KeyCode.LeftArrow, M.Shift, NUDGE_MEANINGFUL),
|
|
307
427
|
command: "transform.nudge",
|
|
308
428
|
args: {
|
|
309
429
|
dx: -10,
|
|
@@ -311,7 +431,7 @@ const DEFAULT_BINDINGS = [
|
|
|
311
431
|
}
|
|
312
432
|
},
|
|
313
433
|
{
|
|
314
|
-
keybinding: kb(KeyCode.RightArrow, M.Shift),
|
|
434
|
+
keybinding: kb(KeyCode.RightArrow, M.Shift, NUDGE_MEANINGFUL),
|
|
315
435
|
command: "transform.nudge",
|
|
316
436
|
args: {
|
|
317
437
|
dx: 10,
|
|
@@ -319,7 +439,7 @@ const DEFAULT_BINDINGS = [
|
|
|
319
439
|
}
|
|
320
440
|
},
|
|
321
441
|
{
|
|
322
|
-
keybinding: kb(KeyCode.UpArrow, M.Shift),
|
|
442
|
+
keybinding: kb(KeyCode.UpArrow, M.Shift, NUDGE_MEANINGFUL),
|
|
323
443
|
command: "transform.nudge",
|
|
324
444
|
args: {
|
|
325
445
|
dx: 0,
|
|
@@ -327,13 +447,42 @@ const DEFAULT_BINDINGS = [
|
|
|
327
447
|
}
|
|
328
448
|
},
|
|
329
449
|
{
|
|
330
|
-
keybinding: kb(KeyCode.DownArrow, M.Shift),
|
|
450
|
+
keybinding: kb(KeyCode.DownArrow, M.Shift, NUDGE_MEANINGFUL),
|
|
331
451
|
command: "transform.nudge",
|
|
332
452
|
args: {
|
|
333
453
|
dx: 0,
|
|
334
454
|
dy: 10
|
|
335
455
|
}
|
|
336
456
|
},
|
|
457
|
+
{
|
|
458
|
+
keybinding: kb(KeyCode.KeyV),
|
|
459
|
+
command: TOOL_SET,
|
|
460
|
+
args: { type: "cursor" }
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
keybinding: kb(KeyCode.KeyR),
|
|
464
|
+
command: TOOL_SET,
|
|
465
|
+
args: {
|
|
466
|
+
type: "insert",
|
|
467
|
+
tag: "rect"
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
keybinding: kb(KeyCode.KeyO),
|
|
472
|
+
command: TOOL_SET,
|
|
473
|
+
args: {
|
|
474
|
+
type: "insert",
|
|
475
|
+
tag: "ellipse"
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
keybinding: kb(KeyCode.KeyL),
|
|
480
|
+
command: TOOL_SET,
|
|
481
|
+
args: {
|
|
482
|
+
type: "insert",
|
|
483
|
+
tag: "line"
|
|
484
|
+
}
|
|
485
|
+
},
|
|
337
486
|
{
|
|
338
487
|
keybinding: kb(KeyCode.BracketRight),
|
|
339
488
|
command: "reorder",
|
|
@@ -360,16 +509,33 @@ function applyDefaultBindings(keymap) {
|
|
|
360
509
|
for (const b of DEFAULT_BINDINGS) keymap.bind(b);
|
|
361
510
|
}
|
|
362
511
|
//#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
|
|
363
520
|
//#region src/core/defs.ts
|
|
364
521
|
var GradientsRegistry = class {
|
|
365
522
|
constructor(doc) {
|
|
366
523
|
this.doc = doc;
|
|
367
524
|
this.listeners = /* @__PURE__ */ new Set();
|
|
368
525
|
this.counter = 0;
|
|
369
|
-
|
|
526
|
+
this._cached = null;
|
|
527
|
+
this._cached_by_id = /* @__PURE__ */ new Map();
|
|
528
|
+
this._dirty = true;
|
|
529
|
+
doc.on_change(() => {
|
|
530
|
+
this._dirty = true;
|
|
531
|
+
this.emit();
|
|
532
|
+
});
|
|
370
533
|
}
|
|
371
534
|
list() {
|
|
535
|
+
if (!this._dirty && this._cached) return this._cached;
|
|
372
536
|
const out = [];
|
|
537
|
+
let any_change = !this._cached;
|
|
538
|
+
const seen = /* @__PURE__ */ new Set();
|
|
373
539
|
const defs = this.find_defs_elements();
|
|
374
540
|
for (const def_id of defs) for (const child of this.doc.element_children_of(def_id)) {
|
|
375
541
|
const tag = this.doc.tag_of(child);
|
|
@@ -378,14 +544,34 @@ var GradientsRegistry = class {
|
|
|
378
544
|
if (!id) continue;
|
|
379
545
|
const definition = this.read_gradient(child, tag);
|
|
380
546
|
if (!definition) continue;
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
547
|
+
const ref_count = this.count_refs(id);
|
|
548
|
+
const prev = this._cached_by_id.get(id);
|
|
549
|
+
if (prev && prev.ref_count === ref_count && gradient_definition_equals(prev.definition, definition)) out.push(prev);
|
|
550
|
+
else {
|
|
551
|
+
const entry = {
|
|
552
|
+
id,
|
|
553
|
+
definition,
|
|
554
|
+
ref_count
|
|
555
|
+
};
|
|
556
|
+
this._cached_by_id.set(id, entry);
|
|
557
|
+
out.push(entry);
|
|
558
|
+
any_change = true;
|
|
559
|
+
}
|
|
560
|
+
seen.add(id);
|
|
386
561
|
}
|
|
387
562
|
}
|
|
388
|
-
|
|
563
|
+
for (const id of this._cached_by_id.keys()) if (!seen.has(id)) {
|
|
564
|
+
this._cached_by_id.delete(id);
|
|
565
|
+
any_change = true;
|
|
566
|
+
}
|
|
567
|
+
if (!any_change && this._cached && array_shallow_equal(this._cached, out)) {
|
|
568
|
+
this._dirty = false;
|
|
569
|
+
return this._cached;
|
|
570
|
+
}
|
|
571
|
+
const frozen = Object.freeze(out);
|
|
572
|
+
this._cached = frozen;
|
|
573
|
+
this._dirty = false;
|
|
574
|
+
return frozen;
|
|
389
575
|
}
|
|
390
576
|
get(id) {
|
|
391
577
|
return this.list().find((g) => g.id === id) ?? null;
|
|
@@ -498,7 +684,7 @@ var GradientsRegistry = class {
|
|
|
498
684
|
};
|
|
499
685
|
}
|
|
500
686
|
write_gradient(node, def) {
|
|
501
|
-
for (const c of
|
|
687
|
+
for (const c of this.doc.children_of(node).slice()) this.doc.remove(c);
|
|
502
688
|
const set_num = (name, v) => {
|
|
503
689
|
this.doc.set_attr(node, name, v === void 0 ? null : String(v));
|
|
504
690
|
};
|
|
@@ -545,290 +731,71 @@ var GradientsRegistry = class {
|
|
|
545
731
|
function escape_regex(s) {
|
|
546
732
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
547
733
|
}
|
|
734
|
+
function gradient_definition_equals(a, b) {
|
|
735
|
+
if (a === b) return true;
|
|
736
|
+
if (a.kind !== b.kind) return false;
|
|
737
|
+
if (a.stops.length !== b.stops.length) return false;
|
|
738
|
+
for (let i = 0; i < a.stops.length; i++) {
|
|
739
|
+
const sa = a.stops[i];
|
|
740
|
+
const sb = b.stops[i];
|
|
741
|
+
if (sa.offset !== sb.offset || sa.color !== sb.color || sa.opacity !== sb.opacity) return false;
|
|
742
|
+
}
|
|
743
|
+
if (a.kind === "linear" && b.kind === "linear") return a.x1 === b.x1 && a.y1 === b.y1 && a.x2 === b.x2 && a.y2 === b.y2 && a.gradient_units === b.gradient_units && a.spread_method === b.spread_method;
|
|
744
|
+
if (a.kind === "radial" && b.kind === "radial") return a.cx === b.cx && a.cy === b.cy && a.r === b.r && a.fx === b.fx && a.fy === b.fy && a.gradient_units === b.gradient_units && a.spread_method === b.spread_method;
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
548
747
|
function create_defs(doc) {
|
|
549
748
|
return { gradients: new GradientsRegistry(doc) };
|
|
550
749
|
}
|
|
551
|
-
const XML_NS = "http://www.w3.org/XML/1998/namespace";
|
|
552
|
-
const XMLNS_NS = "http://www.w3.org/2000/xmlns/";
|
|
553
|
-
let id_counter = 0;
|
|
554
|
-
function fresh_id() {
|
|
555
|
-
return `n${id_counter++}`;
|
|
556
|
-
}
|
|
557
|
-
function reset_id_counter() {
|
|
558
|
-
id_counter = 0;
|
|
559
|
-
}
|
|
560
|
-
function parse_svg(src) {
|
|
561
|
-
reset_id_counter();
|
|
562
|
-
const nodes = /* @__PURE__ */ new Map();
|
|
563
|
-
const prolog = [];
|
|
564
|
-
const epilog = [];
|
|
565
|
-
let i = 0;
|
|
566
|
-
const n = src.length;
|
|
567
|
-
let root = null;
|
|
568
|
-
const open_stack = [];
|
|
569
|
-
/** ns prefix → uri, per ancestor scope (top of stack). */
|
|
570
|
-
const ns_stack = [new Map([["xml", XML_NS], ["xmlns", XMLNS_NS]])];
|
|
571
|
-
/** default ns per ancestor scope (top of stack). */
|
|
572
|
-
const default_ns_stack = [null];
|
|
573
|
-
function push_to_parent(node) {
|
|
574
|
-
nodes.set(node.id, node);
|
|
575
|
-
if (open_stack.length === 0) {
|
|
576
|
-
if (node.kind === "element" && root === null) return;
|
|
577
|
-
if (root === null) prolog.push(node);
|
|
578
|
-
else epilog.push(node);
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
const parent = open_stack[open_stack.length - 1];
|
|
582
|
-
node.parent = parent.id;
|
|
583
|
-
parent.children.push(node.id);
|
|
584
|
-
}
|
|
585
|
-
while (i < n) {
|
|
586
|
-
if (src[i] === "<") {
|
|
587
|
-
if (src.startsWith("<!--", i)) {
|
|
588
|
-
const end = src.indexOf("-->", i + 4);
|
|
589
|
-
if (end === -1) throw new Error("unterminated comment");
|
|
590
|
-
const value = src.slice(i + 4, end);
|
|
591
|
-
push_to_parent({
|
|
592
|
-
kind: "comment",
|
|
593
|
-
id: fresh_id(),
|
|
594
|
-
parent: null,
|
|
595
|
-
value
|
|
596
|
-
});
|
|
597
|
-
i = end + 3;
|
|
598
|
-
continue;
|
|
599
|
-
}
|
|
600
|
-
if (src.startsWith("<![CDATA[", i)) {
|
|
601
|
-
const end = src.indexOf("]]>", i + 9);
|
|
602
|
-
if (end === -1) throw new Error("unterminated CDATA");
|
|
603
|
-
const value = src.slice(i + 9, end);
|
|
604
|
-
push_to_parent({
|
|
605
|
-
kind: "cdata",
|
|
606
|
-
id: fresh_id(),
|
|
607
|
-
parent: null,
|
|
608
|
-
value
|
|
609
|
-
});
|
|
610
|
-
i = end + 3;
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
if (src.startsWith("<!DOCTYPE", i) || src.startsWith("<!doctype", i)) {
|
|
614
|
-
let depth = 1;
|
|
615
|
-
let j = i + 9;
|
|
616
|
-
while (j < n && depth > 0) {
|
|
617
|
-
const c = src[j];
|
|
618
|
-
if (c === "<") depth++;
|
|
619
|
-
else if (c === ">") depth--;
|
|
620
|
-
if (depth === 0) break;
|
|
621
|
-
j++;
|
|
622
|
-
}
|
|
623
|
-
if (j >= n) throw new Error("unterminated doctype");
|
|
624
|
-
push_to_parent({
|
|
625
|
-
kind: "doctype",
|
|
626
|
-
id: fresh_id(),
|
|
627
|
-
parent: null,
|
|
628
|
-
value: src.slice(i + 9, j)
|
|
629
|
-
});
|
|
630
|
-
i = j + 1;
|
|
631
|
-
continue;
|
|
632
|
-
}
|
|
633
|
-
if (src.startsWith("<?", i)) {
|
|
634
|
-
const end = src.indexOf("?>", i + 2);
|
|
635
|
-
if (end === -1) throw new Error("unterminated PI");
|
|
636
|
-
const body = src.slice(i + 2, end);
|
|
637
|
-
const space = body.search(/\s/);
|
|
638
|
-
const target = space === -1 ? body : body.slice(0, space);
|
|
639
|
-
const value = space === -1 ? "" : body.slice(space + 1);
|
|
640
|
-
push_to_parent({
|
|
641
|
-
kind: "pi",
|
|
642
|
-
id: fresh_id(),
|
|
643
|
-
parent: null,
|
|
644
|
-
target,
|
|
645
|
-
value
|
|
646
|
-
});
|
|
647
|
-
i = end + 2;
|
|
648
|
-
continue;
|
|
649
|
-
}
|
|
650
|
-
if (src[i + 1] === "/") {
|
|
651
|
-
const end = src.indexOf(">", i + 2);
|
|
652
|
-
if (end === -1) throw new Error("unterminated end tag");
|
|
653
|
-
const open = open_stack.pop();
|
|
654
|
-
if (!open) throw new Error("unexpected end tag at " + i);
|
|
655
|
-
ns_stack.pop();
|
|
656
|
-
default_ns_stack.pop();
|
|
657
|
-
const m = src.slice(i + 2, end).match(/^(\s*)([^\s]+)(\s*)$/);
|
|
658
|
-
if (m) {
|
|
659
|
-
open.close_tag_leading = m[1];
|
|
660
|
-
open.close_tag_trailing = m[3];
|
|
661
|
-
}
|
|
662
|
-
i = end + 1;
|
|
663
|
-
continue;
|
|
664
|
-
}
|
|
665
|
-
const start = i + 1;
|
|
666
|
-
let j = start;
|
|
667
|
-
while (j < n && !/[\s/>]/.test(src[j])) j++;
|
|
668
|
-
const raw_tag = src.slice(start, j);
|
|
669
|
-
const [prefix, local] = split_qname(raw_tag);
|
|
670
|
-
const { attrs, end_index, self_closing, trailing } = parse_attrs(src, j);
|
|
671
|
-
const new_ns_map = new Map(ns_stack[ns_stack.length - 1]);
|
|
672
|
-
let new_default_ns = default_ns_stack[default_ns_stack.length - 1];
|
|
673
|
-
for (const a of attrs) if (a.prefix === "xmlns") new_ns_map.set(a.local, a.value);
|
|
674
|
-
else if (a.prefix === null && a.local === "xmlns") new_default_ns = a.value;
|
|
675
|
-
for (const a of attrs) if (a.prefix === "xmlns" || a.prefix === null && a.local === "xmlns") a.ns = XMLNS_NS;
|
|
676
|
-
else if (a.prefix) a.ns = new_ns_map.get(a.prefix) ?? null;
|
|
677
|
-
else a.ns = null;
|
|
678
|
-
const element_ns = prefix ? new_ns_map.get(prefix) ?? null : new_default_ns;
|
|
679
|
-
const elem = {
|
|
680
|
-
kind: "element",
|
|
681
|
-
id: fresh_id(),
|
|
682
|
-
parent: null,
|
|
683
|
-
raw_tag,
|
|
684
|
-
prefix,
|
|
685
|
-
local,
|
|
686
|
-
ns: element_ns,
|
|
687
|
-
attrs,
|
|
688
|
-
children: [],
|
|
689
|
-
self_closing,
|
|
690
|
-
open_tag_trailing: trailing,
|
|
691
|
-
close_tag_leading: "",
|
|
692
|
-
close_tag_trailing: ""
|
|
693
|
-
};
|
|
694
|
-
push_to_parent(elem);
|
|
695
|
-
if (root === null) root = elem.id;
|
|
696
|
-
if (!self_closing) {
|
|
697
|
-
open_stack.push(elem);
|
|
698
|
-
ns_stack.push(new_ns_map);
|
|
699
|
-
default_ns_stack.push(new_default_ns);
|
|
700
|
-
}
|
|
701
|
-
i = end_index;
|
|
702
|
-
continue;
|
|
703
|
-
}
|
|
704
|
-
const next = src.indexOf("<", i);
|
|
705
|
-
const end = next === -1 ? n : next;
|
|
706
|
-
const value = decode_entities(src.slice(i, end));
|
|
707
|
-
push_to_parent({
|
|
708
|
-
kind: "text",
|
|
709
|
-
id: fresh_id(),
|
|
710
|
-
parent: null,
|
|
711
|
-
value
|
|
712
|
-
});
|
|
713
|
-
i = end;
|
|
714
|
-
}
|
|
715
|
-
if (open_stack.length > 0) throw new Error(`unclosed element <${open_stack[open_stack.length - 1].raw_tag}>`);
|
|
716
|
-
if (root === null) throw new Error("no root element");
|
|
717
|
-
return {
|
|
718
|
-
prolog,
|
|
719
|
-
root,
|
|
720
|
-
epilog,
|
|
721
|
-
nodes
|
|
722
|
-
};
|
|
723
|
-
}
|
|
724
|
-
function split_qname(qname) {
|
|
725
|
-
const idx = qname.indexOf(":");
|
|
726
|
-
if (idx === -1) return [null, qname];
|
|
727
|
-
return [qname.slice(0, idx), qname.slice(idx + 1)];
|
|
728
|
-
}
|
|
729
|
-
function parse_attrs(src, from) {
|
|
730
|
-
const attrs = [];
|
|
731
|
-
let i = from;
|
|
732
|
-
let pre = "";
|
|
733
|
-
const n = src.length;
|
|
734
|
-
while (i < n) {
|
|
735
|
-
const ws_start = i;
|
|
736
|
-
while (i < n && /\s/.test(src[i])) i++;
|
|
737
|
-
pre += src.slice(ws_start, i);
|
|
738
|
-
if (i >= n) throw new Error("unterminated start tag");
|
|
739
|
-
const c = src[i];
|
|
740
|
-
if (c === "/") {
|
|
741
|
-
if (src[i + 1] !== ">") throw new Error("expected '/>' at " + i);
|
|
742
|
-
pre + "";
|
|
743
|
-
return {
|
|
744
|
-
attrs,
|
|
745
|
-
end_index: i + 2,
|
|
746
|
-
self_closing: true,
|
|
747
|
-
trailing: pre
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
if (c === ">") return {
|
|
751
|
-
attrs,
|
|
752
|
-
end_index: i + 1,
|
|
753
|
-
self_closing: false,
|
|
754
|
-
trailing: pre
|
|
755
|
-
};
|
|
756
|
-
const name_start = i;
|
|
757
|
-
while (i < n && !/[\s=/>]/.test(src[i])) i++;
|
|
758
|
-
const raw_name = src.slice(name_start, i);
|
|
759
|
-
let eq_trivia = "";
|
|
760
|
-
while (i < n && /\s/.test(src[i])) {
|
|
761
|
-
eq_trivia += src[i];
|
|
762
|
-
i++;
|
|
763
|
-
}
|
|
764
|
-
if (src[i] !== "=") {
|
|
765
|
-
const [prefix, local] = split_qname(raw_name);
|
|
766
|
-
attrs.push({
|
|
767
|
-
raw_name,
|
|
768
|
-
prefix,
|
|
769
|
-
local,
|
|
770
|
-
ns: null,
|
|
771
|
-
value: "",
|
|
772
|
-
pre,
|
|
773
|
-
eq_trivia,
|
|
774
|
-
quote: "\""
|
|
775
|
-
});
|
|
776
|
-
pre = "";
|
|
777
|
-
continue;
|
|
778
|
-
}
|
|
779
|
-
i++;
|
|
780
|
-
while (i < n && /\s/.test(src[i])) i++;
|
|
781
|
-
const quote = src[i];
|
|
782
|
-
if (quote !== "\"" && quote !== "'") throw new Error("expected attribute quote at " + i);
|
|
783
|
-
i++;
|
|
784
|
-
const val_start = i;
|
|
785
|
-
while (i < n && src[i] !== quote) i++;
|
|
786
|
-
if (i >= n) throw new Error("unterminated attribute value");
|
|
787
|
-
const raw_value = src.slice(val_start, i);
|
|
788
|
-
i++;
|
|
789
|
-
const [prefix, local] = split_qname(raw_name);
|
|
790
|
-
attrs.push({
|
|
791
|
-
raw_name,
|
|
792
|
-
prefix,
|
|
793
|
-
local,
|
|
794
|
-
ns: null,
|
|
795
|
-
value: decode_entities(raw_value),
|
|
796
|
-
pre,
|
|
797
|
-
eq_trivia,
|
|
798
|
-
quote
|
|
799
|
-
});
|
|
800
|
-
pre = "";
|
|
801
|
-
}
|
|
802
|
-
throw new Error("unterminated start tag");
|
|
803
|
-
}
|
|
804
|
-
const NAMED_ENTITIES = {
|
|
805
|
-
amp: "&",
|
|
806
|
-
lt: "<",
|
|
807
|
-
gt: ">",
|
|
808
|
-
quot: "\"",
|
|
809
|
-
apos: "'"
|
|
810
|
-
};
|
|
811
|
-
function decode_entities(s) {
|
|
812
|
-
return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]*);/g, (_, ent) => {
|
|
813
|
-
if (ent.startsWith("#x") || ent.startsWith("#X")) return String.fromCodePoint(parseInt(ent.slice(2), 16));
|
|
814
|
-
if (ent.startsWith("#")) return String.fromCodePoint(parseInt(ent.slice(1), 10));
|
|
815
|
-
return NAMED_ENTITIES[ent] ?? `&${ent};`;
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
function encode_attr_value(value, quote) {
|
|
819
|
-
let out = value.replace(/&/g, "&").replace(/</g, "<");
|
|
820
|
-
out = quote === "\"" ? out.replace(/"/g, """) : out.replace(/'/g, "'");
|
|
821
|
-
return out;
|
|
822
|
-
}
|
|
823
|
-
function encode_text(value) {
|
|
824
|
-
return value.replace(/&/g, "&").replace(/</g, "<");
|
|
825
|
-
}
|
|
826
750
|
//#endregion
|
|
827
751
|
//#region src/core/document.ts
|
|
752
|
+
/**
|
|
753
|
+
* Attribute names whose writes can shift a node's rendered bounds.
|
|
754
|
+
* Membership drives `_geometry_version` bumps in `set_attr`. Only
|
|
755
|
+
* non-namespaced attribute names — namespaced writes (xlink:href, etc.)
|
|
756
|
+
* never bump because they're references, not geometry.
|
|
757
|
+
*
|
|
758
|
+
* Includes text-shaping attributes (font-*) because they re-shape glyph
|
|
759
|
+
* runs and change `<text>` bbox.
|
|
760
|
+
*/
|
|
761
|
+
const GEOMETRY_ATTRS = new Set([
|
|
762
|
+
"x",
|
|
763
|
+
"y",
|
|
764
|
+
"x1",
|
|
765
|
+
"y1",
|
|
766
|
+
"x2",
|
|
767
|
+
"y2",
|
|
768
|
+
"cx",
|
|
769
|
+
"cy",
|
|
770
|
+
"width",
|
|
771
|
+
"height",
|
|
772
|
+
"r",
|
|
773
|
+
"rx",
|
|
774
|
+
"ry",
|
|
775
|
+
"points",
|
|
776
|
+
"d",
|
|
777
|
+
"transform",
|
|
778
|
+
"viewBox",
|
|
779
|
+
"font-size",
|
|
780
|
+
"font-family",
|
|
781
|
+
"font-weight",
|
|
782
|
+
"font-style",
|
|
783
|
+
"text-anchor",
|
|
784
|
+
"dx",
|
|
785
|
+
"dy",
|
|
786
|
+
"rotate",
|
|
787
|
+
"textLength",
|
|
788
|
+
"lengthAdjust",
|
|
789
|
+
"pathLength",
|
|
790
|
+
"marker-start",
|
|
791
|
+
"marker-mid",
|
|
792
|
+
"marker-end"
|
|
793
|
+
]);
|
|
828
794
|
var SvgDocument = class SvgDocument {
|
|
829
795
|
constructor(svg) {
|
|
830
796
|
this.listeners = /* @__PURE__ */ new Set();
|
|
831
797
|
this._structure_version = 0;
|
|
798
|
+
this._geometry_version = 0;
|
|
832
799
|
this.source = svg;
|
|
833
800
|
const parsed = parse_svg(svg);
|
|
834
801
|
this.original = parsed;
|
|
@@ -849,6 +816,7 @@ var SvgDocument = class SvgDocument {
|
|
|
849
816
|
this.epilog = parsed.epilog;
|
|
850
817
|
this.root = parsed.root;
|
|
851
818
|
this._structure_version++;
|
|
819
|
+
this._geometry_version++;
|
|
852
820
|
this.emit();
|
|
853
821
|
}
|
|
854
822
|
/** Replace document with new svg source (clears edits + history-owned state). */
|
|
@@ -861,6 +829,7 @@ var SvgDocument = class SvgDocument {
|
|
|
861
829
|
this.epilog = parsed.epilog;
|
|
862
830
|
this.root = parsed.root;
|
|
863
831
|
this._structure_version++;
|
|
832
|
+
this._geometry_version++;
|
|
864
833
|
this.emit();
|
|
865
834
|
}
|
|
866
835
|
on_change(fn) {
|
|
@@ -871,6 +840,10 @@ var SvgDocument = class SvgDocument {
|
|
|
871
840
|
get structure_version() {
|
|
872
841
|
return this._structure_version;
|
|
873
842
|
}
|
|
843
|
+
/** See `_geometry_version` for what this counter signals. */
|
|
844
|
+
get geometry_version() {
|
|
845
|
+
return this._geometry_version;
|
|
846
|
+
}
|
|
874
847
|
emit() {
|
|
875
848
|
for (const fn of this.listeners) fn();
|
|
876
849
|
}
|
|
@@ -923,6 +896,48 @@ var SvgDocument = class SvgDocument {
|
|
|
923
896
|
}
|
|
924
897
|
return false;
|
|
925
898
|
}
|
|
899
|
+
/**
|
|
900
|
+
* Filter a selection down to its **subtree roots** — drop any id whose
|
|
901
|
+
* ancestor is also in the input set.
|
|
902
|
+
*
|
|
903
|
+
* Mirrors `pruneNestedNodes` in the main canvas editor's query module
|
|
904
|
+
* ([editor/grida-canvas/query/index.ts:138](../../../../editor/grida-canvas/query/index.ts)) and shares its UX motivation:
|
|
905
|
+
* when a parent and a descendant are both selected, only the parent
|
|
906
|
+
* should drive multi-node mutations — otherwise the descendant
|
|
907
|
+
* accumulates the transform twice (once via the parent's `transform`,
|
|
908
|
+
* once via its own attribute write). Required for `commands.remove`
|
|
909
|
+
* (avoids re-attaching detached descendants on undo) and any multi-
|
|
910
|
+
* member translate path (avoids 2× drift for the Bar-chart marquee
|
|
911
|
+
* case).
|
|
912
|
+
*
|
|
913
|
+
* Order: preserves the input order for retained ids. Duplicates in
|
|
914
|
+
* the input are not deduplicated — callers are responsible (the
|
|
915
|
+
* editor's `commands.select` already dedupes).
|
|
916
|
+
*
|
|
917
|
+
* Performance: `O(n × depth)`. Builds a `Set` over the input once,
|
|
918
|
+
* then walks each id's ancestor chain at most once. The main editor's
|
|
919
|
+
* version is `O(n² × depth)` (per-pair `isAncestor`) — fine at typical
|
|
920
|
+
* selection sizes (a few dozen), worth winning here for free since
|
|
921
|
+
* `parent_of` is `O(1)` on our parent-map.
|
|
922
|
+
*/
|
|
923
|
+
prune_nested_nodes(ids) {
|
|
924
|
+
if (ids.length <= 1) return [...ids];
|
|
925
|
+
const set = new Set(ids);
|
|
926
|
+
const out = [];
|
|
927
|
+
for (const id of ids) {
|
|
928
|
+
let nested = false;
|
|
929
|
+
let cur = this.parent_of(id);
|
|
930
|
+
while (cur !== null) {
|
|
931
|
+
if (set.has(cur)) {
|
|
932
|
+
nested = true;
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
cur = this.parent_of(cur);
|
|
936
|
+
}
|
|
937
|
+
if (!nested) out.push(id);
|
|
938
|
+
}
|
|
939
|
+
return out;
|
|
940
|
+
}
|
|
926
941
|
all_nodes() {
|
|
927
942
|
const out = [];
|
|
928
943
|
const walk = (id) => {
|
|
@@ -960,18 +975,20 @@ var SvgDocument = class SvgDocument {
|
|
|
960
975
|
const n = this.nodes.get(id);
|
|
961
976
|
if (!n || n.kind !== "element") return;
|
|
962
977
|
const structural = name === "id";
|
|
978
|
+
const geometry = ns === null && GEOMETRY_ATTRS.has(name);
|
|
963
979
|
for (let i = 0; i < n.attrs.length; i++) {
|
|
964
980
|
const a = n.attrs[i];
|
|
965
981
|
if (a.local === name && (ns === null || a.ns === ns)) {
|
|
966
982
|
if (value === null) n.attrs.splice(i, 1);
|
|
967
983
|
else a.value = value;
|
|
968
984
|
if (structural) this._structure_version++;
|
|
985
|
+
if (geometry) this._geometry_version++;
|
|
969
986
|
this.emit();
|
|
970
987
|
return;
|
|
971
988
|
}
|
|
972
989
|
}
|
|
973
990
|
if (value !== null) {
|
|
974
|
-
const prefix = ns ===
|
|
991
|
+
const prefix = ns === XLINK_NS ? "xlink" : null;
|
|
975
992
|
n.attrs.push({
|
|
976
993
|
raw_name: prefix ? `${prefix}:${name}` : name,
|
|
977
994
|
prefix,
|
|
@@ -983,6 +1000,7 @@ var SvgDocument = class SvgDocument {
|
|
|
983
1000
|
quote: "\""
|
|
984
1001
|
});
|
|
985
1002
|
if (structural) this._structure_version++;
|
|
1003
|
+
if (geometry) this._geometry_version++;
|
|
986
1004
|
this.emit();
|
|
987
1005
|
}
|
|
988
1006
|
}
|
|
@@ -1021,6 +1039,25 @@ var SvgDocument = class SvgDocument {
|
|
|
1021
1039
|
if (!style) return [];
|
|
1022
1040
|
return parse_inline_style(style);
|
|
1023
1041
|
}
|
|
1042
|
+
/**
|
|
1043
|
+
* Whether `id` can be opened in the flat-string text editor.
|
|
1044
|
+
*
|
|
1045
|
+
* v1 contract: the editor only operates on a *single flat text run*. That
|
|
1046
|
+
* means the target must be a `<text>` or `<tspan>` whose direct children
|
|
1047
|
+
* are all text nodes (or it has no children). A `<text>` containing a
|
|
1048
|
+
* `<tspan>` is *not* honestly editable — `text_of` would drop the tspan
|
|
1049
|
+
* content from the editor's view, and a flat-text write would leave the
|
|
1050
|
+
* tspan dangling. Tspan-as-target is fine and well-defined when it's a
|
|
1051
|
+
* leaf; only the host decides whether to route double-click to a tspan
|
|
1052
|
+
* or its parent text.
|
|
1053
|
+
*/
|
|
1054
|
+
is_text_edit_target(id) {
|
|
1055
|
+
const n = this.nodes.get(id);
|
|
1056
|
+
if (!n || n.kind !== "element") return false;
|
|
1057
|
+
if (n.local !== "text" && n.local !== "tspan") return false;
|
|
1058
|
+
for (const c of n.children) if (this.nodes.get(c)?.kind !== "text") return false;
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1024
1061
|
text_of(id) {
|
|
1025
1062
|
const n = this.nodes.get(id);
|
|
1026
1063
|
if (!n || n.kind !== "element") return "";
|
|
@@ -1048,6 +1085,7 @@ var SvgDocument = class SvgDocument {
|
|
|
1048
1085
|
n.children.push(text_id);
|
|
1049
1086
|
}
|
|
1050
1087
|
this._structure_version++;
|
|
1088
|
+
this._geometry_version++;
|
|
1051
1089
|
this.emit();
|
|
1052
1090
|
}
|
|
1053
1091
|
insert(id, parent, before) {
|
|
@@ -1066,6 +1104,7 @@ var SvgDocument = class SvgDocument {
|
|
|
1066
1104
|
else parent_node.children.splice(ix, 0, id);
|
|
1067
1105
|
node.parent = parent;
|
|
1068
1106
|
this._structure_version++;
|
|
1107
|
+
this._geometry_version++;
|
|
1069
1108
|
this.emit();
|
|
1070
1109
|
}
|
|
1071
1110
|
remove(id) {
|
|
@@ -1077,6 +1116,7 @@ var SvgDocument = class SvgDocument {
|
|
|
1077
1116
|
if (i >= 0) parent.children.splice(i, 1);
|
|
1078
1117
|
n.parent = null;
|
|
1079
1118
|
this._structure_version++;
|
|
1119
|
+
this._geometry_version++;
|
|
1080
1120
|
this.emit();
|
|
1081
1121
|
}
|
|
1082
1122
|
/** Create a new element node and register it (not yet inserted). */
|
|
@@ -1157,6 +1197,56 @@ function parse_inline_style(s) {
|
|
|
1157
1197
|
return out;
|
|
1158
1198
|
}
|
|
1159
1199
|
//#endregion
|
|
1200
|
+
//#region src/core/align.ts
|
|
1201
|
+
/**
|
|
1202
|
+
* Compute per-member translation deltas to align `members` against `target`.
|
|
1203
|
+
*
|
|
1204
|
+
* Convention (matches Figma menu labels and `cmath.rect.alignA`):
|
|
1205
|
+
* - `left` / `right` → shift X so each left/right edge matches target's
|
|
1206
|
+
* - `top` / `bottom` → shift Y so each top/bottom edge matches target's
|
|
1207
|
+
* - `horizontal_centers` → shift X so each center-X matches target's center-X
|
|
1208
|
+
* - `vertical_centers` → shift Y so each center-Y matches target's center-Y
|
|
1209
|
+
*
|
|
1210
|
+
* Members already at the target position are omitted from the returned map —
|
|
1211
|
+
* callers iterate non-zero deltas only.
|
|
1212
|
+
*/
|
|
1213
|
+
function compute_align_deltas(members, target, direction) {
|
|
1214
|
+
const out = /* @__PURE__ */ new Map();
|
|
1215
|
+
for (const m of members) {
|
|
1216
|
+
const d = delta_for(m.bbox, target, direction);
|
|
1217
|
+
if (d.x !== 0 || d.y !== 0) out.set(m.id, d);
|
|
1218
|
+
}
|
|
1219
|
+
return out;
|
|
1220
|
+
}
|
|
1221
|
+
function delta_for(bbox, target, direction) {
|
|
1222
|
+
switch (direction) {
|
|
1223
|
+
case "left": return {
|
|
1224
|
+
x: target.x - bbox.x,
|
|
1225
|
+
y: 0
|
|
1226
|
+
};
|
|
1227
|
+
case "right": return {
|
|
1228
|
+
x: target.x + target.width - (bbox.x + bbox.width),
|
|
1229
|
+
y: 0
|
|
1230
|
+
};
|
|
1231
|
+
case "top": return {
|
|
1232
|
+
x: 0,
|
|
1233
|
+
y: target.y - bbox.y
|
|
1234
|
+
};
|
|
1235
|
+
case "bottom": return {
|
|
1236
|
+
x: 0,
|
|
1237
|
+
y: target.y + target.height - (bbox.y + bbox.height)
|
|
1238
|
+
};
|
|
1239
|
+
case "horizontal_centers": return {
|
|
1240
|
+
x: target.x + target.width / 2 - (bbox.x + bbox.width / 2),
|
|
1241
|
+
y: 0
|
|
1242
|
+
};
|
|
1243
|
+
case "vertical_centers": return {
|
|
1244
|
+
x: 0,
|
|
1245
|
+
y: target.y + target.height / 2 - (bbox.y + bbox.height / 2)
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
//#endregion
|
|
1160
1250
|
//#region src/core/properties.ts
|
|
1161
1251
|
/** SVG properties that inherit per SVG 2 §6 (subset; the common ones). */
|
|
1162
1252
|
const INHERITED = new Set([
|
|
@@ -1300,17 +1390,6 @@ function choose_write_carrier(doc, id, property) {
|
|
|
1300
1390
|
return "presentation_attribute";
|
|
1301
1391
|
}
|
|
1302
1392
|
//#endregion
|
|
1303
|
-
//#region src/types.ts
|
|
1304
|
-
const DEFAULT_STYLE = {
|
|
1305
|
-
chrome_color: "#2563eb",
|
|
1306
|
-
handle_size: 8,
|
|
1307
|
-
handle_fill: "#ffffff",
|
|
1308
|
-
handle_stroke: "#2563eb",
|
|
1309
|
-
endpoint_dot_radius: 5,
|
|
1310
|
-
selection_outline_width: 2,
|
|
1311
|
-
measurement_color: "#ff3a30"
|
|
1312
|
-
};
|
|
1313
|
-
//#endregion
|
|
1314
1393
|
//#region src/core/editor.ts
|
|
1315
1394
|
const PROVIDER_ID = "svg-editor";
|
|
1316
1395
|
/** Max characters in a synthesized display label before truncation. */
|
|
@@ -1322,29 +1401,46 @@ function createSvgEditor(opts) {
|
|
|
1322
1401
|
let selection = [];
|
|
1323
1402
|
let scope = null;
|
|
1324
1403
|
let mode = "select";
|
|
1404
|
+
let tool = TOOL_CURSOR;
|
|
1325
1405
|
let version = 0;
|
|
1326
1406
|
/** Document-edit counter — only bumps on actual mutations, not selection. */
|
|
1327
1407
|
let doc_version = 0;
|
|
1328
1408
|
/** doc_version at the last load()/serialize(); compared to derive `dirty`. */
|
|
1329
1409
|
let baseline_doc_version = 0;
|
|
1410
|
+
/**
|
|
1411
|
+
* Bumps once per `editor.load(svg)` call. The constructor's initial parse
|
|
1412
|
+
* does NOT count — it's the "factory" state. Hosts subscribe via
|
|
1413
|
+
* `subscribe_with_selector(s => s.load_version, ...)` to react to fresh
|
|
1414
|
+
* document loads without firing on every edit.
|
|
1415
|
+
*/
|
|
1416
|
+
let load_version = 0;
|
|
1330
1417
|
let style = {
|
|
1331
1418
|
...DEFAULT_STYLE,
|
|
1332
|
-
...opts.style
|
|
1419
|
+
...opts.style
|
|
1333
1420
|
};
|
|
1334
1421
|
const providers = opts.providers ?? {};
|
|
1335
1422
|
const listeners = /* @__PURE__ */ new Set();
|
|
1336
1423
|
let attached_surface = null;
|
|
1424
|
+
/**
|
|
1425
|
+
* World-space geometry query provider. Set by the DOM surface on
|
|
1426
|
+
* attach (`editor._internal.set_geometry`); cleared on detach. Null
|
|
1427
|
+
* means no renderer is attached — bounds queries cannot be answered.
|
|
1428
|
+
*/
|
|
1429
|
+
let geometry_provider = null;
|
|
1337
1430
|
const modes = ["select", "edit-content"];
|
|
1338
1431
|
function snapshot() {
|
|
1339
1432
|
return Object.freeze({
|
|
1340
1433
|
selection,
|
|
1341
1434
|
scope,
|
|
1342
1435
|
mode,
|
|
1436
|
+
tool,
|
|
1343
1437
|
dirty: doc_version !== baseline_doc_version,
|
|
1344
1438
|
can_undo: history.stack.canUndo,
|
|
1345
1439
|
can_redo: history.stack.canRedo,
|
|
1346
1440
|
version,
|
|
1347
|
-
structure_version: doc.structure_version
|
|
1441
|
+
structure_version: doc.structure_version,
|
|
1442
|
+
geometry_version: doc.geometry_version,
|
|
1443
|
+
load_version
|
|
1348
1444
|
});
|
|
1349
1445
|
}
|
|
1350
1446
|
function emit() {
|
|
@@ -1355,8 +1451,18 @@ function createSvgEditor(opts) {
|
|
|
1355
1451
|
history.on("onChange", () => emit());
|
|
1356
1452
|
history.on("onUndo", () => emit());
|
|
1357
1453
|
history.on("onRedo", () => emit());
|
|
1454
|
+
let last_emitted_geometry_version = doc.geometry_version;
|
|
1455
|
+
const geometry_listeners = /* @__PURE__ */ new Set();
|
|
1456
|
+
const translate_commit_listeners = /* @__PURE__ */ new Set();
|
|
1457
|
+
const notify_translate_commit = () => {
|
|
1458
|
+
for (const cb of translate_commit_listeners) cb();
|
|
1459
|
+
};
|
|
1358
1460
|
doc.on_change(() => {
|
|
1359
1461
|
doc_version++;
|
|
1462
|
+
if (doc.geometry_version !== last_emitted_geometry_version) {
|
|
1463
|
+
last_emitted_geometry_version = doc.geometry_version;
|
|
1464
|
+
for (const cb of geometry_listeners) cb();
|
|
1465
|
+
}
|
|
1360
1466
|
});
|
|
1361
1467
|
function subscribe(fn) {
|
|
1362
1468
|
listeners.add(fn);
|
|
@@ -1376,16 +1482,29 @@ function createSvgEditor(opts) {
|
|
|
1376
1482
|
});
|
|
1377
1483
|
}
|
|
1378
1484
|
function set_selection(next) {
|
|
1485
|
+
if (next.length === selection.length) {
|
|
1486
|
+
let same = true;
|
|
1487
|
+
for (let i = 0; i < next.length; i++) if (next[i] !== selection[i]) {
|
|
1488
|
+
same = false;
|
|
1489
|
+
break;
|
|
1490
|
+
}
|
|
1491
|
+
if (same) return;
|
|
1492
|
+
}
|
|
1379
1493
|
selection = Object.freeze([...next]);
|
|
1380
1494
|
emit();
|
|
1381
1495
|
}
|
|
1382
1496
|
function select(target, opts) {
|
|
1383
1497
|
const ids = typeof target === "string" ? [target] : [...target];
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
}
|
|
1498
|
+
const mode = opts?.mode ?? "replace";
|
|
1499
|
+
if (mode === "replace") {
|
|
1500
|
+
set_selection(ids);
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
const next = new Set(selection);
|
|
1504
|
+
if (mode === "add") for (const id of ids) next.add(id);
|
|
1505
|
+
else for (const id of ids) if (next.has(id)) next.delete(id);
|
|
1506
|
+
else next.add(id);
|
|
1507
|
+
set_selection([...next]);
|
|
1389
1508
|
}
|
|
1390
1509
|
function deselect() {
|
|
1391
1510
|
set_selection([]);
|
|
@@ -1405,18 +1524,121 @@ function createSvgEditor(opts) {
|
|
|
1405
1524
|
mode = next;
|
|
1406
1525
|
emit();
|
|
1407
1526
|
}
|
|
1527
|
+
function tools_equal(a, b) {
|
|
1528
|
+
if (a.type !== b.type) return false;
|
|
1529
|
+
if (a.type === "cursor") return true;
|
|
1530
|
+
return b.type === "insert" && a.tag === b.tag;
|
|
1531
|
+
}
|
|
1532
|
+
function set_tool(next) {
|
|
1533
|
+
if (tools_equal(tool, next)) return;
|
|
1534
|
+
tool = next;
|
|
1535
|
+
emit();
|
|
1536
|
+
}
|
|
1537
|
+
const paint_cache = /* @__PURE__ */ new Map();
|
|
1538
|
+
const property_cache = /* @__PURE__ */ new Map();
|
|
1539
|
+
const properties_cache = /* @__PURE__ */ new Map();
|
|
1540
|
+
let tree_cache = null;
|
|
1541
|
+
const tree_node_pool = /* @__PURE__ */ new Map();
|
|
1542
|
+
function tree_snapshot() {
|
|
1543
|
+
const sv = doc.structure_version;
|
|
1544
|
+
if (tree_cache && tree_cache.structure_version === sv) return tree_cache.value;
|
|
1545
|
+
const map = /* @__PURE__ */ new Map();
|
|
1546
|
+
let any_change = !tree_cache;
|
|
1547
|
+
for (const id of doc.all_elements()) {
|
|
1548
|
+
const tag = doc.tag_of(id);
|
|
1549
|
+
const name = doc.get_attr(id, "id") ?? void 0;
|
|
1550
|
+
const parent = doc.parent_of(id);
|
|
1551
|
+
const children = doc.element_children_of(id);
|
|
1552
|
+
const pooled = tree_node_pool.get(id);
|
|
1553
|
+
if (pooled && pooled.tag === tag && pooled.name === name && pooled.parent === parent && array_shallow_equal(pooled.children, children)) {
|
|
1554
|
+
map.set(id, pooled);
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
const node = {
|
|
1558
|
+
id,
|
|
1559
|
+
tag,
|
|
1560
|
+
name,
|
|
1561
|
+
parent,
|
|
1562
|
+
children
|
|
1563
|
+
};
|
|
1564
|
+
tree_node_pool.set(id, node);
|
|
1565
|
+
map.set(id, node);
|
|
1566
|
+
any_change = true;
|
|
1567
|
+
}
|
|
1568
|
+
for (const id of tree_node_pool.keys()) if (!map.has(id)) {
|
|
1569
|
+
tree_node_pool.delete(id);
|
|
1570
|
+
any_change = true;
|
|
1571
|
+
}
|
|
1572
|
+
if (!any_change && tree_cache) {
|
|
1573
|
+
tree_cache.structure_version = sv;
|
|
1574
|
+
return tree_cache.value;
|
|
1575
|
+
}
|
|
1576
|
+
const snap = {
|
|
1577
|
+
root: doc.root,
|
|
1578
|
+
nodes: map
|
|
1579
|
+
};
|
|
1580
|
+
tree_cache = {
|
|
1581
|
+
structure_version: sv,
|
|
1582
|
+
value: snap
|
|
1583
|
+
};
|
|
1584
|
+
return snap;
|
|
1585
|
+
}
|
|
1586
|
+
function node_property_cached(id, name) {
|
|
1587
|
+
const key = `${id}${name}`;
|
|
1588
|
+
const cached = property_cache.get(key);
|
|
1589
|
+
if (cached && cached.doc_version === doc_version) return cached.value;
|
|
1590
|
+
const next = read_property(doc, id, name);
|
|
1591
|
+
if (cached && property_value_equals(cached.value, next)) {
|
|
1592
|
+
cached.doc_version = doc_version;
|
|
1593
|
+
return cached.value;
|
|
1594
|
+
}
|
|
1595
|
+
property_cache.set(key, {
|
|
1596
|
+
doc_version,
|
|
1597
|
+
value: next
|
|
1598
|
+
});
|
|
1599
|
+
return next;
|
|
1600
|
+
}
|
|
1408
1601
|
function node_properties(id, names) {
|
|
1409
|
-
const
|
|
1410
|
-
|
|
1411
|
-
return
|
|
1602
|
+
const key = `${id}${names.join("")}`;
|
|
1603
|
+
const cached = properties_cache.get(key);
|
|
1604
|
+
if (cached && cached.doc_version === doc_version) return cached.value;
|
|
1605
|
+
const next = {};
|
|
1606
|
+
let changed = !cached;
|
|
1607
|
+
for (const name of names) {
|
|
1608
|
+
const v = node_property_cached(id, name);
|
|
1609
|
+
next[name] = v;
|
|
1610
|
+
if (cached && cached.value[name] !== v) changed = true;
|
|
1611
|
+
}
|
|
1612
|
+
if (cached && !changed) {
|
|
1613
|
+
cached.doc_version = doc_version;
|
|
1614
|
+
return cached.value;
|
|
1615
|
+
}
|
|
1616
|
+
const frozen = Object.freeze(next);
|
|
1617
|
+
properties_cache.set(key, {
|
|
1618
|
+
doc_version,
|
|
1619
|
+
value: frozen
|
|
1620
|
+
});
|
|
1621
|
+
return frozen;
|
|
1412
1622
|
}
|
|
1413
1623
|
function node_paint(id, channel) {
|
|
1624
|
+
const key = `${id}${channel}`;
|
|
1625
|
+
const cached = paint_cache.get(key);
|
|
1626
|
+
if (cached && cached.doc_version === doc_version) return cached.value;
|
|
1414
1627
|
const { declared, provenance } = resolve_declared(doc, id, channel);
|
|
1415
|
-
|
|
1628
|
+
const next = {
|
|
1416
1629
|
declared,
|
|
1417
1630
|
computed: parse_paint(declared),
|
|
1418
1631
|
provenance
|
|
1419
1632
|
};
|
|
1633
|
+
if (cached && paint_value_equals(cached.value, next)) {
|
|
1634
|
+
cached.doc_version = doc_version;
|
|
1635
|
+
return cached.value;
|
|
1636
|
+
}
|
|
1637
|
+
paint_cache.set(key, {
|
|
1638
|
+
doc_version,
|
|
1639
|
+
value: next
|
|
1640
|
+
});
|
|
1641
|
+
return next;
|
|
1420
1642
|
}
|
|
1421
1643
|
function write_property(id, name, value) {
|
|
1422
1644
|
if (choose_write_carrier(doc, id, name) === "inline_style") doc.set_style(id, name, value);
|
|
@@ -1502,29 +1724,427 @@ function createSvgEditor(opts) {
|
|
|
1502
1724
|
});
|
|
1503
1725
|
return { gradient_id };
|
|
1504
1726
|
}
|
|
1727
|
+
/** Shared one-shot translate runner. `stages` selects semantics — see
|
|
1728
|
+
* `core/translate-pipeline/README.md`'s "Stage lists per entry point". */
|
|
1729
|
+
function do_translate_oneshot(delta, stages, label) {
|
|
1730
|
+
if (selection.length === 0) return false;
|
|
1731
|
+
if (delta.dx === 0 && delta.dy === 0) return false;
|
|
1732
|
+
const { apply, revert } = prepare_translate_rpc({
|
|
1733
|
+
doc,
|
|
1734
|
+
ids: selection,
|
|
1735
|
+
delta: {
|
|
1736
|
+
x: delta.dx,
|
|
1737
|
+
y: delta.dy
|
|
1738
|
+
},
|
|
1739
|
+
options: {
|
|
1740
|
+
pixel_grid_quantum: style.snap_to_pixel_grid ? style.pixel_grid_size : null,
|
|
1741
|
+
snap_enabled: style.snap_enabled,
|
|
1742
|
+
snap_threshold_px: style.snap_threshold_px
|
|
1743
|
+
},
|
|
1744
|
+
emit,
|
|
1745
|
+
stages
|
|
1746
|
+
});
|
|
1747
|
+
apply();
|
|
1748
|
+
history.atomic(label, (tx) => {
|
|
1749
|
+
tx.push({
|
|
1750
|
+
providerId: PROVIDER_ID,
|
|
1751
|
+
apply,
|
|
1752
|
+
revert
|
|
1753
|
+
});
|
|
1754
|
+
});
|
|
1755
|
+
return true;
|
|
1756
|
+
}
|
|
1505
1757
|
function translate(delta) {
|
|
1506
|
-
if (
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1758
|
+
if (do_translate_oneshot(delta, void 0, "translate")) notify_translate_commit();
|
|
1759
|
+
}
|
|
1760
|
+
function nudge(delta) {
|
|
1761
|
+
if (do_translate_oneshot(delta, STAGES_NUDGE, "nudge")) notify_translate_commit();
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* One-shot multi-member resize to an explicit target rect. Mirrors a
|
|
1765
|
+
* drag-resize gesture in mechanics — capture per-member baselines,
|
|
1766
|
+
* scale around the union's NW corner, translate the result so the
|
|
1767
|
+
* union NW lands at the requested position — but as a single
|
|
1768
|
+
* atomic step rather than a preview session.
|
|
1769
|
+
*
|
|
1770
|
+
* The function does its own geometry lookup via the
|
|
1771
|
+
* `geometry_provider` registered by the DOM surface. When no surface
|
|
1772
|
+
* is attached, the call is a no-op (returns `false`). Members whose
|
|
1773
|
+
* tag is not resizable are silently filtered.
|
|
1774
|
+
*
|
|
1775
|
+
* Revert restores the captured `transform` attribute and all
|
|
1776
|
+
* geometry attrs the apply step wrote — so a `<rect>` with an
|
|
1777
|
+
* existing `transform` round-trips cleanly. See `apply_translate`'s
|
|
1778
|
+
* `viaTransform` arm for why this matters.
|
|
1779
|
+
*/
|
|
1780
|
+
function resize_to(target, opts) {
|
|
1781
|
+
const ids = opts?.ids ?? selection;
|
|
1782
|
+
if (ids.length === 0) return false;
|
|
1783
|
+
if (!geometry_provider) return false;
|
|
1784
|
+
const members = [];
|
|
1785
|
+
for (const id of ids) {
|
|
1786
|
+
if (!is_resizable(doc.tag_of(id))) continue;
|
|
1787
|
+
const bbox = geometry_provider.bounds_of(id);
|
|
1788
|
+
if (!bbox) continue;
|
|
1789
|
+
members.push({
|
|
1790
|
+
id,
|
|
1791
|
+
rz: capture_resize_baseline(doc, id, bbox),
|
|
1792
|
+
tx_pre: capture_translate_baseline(doc, id),
|
|
1793
|
+
transform_pre: doc.get_attr(id, "transform"),
|
|
1794
|
+
bbox
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
if (members.length === 0) return false;
|
|
1798
|
+
const union = cmath.rect.union(members.map((m) => m.bbox));
|
|
1799
|
+
const sx = union.width === 0 ? 1 : target.width / union.width;
|
|
1800
|
+
const sy = union.height === 0 ? 1 : target.height / union.height;
|
|
1801
|
+
const origin = {
|
|
1802
|
+
x: union.x,
|
|
1803
|
+
y: union.y
|
|
1804
|
+
};
|
|
1805
|
+
const dx = target.x - union.x;
|
|
1806
|
+
const dy = target.y - union.y;
|
|
1512
1807
|
const apply = () => {
|
|
1513
|
-
for (const
|
|
1808
|
+
for (const m of members) apply_resize(doc, m.id, m.rz, sx, sy, origin);
|
|
1809
|
+
if (dx !== 0 || dy !== 0) for (const m of members) {
|
|
1810
|
+
const tx_after = capture_translate_baseline(doc, m.id);
|
|
1811
|
+
apply_translate(doc, m.id, tx_after, dx, dy);
|
|
1812
|
+
}
|
|
1514
1813
|
emit();
|
|
1515
1814
|
};
|
|
1516
1815
|
const revert = () => {
|
|
1517
|
-
for (const
|
|
1816
|
+
for (const m of members) {
|
|
1817
|
+
apply_resize(doc, m.id, m.rz, 1, 1, origin);
|
|
1818
|
+
doc.set_attr(m.id, "transform", m.transform_pre);
|
|
1819
|
+
}
|
|
1518
1820
|
emit();
|
|
1519
1821
|
};
|
|
1520
1822
|
apply();
|
|
1521
|
-
history.atomic("
|
|
1823
|
+
history.atomic("resize-to", (tx) => {
|
|
1522
1824
|
tx.push({
|
|
1523
1825
|
providerId: PROVIDER_ID,
|
|
1524
1826
|
apply,
|
|
1525
1827
|
revert
|
|
1526
1828
|
});
|
|
1527
1829
|
});
|
|
1830
|
+
return true;
|
|
1831
|
+
}
|
|
1832
|
+
/** Shared helper: compute a default rotation pivot from the live
|
|
1833
|
+
* geometry_provider when the caller omitted one. Falls back to (0,0)
|
|
1834
|
+
* if no surface is attached. */
|
|
1835
|
+
function default_rotate_pivot(ids) {
|
|
1836
|
+
if (!geometry_provider || ids.length === 0) return {
|
|
1837
|
+
x: 0,
|
|
1838
|
+
y: 0
|
|
1839
|
+
};
|
|
1840
|
+
const rects = [];
|
|
1841
|
+
for (const id of ids) {
|
|
1842
|
+
const b = geometry_provider.bounds_of(id);
|
|
1843
|
+
if (b) rects.push(b);
|
|
1844
|
+
}
|
|
1845
|
+
if (rects.length === 0) return {
|
|
1846
|
+
x: 0,
|
|
1847
|
+
y: 0
|
|
1848
|
+
};
|
|
1849
|
+
const u = cmath.rect.union(rects);
|
|
1850
|
+
return {
|
|
1851
|
+
x: u.x + u.width / 2,
|
|
1852
|
+
y: u.y + u.height / 2
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
function rotate(angle, opts) {
|
|
1856
|
+
const ids = opts?.ids ?? selection;
|
|
1857
|
+
if (ids.length === 0) return false;
|
|
1858
|
+
const prepared = prepare_rotate_rpc({
|
|
1859
|
+
doc,
|
|
1860
|
+
ids,
|
|
1861
|
+
pivot: opts?.pivot ?? default_rotate_pivot(ids),
|
|
1862
|
+
angle_radians: angle,
|
|
1863
|
+
options: { angle_snap_step_radians: style.angle_snap_step_radians },
|
|
1864
|
+
emit
|
|
1865
|
+
});
|
|
1866
|
+
for (const v of prepared.verdicts.values()) if (v.kind === "refuse") return false;
|
|
1867
|
+
prepared.apply();
|
|
1868
|
+
history.atomic("rotate", (tx) => {
|
|
1869
|
+
tx.push({
|
|
1870
|
+
providerId: PROVIDER_ID,
|
|
1871
|
+
apply: prepared.apply,
|
|
1872
|
+
revert: prepared.revert
|
|
1873
|
+
});
|
|
1874
|
+
});
|
|
1875
|
+
return true;
|
|
1876
|
+
}
|
|
1877
|
+
function rotate_to(angle, opts) {
|
|
1878
|
+
const ids = opts?.ids ?? selection;
|
|
1879
|
+
if (ids.length === 0) return false;
|
|
1880
|
+
const probe = prepare_rotate_rpc({
|
|
1881
|
+
doc,
|
|
1882
|
+
ids,
|
|
1883
|
+
pivot: opts?.pivot ?? default_rotate_pivot(ids),
|
|
1884
|
+
angle_radians: 0,
|
|
1885
|
+
options: { angle_snap_step_radians: style.angle_snap_step_radians },
|
|
1886
|
+
emit: () => {}
|
|
1887
|
+
});
|
|
1888
|
+
for (const v of probe.verdicts.values()) if (v.kind === "refuse") return false;
|
|
1889
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
1890
|
+
const apply = () => {
|
|
1891
|
+
for (const m of probe.plan.members) {
|
|
1892
|
+
const delta = angle - m.baseline.current_rotation_deg * DEG_TO_RAD;
|
|
1893
|
+
apply_rotate(doc, m.id, m.baseline, delta);
|
|
1894
|
+
}
|
|
1895
|
+
emit();
|
|
1896
|
+
};
|
|
1897
|
+
const revert = () => {
|
|
1898
|
+
for (const m of probe.plan.members) apply_rotate(doc, m.id, m.baseline, 0);
|
|
1899
|
+
emit();
|
|
1900
|
+
};
|
|
1901
|
+
apply();
|
|
1902
|
+
history.atomic("rotate-to", (tx) => {
|
|
1903
|
+
tx.push({
|
|
1904
|
+
providerId: PROVIDER_ID,
|
|
1905
|
+
apply,
|
|
1906
|
+
revert
|
|
1907
|
+
});
|
|
1908
|
+
});
|
|
1909
|
+
return true;
|
|
1910
|
+
}
|
|
1911
|
+
function flatten_transform(opts) {
|
|
1912
|
+
const ids = opts?.ids ?? selection;
|
|
1913
|
+
if (ids.length === 0) return false;
|
|
1914
|
+
const members = [];
|
|
1915
|
+
for (const id of ids) {
|
|
1916
|
+
const pre = doc.get_attr(id, "transform");
|
|
1917
|
+
if (pre === null) continue;
|
|
1918
|
+
const ops = parse_transform_list(pre);
|
|
1919
|
+
if (ops === null) continue;
|
|
1920
|
+
if (ops.length === 1 && ops[0].type === "matrix") continue;
|
|
1921
|
+
members.push({
|
|
1922
|
+
id,
|
|
1923
|
+
transform_pre: pre,
|
|
1924
|
+
ops
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
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
|
+
const apply = () => {
|
|
2022
|
+
for (const m of members) {
|
|
2023
|
+
let mat = IDENT;
|
|
2024
|
+
for (const op of m.ops) mat = mul(mat, op_to_mat(op));
|
|
2025
|
+
doc.set_attr(m.id, "transform", emit_transform_list([{
|
|
2026
|
+
type: "matrix",
|
|
2027
|
+
a: mat[0],
|
|
2028
|
+
b: mat[1],
|
|
2029
|
+
c: mat[2],
|
|
2030
|
+
d: mat[3],
|
|
2031
|
+
e: mat[4],
|
|
2032
|
+
f: mat[5]
|
|
2033
|
+
}]));
|
|
2034
|
+
}
|
|
2035
|
+
emit();
|
|
2036
|
+
};
|
|
2037
|
+
const revert = () => {
|
|
2038
|
+
for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
|
|
2039
|
+
emit();
|
|
2040
|
+
};
|
|
2041
|
+
apply();
|
|
2042
|
+
history.atomic("flatten-transform", (tx) => {
|
|
2043
|
+
tx.push({
|
|
2044
|
+
providerId: PROVIDER_ID,
|
|
2045
|
+
apply,
|
|
2046
|
+
revert
|
|
2047
|
+
});
|
|
2048
|
+
});
|
|
2049
|
+
return true;
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Translate selected members so they line up along the requested edge or
|
|
2053
|
+
* center of a reference rect. Same mechanics as `resize_to`: per-member
|
|
2054
|
+
* translate baselines (so `<g>`, transformed, and natively-attributed
|
|
2055
|
+
* nodes all write the cleanest in-place representation), one atomic
|
|
2056
|
+
* history step.
|
|
2057
|
+
*
|
|
2058
|
+
* Reference rect is selection-size dependent:
|
|
2059
|
+
* - multi-selection: union of member bboxes
|
|
2060
|
+
* - single selection: the parent's bbox (root → `<svg>` viewport,
|
|
2061
|
+
* inside a `<g>` → that group's bbox). Refuses when the selected
|
|
2062
|
+
* node IS the root (no container to align against).
|
|
2063
|
+
*
|
|
2064
|
+
* Refuses when `geometry_provider` is null (no surface attached) or when
|
|
2065
|
+
* no member has a resolvable bbox.
|
|
2066
|
+
*/
|
|
2067
|
+
function align(direction, opts) {
|
|
2068
|
+
const ids = opts?.ids ?? selection;
|
|
2069
|
+
if (ids.length === 0) return false;
|
|
2070
|
+
if (!geometry_provider) return false;
|
|
2071
|
+
const members = [];
|
|
2072
|
+
for (const id of ids) {
|
|
2073
|
+
const bbox = geometry_provider.bounds_of(id);
|
|
2074
|
+
if (!bbox) continue;
|
|
2075
|
+
const baseline = capture_translate_baseline(doc, id);
|
|
2076
|
+
if (baseline.type === "unsupported") continue;
|
|
2077
|
+
members.push({
|
|
2078
|
+
id,
|
|
2079
|
+
bbox,
|
|
2080
|
+
baseline
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
if (members.length === 0) return false;
|
|
2084
|
+
let target;
|
|
2085
|
+
if (members.length === 1) {
|
|
2086
|
+
const parent_id = doc.parent_of(members[0].id);
|
|
2087
|
+
if (parent_id === null) return false;
|
|
2088
|
+
const parent_bbox = geometry_provider.bounds_of(parent_id);
|
|
2089
|
+
if (!parent_bbox) return false;
|
|
2090
|
+
target = parent_bbox;
|
|
2091
|
+
} else target = cmath.rect.union(members.map((m) => m.bbox));
|
|
2092
|
+
const deltas = compute_align_deltas(members, target, direction);
|
|
2093
|
+
if (deltas.size === 0) return false;
|
|
2094
|
+
const apply = () => {
|
|
2095
|
+
for (const m of members) {
|
|
2096
|
+
const d = deltas.get(m.id);
|
|
2097
|
+
if (d) apply_translate(doc, m.id, m.baseline, d.x, d.y);
|
|
2098
|
+
}
|
|
2099
|
+
emit();
|
|
2100
|
+
};
|
|
2101
|
+
const revert = () => {
|
|
2102
|
+
for (const m of members) if (deltas.has(m.id)) apply_translate(doc, m.id, m.baseline, 0, 0);
|
|
2103
|
+
emit();
|
|
2104
|
+
};
|
|
2105
|
+
apply();
|
|
2106
|
+
history.atomic(`align ${direction}`, (tx) => {
|
|
2107
|
+
tx.push({
|
|
2108
|
+
providerId: PROVIDER_ID,
|
|
2109
|
+
apply,
|
|
2110
|
+
revert
|
|
2111
|
+
});
|
|
2112
|
+
});
|
|
2113
|
+
return true;
|
|
2114
|
+
}
|
|
2115
|
+
function select_all() {
|
|
2116
|
+
const parent = scope ?? doc.root;
|
|
2117
|
+
const children = doc.element_children_of(parent);
|
|
2118
|
+
if (children.length === 0) return false;
|
|
2119
|
+
set_selection(children);
|
|
2120
|
+
return true;
|
|
2121
|
+
}
|
|
2122
|
+
/**
|
|
2123
|
+
* Cycle the selection to the next / previous sibling. Single-selection
|
|
2124
|
+
* path uses the selected node's parent; empty / multi-selection falls
|
|
2125
|
+
* back to the current scope's first / last child. Wraps at edges.
|
|
2126
|
+
*/
|
|
2127
|
+
function select_sibling(direction) {
|
|
2128
|
+
let parent;
|
|
2129
|
+
let anchor_index;
|
|
2130
|
+
let siblings;
|
|
2131
|
+
if (selection.length === 1) {
|
|
2132
|
+
const current = selection[0];
|
|
2133
|
+
parent = doc.parent_of(current);
|
|
2134
|
+
if (parent === null) return false;
|
|
2135
|
+
siblings = doc.element_children_of(parent);
|
|
2136
|
+
anchor_index = siblings.indexOf(current);
|
|
2137
|
+
if (anchor_index < 0) return false;
|
|
2138
|
+
} else {
|
|
2139
|
+
parent = scope ?? doc.root;
|
|
2140
|
+
siblings = doc.element_children_of(parent);
|
|
2141
|
+
if (siblings.length === 0) return false;
|
|
2142
|
+
anchor_index = direction === "next" ? -1 : siblings.length;
|
|
2143
|
+
}
|
|
2144
|
+
const n = siblings.length;
|
|
2145
|
+
const next = direction === "next" ? (anchor_index + 1) % n : (anchor_index - 1 + n) % n;
|
|
2146
|
+
set_selection([siblings[next]]);
|
|
2147
|
+
return true;
|
|
1528
2148
|
}
|
|
1529
2149
|
function reorder(direction) {
|
|
1530
2150
|
if (selection.length !== 1) return;
|
|
@@ -1572,33 +2192,179 @@ function createSvgEditor(opts) {
|
|
|
1572
2192
|
});
|
|
1573
2193
|
}
|
|
1574
2194
|
function remove() {
|
|
1575
|
-
if (selection.length
|
|
1576
|
-
const
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
const
|
|
2195
|
+
if (selection.length === 0) return;
|
|
2196
|
+
const filtered = doc.prune_nested_nodes(selection).filter((id) => doc.parent_of(id) !== null);
|
|
2197
|
+
if (filtered.length === 0) return;
|
|
2198
|
+
const doc_order = doc.all_elements();
|
|
2199
|
+
const index_of = /* @__PURE__ */ new Map();
|
|
2200
|
+
for (let i = 0; i < doc_order.length; i++) index_of.set(doc_order[i], i);
|
|
2201
|
+
const captures = [...filtered].sort((a, b) => (index_of.get(a) ?? 0) - (index_of.get(b) ?? 0)).map((id) => ({
|
|
2202
|
+
id,
|
|
2203
|
+
parent: doc.parent_of(id),
|
|
2204
|
+
next_sibling: doc.next_element_sibling_of(id)
|
|
2205
|
+
}));
|
|
1580
2206
|
const old_selection = selection;
|
|
1581
2207
|
const apply = () => {
|
|
1582
|
-
doc.remove(
|
|
2208
|
+
for (const c of captures) doc.remove(c.id);
|
|
1583
2209
|
set_selection([]);
|
|
1584
2210
|
};
|
|
1585
2211
|
const revert = () => {
|
|
1586
|
-
|
|
2212
|
+
for (let i = captures.length - 1; i >= 0; i--) {
|
|
2213
|
+
const c = captures[i];
|
|
2214
|
+
doc.insert(c.id, c.parent, c.next_sibling);
|
|
2215
|
+
}
|
|
1587
2216
|
set_selection(old_selection);
|
|
1588
2217
|
};
|
|
1589
2218
|
apply();
|
|
1590
|
-
history.atomic("remove"
|
|
2219
|
+
history.atomic(captures.length === 1 ? "remove" : `remove ${captures.length}`, (tx) => {
|
|
2220
|
+
tx.push({
|
|
2221
|
+
providerId: PROVIDER_ID,
|
|
2222
|
+
apply,
|
|
2223
|
+
revert
|
|
2224
|
+
});
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
function group() {
|
|
2228
|
+
const plan = plan_group(doc, selection);
|
|
2229
|
+
if (!plan) return false;
|
|
2230
|
+
const group_id = doc.create_element("g");
|
|
2231
|
+
const original_selection = selection;
|
|
2232
|
+
const apply = () => {
|
|
2233
|
+
doc.insert(group_id, plan.parent, plan.insert_before);
|
|
2234
|
+
for (const child of plan.children) doc.insert(child, group_id, null);
|
|
2235
|
+
set_selection([group_id]);
|
|
2236
|
+
};
|
|
2237
|
+
const revert = () => {
|
|
2238
|
+
for (let i = plan.children.length - 1; i >= 0; i--) {
|
|
2239
|
+
const child = plan.children[i];
|
|
2240
|
+
doc.insert(child, plan.parent, plan.original_positions.get(child) ?? null);
|
|
2241
|
+
}
|
|
2242
|
+
doc.remove(group_id);
|
|
2243
|
+
set_selection(original_selection);
|
|
2244
|
+
};
|
|
2245
|
+
apply();
|
|
2246
|
+
history.atomic("group", (tx) => {
|
|
2247
|
+
tx.push({
|
|
2248
|
+
providerId: PROVIDER_ID,
|
|
2249
|
+
apply,
|
|
2250
|
+
revert
|
|
2251
|
+
});
|
|
2252
|
+
});
|
|
2253
|
+
return true;
|
|
2254
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* Atomic one-shot insertion. Used by paste, programmatic RPC, and the
|
|
2257
|
+
* click-no-drag commit path inside the insertion gesture driver. One
|
|
2258
|
+
* undo step. Returns the new node id.
|
|
2259
|
+
*
|
|
2260
|
+
* `attrs` are merged on top of `default_paint_attrs(tag)` — caller attrs
|
|
2261
|
+
* win. `opts.parent` defaults to root; `opts.index` (insert-before
|
|
2262
|
+
* sibling index) defaults to append; `opts.select` defaults to `true`.
|
|
2263
|
+
*/
|
|
2264
|
+
function insert(tag, attrs, opts) {
|
|
2265
|
+
const parent = opts?.parent ?? doc.root;
|
|
2266
|
+
const select_after = opts?.select !== false;
|
|
2267
|
+
let insert_before = null;
|
|
2268
|
+
if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
|
|
2269
|
+
const id = doc.create_element(tag);
|
|
2270
|
+
const merged_attrs = {
|
|
2271
|
+
...default_paint_attrs_for(tag),
|
|
2272
|
+
...attrs
|
|
2273
|
+
};
|
|
2274
|
+
const attr_pairs = Object.entries(merged_attrs);
|
|
2275
|
+
const previous_selection = selection;
|
|
2276
|
+
const apply = () => {
|
|
2277
|
+
for (const [name, value] of attr_pairs) doc.set_attr(id, name, value);
|
|
2278
|
+
doc.insert(id, parent, insert_before);
|
|
2279
|
+
if (select_after) set_selection([id]);
|
|
2280
|
+
};
|
|
2281
|
+
const revert = () => {
|
|
2282
|
+
doc.remove(id);
|
|
2283
|
+
if (select_after) set_selection(previous_selection);
|
|
2284
|
+
};
|
|
2285
|
+
apply();
|
|
2286
|
+
history.atomic(`insert ${tag}`, (tx) => {
|
|
1591
2287
|
tx.push({
|
|
1592
2288
|
providerId: PROVIDER_ID,
|
|
1593
2289
|
apply,
|
|
1594
2290
|
revert
|
|
1595
2291
|
});
|
|
1596
2292
|
});
|
|
2293
|
+
return id;
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Preview-bracketed insertion. Used by the pointer-driven drag gesture
|
|
2297
|
+
* in the DOM surface. Per-frame attr writes call `update(attrs)`; one
|
|
2298
|
+
* undo step on `commit()`; clean rollback on `discard()`.
|
|
2299
|
+
*
|
|
2300
|
+
* The node is created and inserted on open so the HUD selection chrome
|
|
2301
|
+
* can render the in-progress shape immediately. On `discard()` the
|
|
2302
|
+
* preview's revert removes the node entirely.
|
|
2303
|
+
*/
|
|
2304
|
+
function insert_preview(tag, initial, opts) {
|
|
2305
|
+
const parent = opts?.parent ?? doc.root;
|
|
2306
|
+
let insert_before = null;
|
|
2307
|
+
if (opts?.index !== void 0) insert_before = doc.element_children_of(parent)[opts.index] ?? null;
|
|
2308
|
+
const id = doc.create_element(tag);
|
|
2309
|
+
const previous_selection = selection;
|
|
2310
|
+
const live_attrs = {
|
|
2311
|
+
...default_paint_attrs_for(tag),
|
|
2312
|
+
...initial
|
|
2313
|
+
};
|
|
2314
|
+
for (const name in live_attrs) doc.set_attr(id, name, live_attrs[name]);
|
|
2315
|
+
doc.insert(id, parent, insert_before);
|
|
2316
|
+
set_selection([id]);
|
|
2317
|
+
const preview = history.preview(`insert ${tag}`);
|
|
2318
|
+
let active = true;
|
|
2319
|
+
const apply = () => {
|
|
2320
|
+
for (const name in live_attrs) doc.set_attr(id, name, live_attrs[name]);
|
|
2321
|
+
if (doc.parent_of(id) === null) doc.insert(id, parent, insert_before);
|
|
2322
|
+
set_selection([id]);
|
|
2323
|
+
};
|
|
2324
|
+
const revert = () => {
|
|
2325
|
+
doc.remove(id);
|
|
2326
|
+
set_selection(previous_selection);
|
|
2327
|
+
};
|
|
2328
|
+
const entry = {
|
|
2329
|
+
providerId: PROVIDER_ID,
|
|
2330
|
+
apply,
|
|
2331
|
+
revert
|
|
2332
|
+
};
|
|
2333
|
+
preview.set(entry);
|
|
2334
|
+
return {
|
|
2335
|
+
id,
|
|
2336
|
+
update(attrs) {
|
|
2337
|
+
if (!active) return;
|
|
2338
|
+
for (const name in attrs) {
|
|
2339
|
+
live_attrs[name] = attrs[name];
|
|
2340
|
+
doc.set_attr(id, name, attrs[name]);
|
|
2341
|
+
}
|
|
2342
|
+
preview.set(entry);
|
|
2343
|
+
},
|
|
2344
|
+
commit() {
|
|
2345
|
+
if (!active) return;
|
|
2346
|
+
active = false;
|
|
2347
|
+
preview.commit();
|
|
2348
|
+
},
|
|
2349
|
+
discard() {
|
|
2350
|
+
if (!active) return;
|
|
2351
|
+
active = false;
|
|
2352
|
+
preview.discard();
|
|
2353
|
+
}
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
/** Per-tag default paint attrs. Wrapped so callers don't need to depend
|
|
2357
|
+
* on the InsertableTag type — `insert()` accepts arbitrary string tags
|
|
2358
|
+
* (so `commands.insert("path", ...)` works for paste / RPC) but only
|
|
2359
|
+
* the closed insertable set gets default paint. */
|
|
2360
|
+
function default_paint_attrs_for(tag) {
|
|
2361
|
+
if (tag === "rect" || tag === "ellipse" || tag === "line") return default_paint_attrs(tag);
|
|
2362
|
+
return {};
|
|
1597
2363
|
}
|
|
1598
2364
|
function set_text(value) {
|
|
1599
2365
|
if (selection.length !== 1) return;
|
|
1600
2366
|
const target = selection[0];
|
|
1601
|
-
if (doc.
|
|
2367
|
+
if (!doc.is_text_edit_target(target)) return;
|
|
1602
2368
|
const original = doc.text_of(target);
|
|
1603
2369
|
if (original === value) return;
|
|
1604
2370
|
const apply = () => {
|
|
@@ -1641,7 +2407,7 @@ function createSvgEditor(opts) {
|
|
|
1641
2407
|
function enter_content_edit(target) {
|
|
1642
2408
|
const id = target ?? (selection.length === 1 ? selection[0] : null);
|
|
1643
2409
|
if (!id) return false;
|
|
1644
|
-
if (doc.
|
|
2410
|
+
if (!doc.is_text_edit_target(id)) return false;
|
|
1645
2411
|
if (!content_edit_driver) return false;
|
|
1646
2412
|
return content_edit_driver(id);
|
|
1647
2413
|
}
|
|
@@ -1650,8 +2416,10 @@ function createSvgEditor(opts) {
|
|
|
1650
2416
|
selection = [];
|
|
1651
2417
|
scope = null;
|
|
1652
2418
|
mode = "select";
|
|
2419
|
+
tool = TOOL_CURSOR;
|
|
1653
2420
|
history.clear();
|
|
1654
2421
|
baseline_doc_version = doc_version;
|
|
2422
|
+
load_version++;
|
|
1655
2423
|
emit();
|
|
1656
2424
|
}
|
|
1657
2425
|
function serialize_svg() {
|
|
@@ -1668,6 +2436,8 @@ function createSvgEditor(opts) {
|
|
|
1668
2436
|
const commands = {
|
|
1669
2437
|
select,
|
|
1670
2438
|
deselect,
|
|
2439
|
+
select_all,
|
|
2440
|
+
select_sibling,
|
|
1671
2441
|
enter_scope,
|
|
1672
2442
|
exit_scope,
|
|
1673
2443
|
set_mode,
|
|
@@ -1677,8 +2447,17 @@ function createSvgEditor(opts) {
|
|
|
1677
2447
|
preview_paint,
|
|
1678
2448
|
set_paint_from_gradient,
|
|
1679
2449
|
translate,
|
|
2450
|
+
nudge,
|
|
2451
|
+
resize_to,
|
|
2452
|
+
rotate,
|
|
2453
|
+
rotate_to,
|
|
2454
|
+
flatten_transform,
|
|
2455
|
+
align,
|
|
1680
2456
|
reorder,
|
|
1681
2457
|
remove,
|
|
2458
|
+
group,
|
|
2459
|
+
insert,
|
|
2460
|
+
insert_preview,
|
|
1682
2461
|
set_text,
|
|
1683
2462
|
load_svg,
|
|
1684
2463
|
serialize_svg,
|
|
@@ -1700,6 +2479,7 @@ function createSvgEditor(opts) {
|
|
|
1700
2479
|
selection = [];
|
|
1701
2480
|
scope = null;
|
|
1702
2481
|
mode = "select";
|
|
2482
|
+
tool = TOOL_CURSOR;
|
|
1703
2483
|
baseline_doc_version = doc_version;
|
|
1704
2484
|
emit();
|
|
1705
2485
|
}
|
|
@@ -1756,18 +2536,7 @@ function createSvgEditor(opts) {
|
|
|
1756
2536
|
return elem_id && elem_id.length > 0 ? `${head} #${elem_id}` : head;
|
|
1757
2537
|
},
|
|
1758
2538
|
tree() {
|
|
1759
|
-
|
|
1760
|
-
for (const id of doc.all_elements()) map.set(id, {
|
|
1761
|
-
id,
|
|
1762
|
-
tag: doc.tag_of(id),
|
|
1763
|
-
name: doc.get_attr(id, "id") ?? void 0,
|
|
1764
|
-
parent: doc.parent_of(id),
|
|
1765
|
-
children: doc.element_children_of(id)
|
|
1766
|
-
});
|
|
1767
|
-
return {
|
|
1768
|
-
root: doc.root,
|
|
1769
|
-
nodes: map
|
|
1770
|
-
};
|
|
2539
|
+
return tree_snapshot();
|
|
1771
2540
|
},
|
|
1772
2541
|
surface_hover() {
|
|
1773
2542
|
return current_surface_hover;
|
|
@@ -1783,7 +2552,17 @@ function createSvgEditor(opts) {
|
|
|
1783
2552
|
surface_hover_listeners.delete(cb);
|
|
1784
2553
|
};
|
|
1785
2554
|
},
|
|
2555
|
+
subscribe_geometry(cb) {
|
|
2556
|
+
geometry_listeners.add(cb);
|
|
2557
|
+
return () => {
|
|
2558
|
+
geometry_listeners.delete(cb);
|
|
2559
|
+
};
|
|
2560
|
+
},
|
|
2561
|
+
get geometry() {
|
|
2562
|
+
return geometry_provider;
|
|
2563
|
+
},
|
|
1786
2564
|
modes,
|
|
2565
|
+
set_tool,
|
|
1787
2566
|
get style() {
|
|
1788
2567
|
return style;
|
|
1789
2568
|
},
|
|
@@ -1797,6 +2576,15 @@ function createSvgEditor(opts) {
|
|
|
1797
2576
|
providers,
|
|
1798
2577
|
_internal: {
|
|
1799
2578
|
doc,
|
|
2579
|
+
history: { preview: (label) => history.preview(label) },
|
|
2580
|
+
emit,
|
|
2581
|
+
subscribe_translate_commit(cb) {
|
|
2582
|
+
translate_commit_listeners.add(cb);
|
|
2583
|
+
return () => {
|
|
2584
|
+
translate_commit_listeners.delete(cb);
|
|
2585
|
+
};
|
|
2586
|
+
},
|
|
2587
|
+
notify_translate_commit,
|
|
1800
2588
|
set_content_edit_driver(fn) {
|
|
1801
2589
|
content_edit_driver = fn;
|
|
1802
2590
|
},
|
|
@@ -1809,6 +2597,9 @@ function createSvgEditor(opts) {
|
|
|
1809
2597
|
},
|
|
1810
2598
|
set_computed_resolver(fn) {
|
|
1811
2599
|
computed_resolver = fn;
|
|
2600
|
+
},
|
|
2601
|
+
set_geometry(p) {
|
|
2602
|
+
geometry_provider = p;
|
|
1812
2603
|
}
|
|
1813
2604
|
},
|
|
1814
2605
|
keymap
|
|
@@ -1817,5 +2608,37 @@ function createSvgEditor(opts) {
|
|
|
1817
2608
|
applyDefaultBindings(keymap);
|
|
1818
2609
|
return public_editor;
|
|
1819
2610
|
}
|
|
2611
|
+
function paint_value_equals(a, b) {
|
|
2612
|
+
if (a === b) return true;
|
|
2613
|
+
if (a.declared !== b.declared) return false;
|
|
2614
|
+
if (a.provenance.carrier !== b.provenance.carrier) return false;
|
|
2615
|
+
if (a.provenance.origin !== b.provenance.origin) return false;
|
|
2616
|
+
return paint_equals(a.computed, b.computed);
|
|
2617
|
+
}
|
|
2618
|
+
function paint_equals(a, b) {
|
|
2619
|
+
if (a === b) return true;
|
|
2620
|
+
if (a == null || b == null) return false;
|
|
2621
|
+
if ("error" in a || "error" in b) return "error" in a && "error" in b && a.error === b.error && a.reason === b.reason;
|
|
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;
|
|
2627
|
+
}
|
|
2628
|
+
if (a.kind === "ref" && b.kind === "ref") return a.id === b.id;
|
|
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;
|
|
2633
|
+
}
|
|
2634
|
+
function property_value_equals(a, b) {
|
|
2635
|
+
if (a === b) return true;
|
|
2636
|
+
if (a.declared !== b.declared) return false;
|
|
2637
|
+
if (a.provenance.carrier !== b.provenance.carrier) return false;
|
|
2638
|
+
if (a.provenance.origin !== b.provenance.origin) return false;
|
|
2639
|
+
if (a.computed === b.computed) return true;
|
|
2640
|
+
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;
|
|
2641
|
+
return false;
|
|
2642
|
+
}
|
|
1820
2643
|
//#endregion
|
|
1821
|
-
export {
|
|
2644
|
+
export { createSvgEditor as t };
|