@grida/svg-editor 1.0.0-alpha.2 → 1.0.0-alpha.21
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 +343 -189
- package/dist/chunk-D7D4PA-g.mjs +13 -0
- package/dist/dom-CQkWJNrK.d.ts +237 -0
- package/dist/dom-CuK0LFUY.js +5276 -0
- package/dist/dom-DHaTIObb.mjs +5221 -0
- package/dist/dom-Dw2SPHgc.d.mts +239 -0
- package/dist/dom.d.mts +3 -16
- package/dist/dom.d.ts +3 -16
- package/dist/dom.js +9 -1
- package/dist/dom.mjs +2 -2
- package/dist/editor-BlByfVyF.js +2936 -0
- package/dist/editor-CJ3ROm0G.mjs +2930 -0
- package/dist/editor-CcW4BVth.d.mts +2359 -0
- package/dist/editor-CxqRhhzP.d.ts +2359 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -2
- package/dist/index.mjs +3 -2
- package/dist/model-C6jCFK_p.mjs +5329 -0
- package/dist/model-DVwjrVYp.js +5512 -0
- package/dist/presets.d.mts +61 -0
- package/dist/presets.d.ts +61 -0
- package/dist/presets.js +60 -0
- package/dist/presets.mjs +54 -0
- package/dist/react.d.mts +133 -12
- package/dist/react.d.ts +133 -12
- package/dist/react.js +214 -19
- package/dist/react.mjs +203 -21
- package/package.json +40 -9
- package/dist/dom-CfP_ZURh.js +0 -963
- package/dist/dom-kA8NDuVh.mjs +0 -929
- package/dist/editor-BryibVvr.d.mts +0 -612
- package/dist/editor-DllAMsDu.js +0 -1835
- package/dist/editor-M6j8XGO5.mjs +0 -1823
- package/dist/editor-klT8wu-x.d.ts +0 -612
- package/dist/paint-DHq_3iwU.js +0 -509
- package/dist/paint-DuCg6Y-K.mjs +0 -461
|
@@ -0,0 +1,2930 @@
|
|
|
1
|
+
import { C as TOOL_SET, S as is_text_input_focused, T as registerDefaultCommands, _ as SVG_NS, a as paint, b as XMLNS_NS, g as subtree, h as transform, i as TOOL_CURSOR, m as group, n as insertions, p as translate_pipeline, r as DEFAULT_STYLE, s as resize_pipeline, u as rotate_pipeline, v as SvgDocument, x as array_shallow_equal, y as WELL_KNOWN_NS_PREFIXES } from "./model-C6jCFK_p.mjs";
|
|
2
|
+
import { HistoryImpl } from "@grida/history";
|
|
3
|
+
import { KeyCode, M, chunkKey, eventToChunk, getKeyboardOS, kb, keybindingsToKeyCodes } from "@grida/keybinding";
|
|
4
|
+
import cmath from "@grida/cmath";
|
|
5
|
+
import { encode_attr_value } from "@grida/svg/parser";
|
|
6
|
+
//#region src/commands/registry.ts
|
|
7
|
+
var CommandRegistry = class {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.map = /* @__PURE__ */ new Map();
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Register a command. Returns an unregister function. Re-registering
|
|
13
|
+
* the same id replaces the previous handler (last writer wins).
|
|
14
|
+
*/
|
|
15
|
+
register(id, handler) {
|
|
16
|
+
this.map.set(id, handler);
|
|
17
|
+
return () => {
|
|
18
|
+
if (this.map.get(id) === handler) this.map.delete(id);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Invoke a command by id. Returns `true` if the handler consumed,
|
|
23
|
+
* `false` otherwise (including unknown ids and handlers that returned
|
|
24
|
+
* `false`/`undefined`).
|
|
25
|
+
*/
|
|
26
|
+
invoke(id, args) {
|
|
27
|
+
const handler = this.map.get(id);
|
|
28
|
+
if (!handler) return false;
|
|
29
|
+
return handler(args) === true;
|
|
30
|
+
}
|
|
31
|
+
has(id) {
|
|
32
|
+
return this.map.has(id);
|
|
33
|
+
}
|
|
34
|
+
/** All registered ids, for debugging / introspection. */
|
|
35
|
+
ids() {
|
|
36
|
+
return Array.from(this.map.keys());
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/keymap/keymap.ts
|
|
41
|
+
/**
|
|
42
|
+
* Keymap — bindings of declarative `Keybinding`s (from `@grida/keybinding`)
|
|
43
|
+
* to command ids.
|
|
44
|
+
*
|
|
45
|
+
* Dispatch is ProseMirror-`chainCommands`-shaped: multiple bindings on
|
|
46
|
+
* the same key are tried in priority order; the first whose handler
|
|
47
|
+
* returns `true` wins, the rest are skipped. This keeps "one key, many
|
|
48
|
+
* meanings" expressible without a `when:` DSL — handlers self-guard on
|
|
49
|
+
* editor state and return `false` when not applicable.
|
|
50
|
+
*
|
|
51
|
+
* The keymap does NOT see modifier-as-signal (e.g. Alt-held for
|
|
52
|
+
* measurement). That stays on the HUD modifiers channel. The keymap
|
|
53
|
+
* only sees Mod+D-shape chords.
|
|
54
|
+
*/
|
|
55
|
+
var Keymap = class {
|
|
56
|
+
constructor(commands, platformGetter = getKeyboardOS) {
|
|
57
|
+
this.commands = commands;
|
|
58
|
+
this.platformGetter = platformGetter;
|
|
59
|
+
this.buckets = /* @__PURE__ */ new Map();
|
|
60
|
+
this.seq = 0;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Bind a key combination to a command. Returns an unbind function.
|
|
64
|
+
* The same `Keybinding` can be bound to multiple commands — they will
|
|
65
|
+
* all be tried in chain order on dispatch.
|
|
66
|
+
*/
|
|
67
|
+
bind(binding) {
|
|
68
|
+
const entry = {
|
|
69
|
+
binding,
|
|
70
|
+
seq: ++this.seq
|
|
71
|
+
};
|
|
72
|
+
for (const hash of this.chunkKeysFor(binding.keybinding)) {
|
|
73
|
+
const list = this.buckets.get(hash);
|
|
74
|
+
if (list) {
|
|
75
|
+
list.push(entry);
|
|
76
|
+
list.sort(compareEntries);
|
|
77
|
+
} else this.buckets.set(hash, [entry]);
|
|
78
|
+
}
|
|
79
|
+
return () => {
|
|
80
|
+
for (const hash of this.chunkKeysFor(binding.keybinding)) {
|
|
81
|
+
const list = this.buckets.get(hash);
|
|
82
|
+
if (!list) continue;
|
|
83
|
+
const idx = list.findIndex((e) => e === entry);
|
|
84
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
85
|
+
if (list.length === 0) this.buckets.delete(hash);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Remove bindings matching the spec. If both filters are passed, only
|
|
91
|
+
* bindings that match BOTH are removed.
|
|
92
|
+
*/
|
|
93
|
+
unbind(spec) {
|
|
94
|
+
const hashFilter = spec.keybinding ? new Set(this.chunkKeysFor(spec.keybinding)) : null;
|
|
95
|
+
for (const [hash, list] of this.buckets) {
|
|
96
|
+
if (hashFilter && !hashFilter.has(hash)) continue;
|
|
97
|
+
const next = list.filter((e) => {
|
|
98
|
+
if (spec.command && e.binding.command !== spec.command) return true;
|
|
99
|
+
return false;
|
|
100
|
+
});
|
|
101
|
+
if (next.length === 0) this.buckets.delete(hash);
|
|
102
|
+
else if (next.length !== list.length) this.buckets.set(hash, next);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** All registered bindings, for introspection. Order is not guaranteed. */
|
|
106
|
+
bindings() {
|
|
107
|
+
const seen = /* @__PURE__ */ new Set();
|
|
108
|
+
for (const list of this.buckets.values()) for (const e of list) seen.add(e.binding);
|
|
109
|
+
return Array.from(seen);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Does the keymap have a binding that matches this event's chord —
|
|
113
|
+
* regardless of whether any handler would consume it? Hosts use this
|
|
114
|
+
* to decide whether to swallow the platform's default action (e.g.
|
|
115
|
+
* `event.preventDefault()` in the browser), so that an advertised
|
|
116
|
+
* shortcut like `Cmd+G` doesn't fall through to the browser's find
|
|
117
|
+
* bar even when the binding's handler rejects.
|
|
118
|
+
*
|
|
119
|
+
* Pure read; runs no handlers, no side effects. Honors the same
|
|
120
|
+
* form-element focus guard `dispatch` uses, so a typing user's
|
|
121
|
+
* keystroke isn't "claimed" — and the browser's native text-editing
|
|
122
|
+
* default (Cmd+A select all, Cmd+Z undo, etc.) wins.
|
|
123
|
+
*/
|
|
124
|
+
claims(event) {
|
|
125
|
+
const chunk = eventToChunk(event);
|
|
126
|
+
if (chunk.keys.length === 0) return false;
|
|
127
|
+
const list = this.buckets.get(chunkKey(chunk));
|
|
128
|
+
if (!list || list.length === 0) return false;
|
|
129
|
+
if (is_text_input_focused()) return list.some(({ binding }) => binding.allowInFormElement === true);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Match the event against bound chunks, then run candidates in chain
|
|
134
|
+
* order. Returns `true` on the first handler that consumes; returns
|
|
135
|
+
* `false` if nothing matched or all matches fell through.
|
|
136
|
+
*
|
|
137
|
+
* **Form-element focus guard.** When a text input is focused
|
|
138
|
+
* (`<input>`, `<textarea>`, contentEditable), bindings are suppressed
|
|
139
|
+
* by default so the platform's native shortcuts (Cmd+A, Cmd+Z, Cmd+C,
|
|
140
|
+
* arrow nav, …) are preserved. A binding can opt out of this guard
|
|
141
|
+
* with `allowInFormElement: true` — see `KeymapBinding`.
|
|
142
|
+
*
|
|
143
|
+
* `dispatch` is browser-agnostic: it does NOT call `preventDefault()`
|
|
144
|
+
* or touch the event in any way. The host decides what to do with the
|
|
145
|
+
* platform default — typically `if (keymap.claims(e)) e.preventDefault()`,
|
|
146
|
+
* which prevents the platform default for advertised shortcuts even
|
|
147
|
+
* when the chain rejects. See README → `editor.keymap`.
|
|
148
|
+
*/
|
|
149
|
+
dispatch(event) {
|
|
150
|
+
const chunk = eventToChunk(event);
|
|
151
|
+
if (chunk.keys.length === 0) return false;
|
|
152
|
+
const hash = chunkKey(chunk);
|
|
153
|
+
const list = this.buckets.get(hash);
|
|
154
|
+
if (!list || list.length === 0) return false;
|
|
155
|
+
const text_focused = is_text_input_focused();
|
|
156
|
+
for (const { binding } of list) {
|
|
157
|
+
if (text_focused && binding.allowInFormElement !== true) continue;
|
|
158
|
+
if (this.commands.invoke(binding.command, binding.args)) return true;
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Compute the set of canonical hashes a `Keybinding` lights up. A
|
|
164
|
+
* binding with aliases (multiple sequences) contributes one hash per
|
|
165
|
+
* single-chunk alias; multi-chunk sequences (chords) are skipped in
|
|
166
|
+
* V1.
|
|
167
|
+
*/
|
|
168
|
+
chunkKeysFor(binding) {
|
|
169
|
+
const sequences = keybindingsToKeyCodes(binding, this.platformGetter());
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const seq of sequences) {
|
|
172
|
+
if (seq.length !== 1) continue;
|
|
173
|
+
out.push(chunkKey(seq[0]));
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
function compareEntries(a, b) {
|
|
179
|
+
const pa = a.binding.priority ?? 0;
|
|
180
|
+
const pb = b.binding.priority ?? 0;
|
|
181
|
+
if (pa !== pb) return pb - pa;
|
|
182
|
+
return a.seq - b.seq;
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/keymap/defaults.ts
|
|
186
|
+
/**
|
|
187
|
+
* Default keybindings shipped with `@grida/svg-editor`.
|
|
188
|
+
*
|
|
189
|
+
* THIS IS THE ONLY FILE where built-in shortcuts are declared. Adding
|
|
190
|
+
* a new shortcut = one new row here (plus, if the target command is
|
|
191
|
+
* new, one new handler in `src/commands/defaults.ts`). That is the
|
|
192
|
+
* V1 design contract.
|
|
193
|
+
*
|
|
194
|
+
* Same key, multiple meanings? Add multiple rows. The chain semantics
|
|
195
|
+
* (handler returns `false` when not applicable) handle the rest.
|
|
196
|
+
*/
|
|
197
|
+
const NUDGE_MEANINGFUL = M.Shift | M.Ctrl;
|
|
198
|
+
const RESIZE_MEANINGFUL = M.Shift | M.Ctrl | M.Alt;
|
|
199
|
+
const DEFAULT_BINDINGS = [
|
|
200
|
+
{
|
|
201
|
+
keybinding: kb(KeyCode.KeyZ, M.CtrlCmd),
|
|
202
|
+
command: "history.undo"
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
keybinding: kb(KeyCode.KeyZ, M.CtrlCmd | M.Shift),
|
|
206
|
+
command: "history.redo"
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
keybinding: kb(KeyCode.KeyY, M.CtrlCmd),
|
|
210
|
+
command: "history.redo"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
keybinding: kb(KeyCode.Escape),
|
|
214
|
+
command: "selection.deselect"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
keybinding: kb(KeyCode.Backspace),
|
|
218
|
+
command: "selection.remove"
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
keybinding: kb(KeyCode.Delete),
|
|
222
|
+
command: "selection.remove"
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
keybinding: kb(KeyCode.KeyG, M.CtrlCmd),
|
|
226
|
+
command: "selection.group"
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
keybinding: kb(KeyCode.KeyG, M.CtrlCmd | M.Shift),
|
|
230
|
+
command: "selection.ungroup"
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
keybinding: kb(KeyCode.KeyD, M.CtrlCmd),
|
|
234
|
+
command: "selection.duplicate"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
keybinding: kb(KeyCode.KeyA, M.CtrlCmd),
|
|
238
|
+
command: "selection.all"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
keybinding: kb(KeyCode.Tab),
|
|
242
|
+
command: "selection.sibling",
|
|
243
|
+
args: "next"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
keybinding: kb(KeyCode.Tab, M.Shift),
|
|
247
|
+
command: "selection.sibling",
|
|
248
|
+
args: "prev"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
keybinding: kb(KeyCode.KeyA, M.Alt),
|
|
252
|
+
command: "selection.align",
|
|
253
|
+
args: "left"
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
keybinding: kb(KeyCode.KeyD, M.Alt),
|
|
257
|
+
command: "selection.align",
|
|
258
|
+
args: "right"
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
keybinding: kb(KeyCode.KeyW, M.Alt),
|
|
262
|
+
command: "selection.align",
|
|
263
|
+
args: "top"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
keybinding: kb(KeyCode.KeyS, M.Alt),
|
|
267
|
+
command: "selection.align",
|
|
268
|
+
args: "bottom"
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
keybinding: kb(KeyCode.KeyH, M.Alt),
|
|
272
|
+
command: "selection.align",
|
|
273
|
+
args: "horizontal_centers"
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
keybinding: kb(KeyCode.KeyV, M.Alt),
|
|
277
|
+
command: "selection.align",
|
|
278
|
+
args: "vertical_centers"
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
keybinding: kb(KeyCode.Enter),
|
|
282
|
+
command: "content.enter"
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
keybinding: kb(KeyCode.Enter),
|
|
286
|
+
command: "hierarchy.enter"
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
keybinding: kb(KeyCode.Enter, M.Shift),
|
|
290
|
+
command: "hierarchy.exit"
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
keybinding: kb(KeyCode.LeftArrow, 0, NUDGE_MEANINGFUL),
|
|
294
|
+
command: "transform.nudge",
|
|
295
|
+
args: {
|
|
296
|
+
dx: -1,
|
|
297
|
+
dy: 0
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
keybinding: kb(KeyCode.RightArrow, 0, NUDGE_MEANINGFUL),
|
|
302
|
+
command: "transform.nudge",
|
|
303
|
+
args: {
|
|
304
|
+
dx: 1,
|
|
305
|
+
dy: 0
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
keybinding: kb(KeyCode.UpArrow, 0, NUDGE_MEANINGFUL),
|
|
310
|
+
command: "transform.nudge",
|
|
311
|
+
args: {
|
|
312
|
+
dx: 0,
|
|
313
|
+
dy: -1
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
keybinding: kb(KeyCode.DownArrow, 0, NUDGE_MEANINGFUL),
|
|
318
|
+
command: "transform.nudge",
|
|
319
|
+
args: {
|
|
320
|
+
dx: 0,
|
|
321
|
+
dy: 1
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
keybinding: kb(KeyCode.LeftArrow, M.Shift, NUDGE_MEANINGFUL),
|
|
326
|
+
command: "transform.nudge",
|
|
327
|
+
args: {
|
|
328
|
+
dx: -10,
|
|
329
|
+
dy: 0
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
keybinding: kb(KeyCode.RightArrow, M.Shift, NUDGE_MEANINGFUL),
|
|
334
|
+
command: "transform.nudge",
|
|
335
|
+
args: {
|
|
336
|
+
dx: 10,
|
|
337
|
+
dy: 0
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
keybinding: kb(KeyCode.UpArrow, M.Shift, NUDGE_MEANINGFUL),
|
|
342
|
+
command: "transform.nudge",
|
|
343
|
+
args: {
|
|
344
|
+
dx: 0,
|
|
345
|
+
dy: -10
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
keybinding: kb(KeyCode.DownArrow, M.Shift, NUDGE_MEANINGFUL),
|
|
350
|
+
command: "transform.nudge",
|
|
351
|
+
args: {
|
|
352
|
+
dx: 0,
|
|
353
|
+
dy: 10
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
keybinding: kb(KeyCode.RightArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
|
|
358
|
+
command: "selection.nudge_resize",
|
|
359
|
+
args: {
|
|
360
|
+
dw: 1,
|
|
361
|
+
dh: 0
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
keybinding: kb(KeyCode.LeftArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
|
|
366
|
+
command: "selection.nudge_resize",
|
|
367
|
+
args: {
|
|
368
|
+
dw: -1,
|
|
369
|
+
dh: 0
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
keybinding: kb(KeyCode.DownArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
|
|
374
|
+
command: "selection.nudge_resize",
|
|
375
|
+
args: {
|
|
376
|
+
dw: 0,
|
|
377
|
+
dh: 1
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
keybinding: kb(KeyCode.UpArrow, M.Ctrl | M.Alt, RESIZE_MEANINGFUL),
|
|
382
|
+
command: "selection.nudge_resize",
|
|
383
|
+
args: {
|
|
384
|
+
dw: 0,
|
|
385
|
+
dh: -1
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
keybinding: kb(KeyCode.RightArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
|
|
390
|
+
command: "selection.nudge_resize",
|
|
391
|
+
args: {
|
|
392
|
+
dw: 10,
|
|
393
|
+
dh: 0
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
keybinding: kb(KeyCode.LeftArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
|
|
398
|
+
command: "selection.nudge_resize",
|
|
399
|
+
args: {
|
|
400
|
+
dw: -10,
|
|
401
|
+
dh: 0
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
keybinding: kb(KeyCode.DownArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
|
|
406
|
+
command: "selection.nudge_resize",
|
|
407
|
+
args: {
|
|
408
|
+
dw: 0,
|
|
409
|
+
dh: 10
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
keybinding: kb(KeyCode.UpArrow, M.Ctrl | M.Alt | M.Shift, RESIZE_MEANINGFUL),
|
|
414
|
+
command: "selection.nudge_resize",
|
|
415
|
+
args: {
|
|
416
|
+
dw: 0,
|
|
417
|
+
dh: -10
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
keybinding: kb(KeyCode.KeyV),
|
|
422
|
+
command: TOOL_SET,
|
|
423
|
+
args: { type: "cursor" }
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
keybinding: kb(KeyCode.KeyR),
|
|
427
|
+
command: TOOL_SET,
|
|
428
|
+
args: {
|
|
429
|
+
type: "insert",
|
|
430
|
+
tag: "rect"
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
keybinding: kb(KeyCode.KeyO),
|
|
435
|
+
command: TOOL_SET,
|
|
436
|
+
args: {
|
|
437
|
+
type: "insert",
|
|
438
|
+
tag: "ellipse"
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
keybinding: kb(KeyCode.KeyL),
|
|
443
|
+
command: TOOL_SET,
|
|
444
|
+
args: {
|
|
445
|
+
type: "insert",
|
|
446
|
+
tag: "line"
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
keybinding: kb(KeyCode.KeyT),
|
|
451
|
+
command: TOOL_SET,
|
|
452
|
+
args: { type: "insert-text" }
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
keybinding: kb(KeyCode.KeyQ),
|
|
456
|
+
command: TOOL_SET,
|
|
457
|
+
args: { type: "lasso" }
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
keybinding: kb(KeyCode.BracketRight),
|
|
461
|
+
command: "reorder",
|
|
462
|
+
args: "bring_to_front"
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
keybinding: kb(KeyCode.BracketLeft),
|
|
466
|
+
command: "reorder",
|
|
467
|
+
args: "send_to_back"
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
keybinding: kb(KeyCode.BracketRight, M.CtrlCmd),
|
|
471
|
+
command: "reorder",
|
|
472
|
+
args: "bring_forward"
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
keybinding: kb(KeyCode.BracketLeft, M.CtrlCmd),
|
|
476
|
+
command: "reorder",
|
|
477
|
+
args: "send_backward"
|
|
478
|
+
}
|
|
479
|
+
];
|
|
480
|
+
/** Register every default binding into a keymap. */
|
|
481
|
+
function applyDefaultBindings(keymap) {
|
|
482
|
+
for (const b of DEFAULT_BINDINGS) keymap.bind(b);
|
|
483
|
+
}
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/core/defs.ts
|
|
486
|
+
var GradientsRegistry = class {
|
|
487
|
+
constructor(doc) {
|
|
488
|
+
this.doc = doc;
|
|
489
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
490
|
+
this.counter = 0;
|
|
491
|
+
this._cached = null;
|
|
492
|
+
this._cached_by_id = /* @__PURE__ */ new Map();
|
|
493
|
+
this._dirty = true;
|
|
494
|
+
doc.on_change(() => {
|
|
495
|
+
this._dirty = true;
|
|
496
|
+
this.emit();
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
list() {
|
|
500
|
+
if (!this._dirty && this._cached) return this._cached;
|
|
501
|
+
const out = [];
|
|
502
|
+
let any_change = !this._cached;
|
|
503
|
+
const seen = /* @__PURE__ */ new Set();
|
|
504
|
+
const defs = this.find_defs_elements();
|
|
505
|
+
for (const def_id of defs) for (const child of this.doc.element_children_of(def_id)) {
|
|
506
|
+
const tag = this.doc.tag_of(child);
|
|
507
|
+
if (tag === "linearGradient" || tag === "radialGradient") {
|
|
508
|
+
const id = this.doc.get_attr(child, "id");
|
|
509
|
+
if (!id) continue;
|
|
510
|
+
const definition = this.read_gradient(child, tag);
|
|
511
|
+
if (!definition) continue;
|
|
512
|
+
const ref_count = this.count_refs(id);
|
|
513
|
+
const prev = this._cached_by_id.get(id);
|
|
514
|
+
if (prev && prev.ref_count === ref_count && gradient_definition_equals(prev.definition, definition)) out.push(prev);
|
|
515
|
+
else {
|
|
516
|
+
const entry = {
|
|
517
|
+
id,
|
|
518
|
+
definition,
|
|
519
|
+
ref_count
|
|
520
|
+
};
|
|
521
|
+
this._cached_by_id.set(id, entry);
|
|
522
|
+
out.push(entry);
|
|
523
|
+
any_change = true;
|
|
524
|
+
}
|
|
525
|
+
seen.add(id);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const id of this._cached_by_id.keys()) if (!seen.has(id)) {
|
|
529
|
+
this._cached_by_id.delete(id);
|
|
530
|
+
any_change = true;
|
|
531
|
+
}
|
|
532
|
+
if (!any_change && this._cached && array_shallow_equal(this._cached, out)) {
|
|
533
|
+
this._dirty = false;
|
|
534
|
+
return this._cached;
|
|
535
|
+
}
|
|
536
|
+
const frozen = Object.freeze(out);
|
|
537
|
+
this._cached = frozen;
|
|
538
|
+
this._dirty = false;
|
|
539
|
+
return frozen;
|
|
540
|
+
}
|
|
541
|
+
get(id) {
|
|
542
|
+
return this.list().find((g) => g.id === id) ?? null;
|
|
543
|
+
}
|
|
544
|
+
upsert(definition, opts) {
|
|
545
|
+
const existing_id = opts?.id;
|
|
546
|
+
if (existing_id) {
|
|
547
|
+
const node = this.find_gradient_node(existing_id);
|
|
548
|
+
if (node !== null) {
|
|
549
|
+
this.write_gradient(node, definition);
|
|
550
|
+
return existing_id;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
const id = existing_id ?? this.fresh_id();
|
|
554
|
+
const defs_id = this.ensure_defs();
|
|
555
|
+
const tag = definition.kind === "linear" ? "linearGradient" : "radialGradient";
|
|
556
|
+
const new_id = this.doc.create_element(tag, { ns: "http://www.w3.org/2000/svg" });
|
|
557
|
+
this.doc.set_attr(new_id, "id", id);
|
|
558
|
+
this.doc.insert(new_id, defs_id, null);
|
|
559
|
+
this.write_gradient(new_id, definition);
|
|
560
|
+
this.emit();
|
|
561
|
+
return id;
|
|
562
|
+
}
|
|
563
|
+
remove(id) {
|
|
564
|
+
const refs = this.count_refs(id);
|
|
565
|
+
if (refs > 0) throw new Error(`[svg-editor] cannot remove gradient "${id}": ${refs} node(s) still reference it`);
|
|
566
|
+
const node = this.find_gradient_node(id);
|
|
567
|
+
if (node !== null) {
|
|
568
|
+
this.doc.remove(node);
|
|
569
|
+
this.emit();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
subscribe(fn) {
|
|
573
|
+
this.listeners.add(fn);
|
|
574
|
+
return () => {
|
|
575
|
+
this.listeners.delete(fn);
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
emit() {
|
|
579
|
+
const snap = this.list();
|
|
580
|
+
for (const fn of this.listeners) fn(snap);
|
|
581
|
+
}
|
|
582
|
+
fresh_id() {
|
|
583
|
+
let id;
|
|
584
|
+
do
|
|
585
|
+
id = `g${++this.counter}`;
|
|
586
|
+
while (this.find_gradient_node(id) !== null);
|
|
587
|
+
return id;
|
|
588
|
+
}
|
|
589
|
+
find_defs_elements() {
|
|
590
|
+
return this.doc.find_by_tag(this.doc.root, "defs");
|
|
591
|
+
}
|
|
592
|
+
ensure_defs() {
|
|
593
|
+
const existing = this.find_defs_elements();
|
|
594
|
+
if (existing.length > 0) return existing[0];
|
|
595
|
+
const defs = this.doc.create_element("defs", { ns: "http://www.w3.org/2000/svg" });
|
|
596
|
+
const first = this.doc.children_of(this.doc.root)[0] ?? null;
|
|
597
|
+
this.doc.insert(defs, this.doc.root, first);
|
|
598
|
+
return defs;
|
|
599
|
+
}
|
|
600
|
+
find_gradient_node(id) {
|
|
601
|
+
for (const def of this.find_defs_elements()) for (const child of this.doc.element_children_of(def)) if (this.doc.get_attr(child, "id") === id) {
|
|
602
|
+
const tag = this.doc.tag_of(child);
|
|
603
|
+
if (tag === "linearGradient" || tag === "radialGradient") return child;
|
|
604
|
+
}
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
read_gradient(id, tag) {
|
|
608
|
+
const stops = [];
|
|
609
|
+
for (const child of this.doc.element_children_of(id)) {
|
|
610
|
+
if (this.doc.tag_of(child) !== "stop") continue;
|
|
611
|
+
const offset = parseFloat(this.doc.get_attr(child, "offset") ?? "0");
|
|
612
|
+
const color = this.doc.get_attr(child, "stop-color") ?? this.doc.get_style(child, "stop-color") ?? "#000000";
|
|
613
|
+
const opacity_str = this.doc.get_attr(child, "stop-opacity") ?? this.doc.get_style(child, "stop-opacity");
|
|
614
|
+
const opacity = opacity_str !== null ? parseFloat(opacity_str) : void 0;
|
|
615
|
+
stops.push({
|
|
616
|
+
offset,
|
|
617
|
+
color,
|
|
618
|
+
...opacity !== void 0 ? { opacity } : {}
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
const gu = this.doc.get_attr(id, "gradientUnits");
|
|
622
|
+
const gradient_units = gu === "userSpaceOnUse" ? "user_space_on_use" : gu === "objectBoundingBox" ? "object_bounding_box" : void 0;
|
|
623
|
+
const sm = this.doc.get_attr(id, "spreadMethod");
|
|
624
|
+
const spread_method = sm === "pad" || sm === "reflect" || sm === "repeat" ? sm : void 0;
|
|
625
|
+
const num = (n) => {
|
|
626
|
+
const v = this.doc.get_attr(id, n);
|
|
627
|
+
return v !== null ? parseFloat(v) : void 0;
|
|
628
|
+
};
|
|
629
|
+
if (tag === "linearGradient") return {
|
|
630
|
+
kind: "linear",
|
|
631
|
+
stops,
|
|
632
|
+
x1: num("x1"),
|
|
633
|
+
y1: num("y1"),
|
|
634
|
+
x2: num("x2"),
|
|
635
|
+
y2: num("y2"),
|
|
636
|
+
gradient_units,
|
|
637
|
+
spread_method
|
|
638
|
+
};
|
|
639
|
+
return {
|
|
640
|
+
kind: "radial",
|
|
641
|
+
stops,
|
|
642
|
+
cx: num("cx"),
|
|
643
|
+
cy: num("cy"),
|
|
644
|
+
r: num("r"),
|
|
645
|
+
fx: num("fx"),
|
|
646
|
+
fy: num("fy"),
|
|
647
|
+
gradient_units,
|
|
648
|
+
spread_method
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
write_gradient(node, def) {
|
|
652
|
+
for (const c of this.doc.children_of(node).slice()) this.doc.remove(c);
|
|
653
|
+
const set_num = (name, v) => {
|
|
654
|
+
this.doc.set_attr(node, name, v === void 0 ? null : String(v));
|
|
655
|
+
};
|
|
656
|
+
if (def.kind === "linear") {
|
|
657
|
+
set_num("x1", def.x1);
|
|
658
|
+
set_num("y1", def.y1);
|
|
659
|
+
set_num("x2", def.x2);
|
|
660
|
+
set_num("y2", def.y2);
|
|
661
|
+
} else {
|
|
662
|
+
set_num("cx", def.cx);
|
|
663
|
+
set_num("cy", def.cy);
|
|
664
|
+
set_num("r", def.r);
|
|
665
|
+
set_num("fx", def.fx);
|
|
666
|
+
set_num("fy", def.fy);
|
|
667
|
+
}
|
|
668
|
+
if (def.gradient_units) this.doc.set_attr(node, "gradientUnits", def.gradient_units === "user_space_on_use" ? "userSpaceOnUse" : "objectBoundingBox");
|
|
669
|
+
if (def.spread_method) this.doc.set_attr(node, "spreadMethod", def.spread_method);
|
|
670
|
+
for (const stop of def.stops) {
|
|
671
|
+
const stop_id = this.doc.create_element("stop", { ns: "http://www.w3.org/2000/svg" });
|
|
672
|
+
this.doc.set_attr(stop_id, "offset", String(stop.offset));
|
|
673
|
+
this.doc.set_attr(stop_id, "stop-color", stop.color);
|
|
674
|
+
if (stop.opacity !== void 0) this.doc.set_attr(stop_id, "stop-opacity", String(stop.opacity));
|
|
675
|
+
this.doc.insert(stop_id, node, null);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
count_refs(id) {
|
|
679
|
+
let count = 0;
|
|
680
|
+
const pattern = new RegExp(`url\\(\\s*["']?#${escape_regex(id)}["']?\\s*\\)`);
|
|
681
|
+
for (const node of this.doc.all_elements()) {
|
|
682
|
+
const fill = this.doc.get_attr(node, "fill");
|
|
683
|
+
const stroke = this.doc.get_attr(node, "stroke");
|
|
684
|
+
const style_fill = this.doc.get_style(node, "fill");
|
|
685
|
+
const style_stroke = this.doc.get_style(node, "stroke");
|
|
686
|
+
for (const v of [
|
|
687
|
+
fill,
|
|
688
|
+
stroke,
|
|
689
|
+
style_fill,
|
|
690
|
+
style_stroke
|
|
691
|
+
]) if (v && pattern.test(v)) count++;
|
|
692
|
+
}
|
|
693
|
+
return count;
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
function escape_regex(s) {
|
|
697
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
698
|
+
}
|
|
699
|
+
function gradient_definition_equals(a, b) {
|
|
700
|
+
if (a === b) return true;
|
|
701
|
+
if (a.kind !== b.kind) return false;
|
|
702
|
+
if (a.stops.length !== b.stops.length) return false;
|
|
703
|
+
for (let i = 0; i < a.stops.length; i++) {
|
|
704
|
+
const sa = a.stops[i];
|
|
705
|
+
const sb = b.stops[i];
|
|
706
|
+
if (sa.offset !== sb.offset || sa.color !== sb.color || sa.opacity !== sb.opacity) return false;
|
|
707
|
+
}
|
|
708
|
+
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;
|
|
709
|
+
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;
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
function create_defs(doc) {
|
|
713
|
+
return { gradients: new GradientsRegistry(doc) };
|
|
714
|
+
}
|
|
715
|
+
//#endregion
|
|
716
|
+
//#region src/core/clipboard.ts
|
|
717
|
+
/**
|
|
718
|
+
* Clipboard payload extraction — selection → standalone SVG document.
|
|
719
|
+
*
|
|
720
|
+
* Implements the copy side of the clipboard FRD
|
|
721
|
+
* ([docs/wg/feat-svg-editor/clipboard.md](../../../../docs/wg/feat-svg-editor/clipboard.md)):
|
|
722
|
+
* the payload is a standalone, namespace-well-formed SVG document — not a
|
|
723
|
+
* private envelope. Assembly is a pure function of (document, selection):
|
|
724
|
+
* no geometry, no environment, no randomness (FRD R6 — the same selection
|
|
725
|
+
* yields the same bytes headless or surface-attached).
|
|
726
|
+
*
|
|
727
|
+
* What the payload carries, and what it deliberately does not
|
|
728
|
+
* (FRD §Extraction — five context kinds):
|
|
729
|
+
*
|
|
730
|
+
* 1. Referenced resources — CARRIED. The outbound `url(#…)` / `href`
|
|
731
|
+
* closure is walked from the closed carrier list below and emitted
|
|
732
|
+
* verbatim in one `<defs>` block.
|
|
733
|
+
* 2. Namespace declarations — CARRIED. Prefixes a subtree borrows from
|
|
734
|
+
* ancestor scope are declared on the payload shell (an undeclared
|
|
735
|
+
* prefix is a well-formedness error, so this includes the deliberate
|
|
736
|
+
* well-known-table repair for e.g. `xlink`).
|
|
737
|
+
* 3. Ancestor transforms — NOT carried (verbatim policy).
|
|
738
|
+
* 4. Inherited presentation / cascade — NOT carried (verbatim policy).
|
|
739
|
+
* 5. Viewport — NOT carried (no `viewBox`, no sizing on the shell).
|
|
740
|
+
*
|
|
741
|
+
* This module is the **payload extraction** operation only. The sibling
|
|
742
|
+
* operation the FRD names — in-document subtree CLONE (duplicate /
|
|
743
|
+
* clone-drag), which must NOT carry the closure — lives in `./subtree`.
|
|
744
|
+
* The two share exactly selection normalization (`subtree.normalize_roots`)
|
|
745
|
+
* and verbatim subtree serialization (`doc.serialize_node`).
|
|
746
|
+
*/
|
|
747
|
+
let clipboard;
|
|
748
|
+
(function(_clipboard) {
|
|
749
|
+
/**
|
|
750
|
+
* Presentation carriers that may hold `url(#…)` references, read both as
|
|
751
|
+
* a presentation attribute and as an inline `style=""` declaration.
|
|
752
|
+
*
|
|
753
|
+
* CLOSED LIST — extending it is a spec change to the FRD's §Extraction 1
|
|
754
|
+
* carrier list, not a bug fix. Deliberately NOT walked in v1 (documented
|
|
755
|
+
* degradations): `<style>` element rules (CSS parsing is the deferred
|
|
756
|
+
* cascade capability), SMIL timing/value references, `cursor`, SVG 2
|
|
757
|
+
* text-layout properties.
|
|
758
|
+
*/
|
|
759
|
+
const URL_REF_PROPS = [
|
|
760
|
+
"fill",
|
|
761
|
+
"stroke",
|
|
762
|
+
"filter",
|
|
763
|
+
"clip-path",
|
|
764
|
+
"mask",
|
|
765
|
+
"marker-start",
|
|
766
|
+
"marker-mid",
|
|
767
|
+
"marker-end",
|
|
768
|
+
"marker"
|
|
769
|
+
];
|
|
770
|
+
/** Set view of {@link URL_REF_PROPS} for membership tests over parsed
|
|
771
|
+
* style declarations. */
|
|
772
|
+
const URL_REF_PROP_SET = new Set(URL_REF_PROPS);
|
|
773
|
+
/**
|
|
774
|
+
* Elements whose `href` / `xlink:href` is a same-document resource
|
|
775
|
+
* reference the closure follows. CLOSED LIST — `<a href>` is navigation,
|
|
776
|
+
* `<image href>` is content, SMIL `href` is an animation target; none of
|
|
777
|
+
* them are walked.
|
|
778
|
+
*/
|
|
779
|
+
const HREF_TAGS = new Set([
|
|
780
|
+
"use",
|
|
781
|
+
"textPath",
|
|
782
|
+
"mpath",
|
|
783
|
+
"feImage",
|
|
784
|
+
"pattern",
|
|
785
|
+
"linearGradient",
|
|
786
|
+
"radialGradient",
|
|
787
|
+
"filter"
|
|
788
|
+
]);
|
|
789
|
+
/**
|
|
790
|
+
* `url(#id)` extractor. Global — a single value can carry several
|
|
791
|
+
* references (`filter: url(#a) blur(2px) url(#b)` is a legal filter
|
|
792
|
+
* function list). Same quoting tolerance as the defs registry's
|
|
793
|
+
* ref-counting pattern.
|
|
794
|
+
*/
|
|
795
|
+
const URL_REF_RE = /url\(\s*["']?#([^"')\s]+)["']?\s*\)/g;
|
|
796
|
+
function extract_payload(doc, selection) {
|
|
797
|
+
const order = subtree.by_document_order(doc);
|
|
798
|
+
const roots = subtree.normalize_roots(doc, selection, order);
|
|
799
|
+
if (roots.length === 0) return null;
|
|
800
|
+
const closure = collect_reference_closure(doc, roots);
|
|
801
|
+
const shell_ns = /* @__PURE__ */ new Map();
|
|
802
|
+
for (const member of [...closure, ...roots].sort(order)) for (const prefix of doc.undeclared_ns_prefixes(member)) {
|
|
803
|
+
if (shell_ns.has(prefix)) continue;
|
|
804
|
+
const uri = resolve_prefix(doc, member, prefix);
|
|
805
|
+
if (uri !== null) shell_ns.set(prefix, uri);
|
|
806
|
+
}
|
|
807
|
+
let shell = `<svg xmlns="${SVG_NS}"`;
|
|
808
|
+
for (const [prefix, uri] of shell_ns) shell += ` xmlns:${prefix}="${encode_attr_value(uri, "\"")}"`;
|
|
809
|
+
shell += ">";
|
|
810
|
+
const defs_block = closure.length > 0 ? `<defs>${closure.map((id) => doc.serialize_node(id)).join("")}</defs>` : "";
|
|
811
|
+
const content = roots.map((id) => doc.serialize_node(id)).join("");
|
|
812
|
+
return `${shell}${defs_block}${content}</svg>`;
|
|
813
|
+
}
|
|
814
|
+
_clipboard.extract_payload = extract_payload;
|
|
815
|
+
function collect_reference_closure(doc, roots) {
|
|
816
|
+
const id_map = /* @__PURE__ */ new Map();
|
|
817
|
+
for (const el of doc.all_elements()) {
|
|
818
|
+
const id_attr = doc.get_attr(el, "id");
|
|
819
|
+
if (id_attr !== null && !id_map.has(id_attr)) id_map.set(id_attr, el);
|
|
820
|
+
}
|
|
821
|
+
const in_forest = (target) => roots.some((r) => doc.contains(r, target));
|
|
822
|
+
const collected = /* @__PURE__ */ new Set();
|
|
823
|
+
const pending = [...roots];
|
|
824
|
+
while (pending.length > 0) {
|
|
825
|
+
const subtree = pending.pop();
|
|
826
|
+
for (const el of elements_of_subtree(doc, subtree)) for (const ref of refs_of(doc, el)) {
|
|
827
|
+
const target = id_map.get(ref);
|
|
828
|
+
if (target === void 0) continue;
|
|
829
|
+
if (in_forest(target)) continue;
|
|
830
|
+
if (collected.has(target)) continue;
|
|
831
|
+
collected.add(target);
|
|
832
|
+
pending.push(target);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return doc.prune_nested_nodes([...collected]).sort(subtree.by_document_order(doc));
|
|
836
|
+
}
|
|
837
|
+
_clipboard.collect_reference_closure = collect_reference_closure;
|
|
838
|
+
/** Preorder element walk of `root`'s subtree, root included. */
|
|
839
|
+
function elements_of_subtree(doc, root) {
|
|
840
|
+
const out = [];
|
|
841
|
+
const walk = (id) => {
|
|
842
|
+
if (!doc.is_element(id)) return;
|
|
843
|
+
out.push(id);
|
|
844
|
+
for (const c of doc.children_of(id)) walk(c);
|
|
845
|
+
};
|
|
846
|
+
walk(root);
|
|
847
|
+
return out;
|
|
848
|
+
}
|
|
849
|
+
/** Same-document reference ids carried by one element, per the closed
|
|
850
|
+
* carrier list. */
|
|
851
|
+
function refs_of(doc, id) {
|
|
852
|
+
const out = [];
|
|
853
|
+
for (const prop of URL_REF_PROPS) {
|
|
854
|
+
const value = doc.get_attr(id, prop);
|
|
855
|
+
if (!value) continue;
|
|
856
|
+
for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
|
|
857
|
+
}
|
|
858
|
+
for (const { property, value } of doc.get_all_styles(id)) {
|
|
859
|
+
if (!URL_REF_PROP_SET.has(property)) continue;
|
|
860
|
+
for (const m of value.matchAll(URL_REF_RE)) out.push(m[1]);
|
|
861
|
+
}
|
|
862
|
+
if (HREF_TAGS.has(doc.tag_of(id))) {
|
|
863
|
+
const href = doc.get_attr(id, "href");
|
|
864
|
+
if (href !== null && href.startsWith("#") && href.length > 1) out.push(href.slice(1));
|
|
865
|
+
}
|
|
866
|
+
return out;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Resolve a prefix a member's subtree borrows from ancestor scope:
|
|
870
|
+
* nearest ancestor `xmlns:<prefix>` declaration wins (correct XML
|
|
871
|
+
* scoping), falling back to the well-known table — the deliberate
|
|
872
|
+
* repair that keeps the payload namespace-well-formed even when the
|
|
873
|
+
* source never declared the prefix (FRD §Extraction 2).
|
|
874
|
+
*/
|
|
875
|
+
function resolve_prefix(doc, member, prefix) {
|
|
876
|
+
let cur = doc.parent_of(member);
|
|
877
|
+
while (cur !== null) {
|
|
878
|
+
const uri = doc.get_attr(cur, prefix, XMLNS_NS);
|
|
879
|
+
if (uri !== null) return uri;
|
|
880
|
+
cur = doc.parent_of(cur);
|
|
881
|
+
}
|
|
882
|
+
return WELL_KNOWN_NS_PREFIXES.get(prefix) ?? null;
|
|
883
|
+
}
|
|
884
|
+
})(clipboard || (clipboard = {}));
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/core/align.ts
|
|
887
|
+
/**
|
|
888
|
+
* Compute per-member translation deltas to align `members` against `target`.
|
|
889
|
+
*
|
|
890
|
+
* Convention (matches Figma menu labels and `cmath.rect.alignA`):
|
|
891
|
+
* - `left` / `right` → shift X so each left/right edge matches target's
|
|
892
|
+
* - `top` / `bottom` → shift Y so each top/bottom edge matches target's
|
|
893
|
+
* - `horizontal_centers` → shift X so each center-X matches target's center-X
|
|
894
|
+
* - `vertical_centers` → shift Y so each center-Y matches target's center-Y
|
|
895
|
+
*
|
|
896
|
+
* Members already at the target position are omitted from the returned map —
|
|
897
|
+
* callers iterate non-zero deltas only.
|
|
898
|
+
*/
|
|
899
|
+
function compute_align_deltas(members, target, direction) {
|
|
900
|
+
const out = /* @__PURE__ */ new Map();
|
|
901
|
+
for (const m of members) {
|
|
902
|
+
const d = delta_for(m.bbox, target, direction);
|
|
903
|
+
if (d.x !== 0 || d.y !== 0) out.set(m.id, d);
|
|
904
|
+
}
|
|
905
|
+
return out;
|
|
906
|
+
}
|
|
907
|
+
function delta_for(bbox, target, direction) {
|
|
908
|
+
switch (direction) {
|
|
909
|
+
case "left": return {
|
|
910
|
+
x: target.x - bbox.x,
|
|
911
|
+
y: 0
|
|
912
|
+
};
|
|
913
|
+
case "right": return {
|
|
914
|
+
x: target.x + target.width - (bbox.x + bbox.width),
|
|
915
|
+
y: 0
|
|
916
|
+
};
|
|
917
|
+
case "top": return {
|
|
918
|
+
x: 0,
|
|
919
|
+
y: target.y - bbox.y
|
|
920
|
+
};
|
|
921
|
+
case "bottom": return {
|
|
922
|
+
x: 0,
|
|
923
|
+
y: target.y + target.height - (bbox.y + bbox.height)
|
|
924
|
+
};
|
|
925
|
+
case "horizontal_centers": return {
|
|
926
|
+
x: target.x + target.width / 2 - (bbox.x + bbox.width / 2),
|
|
927
|
+
y: 0
|
|
928
|
+
};
|
|
929
|
+
case "vertical_centers": return {
|
|
930
|
+
x: 0,
|
|
931
|
+
y: target.y + target.height / 2 - (bbox.y + bbox.height / 2)
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
//#endregion
|
|
936
|
+
//#region src/core/properties.ts
|
|
937
|
+
let properties;
|
|
938
|
+
(function(_properties) {
|
|
939
|
+
/** SVG properties that inherit per SVG 2 §6 (subset; the common ones). */
|
|
940
|
+
const INHERITED = new Set([
|
|
941
|
+
"color",
|
|
942
|
+
"cursor",
|
|
943
|
+
"direction",
|
|
944
|
+
"fill",
|
|
945
|
+
"fill-opacity",
|
|
946
|
+
"fill-rule",
|
|
947
|
+
"font",
|
|
948
|
+
"font-family",
|
|
949
|
+
"font-size",
|
|
950
|
+
"font-style",
|
|
951
|
+
"font-variant",
|
|
952
|
+
"font-weight",
|
|
953
|
+
"letter-spacing",
|
|
954
|
+
"marker",
|
|
955
|
+
"marker-end",
|
|
956
|
+
"marker-mid",
|
|
957
|
+
"marker-start",
|
|
958
|
+
"paint-order",
|
|
959
|
+
"pointer-events",
|
|
960
|
+
"shape-rendering",
|
|
961
|
+
"stroke",
|
|
962
|
+
"stroke-dasharray",
|
|
963
|
+
"stroke-dashoffset",
|
|
964
|
+
"stroke-linecap",
|
|
965
|
+
"stroke-linejoin",
|
|
966
|
+
"stroke-miterlimit",
|
|
967
|
+
"stroke-opacity",
|
|
968
|
+
"stroke-width",
|
|
969
|
+
"text-anchor",
|
|
970
|
+
"text-rendering",
|
|
971
|
+
"visibility",
|
|
972
|
+
"word-spacing",
|
|
973
|
+
"writing-mode"
|
|
974
|
+
]);
|
|
975
|
+
/** Initial values for known properties (subset). */
|
|
976
|
+
const INITIAL = {
|
|
977
|
+
fill: "black",
|
|
978
|
+
stroke: "none",
|
|
979
|
+
"fill-opacity": "1",
|
|
980
|
+
"stroke-opacity": "1",
|
|
981
|
+
"stroke-width": "1",
|
|
982
|
+
opacity: "1",
|
|
983
|
+
visibility: "visible",
|
|
984
|
+
display: "inline"
|
|
985
|
+
};
|
|
986
|
+
function resolve_declared(doc, id, property) {
|
|
987
|
+
const inline = doc.get_style(id, property);
|
|
988
|
+
if (inline !== null && inline !== "") return {
|
|
989
|
+
declared: inline,
|
|
990
|
+
provenance: {
|
|
991
|
+
origin: "author",
|
|
992
|
+
carrier: "inline_style"
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
const attr = doc.get_attr(id, property);
|
|
996
|
+
if (attr !== null && attr !== "") return {
|
|
997
|
+
declared: attr,
|
|
998
|
+
provenance: {
|
|
999
|
+
origin: "author",
|
|
1000
|
+
carrier: "presentation_attribute"
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
if (INHERITED.has(property)) {
|
|
1004
|
+
const parent = doc.parent_of(id);
|
|
1005
|
+
if (parent !== null && doc.is_element(parent)) {
|
|
1006
|
+
const r = resolve_declared(doc, parent, property);
|
|
1007
|
+
if (r.declared !== null) return {
|
|
1008
|
+
declared: r.declared,
|
|
1009
|
+
provenance: {
|
|
1010
|
+
origin: "author",
|
|
1011
|
+
carrier: "inherited"
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
declared: INITIAL[property] ?? null,
|
|
1018
|
+
provenance: {
|
|
1019
|
+
origin: "user_agent",
|
|
1020
|
+
carrier: "defaulted"
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
_properties.resolve_declared = resolve_declared;
|
|
1025
|
+
function compute_known(property, declared) {
|
|
1026
|
+
if (declared === null) return null;
|
|
1027
|
+
const trimmed = declared.trim();
|
|
1028
|
+
if (trimmed === "inherit" || trimmed === "initial" || trimmed === "unset" || trimmed === "revert" || trimmed === "revert-layer") return null;
|
|
1029
|
+
if (/^var\s*\(/i.test(trimmed)) return {
|
|
1030
|
+
error: "invalid_at_computed_value_time",
|
|
1031
|
+
reason: `var() substitution requires a cascade engine (not implemented)`
|
|
1032
|
+
};
|
|
1033
|
+
switch (property) {
|
|
1034
|
+
case "opacity":
|
|
1035
|
+
case "fill-opacity":
|
|
1036
|
+
case "stroke-opacity":
|
|
1037
|
+
case "stroke-width":
|
|
1038
|
+
case "x":
|
|
1039
|
+
case "y":
|
|
1040
|
+
case "width":
|
|
1041
|
+
case "height":
|
|
1042
|
+
case "cx":
|
|
1043
|
+
case "cy":
|
|
1044
|
+
case "r":
|
|
1045
|
+
case "rx":
|
|
1046
|
+
case "ry":
|
|
1047
|
+
case "font-size": {
|
|
1048
|
+
const n = parseFloat(trimmed);
|
|
1049
|
+
return Number.isFinite(n) ? n : trimmed;
|
|
1050
|
+
}
|
|
1051
|
+
default: return trimmed;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
_properties.compute_known = compute_known;
|
|
1055
|
+
function read(doc, id, property) {
|
|
1056
|
+
const { declared, provenance } = resolve_declared(doc, id, property);
|
|
1057
|
+
return {
|
|
1058
|
+
declared,
|
|
1059
|
+
computed: compute_known(property, declared),
|
|
1060
|
+
provenance
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
_properties.read = read;
|
|
1064
|
+
function choose_write_carrier(doc, id, property) {
|
|
1065
|
+
const inline = doc.get_style(id, property);
|
|
1066
|
+
if (inline !== null && inline !== "") return "inline_style";
|
|
1067
|
+
return "presentation_attribute";
|
|
1068
|
+
}
|
|
1069
|
+
_properties.choose_write_carrier = choose_write_carrier;
|
|
1070
|
+
function value_equals(a, b) {
|
|
1071
|
+
if (a === b) return true;
|
|
1072
|
+
if (a.declared !== b.declared) return false;
|
|
1073
|
+
if (a.provenance.carrier !== b.provenance.carrier) return false;
|
|
1074
|
+
if (a.provenance.origin !== b.provenance.origin) return false;
|
|
1075
|
+
if (a.computed === b.computed) return true;
|
|
1076
|
+
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;
|
|
1077
|
+
return false;
|
|
1078
|
+
}
|
|
1079
|
+
_properties.value_equals = value_equals;
|
|
1080
|
+
})(properties || (properties = {}));
|
|
1081
|
+
//#endregion
|
|
1082
|
+
//#region src/core/editor.ts
|
|
1083
|
+
const PROVIDER_ID = "svg-editor";
|
|
1084
|
+
/** Max characters in a synthesized display label before truncation. */
|
|
1085
|
+
const DISPLAY_LABEL_MAX_LEN = 40;
|
|
1086
|
+
/**
|
|
1087
|
+
* Wide internal factory — returns the full object including the
|
|
1088
|
+
* `_internal` / `keymap` surfaces in its inferred type. Stays private.
|
|
1089
|
+
* The public `createSvgEditor` below wraps this and narrows the return
|
|
1090
|
+
* to `SvgEditor` so the published `.d.ts` doesn't advertise internals.
|
|
1091
|
+
*/
|
|
1092
|
+
function _create_svg_editor_internal(opts) {
|
|
1093
|
+
const doc = new SvgDocument(opts.svg);
|
|
1094
|
+
const history = new HistoryImpl();
|
|
1095
|
+
const defs = create_defs(doc);
|
|
1096
|
+
let selection = [];
|
|
1097
|
+
let scope = null;
|
|
1098
|
+
let mode = "select";
|
|
1099
|
+
let tool = TOOL_CURSOR;
|
|
1100
|
+
let version = 0;
|
|
1101
|
+
/** `doc.revision` at the last load()/reset(); compared to derive `dirty`.
|
|
1102
|
+
* The doc's own total mutation counter is the single edit-version
|
|
1103
|
+
* source — `content_version`, `dirty`, and the typed-read memo caches
|
|
1104
|
+
* all derive from it (no editor-side shadow counter to drift). */
|
|
1105
|
+
let baseline_revision = doc.revision;
|
|
1106
|
+
/**
|
|
1107
|
+
* Bumps once per `editor.load(svg)` call. The constructor's initial parse
|
|
1108
|
+
* does NOT count — it's the "factory" state. Hosts subscribe via
|
|
1109
|
+
* `subscribe_with_selector(s => s.load_version, ...)` to react to fresh
|
|
1110
|
+
* document loads without firing on every edit.
|
|
1111
|
+
*/
|
|
1112
|
+
let load_version = 0;
|
|
1113
|
+
let style = {
|
|
1114
|
+
...DEFAULT_STYLE,
|
|
1115
|
+
...opts.style
|
|
1116
|
+
};
|
|
1117
|
+
const providers = opts.providers ?? {};
|
|
1118
|
+
/**
|
|
1119
|
+
* In-memory clipboard buffer — the transport floor (FRD R1: the buffer
|
|
1120
|
+
* write cannot fail; external channels are best-effort on top). NOT part
|
|
1121
|
+
* of `EditorState` and NOT history-managed: it survives `load()` /
|
|
1122
|
+
* `reset()` / undo, like the OS clipboard it mirrors.
|
|
1123
|
+
*/
|
|
1124
|
+
let clipboard_buffer = null;
|
|
1125
|
+
/**
|
|
1126
|
+
* The last committed duplication — read by the NEXT `duplicate()` to
|
|
1127
|
+
* repeat the user's translate delta (gridaco/grida#825; spec
|
|
1128
|
+
* §Repeating offset). Session state like `clipboard_buffer`: not in
|
|
1129
|
+
* `EditorState`, not history-managed (undo/redo replay never re-arms
|
|
1130
|
+
* it — only a user-initiated ⌘D or cloned-drag commit does). Staleness
|
|
1131
|
+
* is caught at use by `subtree.repeat_delta`; the only eager clears are
|
|
1132
|
+
* `load()` / `reset()`, where every NodeId dies wholesale.
|
|
1133
|
+
*/
|
|
1134
|
+
let active_duplication = null;
|
|
1135
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1136
|
+
let attached_surface = null;
|
|
1137
|
+
/**
|
|
1138
|
+
* World-space geometry query provider. Set by the DOM surface on
|
|
1139
|
+
* attach (`editor._internal.set_geometry`); cleared on detach. Null
|
|
1140
|
+
* means no renderer is attached — bounds queries cannot be answered.
|
|
1141
|
+
*/
|
|
1142
|
+
let geometry_provider = null;
|
|
1143
|
+
const modes = ["select", "edit-content"];
|
|
1144
|
+
function snapshot() {
|
|
1145
|
+
return Object.freeze({
|
|
1146
|
+
selection,
|
|
1147
|
+
scope,
|
|
1148
|
+
mode,
|
|
1149
|
+
tool,
|
|
1150
|
+
dirty: doc.revision !== baseline_revision,
|
|
1151
|
+
can_undo: history.stack.canUndo,
|
|
1152
|
+
can_redo: history.stack.canRedo,
|
|
1153
|
+
version,
|
|
1154
|
+
content_version: doc.revision,
|
|
1155
|
+
structure_version: doc.structure_version,
|
|
1156
|
+
geometry_version: doc.geometry_version,
|
|
1157
|
+
load_version
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
function emit() {
|
|
1161
|
+
version++;
|
|
1162
|
+
const s = snapshot();
|
|
1163
|
+
for (const fn of listeners) fn(s);
|
|
1164
|
+
}
|
|
1165
|
+
history.on("onChange", () => emit());
|
|
1166
|
+
history.on("onUndo", () => emit());
|
|
1167
|
+
history.on("onRedo", () => emit());
|
|
1168
|
+
let last_emitted_geometry_version = doc.geometry_version;
|
|
1169
|
+
const geometry_listeners = /* @__PURE__ */ new Set();
|
|
1170
|
+
const translate_commit_listeners = /* @__PURE__ */ new Set();
|
|
1171
|
+
const notify_translate_commit = () => {
|
|
1172
|
+
for (const cb of translate_commit_listeners) cb();
|
|
1173
|
+
};
|
|
1174
|
+
/**
|
|
1175
|
+
* Fan out the geometry channel iff the doc's `geometry_version` has
|
|
1176
|
+
* moved since we last fired. Shared by the `doc.on_change` handler
|
|
1177
|
+
* (mutation-driven bumps) and the surface-driven `bump_geometry` seam
|
|
1178
|
+
* (font-load reflow). Idempotent against a stale version — never
|
|
1179
|
+
* double-fires for the same value.
|
|
1180
|
+
*/
|
|
1181
|
+
function fire_geometry_listeners_if_advanced() {
|
|
1182
|
+
if (doc.geometry_version !== last_emitted_geometry_version) {
|
|
1183
|
+
last_emitted_geometry_version = doc.geometry_version;
|
|
1184
|
+
for (const cb of geometry_listeners) cb();
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
doc.on_change(() => {
|
|
1188
|
+
fire_geometry_listeners_if_advanced();
|
|
1189
|
+
});
|
|
1190
|
+
function subscribe(fn) {
|
|
1191
|
+
listeners.add(fn);
|
|
1192
|
+
return () => {
|
|
1193
|
+
listeners.delete(fn);
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
function subscribe_with_selector(selector, fn, equals = Object.is) {
|
|
1197
|
+
let prev = selector(snapshot());
|
|
1198
|
+
return subscribe((state) => {
|
|
1199
|
+
const next = selector(state);
|
|
1200
|
+
if (!equals(prev, next)) {
|
|
1201
|
+
const p = prev;
|
|
1202
|
+
prev = next;
|
|
1203
|
+
fn(next, p);
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
function set_selection(next) {
|
|
1208
|
+
const pruned = doc.prune_nested_nodes(next);
|
|
1209
|
+
if (pruned.length === selection.length) {
|
|
1210
|
+
let same = true;
|
|
1211
|
+
for (let i = 0; i < pruned.length; i++) if (pruned[i] !== selection[i]) {
|
|
1212
|
+
same = false;
|
|
1213
|
+
break;
|
|
1214
|
+
}
|
|
1215
|
+
if (same) return;
|
|
1216
|
+
}
|
|
1217
|
+
selection = Object.freeze([...pruned]);
|
|
1218
|
+
emit();
|
|
1219
|
+
}
|
|
1220
|
+
function select(target, opts) {
|
|
1221
|
+
const ids = typeof target === "string" ? [target] : [...target];
|
|
1222
|
+
const mode = opts?.mode ?? "replace";
|
|
1223
|
+
if (mode === "replace") {
|
|
1224
|
+
set_selection(ids);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const next = new Set(selection);
|
|
1228
|
+
if (mode === "add") for (const id of ids) next.add(id);
|
|
1229
|
+
else for (const id of ids) if (next.has(id)) next.delete(id);
|
|
1230
|
+
else next.add(id);
|
|
1231
|
+
set_selection([...next]);
|
|
1232
|
+
}
|
|
1233
|
+
function deselect() {
|
|
1234
|
+
set_selection([]);
|
|
1235
|
+
}
|
|
1236
|
+
function enter_scope(group) {
|
|
1237
|
+
scope = group;
|
|
1238
|
+
emit();
|
|
1239
|
+
}
|
|
1240
|
+
function exit_scope() {
|
|
1241
|
+
if (scope === null) return;
|
|
1242
|
+
const parent = doc.parent_of(scope);
|
|
1243
|
+
scope = parent && parent !== doc.root ? parent : null;
|
|
1244
|
+
emit();
|
|
1245
|
+
}
|
|
1246
|
+
function set_mode(next) {
|
|
1247
|
+
if (mode === next) return;
|
|
1248
|
+
mode = next;
|
|
1249
|
+
emit();
|
|
1250
|
+
}
|
|
1251
|
+
function tools_equal(a, b) {
|
|
1252
|
+
if (a.type !== b.type) return false;
|
|
1253
|
+
if (a.type === "cursor" || a.type === "lasso" || a.type === "bend" || a.type === "insert-text") return true;
|
|
1254
|
+
return b.type === "insert" && a.tag === b.tag;
|
|
1255
|
+
}
|
|
1256
|
+
function set_tool(next) {
|
|
1257
|
+
if (tools_equal(tool, next)) return;
|
|
1258
|
+
tool = next;
|
|
1259
|
+
emit();
|
|
1260
|
+
}
|
|
1261
|
+
const paint_cache = /* @__PURE__ */ new Map();
|
|
1262
|
+
const property_cache = /* @__PURE__ */ new Map();
|
|
1263
|
+
const properties_cache = /* @__PURE__ */ new Map();
|
|
1264
|
+
let tree_cache = null;
|
|
1265
|
+
const tree_node_pool = /* @__PURE__ */ new Map();
|
|
1266
|
+
function tree_snapshot() {
|
|
1267
|
+
const sv = doc.structure_version;
|
|
1268
|
+
if (tree_cache && tree_cache.structure_version === sv) return tree_cache.value;
|
|
1269
|
+
const map = /* @__PURE__ */ new Map();
|
|
1270
|
+
let any_change = !tree_cache;
|
|
1271
|
+
for (const id of doc.all_elements()) {
|
|
1272
|
+
const tag = doc.tag_of(id);
|
|
1273
|
+
const name = doc.get_attr(id, "id") ?? void 0;
|
|
1274
|
+
const parent = doc.parent_of(id);
|
|
1275
|
+
const children = doc.element_children_of(id);
|
|
1276
|
+
const pooled = tree_node_pool.get(id);
|
|
1277
|
+
if (pooled && pooled.tag === tag && pooled.name === name && pooled.parent === parent && array_shallow_equal(pooled.children, children)) {
|
|
1278
|
+
map.set(id, pooled);
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
const node = {
|
|
1282
|
+
id,
|
|
1283
|
+
tag,
|
|
1284
|
+
name,
|
|
1285
|
+
parent,
|
|
1286
|
+
children
|
|
1287
|
+
};
|
|
1288
|
+
tree_node_pool.set(id, node);
|
|
1289
|
+
map.set(id, node);
|
|
1290
|
+
any_change = true;
|
|
1291
|
+
}
|
|
1292
|
+
for (const id of tree_node_pool.keys()) if (!map.has(id)) {
|
|
1293
|
+
tree_node_pool.delete(id);
|
|
1294
|
+
any_change = true;
|
|
1295
|
+
}
|
|
1296
|
+
if (!any_change && tree_cache) {
|
|
1297
|
+
tree_cache.structure_version = sv;
|
|
1298
|
+
return tree_cache.value;
|
|
1299
|
+
}
|
|
1300
|
+
const snap = {
|
|
1301
|
+
root: doc.root,
|
|
1302
|
+
nodes: map
|
|
1303
|
+
};
|
|
1304
|
+
tree_cache = {
|
|
1305
|
+
structure_version: sv,
|
|
1306
|
+
value: snap
|
|
1307
|
+
};
|
|
1308
|
+
return snap;
|
|
1309
|
+
}
|
|
1310
|
+
function node_property_cached(id, name) {
|
|
1311
|
+
const key = `${id}${name}`;
|
|
1312
|
+
const cached = property_cache.get(key);
|
|
1313
|
+
if (cached && cached.revision === doc.revision) return cached.value;
|
|
1314
|
+
const next = properties.read(doc, id, name);
|
|
1315
|
+
if (cached && properties.value_equals(cached.value, next)) {
|
|
1316
|
+
cached.revision = doc.revision;
|
|
1317
|
+
return cached.value;
|
|
1318
|
+
}
|
|
1319
|
+
property_cache.set(key, {
|
|
1320
|
+
revision: doc.revision,
|
|
1321
|
+
value: next
|
|
1322
|
+
});
|
|
1323
|
+
return next;
|
|
1324
|
+
}
|
|
1325
|
+
function node_properties(id, names) {
|
|
1326
|
+
const key = `${id}${names.join("")}`;
|
|
1327
|
+
const cached = properties_cache.get(key);
|
|
1328
|
+
if (cached && cached.revision === doc.revision) return cached.value;
|
|
1329
|
+
const next = {};
|
|
1330
|
+
let changed = !cached;
|
|
1331
|
+
for (const name of names) {
|
|
1332
|
+
const v = node_property_cached(id, name);
|
|
1333
|
+
next[name] = v;
|
|
1334
|
+
if (cached && cached.value[name] !== v) changed = true;
|
|
1335
|
+
}
|
|
1336
|
+
if (cached && !changed) {
|
|
1337
|
+
cached.revision = doc.revision;
|
|
1338
|
+
return cached.value;
|
|
1339
|
+
}
|
|
1340
|
+
const frozen = Object.freeze(next);
|
|
1341
|
+
properties_cache.set(key, {
|
|
1342
|
+
revision: doc.revision,
|
|
1343
|
+
value: frozen
|
|
1344
|
+
});
|
|
1345
|
+
return frozen;
|
|
1346
|
+
}
|
|
1347
|
+
function node_paint(id, channel) {
|
|
1348
|
+
const key = `${id}${channel}`;
|
|
1349
|
+
const cached = paint_cache.get(key);
|
|
1350
|
+
if (cached && cached.revision === doc.revision) return cached.value;
|
|
1351
|
+
const { declared, provenance } = properties.resolve_declared(doc, id, channel);
|
|
1352
|
+
const next = {
|
|
1353
|
+
declared,
|
|
1354
|
+
computed: paint.parse(declared),
|
|
1355
|
+
provenance
|
|
1356
|
+
};
|
|
1357
|
+
if (cached && paint.value_equals(cached.value, next)) {
|
|
1358
|
+
cached.revision = doc.revision;
|
|
1359
|
+
return cached.value;
|
|
1360
|
+
}
|
|
1361
|
+
paint_cache.set(key, {
|
|
1362
|
+
revision: doc.revision,
|
|
1363
|
+
value: next
|
|
1364
|
+
});
|
|
1365
|
+
return next;
|
|
1366
|
+
}
|
|
1367
|
+
function write_property(id, name, value) {
|
|
1368
|
+
if (properties.choose_write_carrier(doc, id, name) === "inline_style") doc.set_style(id, name, value);
|
|
1369
|
+
else doc.set_attr(id, name, value);
|
|
1370
|
+
}
|
|
1371
|
+
/** Open `preview_property` sessions, keyed by property name. A discrete
|
|
1372
|
+
* write to the same name supersedes the in-flight gesture: the session
|
|
1373
|
+
* is silently discarded so a later host-side `commit()` cannot replay
|
|
1374
|
+
* the stale previewed value over the discrete write. The stored
|
|
1375
|
+
* function reverts the previewed value and unregisters itself. */
|
|
1376
|
+
const open_property_previews = /* @__PURE__ */ new Map();
|
|
1377
|
+
function supersede_property_preview(name) {
|
|
1378
|
+
open_property_previews.get(name)?.();
|
|
1379
|
+
}
|
|
1380
|
+
/** End EVERY open preview session. Called by operations that detach
|
|
1381
|
+
* nodes (remove / cut, ungroup) or replace the document (load,
|
|
1382
|
+
* reset): the sessions' deltas target nodes that are about to die,
|
|
1383
|
+
* so a later close-time `commit()` would push a dead history step.
|
|
1384
|
+
* Must run BEFORE the destructive mutation — each discard reverts
|
|
1385
|
+
* its in-flight delta against the still-intact document. (Live
|
|
1386
|
+
* iteration is safe: each discard deletes only its own map entry.) */
|
|
1387
|
+
function discard_open_property_previews() {
|
|
1388
|
+
for (const discard of open_property_previews.values()) discard();
|
|
1389
|
+
}
|
|
1390
|
+
function set_property(name, value) {
|
|
1391
|
+
if (selection.length === 0) return;
|
|
1392
|
+
supersede_property_preview(name);
|
|
1393
|
+
const before = [];
|
|
1394
|
+
for (const id of selection) before.push({
|
|
1395
|
+
id,
|
|
1396
|
+
attr: doc.get_attr(id, name),
|
|
1397
|
+
style: doc.get_style(id, name)
|
|
1398
|
+
});
|
|
1399
|
+
const targets = [...selection];
|
|
1400
|
+
const apply = () => {
|
|
1401
|
+
for (const id of targets) write_property(id, name, value);
|
|
1402
|
+
emit();
|
|
1403
|
+
};
|
|
1404
|
+
const revert = () => {
|
|
1405
|
+
for (const b of before) {
|
|
1406
|
+
if (b.style !== null) doc.set_style(b.id, name, b.style);
|
|
1407
|
+
else doc.set_style(b.id, name, null);
|
|
1408
|
+
doc.set_attr(b.id, name, b.attr);
|
|
1409
|
+
}
|
|
1410
|
+
emit();
|
|
1411
|
+
};
|
|
1412
|
+
apply();
|
|
1413
|
+
history.atomic(`set ${name}`, (tx) => {
|
|
1414
|
+
tx.push({
|
|
1415
|
+
providerId: PROVIDER_ID,
|
|
1416
|
+
apply,
|
|
1417
|
+
revert
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
function preview_property(name) {
|
|
1422
|
+
supersede_property_preview(name);
|
|
1423
|
+
const targets = [...selection];
|
|
1424
|
+
const before = [];
|
|
1425
|
+
for (const id of targets) before.push({
|
|
1426
|
+
id,
|
|
1427
|
+
attr: doc.get_attr(id, name),
|
|
1428
|
+
style: doc.get_style(id, name)
|
|
1429
|
+
});
|
|
1430
|
+
const preview = history.preview(`change ${name}`);
|
|
1431
|
+
const live = () => preview.state === "active";
|
|
1432
|
+
const close = () => {
|
|
1433
|
+
if (open_property_previews.get(name) === discard) open_property_previews.delete(name);
|
|
1434
|
+
};
|
|
1435
|
+
const discard = () => {
|
|
1436
|
+
close();
|
|
1437
|
+
if (live()) preview.discard();
|
|
1438
|
+
};
|
|
1439
|
+
open_property_previews.set(name, discard);
|
|
1440
|
+
return {
|
|
1441
|
+
get live() {
|
|
1442
|
+
return live();
|
|
1443
|
+
},
|
|
1444
|
+
update(value) {
|
|
1445
|
+
if (!live()) return;
|
|
1446
|
+
preview.set({
|
|
1447
|
+
providerId: PROVIDER_ID,
|
|
1448
|
+
apply: () => {
|
|
1449
|
+
for (const id of targets) write_property(id, name, value);
|
|
1450
|
+
emit();
|
|
1451
|
+
},
|
|
1452
|
+
revert: () => {
|
|
1453
|
+
for (const b of before) {
|
|
1454
|
+
if (b.style !== null) doc.set_style(b.id, name, b.style);
|
|
1455
|
+
else doc.set_style(b.id, name, null);
|
|
1456
|
+
doc.set_attr(b.id, name, b.attr);
|
|
1457
|
+
}
|
|
1458
|
+
emit();
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
},
|
|
1462
|
+
commit: () => {
|
|
1463
|
+
close();
|
|
1464
|
+
if (live()) preview.commit();
|
|
1465
|
+
},
|
|
1466
|
+
discard
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function set_paint(channel, p) {
|
|
1470
|
+
if (selection.length === 0) return;
|
|
1471
|
+
set_property(channel, paint.serialize(p));
|
|
1472
|
+
}
|
|
1473
|
+
function preview_paint(channel) {
|
|
1474
|
+
const session = preview_property(channel);
|
|
1475
|
+
return {
|
|
1476
|
+
get live() {
|
|
1477
|
+
return session.live;
|
|
1478
|
+
},
|
|
1479
|
+
update: (p) => session.update(paint.serialize(p)),
|
|
1480
|
+
commit: () => session.commit(),
|
|
1481
|
+
discard: () => session.discard()
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
function set_paint_from_gradient(channel, definition, _opts) {
|
|
1485
|
+
const gradient_id = defs.gradients.upsert(definition);
|
|
1486
|
+
set_paint(channel, {
|
|
1487
|
+
kind: "ref",
|
|
1488
|
+
id: gradient_id
|
|
1489
|
+
});
|
|
1490
|
+
return { gradient_id };
|
|
1491
|
+
}
|
|
1492
|
+
/** World→local delta projection shared by every one-shot translate
|
|
1493
|
+
* writer (translate / nudge via `prepare_rpc`, align). Re-expresses a
|
|
1494
|
+
* world-space delta in the frame the target's position attributes are
|
|
1495
|
+
* written in — nested-viewport / transformed-ancestor correctness.
|
|
1496
|
+
* Identity for flat docs and DOM-less hosts (no provider, or a
|
|
1497
|
+
* provider without a layout engine). */
|
|
1498
|
+
const project_world_delta = (id, d) => geometry_provider?.world_delta_to_local?.(id, d) ?? d;
|
|
1499
|
+
/** Shared one-shot translate runner. `stages` selects semantics — see
|
|
1500
|
+
* `core/translate-pipeline/README.md`'s "Stage lists per entry point". */
|
|
1501
|
+
function do_translate_oneshot(delta, stages, label) {
|
|
1502
|
+
if (selection.length === 0) return false;
|
|
1503
|
+
if (delta.dx === 0 && delta.dy === 0) return false;
|
|
1504
|
+
const { apply, revert } = translate_pipeline.prepare_rpc({
|
|
1505
|
+
doc,
|
|
1506
|
+
ids: selection,
|
|
1507
|
+
delta: {
|
|
1508
|
+
x: delta.dx,
|
|
1509
|
+
y: delta.dy
|
|
1510
|
+
},
|
|
1511
|
+
options: {
|
|
1512
|
+
pixel_grid_quantum: style.snap_to_pixel_grid ? style.pixel_grid_size : null,
|
|
1513
|
+
snap_enabled: style.snap_enabled,
|
|
1514
|
+
snap_threshold_px: style.snap_threshold_px
|
|
1515
|
+
},
|
|
1516
|
+
emit,
|
|
1517
|
+
stages,
|
|
1518
|
+
project: project_world_delta
|
|
1519
|
+
});
|
|
1520
|
+
apply();
|
|
1521
|
+
history.atomic(label, (tx) => {
|
|
1522
|
+
tx.push({
|
|
1523
|
+
providerId: PROVIDER_ID,
|
|
1524
|
+
apply,
|
|
1525
|
+
revert
|
|
1526
|
+
});
|
|
1527
|
+
});
|
|
1528
|
+
return true;
|
|
1529
|
+
}
|
|
1530
|
+
function translate(delta) {
|
|
1531
|
+
if (do_translate_oneshot(delta, void 0, "translate")) notify_translate_commit();
|
|
1532
|
+
}
|
|
1533
|
+
function nudge(delta) {
|
|
1534
|
+
if (do_translate_oneshot(delta, translate_pipeline.stages.NUDGE, "nudge")) notify_translate_commit();
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Gate + capture for a resize gesture. Returns the resizable members (with
|
|
1538
|
+
* captured baseline / pre-transform / bbox), or `null` if the gesture can't
|
|
1539
|
+
* run: no geometry provider, empty selection, or — in `all_or_nothing` mode
|
|
1540
|
+
* — any member fails the gate.
|
|
1541
|
+
*
|
|
1542
|
+
* `mode`:
|
|
1543
|
+
* - `"skip"` — drop members failing the `is_resizable_node` gate
|
|
1544
|
+
* (tag + transform class) or lacking a bbox; resize the rest. Used by the
|
|
1545
|
+
* inspector `resize_to` (set-bbox) path.
|
|
1546
|
+
* - `"all_or_nothing"` — refuse the WHOLE gesture (return `null`) if ANY
|
|
1547
|
+
* member fails. Used by keyboard `resize_by` (nudge), matching the resize
|
|
1548
|
+
* HUD, whose handle-drag is rejected when any member is unsafe.
|
|
1549
|
+
*/
|
|
1550
|
+
function collect_resize_members(ids, mode) {
|
|
1551
|
+
if (ids.length === 0) return null;
|
|
1552
|
+
if (!geometry_provider) return null;
|
|
1553
|
+
const members = [];
|
|
1554
|
+
for (const id of ids) {
|
|
1555
|
+
if (!resize_pipeline.intent.is_resizable_node(doc, id)) {
|
|
1556
|
+
if (mode === "all_or_nothing") return null;
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
const bbox = geometry_provider.bounds_of(id);
|
|
1560
|
+
if (!bbox) {
|
|
1561
|
+
if (mode === "all_or_nothing") return null;
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
members.push({
|
|
1565
|
+
id,
|
|
1566
|
+
rz: resize_pipeline.intent.capture_baseline(doc, id, bbox),
|
|
1567
|
+
bbox
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
return members.length === 0 ? null : members;
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* Apply a resize to each member, optionally followed by a uniform group
|
|
1574
|
+
* translate, as ONE atomic history step. `op` resolves each member's scale
|
|
1575
|
+
* factors + scale origin; `group_translate` is the post-scale envelope shift
|
|
1576
|
+
* (group resize only — `null` for per-element). Callers guarantee `members`
|
|
1577
|
+
* is non-empty. Returns `true` when a history step was pushed; `false` when
|
|
1578
|
+
* the gesture is geometrically identity (no member scales and no group
|
|
1579
|
+
* translate) so undo isn't polluted with an empty step. NOTE: a per-tag
|
|
1580
|
+
* constraint that collapses a non-1 factor to identity *inside* the handler
|
|
1581
|
+
* (e.g. `<circle>` uniform `min` on a single-axis nudge) is not detected
|
|
1582
|
+
* here — the op-level factor is still ≠ 1, so that case still pushes a step.
|
|
1583
|
+
*/
|
|
1584
|
+
function commit_resize(members, op, group_translate, label) {
|
|
1585
|
+
const ops = members.map((m) => ({
|
|
1586
|
+
m,
|
|
1587
|
+
...op(m)
|
|
1588
|
+
}));
|
|
1589
|
+
const scales = ops.some(({ sx, sy }) => sx !== 1 || sy !== 1);
|
|
1590
|
+
const translates = !!group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0);
|
|
1591
|
+
if (!scales && !translates) return false;
|
|
1592
|
+
const apply = () => {
|
|
1593
|
+
for (const { m, sx, sy, origin } of ops) resize_pipeline.intent.apply(doc, m.id, m.rz, sx, sy, origin);
|
|
1594
|
+
if (group_translate && (group_translate.dx !== 0 || group_translate.dy !== 0)) for (const m of members) {
|
|
1595
|
+
const tx_after = translate_pipeline.intent.capture_baseline(doc, m.id);
|
|
1596
|
+
translate_pipeline.intent.apply(doc, m.id, tx_after, group_translate.dx, group_translate.dy);
|
|
1597
|
+
}
|
|
1598
|
+
emit();
|
|
1599
|
+
};
|
|
1600
|
+
const revert = () => {
|
|
1601
|
+
for (const { m } of ops) resize_pipeline.intent.restore(doc, m.id, m.rz);
|
|
1602
|
+
emit();
|
|
1603
|
+
};
|
|
1604
|
+
apply();
|
|
1605
|
+
history.atomic(label, (tx) => {
|
|
1606
|
+
tx.push({
|
|
1607
|
+
providerId: PROVIDER_ID,
|
|
1608
|
+
apply,
|
|
1609
|
+
revert
|
|
1610
|
+
});
|
|
1611
|
+
});
|
|
1612
|
+
return true;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* One-shot multi-member resize to an explicit target rect. Mirrors a
|
|
1616
|
+
* drag-resize gesture in mechanics — capture per-member baselines,
|
|
1617
|
+
* scale around the union's NW corner, translate the result so the
|
|
1618
|
+
* union NW lands at the requested position — but as a single
|
|
1619
|
+
* atomic step rather than a preview session. This is the GROUP path:
|
|
1620
|
+
* the whole selection is treated as one envelope.
|
|
1621
|
+
*
|
|
1622
|
+
* The function does its own geometry lookup via the
|
|
1623
|
+
* `geometry_provider` registered by the DOM surface. When no surface
|
|
1624
|
+
* is attached, the call is a no-op (returns `false`). Members that fail
|
|
1625
|
+
* the `is_resizable_node` gate — an unresizable tag (e.g. `<g>`) OR a
|
|
1626
|
+
* non-trivially-transformed element — are silently skipped (see
|
|
1627
|
+
* `collect_resize_members`).
|
|
1628
|
+
*
|
|
1629
|
+
* Revert restores the captured `transform` attribute and all
|
|
1630
|
+
* geometry attrs the apply step wrote — so a `<rect>` with an
|
|
1631
|
+
* existing `transform` round-trips cleanly. See `apply_translate`'s
|
|
1632
|
+
* `viaTransform` arm for why this matters.
|
|
1633
|
+
*/
|
|
1634
|
+
function resize_to(target, opts) {
|
|
1635
|
+
const members = collect_resize_members(opts?.ids ?? selection, "skip");
|
|
1636
|
+
if (!members) return false;
|
|
1637
|
+
const union = cmath.rect.union(members.map((m) => m.bbox));
|
|
1638
|
+
const sx = union.width === 0 ? 1 : target.width / union.width;
|
|
1639
|
+
const sy = union.height === 0 ? 1 : target.height / union.height;
|
|
1640
|
+
const origin = {
|
|
1641
|
+
x: union.x,
|
|
1642
|
+
y: union.y
|
|
1643
|
+
};
|
|
1644
|
+
return commit_resize(members, () => ({
|
|
1645
|
+
sx,
|
|
1646
|
+
sy,
|
|
1647
|
+
origin
|
|
1648
|
+
}), {
|
|
1649
|
+
dx: target.x - union.x,
|
|
1650
|
+
dy: target.y - union.y
|
|
1651
|
+
}, opts?.label ?? "resize-to");
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Resize by a `{dw, dh}` delta — the core verb behind keyboard nudge-resize
|
|
1655
|
+
* (`Ctrl+Alt+Arrow`). This is the PER-ELEMENT path: each selected member
|
|
1656
|
+
* grows/shrinks by the delta around ITS OWN NW corner, so members keep their
|
|
1657
|
+
* positions relative to one another. This deliberately differs from
|
|
1658
|
+
* {@link resize_to} (the group/envelope path): a HUD group-resize scales the
|
|
1659
|
+
* whole selection around the shared union origin, translating off-origin
|
|
1660
|
+
* members — correct for a drag handle, wrong for a keyboard nudge, whose UX
|
|
1661
|
+
* is "apply the delta to each".
|
|
1662
|
+
*
|
|
1663
|
+
* ALL-OR-NOTHING gate (`collect_resize_members("all_or_nothing")`): refuses
|
|
1664
|
+
* (returns `false`, no history step) on empty selection, no geometry
|
|
1665
|
+
* provider, or any member failing the `is_resizable_node` gate — matching
|
|
1666
|
+
* the resize HUD rather than `resize_to`'s per-member skip.
|
|
1667
|
+
*/
|
|
1668
|
+
function resize_by(delta, opts) {
|
|
1669
|
+
const members = collect_resize_members(opts?.ids ?? selection, "all_or_nothing");
|
|
1670
|
+
if (!members) return false;
|
|
1671
|
+
const axis = (size, d) => size === 0 ? 1 : Math.max(0, size + d) / size;
|
|
1672
|
+
return commit_resize(members, (m) => ({
|
|
1673
|
+
sx: axis(m.bbox.width, delta.dw),
|
|
1674
|
+
sy: axis(m.bbox.height, delta.dh),
|
|
1675
|
+
origin: {
|
|
1676
|
+
x: m.bbox.x,
|
|
1677
|
+
y: m.bbox.y
|
|
1678
|
+
}
|
|
1679
|
+
}), null, "nudge-resize");
|
|
1680
|
+
}
|
|
1681
|
+
/** Shared helper: compute a default rotation pivot from the live
|
|
1682
|
+
* geometry_provider when the caller omitted one. Falls back to (0,0)
|
|
1683
|
+
* if no surface is attached. */
|
|
1684
|
+
function default_rotate_pivot(ids) {
|
|
1685
|
+
if (!geometry_provider || ids.length === 0) return {
|
|
1686
|
+
x: 0,
|
|
1687
|
+
y: 0
|
|
1688
|
+
};
|
|
1689
|
+
const rects = [];
|
|
1690
|
+
for (const id of ids) {
|
|
1691
|
+
const b = geometry_provider.bounds_of(id);
|
|
1692
|
+
if (b) rects.push(b);
|
|
1693
|
+
}
|
|
1694
|
+
if (rects.length === 0) return {
|
|
1695
|
+
x: 0,
|
|
1696
|
+
y: 0
|
|
1697
|
+
};
|
|
1698
|
+
const u = cmath.rect.union(rects);
|
|
1699
|
+
return {
|
|
1700
|
+
x: u.x + u.width / 2,
|
|
1701
|
+
y: u.y + u.height / 2
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
function rotate(angle, opts) {
|
|
1705
|
+
const ids = opts?.ids ?? selection;
|
|
1706
|
+
if (ids.length === 0) return false;
|
|
1707
|
+
const pivot = opts?.pivot ?? default_rotate_pivot(ids);
|
|
1708
|
+
const prepared = rotate_pipeline.prepare_rpc({
|
|
1709
|
+
doc,
|
|
1710
|
+
ids,
|
|
1711
|
+
pivot,
|
|
1712
|
+
angle_radians: angle,
|
|
1713
|
+
options: { angle_snap_step_radians: style.angle_snap_step_radians },
|
|
1714
|
+
emit
|
|
1715
|
+
});
|
|
1716
|
+
for (const v of prepared.verdicts.values()) if (v.kind === "refuse") return false;
|
|
1717
|
+
prepared.apply();
|
|
1718
|
+
history.atomic("rotate", (tx) => {
|
|
1719
|
+
tx.push({
|
|
1720
|
+
providerId: PROVIDER_ID,
|
|
1721
|
+
apply: prepared.apply,
|
|
1722
|
+
revert: prepared.revert
|
|
1723
|
+
});
|
|
1724
|
+
});
|
|
1725
|
+
return true;
|
|
1726
|
+
}
|
|
1727
|
+
function rotate_to(angle, opts) {
|
|
1728
|
+
const ids = opts?.ids ?? selection;
|
|
1729
|
+
if (ids.length === 0) return false;
|
|
1730
|
+
const pivot = opts?.pivot ?? default_rotate_pivot(ids);
|
|
1731
|
+
const probe = rotate_pipeline.prepare_rpc({
|
|
1732
|
+
doc,
|
|
1733
|
+
ids,
|
|
1734
|
+
pivot,
|
|
1735
|
+
angle_radians: 0,
|
|
1736
|
+
options: { angle_snap_step_radians: style.angle_snap_step_radians },
|
|
1737
|
+
emit: () => {}
|
|
1738
|
+
});
|
|
1739
|
+
for (const v of probe.verdicts.values()) if (v.kind === "refuse") return false;
|
|
1740
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
1741
|
+
const apply = () => {
|
|
1742
|
+
for (const m of probe.plan.members) {
|
|
1743
|
+
const delta = angle - m.baseline.current_rotation_deg * DEG_TO_RAD;
|
|
1744
|
+
rotate_pipeline.intent.apply(doc, m.id, m.baseline, delta);
|
|
1745
|
+
}
|
|
1746
|
+
emit();
|
|
1747
|
+
};
|
|
1748
|
+
const revert = () => {
|
|
1749
|
+
for (const m of probe.plan.members) rotate_pipeline.intent.apply(doc, m.id, m.baseline, 0);
|
|
1750
|
+
emit();
|
|
1751
|
+
};
|
|
1752
|
+
apply();
|
|
1753
|
+
history.atomic("rotate-to", (tx) => {
|
|
1754
|
+
tx.push({
|
|
1755
|
+
providerId: PROVIDER_ID,
|
|
1756
|
+
apply,
|
|
1757
|
+
revert
|
|
1758
|
+
});
|
|
1759
|
+
});
|
|
1760
|
+
return true;
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Relative affine compose about a pivot. See the `Commands.transform`
|
|
1764
|
+
* doc for the full contract. This function owns ONLY the pivot/effective-
|
|
1765
|
+
* matrix computation (which needs `geometry_provider`); the parse→fold→
|
|
1766
|
+
* emit round-trip is delegated per-member to the pure
|
|
1767
|
+
* `transform.apply_affine` helper.
|
|
1768
|
+
*/
|
|
1769
|
+
function apply_transform(matrix, opts) {
|
|
1770
|
+
const ids = opts?.ids ?? selection;
|
|
1771
|
+
if (ids.length === 0) return false;
|
|
1772
|
+
if (!geometry_provider) return false;
|
|
1773
|
+
for (const id of ids) if (rotate_pipeline.intent.is_transformable(doc, id).kind === "refuse") return false;
|
|
1774
|
+
const pivot = opts?.pivot ?? default_rotate_pivot(ids);
|
|
1775
|
+
const [a, b, c, d, e, f] = matrix;
|
|
1776
|
+
const requested = [[
|
|
1777
|
+
a,
|
|
1778
|
+
c,
|
|
1779
|
+
e
|
|
1780
|
+
], [
|
|
1781
|
+
b,
|
|
1782
|
+
d,
|
|
1783
|
+
f
|
|
1784
|
+
]];
|
|
1785
|
+
const t_pivot = [[
|
|
1786
|
+
1,
|
|
1787
|
+
0,
|
|
1788
|
+
pivot.x
|
|
1789
|
+
], [
|
|
1790
|
+
0,
|
|
1791
|
+
1,
|
|
1792
|
+
pivot.y
|
|
1793
|
+
]];
|
|
1794
|
+
const t_neg_pivot = [[
|
|
1795
|
+
1,
|
|
1796
|
+
0,
|
|
1797
|
+
-pivot.x
|
|
1798
|
+
], [
|
|
1799
|
+
0,
|
|
1800
|
+
1,
|
|
1801
|
+
-pivot.y
|
|
1802
|
+
]];
|
|
1803
|
+
const effective = cmath.transform.multiply(cmath.transform.multiply(t_pivot, requested), t_neg_pivot);
|
|
1804
|
+
const members = ids.map((id) => ({
|
|
1805
|
+
id,
|
|
1806
|
+
transform_pre: doc.get_attr(id, "transform")
|
|
1807
|
+
}));
|
|
1808
|
+
const apply = () => {
|
|
1809
|
+
for (const m of members) doc.set_attr(m.id, "transform", transform.apply_affine(m.transform_pre, effective));
|
|
1810
|
+
emit();
|
|
1811
|
+
};
|
|
1812
|
+
const revert = () => {
|
|
1813
|
+
for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
|
|
1814
|
+
emit();
|
|
1815
|
+
};
|
|
1816
|
+
apply();
|
|
1817
|
+
history.atomic("transform", (tx) => {
|
|
1818
|
+
tx.push({
|
|
1819
|
+
providerId: PROVIDER_ID,
|
|
1820
|
+
apply,
|
|
1821
|
+
revert
|
|
1822
|
+
});
|
|
1823
|
+
});
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
function flatten_transform(opts) {
|
|
1827
|
+
const ids = opts?.ids ?? selection;
|
|
1828
|
+
if (ids.length === 0) return false;
|
|
1829
|
+
const members = [];
|
|
1830
|
+
for (const id of ids) {
|
|
1831
|
+
const pre = doc.get_attr(id, "transform");
|
|
1832
|
+
if (pre === null) continue;
|
|
1833
|
+
const ops = transform.parse(pre);
|
|
1834
|
+
if (ops === null) continue;
|
|
1835
|
+
if (ops.length === 1 && ops[0].type === "matrix") continue;
|
|
1836
|
+
members.push({
|
|
1837
|
+
id,
|
|
1838
|
+
transform_pre: pre,
|
|
1839
|
+
ops
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
if (members.length === 0) return false;
|
|
1843
|
+
const apply = () => {
|
|
1844
|
+
for (const m of members) {
|
|
1845
|
+
let mat = FLATTEN_IDENT;
|
|
1846
|
+
for (const op of m.ops) mat = flatten_mul(mat, flatten_op_to_mat(op));
|
|
1847
|
+
doc.set_attr(m.id, "transform", transform.emit([{
|
|
1848
|
+
type: "matrix",
|
|
1849
|
+
a: mat[0],
|
|
1850
|
+
b: mat[1],
|
|
1851
|
+
c: mat[2],
|
|
1852
|
+
d: mat[3],
|
|
1853
|
+
e: mat[4],
|
|
1854
|
+
f: mat[5]
|
|
1855
|
+
}]));
|
|
1856
|
+
}
|
|
1857
|
+
emit();
|
|
1858
|
+
};
|
|
1859
|
+
const revert = () => {
|
|
1860
|
+
for (const m of members) doc.set_attr(m.id, "transform", m.transform_pre);
|
|
1861
|
+
emit();
|
|
1862
|
+
};
|
|
1863
|
+
apply();
|
|
1864
|
+
history.atomic("flatten-transform", (tx) => {
|
|
1865
|
+
tx.push({
|
|
1866
|
+
providerId: PROVIDER_ID,
|
|
1867
|
+
apply,
|
|
1868
|
+
revert
|
|
1869
|
+
});
|
|
1870
|
+
});
|
|
1871
|
+
return true;
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Translate selected members so they line up along the requested edge or
|
|
1875
|
+
* center of a reference rect. Same mechanics as `resize_to`: per-member
|
|
1876
|
+
* translate baselines (so `<g>`, transformed, and natively-attributed
|
|
1877
|
+
* nodes all write the cleanest in-place representation), one atomic
|
|
1878
|
+
* history step. Deltas are computed in world space and re-expressed in
|
|
1879
|
+
* each member's local frame before writing (`world_delta_to_local`),
|
|
1880
|
+
* so members under scaled/rotated ancestors land exactly and a repeat
|
|
1881
|
+
* invocation is a no-op.
|
|
1882
|
+
*
|
|
1883
|
+
* Reference rect is selection-size dependent:
|
|
1884
|
+
* - multi-selection: union of member bboxes
|
|
1885
|
+
* - single selection: the parent's bbox (root → `<svg>` viewport,
|
|
1886
|
+
* inside a `<g>` → that group's bbox). Refuses when the selected
|
|
1887
|
+
* node IS the root (no container to align against).
|
|
1888
|
+
*
|
|
1889
|
+
* Refuses when `geometry_provider` is null (no surface attached) or when
|
|
1890
|
+
* no member has a resolvable bbox.
|
|
1891
|
+
*/
|
|
1892
|
+
function align(direction, opts) {
|
|
1893
|
+
const ids = opts?.ids ?? selection;
|
|
1894
|
+
if (ids.length === 0) return false;
|
|
1895
|
+
if (!geometry_provider) return false;
|
|
1896
|
+
const members = [];
|
|
1897
|
+
for (const id of ids) {
|
|
1898
|
+
const bbox = geometry_provider.bounds_of(id);
|
|
1899
|
+
if (!bbox) continue;
|
|
1900
|
+
const baseline = translate_pipeline.intent.capture_baseline(doc, id);
|
|
1901
|
+
if (baseline.type === "unsupported") continue;
|
|
1902
|
+
members.push({
|
|
1903
|
+
id,
|
|
1904
|
+
bbox,
|
|
1905
|
+
baseline
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
if (members.length === 0) return false;
|
|
1909
|
+
let target;
|
|
1910
|
+
if (members.length === 1) {
|
|
1911
|
+
const parent_id = doc.parent_of(members[0].id);
|
|
1912
|
+
if (parent_id === null) return false;
|
|
1913
|
+
const parent_bbox = geometry_provider.bounds_of(parent_id);
|
|
1914
|
+
if (!parent_bbox) return false;
|
|
1915
|
+
target = parent_bbox;
|
|
1916
|
+
} else target = cmath.rect.union(members.map((m) => m.bbox));
|
|
1917
|
+
const world_deltas = compute_align_deltas(members, target, direction);
|
|
1918
|
+
if (world_deltas.size === 0) return false;
|
|
1919
|
+
const deltas = /* @__PURE__ */ new Map();
|
|
1920
|
+
for (const [id, d] of world_deltas) deltas.set(id, project_world_delta(id, d));
|
|
1921
|
+
const apply = () => {
|
|
1922
|
+
for (const m of members) {
|
|
1923
|
+
const d = deltas.get(m.id);
|
|
1924
|
+
if (d) translate_pipeline.intent.apply(doc, m.id, m.baseline, d.x, d.y);
|
|
1925
|
+
}
|
|
1926
|
+
emit();
|
|
1927
|
+
};
|
|
1928
|
+
const revert = () => {
|
|
1929
|
+
for (const m of members) if (deltas.has(m.id)) translate_pipeline.intent.apply(doc, m.id, m.baseline, 0, 0);
|
|
1930
|
+
emit();
|
|
1931
|
+
};
|
|
1932
|
+
apply();
|
|
1933
|
+
history.atomic(`align ${direction}`, (tx) => {
|
|
1934
|
+
tx.push({
|
|
1935
|
+
providerId: PROVIDER_ID,
|
|
1936
|
+
apply,
|
|
1937
|
+
revert
|
|
1938
|
+
});
|
|
1939
|
+
});
|
|
1940
|
+
return true;
|
|
1941
|
+
}
|
|
1942
|
+
function select_all() {
|
|
1943
|
+
const parent = scope ?? doc.root;
|
|
1944
|
+
const children = doc.element_children_of(parent);
|
|
1945
|
+
if (children.length === 0) return false;
|
|
1946
|
+
set_selection(children);
|
|
1947
|
+
return true;
|
|
1948
|
+
}
|
|
1949
|
+
/**
|
|
1950
|
+
* Cycle the selection to the next / previous sibling. Single-selection
|
|
1951
|
+
* path uses the selected node's parent; empty / multi-selection falls
|
|
1952
|
+
* back to the current scope's first / last child. Wraps at edges.
|
|
1953
|
+
*/
|
|
1954
|
+
function select_sibling(direction) {
|
|
1955
|
+
let parent;
|
|
1956
|
+
let anchor_index;
|
|
1957
|
+
let siblings;
|
|
1958
|
+
if (selection.length === 1) {
|
|
1959
|
+
const current = selection[0];
|
|
1960
|
+
parent = doc.parent_of(current);
|
|
1961
|
+
if (parent === null) return false;
|
|
1962
|
+
siblings = doc.element_children_of(parent);
|
|
1963
|
+
anchor_index = siblings.indexOf(current);
|
|
1964
|
+
if (anchor_index < 0) return false;
|
|
1965
|
+
} else {
|
|
1966
|
+
parent = scope ?? doc.root;
|
|
1967
|
+
siblings = doc.element_children_of(parent);
|
|
1968
|
+
if (siblings.length === 0) return false;
|
|
1969
|
+
anchor_index = direction === "next" ? -1 : siblings.length;
|
|
1970
|
+
}
|
|
1971
|
+
const n = siblings.length;
|
|
1972
|
+
const next = direction === "next" ? (anchor_index + 1) % n : (anchor_index - 1 + n) % n;
|
|
1973
|
+
set_selection([siblings[next]]);
|
|
1974
|
+
return true;
|
|
1975
|
+
}
|
|
1976
|
+
function reorder(direction) {
|
|
1977
|
+
if (selection.length !== 1) return;
|
|
1978
|
+
const target = selection[0];
|
|
1979
|
+
const parent = doc.parent_of(target);
|
|
1980
|
+
if (parent === null) return;
|
|
1981
|
+
const siblings = doc.element_children_of(parent);
|
|
1982
|
+
const i = siblings.indexOf(target);
|
|
1983
|
+
if (i < 0) return;
|
|
1984
|
+
const original_before = siblings[i + 1] ?? null;
|
|
1985
|
+
let new_before;
|
|
1986
|
+
switch (direction) {
|
|
1987
|
+
case "bring_forward":
|
|
1988
|
+
if (i >= siblings.length - 1) return;
|
|
1989
|
+
new_before = siblings[i + 2] ?? null;
|
|
1990
|
+
break;
|
|
1991
|
+
case "send_backward":
|
|
1992
|
+
if (i <= 0) return;
|
|
1993
|
+
new_before = siblings[i - 1];
|
|
1994
|
+
break;
|
|
1995
|
+
case "bring_to_front":
|
|
1996
|
+
if (i === siblings.length - 1) return;
|
|
1997
|
+
new_before = null;
|
|
1998
|
+
break;
|
|
1999
|
+
case "send_to_back":
|
|
2000
|
+
if (i === 0) return;
|
|
2001
|
+
new_before = siblings[0];
|
|
2002
|
+
break;
|
|
2003
|
+
}
|
|
2004
|
+
const apply = () => {
|
|
2005
|
+
doc.insert(target, parent, new_before);
|
|
2006
|
+
emit();
|
|
2007
|
+
};
|
|
2008
|
+
const revert = () => {
|
|
2009
|
+
doc.insert(target, parent, original_before);
|
|
2010
|
+
emit();
|
|
2011
|
+
};
|
|
2012
|
+
apply();
|
|
2013
|
+
history.atomic(`reorder: ${direction}`, (tx) => {
|
|
2014
|
+
tx.push({
|
|
2015
|
+
providerId: PROVIDER_ID,
|
|
2016
|
+
apply,
|
|
2017
|
+
revert
|
|
2018
|
+
});
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
function remove() {
|
|
2022
|
+
remove_selection("remove");
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Shared deletion body for `remove` and `cut` — identical
|
|
2026
|
+
* capture/revert semantics, differing only in the history label
|
|
2027
|
+
* (`verb`), so undo attribution names the gesture that caused the
|
|
2028
|
+
* deletion.
|
|
2029
|
+
*/
|
|
2030
|
+
function remove_selection(verb) {
|
|
2031
|
+
if (selection.length === 0) return;
|
|
2032
|
+
const filtered = doc.prune_nested_nodes(selection).filter((id) => doc.parent_of(id) !== null);
|
|
2033
|
+
if (filtered.length === 0) return;
|
|
2034
|
+
discard_open_property_previews();
|
|
2035
|
+
const captures = [...filtered].sort(subtree.by_document_order(doc)).map((id) => ({
|
|
2036
|
+
id,
|
|
2037
|
+
parent: doc.parent_of(id),
|
|
2038
|
+
next_sibling: doc.next_element_sibling_of(id)
|
|
2039
|
+
}));
|
|
2040
|
+
const old_selection = selection;
|
|
2041
|
+
const apply = () => {
|
|
2042
|
+
for (const c of captures) doc.remove(c.id);
|
|
2043
|
+
set_selection([]);
|
|
2044
|
+
};
|
|
2045
|
+
const revert = () => {
|
|
2046
|
+
for (let i = captures.length - 1; i >= 0; i--) {
|
|
2047
|
+
const c = captures[i];
|
|
2048
|
+
doc.insert(c.id, c.parent, c.next_sibling);
|
|
2049
|
+
}
|
|
2050
|
+
set_selection(old_selection);
|
|
2051
|
+
};
|
|
2052
|
+
apply();
|
|
2053
|
+
history.atomic(captures.length === 1 ? verb : `${verb} ${captures.length}`, (tx) => {
|
|
2054
|
+
tx.push({
|
|
2055
|
+
providerId: PROVIDER_ID,
|
|
2056
|
+
apply,
|
|
2057
|
+
revert
|
|
2058
|
+
});
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
function group$1() {
|
|
2062
|
+
const plan = group.plan(doc, selection);
|
|
2063
|
+
if (!plan) return false;
|
|
2064
|
+
const group_id = doc.create_element("g");
|
|
2065
|
+
const original_selection = selection;
|
|
2066
|
+
const apply = () => {
|
|
2067
|
+
doc.insert(group_id, plan.parent, plan.insert_before);
|
|
2068
|
+
for (const child of plan.children) doc.insert(child, group_id, null);
|
|
2069
|
+
set_selection([group_id]);
|
|
2070
|
+
};
|
|
2071
|
+
const revert = () => {
|
|
2072
|
+
for (let i = plan.children.length - 1; i >= 0; i--) {
|
|
2073
|
+
const child = plan.children[i];
|
|
2074
|
+
doc.insert(child, plan.parent, plan.original_positions.get(child) ?? null);
|
|
2075
|
+
}
|
|
2076
|
+
doc.remove(group_id);
|
|
2077
|
+
set_selection(original_selection);
|
|
2078
|
+
};
|
|
2079
|
+
apply();
|
|
2080
|
+
history.atomic("group", (tx) => {
|
|
2081
|
+
tx.push({
|
|
2082
|
+
providerId: PROVIDER_ID,
|
|
2083
|
+
apply,
|
|
2084
|
+
revert
|
|
2085
|
+
});
|
|
2086
|
+
});
|
|
2087
|
+
return true;
|
|
2088
|
+
}
|
|
2089
|
+
function ungroup(opts) {
|
|
2090
|
+
let target;
|
|
2091
|
+
if (opts?.id !== void 0) target = opts.id;
|
|
2092
|
+
else {
|
|
2093
|
+
if (selection.length !== 1) return false;
|
|
2094
|
+
target = selection[0];
|
|
2095
|
+
}
|
|
2096
|
+
discard_open_property_previews();
|
|
2097
|
+
const plan = group.plan_ungroup(doc, target);
|
|
2098
|
+
if (!plan) return false;
|
|
2099
|
+
const group_id = plan.group_id;
|
|
2100
|
+
const group_next_sibling = doc.next_element_sibling_of(group_id);
|
|
2101
|
+
const original_child_transforms = /* @__PURE__ */ new Map();
|
|
2102
|
+
for (const child of plan.children) original_child_transforms.set(child, doc.get_attr(child, "transform"));
|
|
2103
|
+
const group_ops = plan.group_transform === null ? [] : transform.parse(plan.group_transform) ?? [];
|
|
2104
|
+
const original_selection = selection;
|
|
2105
|
+
const apply = () => {
|
|
2106
|
+
if (group_ops.length > 0) for (const child of plan.children) {
|
|
2107
|
+
const child_ops = transform.parse(doc.get_attr(child, "transform")) ?? [];
|
|
2108
|
+
const next = transform.emit([...group_ops, ...child_ops]);
|
|
2109
|
+
doc.set_attr(child, "transform", next === "" ? null : next);
|
|
2110
|
+
}
|
|
2111
|
+
for (const child of plan.children) doc.insert(child, plan.parent, group_id);
|
|
2112
|
+
doc.remove(group_id);
|
|
2113
|
+
set_selection(plan.children);
|
|
2114
|
+
};
|
|
2115
|
+
const revert = () => {
|
|
2116
|
+
doc.insert(group_id, plan.parent, group_next_sibling);
|
|
2117
|
+
for (const child of plan.children) doc.insert(child, group_id, null);
|
|
2118
|
+
if (group_ops.length > 0) for (const child of plan.children) doc.set_attr(child, "transform", original_child_transforms.get(child) ?? null);
|
|
2119
|
+
set_selection(original_selection);
|
|
2120
|
+
};
|
|
2121
|
+
apply();
|
|
2122
|
+
history.atomic("ungroup", (tx) => {
|
|
2123
|
+
tx.push({
|
|
2124
|
+
providerId: PROVIDER_ID,
|
|
2125
|
+
apply,
|
|
2126
|
+
revert
|
|
2127
|
+
});
|
|
2128
|
+
});
|
|
2129
|
+
return true;
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Atomic one-shot insertion. Used by paste, programmatic RPC, and the
|
|
2133
|
+
* click-no-drag commit path inside the insertion gesture driver. One
|
|
2134
|
+
* undo step. Returns the new node id.
|
|
2135
|
+
*
|
|
2136
|
+
* `attrs` are merged on top of `default_paint_attrs(tag)` — caller attrs
|
|
2137
|
+
* win. `opts.parent` defaults to root; `opts.index` (insert-before
|
|
2138
|
+
* sibling index) defaults to append; `opts.select` defaults to `true`.
|
|
2139
|
+
*/
|
|
2140
|
+
/**
|
|
2141
|
+
* Resolve an optional `index` (position in `parent`'s element-children
|
|
2142
|
+
* list to insert AT — anything at or after it shifts; out-of-range or
|
|
2143
|
+
* `undefined` appends) to an insert-before anchor. Shared by `insert`,
|
|
2144
|
+
* `insert_fragment`, and `insert_preview`.
|
|
2145
|
+
*/
|
|
2146
|
+
function resolve_insert_before(parent, index) {
|
|
2147
|
+
if (index === void 0) return null;
|
|
2148
|
+
return doc.element_children_of(parent)[index] ?? null;
|
|
2149
|
+
}
|
|
2150
|
+
function insert(tag, attrs, opts) {
|
|
2151
|
+
const parent = opts?.parent ?? doc.root;
|
|
2152
|
+
const select_after = opts?.select !== false;
|
|
2153
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2154
|
+
const id = doc.create_element(tag);
|
|
2155
|
+
const merged_attrs = {
|
|
2156
|
+
...default_paint_attrs_for(tag),
|
|
2157
|
+
...attrs
|
|
2158
|
+
};
|
|
2159
|
+
const attr_pairs = Object.entries(merged_attrs);
|
|
2160
|
+
const previous_selection = selection;
|
|
2161
|
+
const apply = () => {
|
|
2162
|
+
for (const [name, value] of attr_pairs) doc.set_attr(id, name, value);
|
|
2163
|
+
doc.insert(id, parent, insert_before);
|
|
2164
|
+
if (select_after) set_selection([id]);
|
|
2165
|
+
};
|
|
2166
|
+
const revert = () => {
|
|
2167
|
+
doc.remove(id);
|
|
2168
|
+
if (select_after) set_selection(previous_selection);
|
|
2169
|
+
};
|
|
2170
|
+
apply();
|
|
2171
|
+
history.atomic(`insert ${tag}`, (tx) => {
|
|
2172
|
+
tx.push({
|
|
2173
|
+
providerId: PROVIDER_ID,
|
|
2174
|
+
apply,
|
|
2175
|
+
revert
|
|
2176
|
+
});
|
|
2177
|
+
});
|
|
2178
|
+
return id;
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Atomic fragment insertion — contract in {@link Commands.insert_fragment}.
|
|
2182
|
+
* Parses + adopts via `doc.create_fragment` (subtrees registered but
|
|
2183
|
+
* detached, like `create_element` — history.redo finds them via
|
|
2184
|
+
* closure), computes the namespace hoist plan, then brackets inserts +
|
|
2185
|
+
* hoisted declarations + selection in ONE history step.
|
|
2186
|
+
*/
|
|
2187
|
+
function insert_fragment(svg, opts) {
|
|
2188
|
+
return insert_fragment_impl(svg, opts, "insert fragment");
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Label-bearing body shared by `insert_fragment` and `paste` — same
|
|
2192
|
+
* atomic insertion, differing only in history attribution (undo for a
|
|
2193
|
+
* paste gesture should read "paste", not "insert fragment").
|
|
2194
|
+
*/
|
|
2195
|
+
function insert_fragment_impl(svg, opts, label) {
|
|
2196
|
+
const parent = opts?.parent ?? doc.root;
|
|
2197
|
+
if (!doc.is_element(parent) || !doc.contains(doc.root, parent)) throw new Error(`insert_fragment: parent ${JSON.stringify(parent)} is not an element in the current document`);
|
|
2198
|
+
const select_after = opts?.select !== false;
|
|
2199
|
+
const { roots, xmlns } = doc.create_fragment(svg);
|
|
2200
|
+
if (roots.length === 0) return [];
|
|
2201
|
+
const known_uri = new Map(WELL_KNOWN_NS_PREFIXES);
|
|
2202
|
+
for (const d of xmlns) known_uri.set(d.prefix, d.uri);
|
|
2203
|
+
const hoist = [];
|
|
2204
|
+
const considered = /* @__PURE__ */ new Set();
|
|
2205
|
+
for (const id of roots) for (const prefix of doc.undeclared_ns_prefixes(id)) {
|
|
2206
|
+
if (considered.has(prefix)) continue;
|
|
2207
|
+
considered.add(prefix);
|
|
2208
|
+
if (doc.get_attr(doc.root, prefix, XMLNS_NS) !== null) continue;
|
|
2209
|
+
const uri = known_uri.get(prefix);
|
|
2210
|
+
if (uri === void 0) continue;
|
|
2211
|
+
hoist.push({
|
|
2212
|
+
prefix,
|
|
2213
|
+
uri
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2217
|
+
const previous_selection = selection;
|
|
2218
|
+
const apply = () => {
|
|
2219
|
+
for (const { prefix, uri } of hoist) doc.declare_xmlns(prefix, uri);
|
|
2220
|
+
for (const id of roots) doc.insert(id, parent, insert_before);
|
|
2221
|
+
if (select_after) set_selection(roots);
|
|
2222
|
+
};
|
|
2223
|
+
const revert = () => {
|
|
2224
|
+
for (let i = roots.length - 1; i >= 0; i--) doc.remove(roots[i]);
|
|
2225
|
+
for (const { prefix } of hoist) doc.set_attr(doc.root, prefix, null, XMLNS_NS);
|
|
2226
|
+
if (select_after) set_selection(previous_selection);
|
|
2227
|
+
};
|
|
2228
|
+
apply();
|
|
2229
|
+
history.atomic(label, (tx) => {
|
|
2230
|
+
tx.push({
|
|
2231
|
+
providerId: PROVIDER_ID,
|
|
2232
|
+
apply,
|
|
2233
|
+
revert
|
|
2234
|
+
});
|
|
2235
|
+
});
|
|
2236
|
+
return roots;
|
|
2237
|
+
}
|
|
2238
|
+
function copy_impl(deliver_external) {
|
|
2239
|
+
const payload = clipboard.extract_payload(doc, selection);
|
|
2240
|
+
if (payload === null) return null;
|
|
2241
|
+
clipboard_buffer = payload;
|
|
2242
|
+
if (deliver_external && providers.clipboard) providers.clipboard.write(payload).catch((err) => {
|
|
2243
|
+
console.warn("[svg-editor] clipboard provider write failed:", err);
|
|
2244
|
+
});
|
|
2245
|
+
return payload;
|
|
2246
|
+
}
|
|
2247
|
+
function copy() {
|
|
2248
|
+
return copy_impl(true);
|
|
2249
|
+
}
|
|
2250
|
+
function cut_impl(deliver_external) {
|
|
2251
|
+
if (selection.length === 0) return null;
|
|
2252
|
+
discard_open_property_previews();
|
|
2253
|
+
const payload = copy_impl(deliver_external);
|
|
2254
|
+
if (payload === null) return null;
|
|
2255
|
+
remove_selection("cut");
|
|
2256
|
+
return payload;
|
|
2257
|
+
}
|
|
2258
|
+
function cut() {
|
|
2259
|
+
return cut_impl(true);
|
|
2260
|
+
}
|
|
2261
|
+
function paste(text) {
|
|
2262
|
+
if (text !== void 0 && typeof text !== "string") throw new TypeError(`paste(text) requires a string when provided, got ${text === null ? "null" : typeof text}`);
|
|
2263
|
+
const source = text ?? clipboard_buffer;
|
|
2264
|
+
if (source === null) return [];
|
|
2265
|
+
try {
|
|
2266
|
+
return insert_fragment_impl(source, void 0, "paste");
|
|
2267
|
+
} catch (err) {
|
|
2268
|
+
if (err instanceof TypeError) throw err;
|
|
2269
|
+
return [];
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
/**
|
|
2273
|
+
* Duplicate over `subtree.clone_plan` — see the `Commands` doc. Same
|
|
2274
|
+
* atomic shape as `insert_fragment_impl`: closures own the
|
|
2275
|
+
* insert/remove pair so redo re-inserts the same NodeIds.
|
|
2276
|
+
*
|
|
2277
|
+
* Repeating offset (gridaco/grida#825, spec §Repeating offset): when
|
|
2278
|
+
* the targets are exactly the previous duplication's clones and
|
|
2279
|
+
* geometry witnesses a rigid translate between that record's origins
|
|
2280
|
+
* and clones, the fresh clones land displaced by the same delta. The
|
|
2281
|
+
* offset rides the translate pipeline INSIDE the same atomic step —
|
|
2282
|
+
* one undo removes copy + offset together. Clone baselines are
|
|
2283
|
+
* key-swapped from the origins (a clone is a verbatim copy at rest —
|
|
2284
|
+
* the orchestrator's `enter_clone` trick), so nothing reads the
|
|
2285
|
+
* detached clones. Any failed precondition degrades to plain
|
|
2286
|
+
* duplicate-in-place; never an error.
|
|
2287
|
+
*/
|
|
2288
|
+
function duplicate() {
|
|
2289
|
+
const plan = subtree.clone_plan(doc, selection);
|
|
2290
|
+
if (plan.length === 0) return [];
|
|
2291
|
+
const clones = plan.map((p) => p.clone);
|
|
2292
|
+
const origins = plan.map((p) => p.origin);
|
|
2293
|
+
const previous_selection = selection;
|
|
2294
|
+
const delta = subtree.repeat_delta(active_duplication, origins, (id) => geometry_provider ? geometry_provider.bounds_of(id) : null);
|
|
2295
|
+
let offset_plan = null;
|
|
2296
|
+
if (delta) {
|
|
2297
|
+
const baselines = /* @__PURE__ */ new Map();
|
|
2298
|
+
for (const p of plan) baselines.set(p.clone, translate_pipeline.intent.capture_baseline(doc, p.origin));
|
|
2299
|
+
offset_plan = {
|
|
2300
|
+
ids: clones,
|
|
2301
|
+
baselines,
|
|
2302
|
+
delta
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
const apply = () => {
|
|
2306
|
+
subtree.insert_plan(doc, plan);
|
|
2307
|
+
if (offset_plan) translate_pipeline.apply(doc, offset_plan, project_world_delta);
|
|
2308
|
+
set_selection(clones);
|
|
2309
|
+
};
|
|
2310
|
+
const revert = () => {
|
|
2311
|
+
if (offset_plan) translate_pipeline.revert(doc, offset_plan);
|
|
2312
|
+
subtree.remove_plan(doc, plan);
|
|
2313
|
+
set_selection(previous_selection);
|
|
2314
|
+
};
|
|
2315
|
+
apply();
|
|
2316
|
+
history.atomic("duplicate", (tx) => {
|
|
2317
|
+
tx.push({
|
|
2318
|
+
providerId: PROVIDER_ID,
|
|
2319
|
+
apply,
|
|
2320
|
+
revert
|
|
2321
|
+
});
|
|
2322
|
+
});
|
|
2323
|
+
active_duplication = {
|
|
2324
|
+
origins,
|
|
2325
|
+
clones
|
|
2326
|
+
};
|
|
2327
|
+
return clones;
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* Preview-bracketed insertion. Used by the pointer-driven drag gesture
|
|
2331
|
+
* in the DOM surface. Per-frame attr writes call `update(attrs)`; one
|
|
2332
|
+
* undo step on `commit()`; clean rollback on `discard()`.
|
|
2333
|
+
*
|
|
2334
|
+
* The node is created and inserted on open so the HUD selection chrome
|
|
2335
|
+
* can render the in-progress shape immediately. On `discard()` the
|
|
2336
|
+
* preview's revert removes the node entirely.
|
|
2337
|
+
*/
|
|
2338
|
+
function insert_preview(tag, initial, opts) {
|
|
2339
|
+
const parent = opts?.parent ?? doc.root;
|
|
2340
|
+
const insert_before = resolve_insert_before(parent, opts?.index);
|
|
2341
|
+
const id = doc.create_element(tag);
|
|
2342
|
+
const previous_selection = selection;
|
|
2343
|
+
const live_attrs = {
|
|
2344
|
+
...default_paint_attrs_for(tag),
|
|
2345
|
+
...initial
|
|
2346
|
+
};
|
|
2347
|
+
for (const name in live_attrs) doc.set_attr(id, name, live_attrs[name]);
|
|
2348
|
+
doc.insert(id, parent, insert_before);
|
|
2349
|
+
set_selection([id]);
|
|
2350
|
+
const preview = history.preview(`insert ${tag}`);
|
|
2351
|
+
const live = () => preview.state === "active";
|
|
2352
|
+
const apply = () => {
|
|
2353
|
+
for (const name in live_attrs) doc.set_attr(id, name, live_attrs[name]);
|
|
2354
|
+
if (doc.parent_of(id) === null) doc.insert(id, parent, insert_before);
|
|
2355
|
+
set_selection([id]);
|
|
2356
|
+
};
|
|
2357
|
+
const revert = () => {
|
|
2358
|
+
doc.remove(id);
|
|
2359
|
+
set_selection(previous_selection);
|
|
2360
|
+
};
|
|
2361
|
+
const entry = {
|
|
2362
|
+
providerId: PROVIDER_ID,
|
|
2363
|
+
apply,
|
|
2364
|
+
revert
|
|
2365
|
+
};
|
|
2366
|
+
preview.set(entry);
|
|
2367
|
+
return {
|
|
2368
|
+
id,
|
|
2369
|
+
update(attrs) {
|
|
2370
|
+
if (!live()) return;
|
|
2371
|
+
for (const name in attrs) {
|
|
2372
|
+
live_attrs[name] = attrs[name];
|
|
2373
|
+
doc.set_attr(id, name, attrs[name]);
|
|
2374
|
+
}
|
|
2375
|
+
preview.set(entry);
|
|
2376
|
+
},
|
|
2377
|
+
commit() {
|
|
2378
|
+
if (!live()) return;
|
|
2379
|
+
preview.commit();
|
|
2380
|
+
},
|
|
2381
|
+
discard() {
|
|
2382
|
+
if (!live()) return;
|
|
2383
|
+
preview.discard();
|
|
2384
|
+
}
|
|
2385
|
+
};
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Text-creation bracket for the click-to-place text tool. Creates an
|
|
2389
|
+
* empty `<text>` with `initial` attrs, opens a single history preview,
|
|
2390
|
+
* and selects it — the DOM surface then mounts inline content-edit on
|
|
2391
|
+
* it. The surface finalizes the returned session when content-edit
|
|
2392
|
+
* exits:
|
|
2393
|
+
*
|
|
2394
|
+
* - `commit()` — snapshots the live text content into the delta and
|
|
2395
|
+
* commits ONE undo step (create + text together). Redo replays both,
|
|
2396
|
+
* so a redone text insert keeps its content (a plain `insert_preview`
|
|
2397
|
+
* would lose it — text is not an attribute).
|
|
2398
|
+
* - `discard()` — rolls the creation back entirely: no node, no
|
|
2399
|
+
* committed history entry. This is the empty-equals-delete rule for a
|
|
2400
|
+
* freshly-placed node (design:
|
|
2401
|
+
* `docs/wg/feat-svg-editor/text-tool.md`).
|
|
2402
|
+
*
|
|
2403
|
+
* The node is inserted empty on open (so the caret has somewhere to
|
|
2404
|
+
* live); live edits mutate its text in place, and `commit()` reads the
|
|
2405
|
+
* final text back off the document.
|
|
2406
|
+
*/
|
|
2407
|
+
function insert_text_preview(initial, opts) {
|
|
2408
|
+
const parent = opts?.parent ?? doc.root;
|
|
2409
|
+
const id = doc.create_element("text");
|
|
2410
|
+
const previous_selection = selection;
|
|
2411
|
+
const attrs = { ...initial };
|
|
2412
|
+
let committed_text = "";
|
|
2413
|
+
const apply = () => {
|
|
2414
|
+
for (const name in attrs) doc.set_attr(id, name, attrs[name]);
|
|
2415
|
+
if (doc.parent_of(id) === null) doc.insert(id, parent, null);
|
|
2416
|
+
doc.set_text(id, committed_text);
|
|
2417
|
+
set_selection([id]);
|
|
2418
|
+
};
|
|
2419
|
+
const revert = () => {
|
|
2420
|
+
doc.remove(id);
|
|
2421
|
+
set_selection(previous_selection);
|
|
2422
|
+
};
|
|
2423
|
+
const preview = history.preview("insert text");
|
|
2424
|
+
const live = () => preview.state === "active";
|
|
2425
|
+
preview.set({
|
|
2426
|
+
providerId: PROVIDER_ID,
|
|
2427
|
+
apply,
|
|
2428
|
+
revert
|
|
2429
|
+
});
|
|
2430
|
+
return {
|
|
2431
|
+
id,
|
|
2432
|
+
commit() {
|
|
2433
|
+
if (!live()) return;
|
|
2434
|
+
committed_text = doc.text_of(id);
|
|
2435
|
+
preview.commit();
|
|
2436
|
+
},
|
|
2437
|
+
discard() {
|
|
2438
|
+
if (!live()) return;
|
|
2439
|
+
preview.discard();
|
|
2440
|
+
}
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
/** Per-tag default paint attrs. Wrapped so callers don't need to depend
|
|
2444
|
+
* on the InsertableTag type — `insert()` accepts arbitrary string tags
|
|
2445
|
+
* (so `commands.insert("path", ...)` works for paste / RPC) but only
|
|
2446
|
+
* the closed insertable set gets default paint. */
|
|
2447
|
+
function default_paint_attrs_for(tag) {
|
|
2448
|
+
if (tag === "rect" || tag === "ellipse" || tag === "line") return insertions.default_paint_attrs(tag);
|
|
2449
|
+
return {};
|
|
2450
|
+
}
|
|
2451
|
+
function set_text(value) {
|
|
2452
|
+
if (selection.length !== 1) return;
|
|
2453
|
+
const target = selection[0];
|
|
2454
|
+
if (!doc.is_text_edit_target(target)) return;
|
|
2455
|
+
const original = doc.text_of(target);
|
|
2456
|
+
if (original === value) return;
|
|
2457
|
+
const apply = () => {
|
|
2458
|
+
doc.set_text(target, value);
|
|
2459
|
+
emit();
|
|
2460
|
+
};
|
|
2461
|
+
const revert = () => {
|
|
2462
|
+
doc.set_text(target, original);
|
|
2463
|
+
emit();
|
|
2464
|
+
};
|
|
2465
|
+
apply();
|
|
2466
|
+
history.atomic("edit text", (tx) => {
|
|
2467
|
+
tx.push({
|
|
2468
|
+
providerId: PROVIDER_ID,
|
|
2469
|
+
apply,
|
|
2470
|
+
revert
|
|
2471
|
+
});
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
let content_edit_driver = null;
|
|
2475
|
+
let computed_resolver = null;
|
|
2476
|
+
function dom_computed_property(id, name) {
|
|
2477
|
+
return computed_resolver?.computed_property(id, name) ?? null;
|
|
2478
|
+
}
|
|
2479
|
+
function dom_computed_paint(id, channel) {
|
|
2480
|
+
return computed_resolver?.computed_paint(id, channel) ?? null;
|
|
2481
|
+
}
|
|
2482
|
+
let current_surface_hover = null;
|
|
2483
|
+
let surface_hover_override = null;
|
|
2484
|
+
const surface_hover_listeners = /* @__PURE__ */ new Set();
|
|
2485
|
+
let surface_hover_override_driver = null;
|
|
2486
|
+
function notify_surface_hover() {
|
|
2487
|
+
for (const cb of surface_hover_listeners) cb();
|
|
2488
|
+
}
|
|
2489
|
+
function _set_current_surface_hover(id) {
|
|
2490
|
+
if (current_surface_hover === id) return;
|
|
2491
|
+
current_surface_hover = id;
|
|
2492
|
+
notify_surface_hover();
|
|
2493
|
+
}
|
|
2494
|
+
const pick_listeners = /* @__PURE__ */ new Set();
|
|
2495
|
+
function notify_pick(e) {
|
|
2496
|
+
for (const cb of pick_listeners) cb(e);
|
|
2497
|
+
}
|
|
2498
|
+
function enter_content_edit(target) {
|
|
2499
|
+
const id = target ?? (selection.length === 1 ? selection[0] : null);
|
|
2500
|
+
if (!id) return false;
|
|
2501
|
+
if (!doc.is_text_edit_target(id) && doc.is_vector_edit_target(id) === null) return false;
|
|
2502
|
+
if (!content_edit_driver) return false;
|
|
2503
|
+
return content_edit_driver(id);
|
|
2504
|
+
}
|
|
2505
|
+
function load_svg(svg) {
|
|
2506
|
+
discard_open_property_previews();
|
|
2507
|
+
history.clear();
|
|
2508
|
+
doc.load(svg);
|
|
2509
|
+
selection = [];
|
|
2510
|
+
scope = null;
|
|
2511
|
+
mode = "select";
|
|
2512
|
+
tool = TOOL_CURSOR;
|
|
2513
|
+
active_duplication = null;
|
|
2514
|
+
baseline_revision = doc.revision;
|
|
2515
|
+
load_version++;
|
|
2516
|
+
emit();
|
|
2517
|
+
}
|
|
2518
|
+
function serialize_svg() {
|
|
2519
|
+
return doc.serialize();
|
|
2520
|
+
}
|
|
2521
|
+
function undo() {
|
|
2522
|
+
history.undo();
|
|
2523
|
+
}
|
|
2524
|
+
function redo() {
|
|
2525
|
+
history.redo();
|
|
2526
|
+
}
|
|
2527
|
+
const registry = new CommandRegistry();
|
|
2528
|
+
const keymap = new Keymap(registry);
|
|
2529
|
+
const commands = {
|
|
2530
|
+
select,
|
|
2531
|
+
deselect,
|
|
2532
|
+
select_all,
|
|
2533
|
+
select_sibling,
|
|
2534
|
+
enter_scope,
|
|
2535
|
+
exit_scope,
|
|
2536
|
+
set_mode,
|
|
2537
|
+
set_property,
|
|
2538
|
+
preview_property,
|
|
2539
|
+
set_paint,
|
|
2540
|
+
preview_paint,
|
|
2541
|
+
set_paint_from_gradient,
|
|
2542
|
+
translate,
|
|
2543
|
+
nudge,
|
|
2544
|
+
resize_to,
|
|
2545
|
+
resize_by,
|
|
2546
|
+
rotate,
|
|
2547
|
+
rotate_to,
|
|
2548
|
+
transform: apply_transform,
|
|
2549
|
+
flatten_transform,
|
|
2550
|
+
align,
|
|
2551
|
+
reorder,
|
|
2552
|
+
remove,
|
|
2553
|
+
copy,
|
|
2554
|
+
cut,
|
|
2555
|
+
paste,
|
|
2556
|
+
duplicate,
|
|
2557
|
+
group: group$1,
|
|
2558
|
+
ungroup,
|
|
2559
|
+
insert,
|
|
2560
|
+
insert_fragment,
|
|
2561
|
+
insert_preview,
|
|
2562
|
+
set_text,
|
|
2563
|
+
load_svg,
|
|
2564
|
+
serialize_svg,
|
|
2565
|
+
undo,
|
|
2566
|
+
redo,
|
|
2567
|
+
register: (id, handler) => registry.register(id, handler),
|
|
2568
|
+
invoke: (id, args) => registry.invoke(id, args),
|
|
2569
|
+
has: (id) => registry.has(id)
|
|
2570
|
+
};
|
|
2571
|
+
function load(svg) {
|
|
2572
|
+
load_svg(svg);
|
|
2573
|
+
}
|
|
2574
|
+
function serialize() {
|
|
2575
|
+
return doc.serialize();
|
|
2576
|
+
}
|
|
2577
|
+
function reset() {
|
|
2578
|
+
discard_open_property_previews();
|
|
2579
|
+
history.clear();
|
|
2580
|
+
doc.reset_to_original();
|
|
2581
|
+
selection = [];
|
|
2582
|
+
scope = null;
|
|
2583
|
+
mode = "select";
|
|
2584
|
+
tool = TOOL_CURSOR;
|
|
2585
|
+
active_duplication = null;
|
|
2586
|
+
baseline_revision = doc.revision;
|
|
2587
|
+
emit();
|
|
2588
|
+
}
|
|
2589
|
+
function attach(surface) {
|
|
2590
|
+
if (attached_surface) attached_surface.dispose();
|
|
2591
|
+
attached_surface = surface;
|
|
2592
|
+
return { detach() {
|
|
2593
|
+
if (attached_surface === surface) {
|
|
2594
|
+
attached_surface.dispose();
|
|
2595
|
+
attached_surface = null;
|
|
2596
|
+
}
|
|
2597
|
+
} };
|
|
2598
|
+
}
|
|
2599
|
+
function detach() {
|
|
2600
|
+
if (attached_surface) {
|
|
2601
|
+
attached_surface.dispose();
|
|
2602
|
+
attached_surface = null;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
function dispose() {
|
|
2606
|
+
detach();
|
|
2607
|
+
listeners.clear();
|
|
2608
|
+
surface_hover_listeners.clear();
|
|
2609
|
+
geometry_listeners.clear();
|
|
2610
|
+
translate_commit_listeners.clear();
|
|
2611
|
+
pick_listeners.clear();
|
|
2612
|
+
}
|
|
2613
|
+
function set_style(partial) {
|
|
2614
|
+
style = {
|
|
2615
|
+
...style,
|
|
2616
|
+
...partial
|
|
2617
|
+
};
|
|
2618
|
+
emit();
|
|
2619
|
+
}
|
|
2620
|
+
const public_editor = {
|
|
2621
|
+
/**
|
|
2622
|
+
* Low-level IR handle. Mutating directly bypasses history; prefer
|
|
2623
|
+
* `editor.commands` for app code.
|
|
2624
|
+
*/
|
|
2625
|
+
document: doc,
|
|
2626
|
+
get state() {
|
|
2627
|
+
return snapshot();
|
|
2628
|
+
},
|
|
2629
|
+
subscribe,
|
|
2630
|
+
subscribe_with_selector,
|
|
2631
|
+
node_properties,
|
|
2632
|
+
node_paint,
|
|
2633
|
+
dom_computed_property,
|
|
2634
|
+
dom_computed_paint,
|
|
2635
|
+
/**
|
|
2636
|
+
* Enter content-edit mode on a `<text>` node. Returns `false` (no-op)
|
|
2637
|
+
* when no DOM surface is attached.
|
|
2638
|
+
*/
|
|
2639
|
+
enter_content_edit,
|
|
2640
|
+
defs,
|
|
2641
|
+
commands,
|
|
2642
|
+
/**
|
|
2643
|
+
* Human-readable label for hierarchy panels. SVG has no native "name";
|
|
2644
|
+
* this is the package's single source of truth so panels don't reinvent
|
|
2645
|
+
* the rule.
|
|
2646
|
+
*
|
|
2647
|
+
* Rule:
|
|
2648
|
+
* - `<text>` → text content, whitespace-collapsed and truncated at
|
|
2649
|
+
* ~40 chars (falls back to `"text"` for empty content).
|
|
2650
|
+
* - Otherwise → tag name, suffixed with `#id` when the `id` attribute
|
|
2651
|
+
* is present (e.g. `"rect #sun"`).
|
|
2652
|
+
*
|
|
2653
|
+
* `opts.tagLabel` lets callers substitute a friendlier or localized
|
|
2654
|
+
* term for the raw tag (e.g. `"rect"` → `"Rectangle"`). Only invoked
|
|
2655
|
+
* on the non-text branch.
|
|
2656
|
+
*/
|
|
2657
|
+
display_label(id, opts) {
|
|
2658
|
+
const tag = doc.tag_of(id);
|
|
2659
|
+
if (tag === "text") {
|
|
2660
|
+
const collapsed = doc.text_of(id).replace(/\s+/g, " ").trim();
|
|
2661
|
+
if (collapsed.length === 0) return "text";
|
|
2662
|
+
return collapsed.length > DISPLAY_LABEL_MAX_LEN ? `${collapsed.slice(0, DISPLAY_LABEL_MAX_LEN)}…` : collapsed;
|
|
2663
|
+
}
|
|
2664
|
+
const elem_id = doc.get_attr(id, "id");
|
|
2665
|
+
const head = opts?.tagLabel ? opts.tagLabel(tag) : tag;
|
|
2666
|
+
return elem_id && elem_id.length > 0 ? `${head} #${elem_id}` : head;
|
|
2667
|
+
},
|
|
2668
|
+
tree() {
|
|
2669
|
+
return tree_snapshot();
|
|
2670
|
+
},
|
|
2671
|
+
/**
|
|
2672
|
+
* The effective hover from the attached HUD surface — what's under the
|
|
2673
|
+
* pointer, OR whatever `set_surface_hover_override` last pushed. Used
|
|
2674
|
+
* by out-of-canvas UI (layers panel, breadcrumbs) to mirror the canvas
|
|
2675
|
+
* highlight. Returns `null` when nothing is hovered.
|
|
2676
|
+
*/
|
|
2677
|
+
surface_hover() {
|
|
2678
|
+
return current_surface_hover;
|
|
2679
|
+
},
|
|
2680
|
+
/**
|
|
2681
|
+
* Push a hover override into the HUD surface — e.g. when the user
|
|
2682
|
+
* hovers a row in a layers panel. The HUD will render the override's
|
|
2683
|
+
* outline and (when applicable) drive measurement to that node.
|
|
2684
|
+
* Pass `null` to clear and let the pointer pick take over again.
|
|
2685
|
+
*/
|
|
2686
|
+
set_surface_hover_override(id) {
|
|
2687
|
+
if (surface_hover_override === id) return;
|
|
2688
|
+
surface_hover_override = id;
|
|
2689
|
+
if (surface_hover_override_driver) surface_hover_override_driver(id);
|
|
2690
|
+
},
|
|
2691
|
+
/**
|
|
2692
|
+
* Subscribe to changes in the effective surface hover. Fires when the
|
|
2693
|
+
* HUD reports a new pointer pick AND when an override is set/cleared.
|
|
2694
|
+
* Cheap channel — does NOT bump `state.version`.
|
|
2695
|
+
*/
|
|
2696
|
+
subscribe_surface_hover(cb) {
|
|
2697
|
+
surface_hover_listeners.add(cb);
|
|
2698
|
+
return () => {
|
|
2699
|
+
surface_hover_listeners.delete(cb);
|
|
2700
|
+
};
|
|
2701
|
+
},
|
|
2702
|
+
/**
|
|
2703
|
+
* Subscribe to pick (tap) outcomes — a discrete click on the canvas,
|
|
2704
|
+
* reporting the document-space point and the node under it (`null` for
|
|
2705
|
+
* empty canvas), plus the button and modifier snapshot. Fires once per
|
|
2706
|
+
* tap, after the editor's own selection handling. Observe-only: a pick
|
|
2707
|
+
* cannot alter selection, and the channel does NOT bump `state.version`.
|
|
2708
|
+
* See {@link PickEvent}.
|
|
2709
|
+
*
|
|
2710
|
+
* @unstable
|
|
2711
|
+
*/
|
|
2712
|
+
subscribe_pick(cb) {
|
|
2713
|
+
pick_listeners.add(cb);
|
|
2714
|
+
return () => {
|
|
2715
|
+
pick_listeners.delete(cb);
|
|
2716
|
+
};
|
|
2717
|
+
},
|
|
2718
|
+
/**
|
|
2719
|
+
* Subscribe to bounds-affecting changes. Fires when any document
|
|
2720
|
+
* mutation advances `state.geometry_version` — drag, resize, text
|
|
2721
|
+
* edit, structural insert/remove. Skips presentation-only writes
|
|
2722
|
+
* (fill, opacity, stroke-color).
|
|
2723
|
+
*/
|
|
2724
|
+
subscribe_geometry(cb) {
|
|
2725
|
+
geometry_listeners.add(cb);
|
|
2726
|
+
return () => {
|
|
2727
|
+
geometry_listeners.delete(cb);
|
|
2728
|
+
};
|
|
2729
|
+
},
|
|
2730
|
+
/**
|
|
2731
|
+
* World-space geometry queries. Non-null when a DOM surface is
|
|
2732
|
+
* attached; null otherwise (queries need a renderer to read bbox
|
|
2733
|
+
* from). Read-only — never mutates document state.
|
|
2734
|
+
*/
|
|
2735
|
+
get geometry() {
|
|
2736
|
+
return geometry_provider;
|
|
2737
|
+
},
|
|
2738
|
+
modes,
|
|
2739
|
+
/** Switch the active tool. No history entry; bumps `state.version`. */
|
|
2740
|
+
set_tool,
|
|
2741
|
+
get style() {
|
|
2742
|
+
return style;
|
|
2743
|
+
},
|
|
2744
|
+
set_style,
|
|
2745
|
+
load,
|
|
2746
|
+
serialize,
|
|
2747
|
+
/**
|
|
2748
|
+
* Serialize a single element's subtree as an SVG **fragment**, using the
|
|
2749
|
+
* same trivia-preserving rules as {@link serialize} — for handing "the
|
|
2750
|
+
* markup of the element the user selected" to a downstream consumer
|
|
2751
|
+
* (e.g. an AI agent) without re-serializing the whole document.
|
|
2752
|
+
*
|
|
2753
|
+
* Fragment, not document (see `SvgDocument.serialize_node`): it does NOT
|
|
2754
|
+
* carry `serialize()`'s whole-document round-trip guarantee. Namespace
|
|
2755
|
+
* declarations on an ancestor (`xmlns:xlink`, normally on the root
|
|
2756
|
+
* `<svg>`) are NOT inlined — a node using `xlink:href` serializes without
|
|
2757
|
+
* `xmlns:xlink`. Throws on an unknown id or a non-element node.
|
|
2758
|
+
*/
|
|
2759
|
+
serialize_node(id) {
|
|
2760
|
+
return doc.serialize_node(id);
|
|
2761
|
+
},
|
|
2762
|
+
reset,
|
|
2763
|
+
attach,
|
|
2764
|
+
detach,
|
|
2765
|
+
dispose,
|
|
2766
|
+
providers,
|
|
2767
|
+
_internal: {
|
|
2768
|
+
doc,
|
|
2769
|
+
history: {
|
|
2770
|
+
preview: (label) => history.preview(label),
|
|
2771
|
+
undo_label: () => history.stack.undoLabel
|
|
2772
|
+
},
|
|
2773
|
+
clipboard: {
|
|
2774
|
+
copy: () => copy_impl(false),
|
|
2775
|
+
cut: () => cut_impl(false)
|
|
2776
|
+
},
|
|
2777
|
+
insert_text_preview,
|
|
2778
|
+
emit,
|
|
2779
|
+
subscribe_translate_commit(cb) {
|
|
2780
|
+
translate_commit_listeners.add(cb);
|
|
2781
|
+
return () => {
|
|
2782
|
+
translate_commit_listeners.delete(cb);
|
|
2783
|
+
};
|
|
2784
|
+
},
|
|
2785
|
+
notify_translate_commit,
|
|
2786
|
+
seed_duplication(record) {
|
|
2787
|
+
active_duplication = record;
|
|
2788
|
+
},
|
|
2789
|
+
set_content_edit_driver(fn) {
|
|
2790
|
+
content_edit_driver = fn;
|
|
2791
|
+
},
|
|
2792
|
+
set_surface_hover_override_driver(fn) {
|
|
2793
|
+
surface_hover_override_driver = fn;
|
|
2794
|
+
if (fn) fn(surface_hover_override);
|
|
2795
|
+
},
|
|
2796
|
+
push_surface_hover(id) {
|
|
2797
|
+
_set_current_surface_hover(id);
|
|
2798
|
+
},
|
|
2799
|
+
push_pick(e) {
|
|
2800
|
+
notify_pick(e);
|
|
2801
|
+
},
|
|
2802
|
+
set_computed_resolver(fn) {
|
|
2803
|
+
computed_resolver = fn;
|
|
2804
|
+
},
|
|
2805
|
+
set_geometry(p) {
|
|
2806
|
+
geometry_provider = p;
|
|
2807
|
+
},
|
|
2808
|
+
register_command(id, handler) {
|
|
2809
|
+
return registry.register(id, handler);
|
|
2810
|
+
},
|
|
2811
|
+
bump_geometry() {
|
|
2812
|
+
doc.bump_geometry();
|
|
2813
|
+
fire_geometry_listeners_if_advanced();
|
|
2814
|
+
}
|
|
2815
|
+
},
|
|
2816
|
+
keymap
|
|
2817
|
+
};
|
|
2818
|
+
registerDefaultCommands(registry, public_editor);
|
|
2819
|
+
applyDefaultBindings(keymap);
|
|
2820
|
+
return public_editor;
|
|
2821
|
+
}
|
|
2822
|
+
/**
|
|
2823
|
+
* Construct a headless SVG editor. The returned object is the public
|
|
2824
|
+
* editor surface — observation (`state`, `subscribe`), commands
|
|
2825
|
+
* (`commands.*`), lifecycle (`attach` / `dispose`), and the typed-read
|
|
2826
|
+
* caches (`node_paint`, `node_properties`). Surfaces (DOM, headless)
|
|
2827
|
+
* attach later via `editor.attach(surface)`.
|
|
2828
|
+
*/
|
|
2829
|
+
function createSvgEditor(opts) {
|
|
2830
|
+
if (opts == null || typeof opts.svg !== "string") {
|
|
2831
|
+
const got = opts == null ? String(opts) : opts.svg === null ? "null" : typeof opts.svg;
|
|
2832
|
+
throw new TypeError(`createSvgEditor({ svg }) requires { svg: string }, got svg=${got}`);
|
|
2833
|
+
}
|
|
2834
|
+
return _create_svg_editor_internal(opts);
|
|
2835
|
+
}
|
|
2836
|
+
const FLATTEN_IDENT = [
|
|
2837
|
+
1,
|
|
2838
|
+
0,
|
|
2839
|
+
0,
|
|
2840
|
+
1,
|
|
2841
|
+
0,
|
|
2842
|
+
0
|
|
2843
|
+
];
|
|
2844
|
+
function flatten_mul(m1, m2) {
|
|
2845
|
+
const [a1, b1, c1, d1, e1, f1] = m1;
|
|
2846
|
+
const [a2, b2, c2, d2, e2, f2] = m2;
|
|
2847
|
+
return [
|
|
2848
|
+
a1 * a2 + c1 * b2,
|
|
2849
|
+
b1 * a2 + d1 * b2,
|
|
2850
|
+
a1 * c2 + c1 * d2,
|
|
2851
|
+
b1 * c2 + d1 * d2,
|
|
2852
|
+
a1 * e2 + c1 * f2 + e1,
|
|
2853
|
+
b1 * e2 + d1 * f2 + f1
|
|
2854
|
+
];
|
|
2855
|
+
}
|
|
2856
|
+
function flatten_op_to_mat(op) {
|
|
2857
|
+
switch (op.type) {
|
|
2858
|
+
case "matrix": return [
|
|
2859
|
+
op.a,
|
|
2860
|
+
op.b,
|
|
2861
|
+
op.c,
|
|
2862
|
+
op.d,
|
|
2863
|
+
op.e,
|
|
2864
|
+
op.f
|
|
2865
|
+
];
|
|
2866
|
+
case "translate": return [
|
|
2867
|
+
1,
|
|
2868
|
+
0,
|
|
2869
|
+
0,
|
|
2870
|
+
1,
|
|
2871
|
+
op.tx,
|
|
2872
|
+
op.ty
|
|
2873
|
+
];
|
|
2874
|
+
case "rotate": {
|
|
2875
|
+
const rad = op.angle * Math.PI / 180;
|
|
2876
|
+
const c = Math.cos(rad);
|
|
2877
|
+
const s = Math.sin(rad);
|
|
2878
|
+
if (op.cx === 0 && op.cy === 0) return [
|
|
2879
|
+
c,
|
|
2880
|
+
s,
|
|
2881
|
+
-s,
|
|
2882
|
+
c,
|
|
2883
|
+
0,
|
|
2884
|
+
0
|
|
2885
|
+
];
|
|
2886
|
+
const e = op.cx - c * op.cx + s * op.cy;
|
|
2887
|
+
const f = op.cy - s * op.cx - c * op.cy;
|
|
2888
|
+
return [
|
|
2889
|
+
c,
|
|
2890
|
+
s,
|
|
2891
|
+
-s,
|
|
2892
|
+
c,
|
|
2893
|
+
e,
|
|
2894
|
+
f
|
|
2895
|
+
];
|
|
2896
|
+
}
|
|
2897
|
+
case "scale": return [
|
|
2898
|
+
op.sx,
|
|
2899
|
+
0,
|
|
2900
|
+
0,
|
|
2901
|
+
op.sy,
|
|
2902
|
+
0,
|
|
2903
|
+
0
|
|
2904
|
+
];
|
|
2905
|
+
case "skewX": {
|
|
2906
|
+
const rad = op.angle * Math.PI / 180;
|
|
2907
|
+
return [
|
|
2908
|
+
1,
|
|
2909
|
+
0,
|
|
2910
|
+
Math.tan(rad),
|
|
2911
|
+
1,
|
|
2912
|
+
0,
|
|
2913
|
+
0
|
|
2914
|
+
];
|
|
2915
|
+
}
|
|
2916
|
+
case "skewY": {
|
|
2917
|
+
const rad = op.angle * Math.PI / 180;
|
|
2918
|
+
return [
|
|
2919
|
+
1,
|
|
2920
|
+
Math.tan(rad),
|
|
2921
|
+
0,
|
|
2922
|
+
1,
|
|
2923
|
+
0,
|
|
2924
|
+
0
|
|
2925
|
+
];
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
//#endregion
|
|
2930
|
+
export { createSvgEditor as t };
|