@grida/svg-editor 1.0.0-alpha.1
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/LICENSE +201 -0
- package/README.md +831 -0
- package/dist/dom-CfP_ZURh.js +963 -0
- package/dist/dom-kA8NDuVh.mjs +929 -0
- package/dist/dom.d.mts +16 -0
- package/dist/dom.d.ts +16 -0
- package/dist/dom.js +3 -0
- package/dist/dom.mjs +2 -0
- package/dist/editor-B5z-gTML.mjs +1821 -0
- package/dist/editor-CTtU2gu4.d.ts +607 -0
- package/dist/editor-DQWUWrVZ.js +1833 -0
- package/dist/editor-JY7AQrR1.d.mts +607 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/index.mjs +2 -0
- package/dist/paint-DHq_3iwU.js +509 -0
- package/dist/paint-DuCg6Y-K.mjs +461 -0
- package/dist/react.d.mts +49 -0
- package/dist/react.d.ts +49 -0
- package/dist/react.js +97 -0
- package/dist/react.mjs +92 -0
- package/package.json +66 -0
|
@@ -0,0 +1,1821 @@
|
|
|
1
|
+
import { i as apply_translate, n as serialize_paint, o as capture_translate_baseline, t as parse_paint } from "./paint-DuCg6Y-K.mjs";
|
|
2
|
+
import { HistoryImpl } from "@grida/history";
|
|
3
|
+
import { KeyCode, M, chunkKey, eventToChunk, getKeyboardOS, kb, keybindingsToKeyCodes } from "@grida/keybinding";
|
|
4
|
+
//#region src/commands/registry.ts
|
|
5
|
+
var CommandRegistry = class {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.map = /* @__PURE__ */ new Map();
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Register a command. Returns an unregister function. Re-registering
|
|
11
|
+
* the same id replaces the previous handler (last writer wins).
|
|
12
|
+
*/
|
|
13
|
+
register(id, handler) {
|
|
14
|
+
this.map.set(id, handler);
|
|
15
|
+
return () => {
|
|
16
|
+
if (this.map.get(id) === handler) this.map.delete(id);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Invoke a command by id. Returns `true` if the handler consumed,
|
|
21
|
+
* `false` otherwise (including unknown ids and handlers that returned
|
|
22
|
+
* `false`/`undefined`).
|
|
23
|
+
*/
|
|
24
|
+
invoke(id, args) {
|
|
25
|
+
const handler = this.map.get(id);
|
|
26
|
+
if (!handler) return false;
|
|
27
|
+
return handler(args) === true;
|
|
28
|
+
}
|
|
29
|
+
has(id) {
|
|
30
|
+
return this.map.has(id);
|
|
31
|
+
}
|
|
32
|
+
/** All registered ids, for debugging / introspection. */
|
|
33
|
+
ids() {
|
|
34
|
+
return Array.from(this.map.keys());
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
//#region src/commands/defaults.ts
|
|
39
|
+
function registerDefaultCommands(reg, editor) {
|
|
40
|
+
reg.register("history.undo", () => {
|
|
41
|
+
if (!editor.state.can_undo) return false;
|
|
42
|
+
editor.commands.undo();
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
reg.register("history.redo", () => {
|
|
46
|
+
if (!editor.state.can_redo) return false;
|
|
47
|
+
editor.commands.redo();
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
reg.register("selection.deselect", () => {
|
|
51
|
+
if (editor.state.selection.length === 0) return false;
|
|
52
|
+
editor.commands.deselect();
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
reg.register("selection.remove", () => {
|
|
56
|
+
if (editor.state.selection.length === 0) return false;
|
|
57
|
+
editor.commands.remove();
|
|
58
|
+
return true;
|
|
59
|
+
});
|
|
60
|
+
reg.register("hierarchy.enter", () => {
|
|
61
|
+
if (editor.state.selection.length !== 1) return false;
|
|
62
|
+
const id = editor.state.selection[0];
|
|
63
|
+
const node = editor.tree().nodes.get(id);
|
|
64
|
+
if (!node || node.children.length === 0) return false;
|
|
65
|
+
editor.commands.select(node.children[0]);
|
|
66
|
+
return true;
|
|
67
|
+
});
|
|
68
|
+
reg.register("hierarchy.exit", () => {
|
|
69
|
+
if (editor.state.selection.length !== 1) return false;
|
|
70
|
+
const id = editor.state.selection[0];
|
|
71
|
+
const tree = editor.tree();
|
|
72
|
+
const node = tree.nodes.get(id);
|
|
73
|
+
if (!node || node.parent === null || node.parent === tree.root) return false;
|
|
74
|
+
editor.commands.select(node.parent);
|
|
75
|
+
return true;
|
|
76
|
+
});
|
|
77
|
+
reg.register("transform.nudge", (args) => {
|
|
78
|
+
if (editor.state.selection.length === 0) return false;
|
|
79
|
+
const { dx, dy } = args;
|
|
80
|
+
editor.commands.translate({
|
|
81
|
+
dx,
|
|
82
|
+
dy
|
|
83
|
+
});
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
reg.register("reorder", (args) => {
|
|
87
|
+
if (editor.state.selection.length !== 1) return false;
|
|
88
|
+
editor.commands.reorder(args);
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/keymap/keymap.ts
|
|
94
|
+
/**
|
|
95
|
+
* Keymap — bindings of declarative `Keybinding`s (from `@grida/keybinding`)
|
|
96
|
+
* to command ids.
|
|
97
|
+
*
|
|
98
|
+
* Dispatch is ProseMirror-`chainCommands`-shaped: multiple bindings on
|
|
99
|
+
* the same key are tried in priority order; the first whose handler
|
|
100
|
+
* returns `true` wins, the rest are skipped. This keeps "one key, many
|
|
101
|
+
* meanings" expressible without a `when:` DSL — handlers self-guard on
|
|
102
|
+
* editor state and return `false` when not applicable.
|
|
103
|
+
*
|
|
104
|
+
* The keymap does NOT see modifier-as-signal (e.g. Alt-held for
|
|
105
|
+
* measurement). That stays on the HUD modifiers channel. The keymap
|
|
106
|
+
* only sees Mod+D-shape chords.
|
|
107
|
+
*/
|
|
108
|
+
/** Modifiers that, when held, allow a binding to fire even inside a text input. */
|
|
109
|
+
const TEXT_INPUT_SAFE_MODS = new Set([
|
|
110
|
+
KeyCode.Meta,
|
|
111
|
+
KeyCode.Ctrl,
|
|
112
|
+
KeyCode.Alt
|
|
113
|
+
]);
|
|
114
|
+
function is_text_input_focused() {
|
|
115
|
+
if (typeof document === "undefined") return false;
|
|
116
|
+
const el = document.activeElement;
|
|
117
|
+
if (!el) return false;
|
|
118
|
+
const tag = el.tagName;
|
|
119
|
+
if (tag === "INPUT" || tag === "TEXTAREA") return true;
|
|
120
|
+
if (el.isContentEditable) return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
var Keymap = class {
|
|
124
|
+
constructor(commands, platformGetter = getKeyboardOS) {
|
|
125
|
+
this.commands = commands;
|
|
126
|
+
this.platformGetter = platformGetter;
|
|
127
|
+
this.buckets = /* @__PURE__ */ new Map();
|
|
128
|
+
this.seq = 0;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Bind a key combination to a command. Returns an unbind function.
|
|
132
|
+
* The same `Keybinding` can be bound to multiple commands — they will
|
|
133
|
+
* all be tried in chain order on dispatch.
|
|
134
|
+
*/
|
|
135
|
+
bind(binding) {
|
|
136
|
+
const entry = {
|
|
137
|
+
binding,
|
|
138
|
+
seq: ++this.seq
|
|
139
|
+
};
|
|
140
|
+
for (const hash of this.chunkKeysFor(binding.keybinding)) {
|
|
141
|
+
const list = this.buckets.get(hash);
|
|
142
|
+
if (list) {
|
|
143
|
+
list.push(entry);
|
|
144
|
+
list.sort(compareEntries);
|
|
145
|
+
} else this.buckets.set(hash, [entry]);
|
|
146
|
+
}
|
|
147
|
+
return () => {
|
|
148
|
+
for (const hash of this.chunkKeysFor(binding.keybinding)) {
|
|
149
|
+
const list = this.buckets.get(hash);
|
|
150
|
+
if (!list) continue;
|
|
151
|
+
const idx = list.findIndex((e) => e === entry);
|
|
152
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
153
|
+
if (list.length === 0) this.buckets.delete(hash);
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Remove bindings matching the spec. If both filters are passed, only
|
|
159
|
+
* bindings that match BOTH are removed.
|
|
160
|
+
*/
|
|
161
|
+
unbind(spec) {
|
|
162
|
+
const hashFilter = spec.keybinding ? new Set(this.chunkKeysFor(spec.keybinding)) : null;
|
|
163
|
+
for (const [hash, list] of this.buckets) {
|
|
164
|
+
if (hashFilter && !hashFilter.has(hash)) continue;
|
|
165
|
+
const next = list.filter((e) => {
|
|
166
|
+
if (spec.command && e.binding.command !== spec.command) return true;
|
|
167
|
+
return false;
|
|
168
|
+
});
|
|
169
|
+
if (next.length === 0) this.buckets.delete(hash);
|
|
170
|
+
else if (next.length !== list.length) this.buckets.set(hash, next);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** All registered bindings, for introspection. Order is not guaranteed. */
|
|
174
|
+
bindings() {
|
|
175
|
+
const seen = /* @__PURE__ */ new Set();
|
|
176
|
+
for (const list of this.buckets.values()) for (const e of list) seen.add(e.binding);
|
|
177
|
+
return Array.from(seen);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Match the event against bound chunks, then run candidates in chain
|
|
181
|
+
* order. Returns `true` and calls `preventDefault()` on the first
|
|
182
|
+
* handler that consumes; returns `false` if nothing matched or all
|
|
183
|
+
* matches fell through.
|
|
184
|
+
*/
|
|
185
|
+
dispatch(event) {
|
|
186
|
+
const chunk = eventToChunk(event);
|
|
187
|
+
if (chunk.keys.length === 0) return false;
|
|
188
|
+
const hash = chunkKey(chunk);
|
|
189
|
+
const list = this.buckets.get(hash);
|
|
190
|
+
if (!list || list.length === 0) return false;
|
|
191
|
+
const text_focused = is_text_input_focused();
|
|
192
|
+
for (const { binding } of list) {
|
|
193
|
+
if (text_focused && !this.has_safe_mod(chunk.mods)) continue;
|
|
194
|
+
if (this.commands.invoke(binding.command, binding.args)) {
|
|
195
|
+
event.preventDefault();
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Compute the set of canonical hashes a `Keybinding` lights up. A
|
|
203
|
+
* binding with aliases (multiple sequences) contributes one hash per
|
|
204
|
+
* single-chunk alias; multi-chunk sequences (chords) are skipped in
|
|
205
|
+
* V1.
|
|
206
|
+
*/
|
|
207
|
+
chunkKeysFor(binding) {
|
|
208
|
+
const sequences = keybindingsToKeyCodes(binding, this.platformGetter());
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const seq of sequences) {
|
|
211
|
+
if (seq.length !== 1) continue;
|
|
212
|
+
out.push(chunkKey(seq[0]));
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
has_safe_mod(mods) {
|
|
217
|
+
for (const m of mods) if (TEXT_INPUT_SAFE_MODS.has(m)) return true;
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
function compareEntries(a, b) {
|
|
222
|
+
const pa = a.binding.priority ?? 0;
|
|
223
|
+
const pb = b.binding.priority ?? 0;
|
|
224
|
+
if (pa !== pb) return pb - pa;
|
|
225
|
+
return a.seq - b.seq;
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/keymap/defaults.ts
|
|
229
|
+
/**
|
|
230
|
+
* Default keybindings shipped with `@grida/svg-editor`.
|
|
231
|
+
*
|
|
232
|
+
* THIS IS THE ONLY FILE where built-in shortcuts are declared. Adding
|
|
233
|
+
* a new shortcut = one new row here (plus, if the target command is
|
|
234
|
+
* new, one new handler in `src/commands/defaults.ts`). That is the
|
|
235
|
+
* V1 design contract.
|
|
236
|
+
*
|
|
237
|
+
* Same key, multiple meanings? Add multiple rows. The chain semantics
|
|
238
|
+
* (handler returns `false` when not applicable) handle the rest.
|
|
239
|
+
*/
|
|
240
|
+
const DEFAULT_BINDINGS = [
|
|
241
|
+
{
|
|
242
|
+
keybinding: kb(KeyCode.KeyZ, M.CtrlCmd),
|
|
243
|
+
command: "history.undo"
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
keybinding: kb(KeyCode.KeyZ, M.CtrlCmd | M.Shift),
|
|
247
|
+
command: "history.redo"
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
keybinding: kb(KeyCode.KeyY, M.CtrlCmd),
|
|
251
|
+
command: "history.redo"
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
keybinding: kb(KeyCode.Escape),
|
|
255
|
+
command: "selection.deselect"
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
keybinding: kb(KeyCode.Backspace),
|
|
259
|
+
command: "selection.remove"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
keybinding: kb(KeyCode.Delete),
|
|
263
|
+
command: "selection.remove"
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
keybinding: kb(KeyCode.Enter),
|
|
267
|
+
command: "hierarchy.enter"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
keybinding: kb(KeyCode.Enter, M.Shift),
|
|
271
|
+
command: "hierarchy.exit"
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
keybinding: kb(KeyCode.LeftArrow),
|
|
275
|
+
command: "transform.nudge",
|
|
276
|
+
args: {
|
|
277
|
+
dx: -1,
|
|
278
|
+
dy: 0
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
keybinding: kb(KeyCode.RightArrow),
|
|
283
|
+
command: "transform.nudge",
|
|
284
|
+
args: {
|
|
285
|
+
dx: 1,
|
|
286
|
+
dy: 0
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
keybinding: kb(KeyCode.UpArrow),
|
|
291
|
+
command: "transform.nudge",
|
|
292
|
+
args: {
|
|
293
|
+
dx: 0,
|
|
294
|
+
dy: -1
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
keybinding: kb(KeyCode.DownArrow),
|
|
299
|
+
command: "transform.nudge",
|
|
300
|
+
args: {
|
|
301
|
+
dx: 0,
|
|
302
|
+
dy: 1
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
keybinding: kb(KeyCode.LeftArrow, M.Shift),
|
|
307
|
+
command: "transform.nudge",
|
|
308
|
+
args: {
|
|
309
|
+
dx: -10,
|
|
310
|
+
dy: 0
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
keybinding: kb(KeyCode.RightArrow, M.Shift),
|
|
315
|
+
command: "transform.nudge",
|
|
316
|
+
args: {
|
|
317
|
+
dx: 10,
|
|
318
|
+
dy: 0
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
keybinding: kb(KeyCode.UpArrow, M.Shift),
|
|
323
|
+
command: "transform.nudge",
|
|
324
|
+
args: {
|
|
325
|
+
dx: 0,
|
|
326
|
+
dy: -10
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
keybinding: kb(KeyCode.DownArrow, M.Shift),
|
|
331
|
+
command: "transform.nudge",
|
|
332
|
+
args: {
|
|
333
|
+
dx: 0,
|
|
334
|
+
dy: 10
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
keybinding: kb(KeyCode.BracketRight),
|
|
339
|
+
command: "reorder",
|
|
340
|
+
args: "bring_to_front"
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
keybinding: kb(KeyCode.BracketLeft),
|
|
344
|
+
command: "reorder",
|
|
345
|
+
args: "send_to_back"
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
keybinding: kb(KeyCode.BracketRight, M.CtrlCmd),
|
|
349
|
+
command: "reorder",
|
|
350
|
+
args: "bring_forward"
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
keybinding: kb(KeyCode.BracketLeft, M.CtrlCmd),
|
|
354
|
+
command: "reorder",
|
|
355
|
+
args: "send_backward"
|
|
356
|
+
}
|
|
357
|
+
];
|
|
358
|
+
/** Register every default binding into a keymap. */
|
|
359
|
+
function applyDefaultBindings(keymap) {
|
|
360
|
+
for (const b of DEFAULT_BINDINGS) keymap.bind(b);
|
|
361
|
+
}
|
|
362
|
+
//#endregion
|
|
363
|
+
//#region src/core/defs.ts
|
|
364
|
+
var GradientsRegistry = class {
|
|
365
|
+
constructor(doc) {
|
|
366
|
+
this.doc = doc;
|
|
367
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
368
|
+
this.counter = 0;
|
|
369
|
+
doc.on_change(() => this.emit());
|
|
370
|
+
}
|
|
371
|
+
list() {
|
|
372
|
+
const out = [];
|
|
373
|
+
const defs = this.find_defs_elements();
|
|
374
|
+
for (const def_id of defs) for (const child of this.doc.element_children_of(def_id)) {
|
|
375
|
+
const tag = this.doc.tag_of(child);
|
|
376
|
+
if (tag === "linearGradient" || tag === "radialGradient") {
|
|
377
|
+
const id = this.doc.get_attr(child, "id");
|
|
378
|
+
if (!id) continue;
|
|
379
|
+
const definition = this.read_gradient(child, tag);
|
|
380
|
+
if (!definition) continue;
|
|
381
|
+
out.push({
|
|
382
|
+
id,
|
|
383
|
+
definition,
|
|
384
|
+
ref_count: this.count_refs(id)
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return out;
|
|
389
|
+
}
|
|
390
|
+
get(id) {
|
|
391
|
+
return this.list().find((g) => g.id === id) ?? null;
|
|
392
|
+
}
|
|
393
|
+
upsert(definition, opts) {
|
|
394
|
+
const existing_id = opts?.id;
|
|
395
|
+
if (existing_id) {
|
|
396
|
+
const node = this.find_gradient_node(existing_id);
|
|
397
|
+
if (node !== null) {
|
|
398
|
+
this.write_gradient(node, definition);
|
|
399
|
+
return existing_id;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const id = existing_id ?? this.fresh_id();
|
|
403
|
+
const defs_id = this.ensure_defs();
|
|
404
|
+
const tag = definition.kind === "linear" ? "linearGradient" : "radialGradient";
|
|
405
|
+
const new_id = this.doc.create_element(tag, { ns: "http://www.w3.org/2000/svg" });
|
|
406
|
+
this.doc.set_attr(new_id, "id", id);
|
|
407
|
+
this.doc.insert(new_id, defs_id, null);
|
|
408
|
+
this.write_gradient(new_id, definition);
|
|
409
|
+
this.emit();
|
|
410
|
+
return id;
|
|
411
|
+
}
|
|
412
|
+
remove(id) {
|
|
413
|
+
const refs = this.count_refs(id);
|
|
414
|
+
if (refs > 0) throw new Error(`[svg-editor] cannot remove gradient "${id}": ${refs} node(s) still reference it`);
|
|
415
|
+
const node = this.find_gradient_node(id);
|
|
416
|
+
if (node !== null) {
|
|
417
|
+
this.doc.remove(node);
|
|
418
|
+
this.emit();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
subscribe(fn) {
|
|
422
|
+
this.listeners.add(fn);
|
|
423
|
+
return () => {
|
|
424
|
+
this.listeners.delete(fn);
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
emit() {
|
|
428
|
+
const snap = this.list();
|
|
429
|
+
for (const fn of this.listeners) fn(snap);
|
|
430
|
+
}
|
|
431
|
+
fresh_id() {
|
|
432
|
+
let id;
|
|
433
|
+
do
|
|
434
|
+
id = `g${++this.counter}`;
|
|
435
|
+
while (this.find_gradient_node(id) !== null);
|
|
436
|
+
return id;
|
|
437
|
+
}
|
|
438
|
+
find_defs_elements() {
|
|
439
|
+
return this.doc.find_by_tag(this.doc.root, "defs");
|
|
440
|
+
}
|
|
441
|
+
ensure_defs() {
|
|
442
|
+
const existing = this.find_defs_elements();
|
|
443
|
+
if (existing.length > 0) return existing[0];
|
|
444
|
+
const defs = this.doc.create_element("defs", { ns: "http://www.w3.org/2000/svg" });
|
|
445
|
+
const first = this.doc.children_of(this.doc.root)[0] ?? null;
|
|
446
|
+
this.doc.insert(defs, this.doc.root, first);
|
|
447
|
+
return defs;
|
|
448
|
+
}
|
|
449
|
+
find_gradient_node(id) {
|
|
450
|
+
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) {
|
|
451
|
+
const tag = this.doc.tag_of(child);
|
|
452
|
+
if (tag === "linearGradient" || tag === "radialGradient") return child;
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
read_gradient(id, tag) {
|
|
457
|
+
const stops = [];
|
|
458
|
+
for (const child of this.doc.element_children_of(id)) {
|
|
459
|
+
if (this.doc.tag_of(child) !== "stop") continue;
|
|
460
|
+
const offset = parseFloat(this.doc.get_attr(child, "offset") ?? "0");
|
|
461
|
+
const color = this.doc.get_attr(child, "stop-color") ?? this.doc.get_style(child, "stop-color") ?? "#000000";
|
|
462
|
+
const opacity_str = this.doc.get_attr(child, "stop-opacity") ?? this.doc.get_style(child, "stop-opacity");
|
|
463
|
+
const opacity = opacity_str !== null ? parseFloat(opacity_str) : void 0;
|
|
464
|
+
stops.push({
|
|
465
|
+
offset,
|
|
466
|
+
color,
|
|
467
|
+
...opacity !== void 0 ? { opacity } : {}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const gu = this.doc.get_attr(id, "gradientUnits");
|
|
471
|
+
const gradient_units = gu === "userSpaceOnUse" ? "user_space_on_use" : gu === "objectBoundingBox" ? "object_bounding_box" : void 0;
|
|
472
|
+
const sm = this.doc.get_attr(id, "spreadMethod");
|
|
473
|
+
const spread_method = sm === "pad" || sm === "reflect" || sm === "repeat" ? sm : void 0;
|
|
474
|
+
const num = (n) => {
|
|
475
|
+
const v = this.doc.get_attr(id, n);
|
|
476
|
+
return v !== null ? parseFloat(v) : void 0;
|
|
477
|
+
};
|
|
478
|
+
if (tag === "linearGradient") return {
|
|
479
|
+
kind: "linear",
|
|
480
|
+
stops,
|
|
481
|
+
x1: num("x1"),
|
|
482
|
+
y1: num("y1"),
|
|
483
|
+
x2: num("x2"),
|
|
484
|
+
y2: num("y2"),
|
|
485
|
+
gradient_units,
|
|
486
|
+
spread_method
|
|
487
|
+
};
|
|
488
|
+
return {
|
|
489
|
+
kind: "radial",
|
|
490
|
+
stops,
|
|
491
|
+
cx: num("cx"),
|
|
492
|
+
cy: num("cy"),
|
|
493
|
+
r: num("r"),
|
|
494
|
+
fx: num("fx"),
|
|
495
|
+
fy: num("fy"),
|
|
496
|
+
gradient_units,
|
|
497
|
+
spread_method
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
write_gradient(node, def) {
|
|
501
|
+
for (const c of [...this.doc.children_of(node)]) this.doc.remove(c);
|
|
502
|
+
const set_num = (name, v) => {
|
|
503
|
+
this.doc.set_attr(node, name, v === void 0 ? null : String(v));
|
|
504
|
+
};
|
|
505
|
+
if (def.kind === "linear") {
|
|
506
|
+
set_num("x1", def.x1);
|
|
507
|
+
set_num("y1", def.y1);
|
|
508
|
+
set_num("x2", def.x2);
|
|
509
|
+
set_num("y2", def.y2);
|
|
510
|
+
} else {
|
|
511
|
+
set_num("cx", def.cx);
|
|
512
|
+
set_num("cy", def.cy);
|
|
513
|
+
set_num("r", def.r);
|
|
514
|
+
set_num("fx", def.fx);
|
|
515
|
+
set_num("fy", def.fy);
|
|
516
|
+
}
|
|
517
|
+
if (def.gradient_units) this.doc.set_attr(node, "gradientUnits", def.gradient_units === "user_space_on_use" ? "userSpaceOnUse" : "objectBoundingBox");
|
|
518
|
+
if (def.spread_method) this.doc.set_attr(node, "spreadMethod", def.spread_method);
|
|
519
|
+
for (const stop of def.stops) {
|
|
520
|
+
const stop_id = this.doc.create_element("stop", { ns: "http://www.w3.org/2000/svg" });
|
|
521
|
+
this.doc.set_attr(stop_id, "offset", String(stop.offset));
|
|
522
|
+
this.doc.set_attr(stop_id, "stop-color", stop.color);
|
|
523
|
+
if (stop.opacity !== void 0) this.doc.set_attr(stop_id, "stop-opacity", String(stop.opacity));
|
|
524
|
+
this.doc.insert(stop_id, node, null);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
count_refs(id) {
|
|
528
|
+
let count = 0;
|
|
529
|
+
const pattern = new RegExp(`url\\(\\s*["']?#${escape_regex(id)}["']?\\s*\\)`);
|
|
530
|
+
for (const node of this.doc.all_elements()) {
|
|
531
|
+
const fill = this.doc.get_attr(node, "fill");
|
|
532
|
+
const stroke = this.doc.get_attr(node, "stroke");
|
|
533
|
+
const style_fill = this.doc.get_style(node, "fill");
|
|
534
|
+
const style_stroke = this.doc.get_style(node, "stroke");
|
|
535
|
+
for (const v of [
|
|
536
|
+
fill,
|
|
537
|
+
stroke,
|
|
538
|
+
style_fill,
|
|
539
|
+
style_stroke
|
|
540
|
+
]) if (v && pattern.test(v)) count++;
|
|
541
|
+
}
|
|
542
|
+
return count;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
function escape_regex(s) {
|
|
546
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
547
|
+
}
|
|
548
|
+
function create_defs(doc) {
|
|
549
|
+
return { gradients: new GradientsRegistry(doc) };
|
|
550
|
+
}
|
|
551
|
+
const XML_NS = "http://www.w3.org/XML/1998/namespace";
|
|
552
|
+
const XMLNS_NS = "http://www.w3.org/2000/xmlns/";
|
|
553
|
+
let id_counter = 0;
|
|
554
|
+
function fresh_id() {
|
|
555
|
+
return `n${id_counter++}`;
|
|
556
|
+
}
|
|
557
|
+
function reset_id_counter() {
|
|
558
|
+
id_counter = 0;
|
|
559
|
+
}
|
|
560
|
+
function parse_svg(src) {
|
|
561
|
+
reset_id_counter();
|
|
562
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
563
|
+
const prolog = [];
|
|
564
|
+
const epilog = [];
|
|
565
|
+
let i = 0;
|
|
566
|
+
const n = src.length;
|
|
567
|
+
let root = null;
|
|
568
|
+
const open_stack = [];
|
|
569
|
+
/** ns prefix → uri, per ancestor scope (top of stack). */
|
|
570
|
+
const ns_stack = [new Map([["xml", XML_NS], ["xmlns", XMLNS_NS]])];
|
|
571
|
+
/** default ns per ancestor scope (top of stack). */
|
|
572
|
+
const default_ns_stack = [null];
|
|
573
|
+
function push_to_parent(node) {
|
|
574
|
+
nodes.set(node.id, node);
|
|
575
|
+
if (open_stack.length === 0) {
|
|
576
|
+
if (node.kind === "element" && root === null) return;
|
|
577
|
+
if (root === null) prolog.push(node);
|
|
578
|
+
else epilog.push(node);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const parent = open_stack[open_stack.length - 1];
|
|
582
|
+
node.parent = parent.id;
|
|
583
|
+
parent.children.push(node.id);
|
|
584
|
+
}
|
|
585
|
+
while (i < n) {
|
|
586
|
+
if (src[i] === "<") {
|
|
587
|
+
if (src.startsWith("<!--", i)) {
|
|
588
|
+
const end = src.indexOf("-->", i + 4);
|
|
589
|
+
if (end === -1) throw new Error("unterminated comment");
|
|
590
|
+
const value = src.slice(i + 4, end);
|
|
591
|
+
push_to_parent({
|
|
592
|
+
kind: "comment",
|
|
593
|
+
id: fresh_id(),
|
|
594
|
+
parent: null,
|
|
595
|
+
value
|
|
596
|
+
});
|
|
597
|
+
i = end + 3;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (src.startsWith("<![CDATA[", i)) {
|
|
601
|
+
const end = src.indexOf("]]>", i + 9);
|
|
602
|
+
if (end === -1) throw new Error("unterminated CDATA");
|
|
603
|
+
const value = src.slice(i + 9, end);
|
|
604
|
+
push_to_parent({
|
|
605
|
+
kind: "cdata",
|
|
606
|
+
id: fresh_id(),
|
|
607
|
+
parent: null,
|
|
608
|
+
value
|
|
609
|
+
});
|
|
610
|
+
i = end + 3;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (src.startsWith("<!DOCTYPE", i) || src.startsWith("<!doctype", i)) {
|
|
614
|
+
let depth = 1;
|
|
615
|
+
let j = i + 9;
|
|
616
|
+
while (j < n && depth > 0) {
|
|
617
|
+
const c = src[j];
|
|
618
|
+
if (c === "<") depth++;
|
|
619
|
+
else if (c === ">") depth--;
|
|
620
|
+
if (depth === 0) break;
|
|
621
|
+
j++;
|
|
622
|
+
}
|
|
623
|
+
if (j >= n) throw new Error("unterminated doctype");
|
|
624
|
+
push_to_parent({
|
|
625
|
+
kind: "doctype",
|
|
626
|
+
id: fresh_id(),
|
|
627
|
+
parent: null,
|
|
628
|
+
value: src.slice(i + 9, j)
|
|
629
|
+
});
|
|
630
|
+
i = j + 1;
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (src.startsWith("<?", i)) {
|
|
634
|
+
const end = src.indexOf("?>", i + 2);
|
|
635
|
+
if (end === -1) throw new Error("unterminated PI");
|
|
636
|
+
const body = src.slice(i + 2, end);
|
|
637
|
+
const space = body.search(/\s/);
|
|
638
|
+
const target = space === -1 ? body : body.slice(0, space);
|
|
639
|
+
const value = space === -1 ? "" : body.slice(space + 1);
|
|
640
|
+
push_to_parent({
|
|
641
|
+
kind: "pi",
|
|
642
|
+
id: fresh_id(),
|
|
643
|
+
parent: null,
|
|
644
|
+
target,
|
|
645
|
+
value
|
|
646
|
+
});
|
|
647
|
+
i = end + 2;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
if (src[i + 1] === "/") {
|
|
651
|
+
const end = src.indexOf(">", i + 2);
|
|
652
|
+
if (end === -1) throw new Error("unterminated end tag");
|
|
653
|
+
const open = open_stack.pop();
|
|
654
|
+
if (!open) throw new Error("unexpected end tag at " + i);
|
|
655
|
+
ns_stack.pop();
|
|
656
|
+
default_ns_stack.pop();
|
|
657
|
+
const m = src.slice(i + 2, end).match(/^(\s*)([^\s]+)(\s*)$/);
|
|
658
|
+
if (m) {
|
|
659
|
+
open.close_tag_leading = m[1];
|
|
660
|
+
open.close_tag_trailing = m[3];
|
|
661
|
+
}
|
|
662
|
+
i = end + 1;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const start = i + 1;
|
|
666
|
+
let j = start;
|
|
667
|
+
while (j < n && !/[\s/>]/.test(src[j])) j++;
|
|
668
|
+
const raw_tag = src.slice(start, j);
|
|
669
|
+
const [prefix, local] = split_qname(raw_tag);
|
|
670
|
+
const { attrs, end_index, self_closing, trailing } = parse_attrs(src, j);
|
|
671
|
+
const new_ns_map = new Map(ns_stack[ns_stack.length - 1]);
|
|
672
|
+
let new_default_ns = default_ns_stack[default_ns_stack.length - 1];
|
|
673
|
+
for (const a of attrs) if (a.prefix === "xmlns") new_ns_map.set(a.local, a.value);
|
|
674
|
+
else if (a.prefix === null && a.local === "xmlns") new_default_ns = a.value;
|
|
675
|
+
for (const a of attrs) if (a.prefix === "xmlns" || a.prefix === null && a.local === "xmlns") a.ns = XMLNS_NS;
|
|
676
|
+
else if (a.prefix) a.ns = new_ns_map.get(a.prefix) ?? null;
|
|
677
|
+
else a.ns = null;
|
|
678
|
+
const element_ns = prefix ? new_ns_map.get(prefix) ?? null : new_default_ns;
|
|
679
|
+
const elem = {
|
|
680
|
+
kind: "element",
|
|
681
|
+
id: fresh_id(),
|
|
682
|
+
parent: null,
|
|
683
|
+
raw_tag,
|
|
684
|
+
prefix,
|
|
685
|
+
local,
|
|
686
|
+
ns: element_ns,
|
|
687
|
+
attrs,
|
|
688
|
+
children: [],
|
|
689
|
+
self_closing,
|
|
690
|
+
open_tag_trailing: trailing,
|
|
691
|
+
close_tag_leading: "",
|
|
692
|
+
close_tag_trailing: ""
|
|
693
|
+
};
|
|
694
|
+
push_to_parent(elem);
|
|
695
|
+
if (root === null) root = elem.id;
|
|
696
|
+
if (!self_closing) {
|
|
697
|
+
open_stack.push(elem);
|
|
698
|
+
ns_stack.push(new_ns_map);
|
|
699
|
+
default_ns_stack.push(new_default_ns);
|
|
700
|
+
}
|
|
701
|
+
i = end_index;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const next = src.indexOf("<", i);
|
|
705
|
+
const end = next === -1 ? n : next;
|
|
706
|
+
const value = decode_entities(src.slice(i, end));
|
|
707
|
+
push_to_parent({
|
|
708
|
+
kind: "text",
|
|
709
|
+
id: fresh_id(),
|
|
710
|
+
parent: null,
|
|
711
|
+
value
|
|
712
|
+
});
|
|
713
|
+
i = end;
|
|
714
|
+
}
|
|
715
|
+
if (open_stack.length > 0) throw new Error(`unclosed element <${open_stack[open_stack.length - 1].raw_tag}>`);
|
|
716
|
+
if (root === null) throw new Error("no root element");
|
|
717
|
+
return {
|
|
718
|
+
prolog,
|
|
719
|
+
root,
|
|
720
|
+
epilog,
|
|
721
|
+
nodes
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
function split_qname(qname) {
|
|
725
|
+
const idx = qname.indexOf(":");
|
|
726
|
+
if (idx === -1) return [null, qname];
|
|
727
|
+
return [qname.slice(0, idx), qname.slice(idx + 1)];
|
|
728
|
+
}
|
|
729
|
+
function parse_attrs(src, from) {
|
|
730
|
+
const attrs = [];
|
|
731
|
+
let i = from;
|
|
732
|
+
let pre = "";
|
|
733
|
+
const n = src.length;
|
|
734
|
+
while (i < n) {
|
|
735
|
+
const ws_start = i;
|
|
736
|
+
while (i < n && /\s/.test(src[i])) i++;
|
|
737
|
+
pre += src.slice(ws_start, i);
|
|
738
|
+
if (i >= n) throw new Error("unterminated start tag");
|
|
739
|
+
const c = src[i];
|
|
740
|
+
if (c === "/") {
|
|
741
|
+
if (src[i + 1] !== ">") throw new Error("expected '/>' at " + i);
|
|
742
|
+
pre + "";
|
|
743
|
+
return {
|
|
744
|
+
attrs,
|
|
745
|
+
end_index: i + 2,
|
|
746
|
+
self_closing: true,
|
|
747
|
+
trailing: pre
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
if (c === ">") return {
|
|
751
|
+
attrs,
|
|
752
|
+
end_index: i + 1,
|
|
753
|
+
self_closing: false,
|
|
754
|
+
trailing: pre
|
|
755
|
+
};
|
|
756
|
+
const name_start = i;
|
|
757
|
+
while (i < n && !/[\s=/>]/.test(src[i])) i++;
|
|
758
|
+
const raw_name = src.slice(name_start, i);
|
|
759
|
+
let eq_trivia = "";
|
|
760
|
+
while (i < n && /\s/.test(src[i])) {
|
|
761
|
+
eq_trivia += src[i];
|
|
762
|
+
i++;
|
|
763
|
+
}
|
|
764
|
+
if (src[i] !== "=") {
|
|
765
|
+
const [prefix, local] = split_qname(raw_name);
|
|
766
|
+
attrs.push({
|
|
767
|
+
raw_name,
|
|
768
|
+
prefix,
|
|
769
|
+
local,
|
|
770
|
+
ns: null,
|
|
771
|
+
value: "",
|
|
772
|
+
pre,
|
|
773
|
+
eq_trivia,
|
|
774
|
+
quote: "\""
|
|
775
|
+
});
|
|
776
|
+
pre = "";
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
i++;
|
|
780
|
+
while (i < n && /\s/.test(src[i])) i++;
|
|
781
|
+
const quote = src[i];
|
|
782
|
+
if (quote !== "\"" && quote !== "'") throw new Error("expected attribute quote at " + i);
|
|
783
|
+
i++;
|
|
784
|
+
const val_start = i;
|
|
785
|
+
while (i < n && src[i] !== quote) i++;
|
|
786
|
+
if (i >= n) throw new Error("unterminated attribute value");
|
|
787
|
+
const raw_value = src.slice(val_start, i);
|
|
788
|
+
i++;
|
|
789
|
+
const [prefix, local] = split_qname(raw_name);
|
|
790
|
+
attrs.push({
|
|
791
|
+
raw_name,
|
|
792
|
+
prefix,
|
|
793
|
+
local,
|
|
794
|
+
ns: null,
|
|
795
|
+
value: decode_entities(raw_value),
|
|
796
|
+
pre,
|
|
797
|
+
eq_trivia,
|
|
798
|
+
quote
|
|
799
|
+
});
|
|
800
|
+
pre = "";
|
|
801
|
+
}
|
|
802
|
+
throw new Error("unterminated start tag");
|
|
803
|
+
}
|
|
804
|
+
const NAMED_ENTITIES = {
|
|
805
|
+
amp: "&",
|
|
806
|
+
lt: "<",
|
|
807
|
+
gt: ">",
|
|
808
|
+
quot: "\"",
|
|
809
|
+
apos: "'"
|
|
810
|
+
};
|
|
811
|
+
function decode_entities(s) {
|
|
812
|
+
return s.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z][a-zA-Z0-9]*);/g, (_, ent) => {
|
|
813
|
+
if (ent.startsWith("#x") || ent.startsWith("#X")) return String.fromCodePoint(parseInt(ent.slice(2), 16));
|
|
814
|
+
if (ent.startsWith("#")) return String.fromCodePoint(parseInt(ent.slice(1), 10));
|
|
815
|
+
return NAMED_ENTITIES[ent] ?? `&${ent};`;
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
function encode_attr_value(value, quote) {
|
|
819
|
+
let out = value.replace(/&/g, "&").replace(/</g, "<");
|
|
820
|
+
out = quote === "\"" ? out.replace(/"/g, """) : out.replace(/'/g, "'");
|
|
821
|
+
return out;
|
|
822
|
+
}
|
|
823
|
+
function encode_text(value) {
|
|
824
|
+
return value.replace(/&/g, "&").replace(/</g, "<");
|
|
825
|
+
}
|
|
826
|
+
//#endregion
|
|
827
|
+
//#region src/core/document.ts
|
|
828
|
+
var SvgDocument = class SvgDocument {
|
|
829
|
+
constructor(svg) {
|
|
830
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
831
|
+
this._structure_version = 0;
|
|
832
|
+
this.source = svg;
|
|
833
|
+
const parsed = parse_svg(svg);
|
|
834
|
+
this.original = parsed;
|
|
835
|
+
this.nodes = parsed.nodes;
|
|
836
|
+
this.prolog = parsed.prolog;
|
|
837
|
+
this.epilog = parsed.epilog;
|
|
838
|
+
this.root = parsed.root;
|
|
839
|
+
}
|
|
840
|
+
static parse(svg) {
|
|
841
|
+
return new SvgDocument(svg);
|
|
842
|
+
}
|
|
843
|
+
/** Reload from the original parse, discarding all edits. */
|
|
844
|
+
reset_to_original() {
|
|
845
|
+
const parsed = parse_svg(this.source);
|
|
846
|
+
this.original = parsed;
|
|
847
|
+
this.nodes = parsed.nodes;
|
|
848
|
+
this.prolog = parsed.prolog;
|
|
849
|
+
this.epilog = parsed.epilog;
|
|
850
|
+
this.root = parsed.root;
|
|
851
|
+
this._structure_version++;
|
|
852
|
+
this.emit();
|
|
853
|
+
}
|
|
854
|
+
/** Replace document with new svg source (clears edits + history-owned state). */
|
|
855
|
+
load(svg) {
|
|
856
|
+
this.source = svg;
|
|
857
|
+
const parsed = parse_svg(svg);
|
|
858
|
+
this.original = parsed;
|
|
859
|
+
this.nodes = parsed.nodes;
|
|
860
|
+
this.prolog = parsed.prolog;
|
|
861
|
+
this.epilog = parsed.epilog;
|
|
862
|
+
this.root = parsed.root;
|
|
863
|
+
this._structure_version++;
|
|
864
|
+
this.emit();
|
|
865
|
+
}
|
|
866
|
+
on_change(fn) {
|
|
867
|
+
this.listeners.add(fn);
|
|
868
|
+
return () => this.listeners.delete(fn);
|
|
869
|
+
}
|
|
870
|
+
/** See `_structure_version` for what this counter signals. */
|
|
871
|
+
get structure_version() {
|
|
872
|
+
return this._structure_version;
|
|
873
|
+
}
|
|
874
|
+
emit() {
|
|
875
|
+
for (const fn of this.listeners) fn();
|
|
876
|
+
}
|
|
877
|
+
/** Notify subscribers — for callers that mutate directly via setAttr/etc. */
|
|
878
|
+
notify() {
|
|
879
|
+
this.emit();
|
|
880
|
+
}
|
|
881
|
+
get(id) {
|
|
882
|
+
return this.nodes.get(id) ?? null;
|
|
883
|
+
}
|
|
884
|
+
is_element(id) {
|
|
885
|
+
return this.nodes.get(id)?.kind === "element";
|
|
886
|
+
}
|
|
887
|
+
parent_of(id) {
|
|
888
|
+
return this.nodes.get(id)?.parent ?? null;
|
|
889
|
+
}
|
|
890
|
+
children_of(id) {
|
|
891
|
+
const n = this.nodes.get(id);
|
|
892
|
+
if (!n || n.kind !== "element") return [];
|
|
893
|
+
return n.children;
|
|
894
|
+
}
|
|
895
|
+
/** Element children only — text/comment/cdata filtered out. */
|
|
896
|
+
element_children_of(id) {
|
|
897
|
+
return this.children_of(id).filter((c) => this.is_element(c));
|
|
898
|
+
}
|
|
899
|
+
next_sibling_of(id) {
|
|
900
|
+
const parent = this.parent_of(id);
|
|
901
|
+
if (parent === null) return null;
|
|
902
|
+
const siblings = this.children_of(parent);
|
|
903
|
+
const i = siblings.indexOf(id);
|
|
904
|
+
return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
|
|
905
|
+
}
|
|
906
|
+
next_element_sibling_of(id) {
|
|
907
|
+
const parent = this.parent_of(id);
|
|
908
|
+
if (parent === null) return null;
|
|
909
|
+
const siblings = this.element_children_of(parent);
|
|
910
|
+
const i = siblings.indexOf(id);
|
|
911
|
+
return i >= 0 && i + 1 < siblings.length ? siblings[i + 1] : null;
|
|
912
|
+
}
|
|
913
|
+
tag_of(id) {
|
|
914
|
+
const n = this.nodes.get(id);
|
|
915
|
+
return n && n.kind === "element" ? n.local : "";
|
|
916
|
+
}
|
|
917
|
+
contains(ancestor, descendant) {
|
|
918
|
+
if (ancestor === descendant) return true;
|
|
919
|
+
let cur = this.parent_of(descendant);
|
|
920
|
+
while (cur !== null) {
|
|
921
|
+
if (cur === ancestor) return true;
|
|
922
|
+
cur = this.parent_of(cur);
|
|
923
|
+
}
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
all_nodes() {
|
|
927
|
+
const out = [];
|
|
928
|
+
const walk = (id) => {
|
|
929
|
+
out.push(id);
|
|
930
|
+
const c = this.children_of(id);
|
|
931
|
+
for (const ch of c) walk(ch);
|
|
932
|
+
};
|
|
933
|
+
walk(this.root);
|
|
934
|
+
return out;
|
|
935
|
+
}
|
|
936
|
+
all_elements() {
|
|
937
|
+
return this.all_nodes().filter((id) => this.is_element(id));
|
|
938
|
+
}
|
|
939
|
+
find_by_tag(ancestor, tag) {
|
|
940
|
+
const out = [];
|
|
941
|
+
const walk = (id) => {
|
|
942
|
+
if (id !== ancestor && this.is_element(id) && this.tag_of(id) === tag) out.push(id);
|
|
943
|
+
for (const c of this.children_of(id)) walk(c);
|
|
944
|
+
};
|
|
945
|
+
walk(ancestor);
|
|
946
|
+
return out;
|
|
947
|
+
}
|
|
948
|
+
/** Read attribute by local name, optionally namespace-filtered. */
|
|
949
|
+
get_attr(id, name, ns = null) {
|
|
950
|
+
const n = this.nodes.get(id);
|
|
951
|
+
if (!n || n.kind !== "element") return null;
|
|
952
|
+
for (const a of n.attrs) if (a.local === name && (ns === null || a.ns === ns)) return a.value;
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Set / remove an attribute. If the attribute exists, it is mutated in place
|
|
957
|
+
* (preserving source position). If it doesn't, it's appended.
|
|
958
|
+
*/
|
|
959
|
+
set_attr(id, name, value, ns = null) {
|
|
960
|
+
const n = this.nodes.get(id);
|
|
961
|
+
if (!n || n.kind !== "element") return;
|
|
962
|
+
const structural = name === "id";
|
|
963
|
+
for (let i = 0; i < n.attrs.length; i++) {
|
|
964
|
+
const a = n.attrs[i];
|
|
965
|
+
if (a.local === name && (ns === null || a.ns === ns)) {
|
|
966
|
+
if (value === null) n.attrs.splice(i, 1);
|
|
967
|
+
else a.value = value;
|
|
968
|
+
if (structural) this._structure_version++;
|
|
969
|
+
this.emit();
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (value !== null) {
|
|
974
|
+
const prefix = ns === "http://www.w3.org/1999/xlink" ? "xlink" : null;
|
|
975
|
+
n.attrs.push({
|
|
976
|
+
raw_name: prefix ? `${prefix}:${name}` : name,
|
|
977
|
+
prefix,
|
|
978
|
+
local: name,
|
|
979
|
+
ns,
|
|
980
|
+
value,
|
|
981
|
+
pre: " ",
|
|
982
|
+
eq_trivia: "",
|
|
983
|
+
quote: "\""
|
|
984
|
+
});
|
|
985
|
+
if (structural) this._structure_version++;
|
|
986
|
+
this.emit();
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
attributes_of(id) {
|
|
990
|
+
const n = this.nodes.get(id);
|
|
991
|
+
if (!n || n.kind !== "element") return [];
|
|
992
|
+
return n.attrs.map((a) => ({
|
|
993
|
+
name: a.local,
|
|
994
|
+
ns: a.ns,
|
|
995
|
+
value: a.value
|
|
996
|
+
}));
|
|
997
|
+
}
|
|
998
|
+
get_style(id, property) {
|
|
999
|
+
const style = this.get_attr(id, "style");
|
|
1000
|
+
if (!style) return null;
|
|
1001
|
+
const decls = parse_inline_style(style);
|
|
1002
|
+
for (const d of decls) if (d.property === property) return d.value;
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
set_style(id, property, value) {
|
|
1006
|
+
const decls = parse_inline_style(this.get_attr(id, "style") ?? "");
|
|
1007
|
+
const idx = decls.findIndex((d) => d.property === property);
|
|
1008
|
+
if (value === null) {
|
|
1009
|
+
if (idx === -1) return;
|
|
1010
|
+
decls.splice(idx, 1);
|
|
1011
|
+
} else if (idx === -1) decls.push({
|
|
1012
|
+
property,
|
|
1013
|
+
value
|
|
1014
|
+
});
|
|
1015
|
+
else decls[idx].value = value;
|
|
1016
|
+
const next = decls.map((d) => `${d.property}: ${d.value}`).join("; ");
|
|
1017
|
+
this.set_attr(id, "style", next === "" ? null : next);
|
|
1018
|
+
}
|
|
1019
|
+
get_all_styles(id) {
|
|
1020
|
+
const style = this.get_attr(id, "style");
|
|
1021
|
+
if (!style) return [];
|
|
1022
|
+
return parse_inline_style(style);
|
|
1023
|
+
}
|
|
1024
|
+
text_of(id) {
|
|
1025
|
+
const n = this.nodes.get(id);
|
|
1026
|
+
if (!n || n.kind !== "element") return "";
|
|
1027
|
+
let out = "";
|
|
1028
|
+
for (const c of n.children) {
|
|
1029
|
+
const cn = this.nodes.get(c);
|
|
1030
|
+
if (cn?.kind === "text") out += cn.value;
|
|
1031
|
+
}
|
|
1032
|
+
return out;
|
|
1033
|
+
}
|
|
1034
|
+
/** Replace all direct text children with a single text node carrying `value`. */
|
|
1035
|
+
set_text(id, value) {
|
|
1036
|
+
const n = this.nodes.get(id);
|
|
1037
|
+
if (!n || n.kind !== "element") return;
|
|
1038
|
+
n.children = n.children.filter((c) => this.nodes.get(c)?.kind !== "text");
|
|
1039
|
+
if (value !== "") {
|
|
1040
|
+
const text_id = `t${Math.random().toString(36).slice(2, 10)}`;
|
|
1041
|
+
const text_node = {
|
|
1042
|
+
kind: "text",
|
|
1043
|
+
id: text_id,
|
|
1044
|
+
parent: id,
|
|
1045
|
+
value
|
|
1046
|
+
};
|
|
1047
|
+
this.nodes.set(text_id, text_node);
|
|
1048
|
+
n.children.push(text_id);
|
|
1049
|
+
}
|
|
1050
|
+
this._structure_version++;
|
|
1051
|
+
this.emit();
|
|
1052
|
+
}
|
|
1053
|
+
insert(id, parent, before) {
|
|
1054
|
+
const node = this.nodes.get(id);
|
|
1055
|
+
const parent_node = this.nodes.get(parent);
|
|
1056
|
+
if (!node || !parent_node || parent_node.kind !== "element") return;
|
|
1057
|
+
if (node.parent !== null) {
|
|
1058
|
+
const old_parent = this.nodes.get(node.parent);
|
|
1059
|
+
if (old_parent && old_parent.kind === "element") {
|
|
1060
|
+
const i = old_parent.children.indexOf(id);
|
|
1061
|
+
if (i >= 0) old_parent.children.splice(i, 1);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
const ix = before === null ? -1 : parent_node.children.indexOf(before);
|
|
1065
|
+
if (ix < 0) parent_node.children.push(id);
|
|
1066
|
+
else parent_node.children.splice(ix, 0, id);
|
|
1067
|
+
node.parent = parent;
|
|
1068
|
+
this._structure_version++;
|
|
1069
|
+
this.emit();
|
|
1070
|
+
}
|
|
1071
|
+
remove(id) {
|
|
1072
|
+
const n = this.nodes.get(id);
|
|
1073
|
+
if (!n || n.parent === null) return;
|
|
1074
|
+
const parent = this.nodes.get(n.parent);
|
|
1075
|
+
if (!parent || parent.kind !== "element") return;
|
|
1076
|
+
const i = parent.children.indexOf(id);
|
|
1077
|
+
if (i >= 0) parent.children.splice(i, 1);
|
|
1078
|
+
n.parent = null;
|
|
1079
|
+
this._structure_version++;
|
|
1080
|
+
this.emit();
|
|
1081
|
+
}
|
|
1082
|
+
/** Create a new element node and register it (not yet inserted). */
|
|
1083
|
+
create_element(local, opts) {
|
|
1084
|
+
const id = `e${Math.random().toString(36).slice(2, 10)}`;
|
|
1085
|
+
const prefix = opts?.prefix ?? null;
|
|
1086
|
+
const ns = opts?.ns ?? null;
|
|
1087
|
+
const node = {
|
|
1088
|
+
kind: "element",
|
|
1089
|
+
id,
|
|
1090
|
+
parent: null,
|
|
1091
|
+
raw_tag: prefix ? `${prefix}:${local}` : local,
|
|
1092
|
+
prefix,
|
|
1093
|
+
local,
|
|
1094
|
+
ns,
|
|
1095
|
+
attrs: [],
|
|
1096
|
+
children: [],
|
|
1097
|
+
self_closing: false,
|
|
1098
|
+
open_tag_trailing: "",
|
|
1099
|
+
close_tag_leading: "",
|
|
1100
|
+
close_tag_trailing: ""
|
|
1101
|
+
};
|
|
1102
|
+
this.nodes.set(id, node);
|
|
1103
|
+
return id;
|
|
1104
|
+
}
|
|
1105
|
+
serialize() {
|
|
1106
|
+
let out = "";
|
|
1107
|
+
for (const p of this.prolog) out += this.emit_node(p);
|
|
1108
|
+
out += this.emit_node(this.nodes.get(this.root));
|
|
1109
|
+
for (const e of this.epilog) out += this.emit_node(e);
|
|
1110
|
+
return out;
|
|
1111
|
+
}
|
|
1112
|
+
emit_node(n) {
|
|
1113
|
+
switch (n.kind) {
|
|
1114
|
+
case "text": return encode_text(n.value);
|
|
1115
|
+
case "comment": return `<!--${n.value}-->`;
|
|
1116
|
+
case "cdata": return `<![CDATA[${n.value}]]>`;
|
|
1117
|
+
case "pi": {
|
|
1118
|
+
const pi = n;
|
|
1119
|
+
return `<?${pi.target}${pi.value ? " " + pi.value : ""}?>`;
|
|
1120
|
+
}
|
|
1121
|
+
case "doctype": return `<!DOCTYPE${n.value}>`;
|
|
1122
|
+
case "element": {
|
|
1123
|
+
const e = n;
|
|
1124
|
+
let s = `<${e.raw_tag}`;
|
|
1125
|
+
for (const a of e.attrs) s += this.emit_attr(a);
|
|
1126
|
+
if (e.children.length === 0 && e.self_closing) {
|
|
1127
|
+
s += `${e.open_tag_trailing}/>`;
|
|
1128
|
+
return s;
|
|
1129
|
+
}
|
|
1130
|
+
s += `${e.open_tag_trailing}>`;
|
|
1131
|
+
for (const cid of e.children) {
|
|
1132
|
+
const cn = this.nodes.get(cid);
|
|
1133
|
+
if (cn) s += this.emit_node(cn);
|
|
1134
|
+
}
|
|
1135
|
+
s += `</${e.close_tag_leading}${e.raw_tag}${e.close_tag_trailing}>`;
|
|
1136
|
+
return s;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
emit_attr(a) {
|
|
1141
|
+
return `${a.pre}${a.raw_name}${a.eq_trivia}=${a.quote}${encode_attr_value(a.value, a.quote)}${a.quote}`;
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
function parse_inline_style(s) {
|
|
1145
|
+
const out = [];
|
|
1146
|
+
const decls = s.split(";");
|
|
1147
|
+
for (const decl of decls) {
|
|
1148
|
+
const colon = decl.indexOf(":");
|
|
1149
|
+
if (colon === -1) continue;
|
|
1150
|
+
const property = decl.slice(0, colon).trim();
|
|
1151
|
+
const value = decl.slice(colon + 1).trim();
|
|
1152
|
+
if (property) out.push({
|
|
1153
|
+
property,
|
|
1154
|
+
value
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
return out;
|
|
1158
|
+
}
|
|
1159
|
+
//#endregion
|
|
1160
|
+
//#region src/core/properties.ts
|
|
1161
|
+
/** SVG properties that inherit per SVG 2 §6 (subset; the common ones). */
|
|
1162
|
+
const INHERITED = new Set([
|
|
1163
|
+
"color",
|
|
1164
|
+
"cursor",
|
|
1165
|
+
"direction",
|
|
1166
|
+
"fill",
|
|
1167
|
+
"fill-opacity",
|
|
1168
|
+
"fill-rule",
|
|
1169
|
+
"font",
|
|
1170
|
+
"font-family",
|
|
1171
|
+
"font-size",
|
|
1172
|
+
"font-style",
|
|
1173
|
+
"font-variant",
|
|
1174
|
+
"font-weight",
|
|
1175
|
+
"letter-spacing",
|
|
1176
|
+
"marker",
|
|
1177
|
+
"marker-end",
|
|
1178
|
+
"marker-mid",
|
|
1179
|
+
"marker-start",
|
|
1180
|
+
"paint-order",
|
|
1181
|
+
"pointer-events",
|
|
1182
|
+
"shape-rendering",
|
|
1183
|
+
"stroke",
|
|
1184
|
+
"stroke-dasharray",
|
|
1185
|
+
"stroke-dashoffset",
|
|
1186
|
+
"stroke-linecap",
|
|
1187
|
+
"stroke-linejoin",
|
|
1188
|
+
"stroke-miterlimit",
|
|
1189
|
+
"stroke-opacity",
|
|
1190
|
+
"stroke-width",
|
|
1191
|
+
"text-anchor",
|
|
1192
|
+
"text-rendering",
|
|
1193
|
+
"visibility",
|
|
1194
|
+
"word-spacing",
|
|
1195
|
+
"writing-mode"
|
|
1196
|
+
]);
|
|
1197
|
+
/** Initial values for known properties (subset). */
|
|
1198
|
+
const INITIAL = {
|
|
1199
|
+
fill: "black",
|
|
1200
|
+
stroke: "none",
|
|
1201
|
+
"fill-opacity": "1",
|
|
1202
|
+
"stroke-opacity": "1",
|
|
1203
|
+
"stroke-width": "1",
|
|
1204
|
+
opacity: "1",
|
|
1205
|
+
visibility: "visible",
|
|
1206
|
+
display: "inline"
|
|
1207
|
+
};
|
|
1208
|
+
/**
|
|
1209
|
+
* Resolve a property's declared value and its provenance for a single node.
|
|
1210
|
+
*
|
|
1211
|
+
* The cascade engine here covers what the README says is in scope:
|
|
1212
|
+
* presentation attributes + inline style + parent inheritance + initial.
|
|
1213
|
+
* `<style>` block matching is deferred.
|
|
1214
|
+
*/
|
|
1215
|
+
function resolve_declared(doc, id, property) {
|
|
1216
|
+
const inline = doc.get_style(id, property);
|
|
1217
|
+
if (inline !== null && inline !== "") return {
|
|
1218
|
+
declared: inline,
|
|
1219
|
+
provenance: {
|
|
1220
|
+
origin: "author",
|
|
1221
|
+
carrier: "inline_style"
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
const attr = doc.get_attr(id, property);
|
|
1225
|
+
if (attr !== null && attr !== "") return {
|
|
1226
|
+
declared: attr,
|
|
1227
|
+
provenance: {
|
|
1228
|
+
origin: "author",
|
|
1229
|
+
carrier: "presentation_attribute"
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
if (INHERITED.has(property)) {
|
|
1233
|
+
const parent = doc.parent_of(id);
|
|
1234
|
+
if (parent !== null && doc.is_element(parent)) {
|
|
1235
|
+
const r = resolve_declared(doc, parent, property);
|
|
1236
|
+
if (r.declared !== null) return {
|
|
1237
|
+
declared: r.declared,
|
|
1238
|
+
provenance: {
|
|
1239
|
+
origin: "author",
|
|
1240
|
+
carrier: "inherited"
|
|
1241
|
+
}
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return {
|
|
1246
|
+
declared: INITIAL[property] ?? null,
|
|
1247
|
+
provenance: {
|
|
1248
|
+
origin: "user_agent",
|
|
1249
|
+
carrier: "defaulted"
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Type-parsed computed value for known properties. Unknown property names
|
|
1255
|
+
* return the declared string as-is.
|
|
1256
|
+
*/
|
|
1257
|
+
function compute_known(property, declared) {
|
|
1258
|
+
if (declared === null) return null;
|
|
1259
|
+
const trimmed = declared.trim();
|
|
1260
|
+
if (trimmed === "inherit" || trimmed === "initial" || trimmed === "unset" || trimmed === "revert" || trimmed === "revert-layer") return null;
|
|
1261
|
+
if (/^var\s*\(/i.test(trimmed)) return {
|
|
1262
|
+
error: "invalid_at_computed_value_time",
|
|
1263
|
+
reason: `var() substitution requires a cascade engine (not implemented)`
|
|
1264
|
+
};
|
|
1265
|
+
switch (property) {
|
|
1266
|
+
case "opacity":
|
|
1267
|
+
case "fill-opacity":
|
|
1268
|
+
case "stroke-opacity":
|
|
1269
|
+
case "stroke-width":
|
|
1270
|
+
case "x":
|
|
1271
|
+
case "y":
|
|
1272
|
+
case "width":
|
|
1273
|
+
case "height":
|
|
1274
|
+
case "cx":
|
|
1275
|
+
case "cy":
|
|
1276
|
+
case "r":
|
|
1277
|
+
case "rx":
|
|
1278
|
+
case "ry":
|
|
1279
|
+
case "font-size": {
|
|
1280
|
+
const n = parseFloat(trimmed);
|
|
1281
|
+
return Number.isFinite(n) ? n : trimmed;
|
|
1282
|
+
}
|
|
1283
|
+
default: return trimmed;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
function read_property(doc, id, property) {
|
|
1287
|
+
const { declared, provenance } = resolve_declared(doc, id, property);
|
|
1288
|
+
return {
|
|
1289
|
+
declared,
|
|
1290
|
+
computed: compute_known(property, declared),
|
|
1291
|
+
provenance
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
/** Which carrier should a `set_property` write to? Per the README (P1):
|
|
1295
|
+
* whichever carrier currently wins the cascade. If nothing wins (defaulted /
|
|
1296
|
+
* inherited), write a presentation attribute by default. */
|
|
1297
|
+
function choose_write_carrier(doc, id, property) {
|
|
1298
|
+
const inline = doc.get_style(id, property);
|
|
1299
|
+
if (inline !== null && inline !== "") return "inline_style";
|
|
1300
|
+
return "presentation_attribute";
|
|
1301
|
+
}
|
|
1302
|
+
//#endregion
|
|
1303
|
+
//#region src/types.ts
|
|
1304
|
+
const DEFAULT_STYLE = {
|
|
1305
|
+
chrome_color: "#2563eb",
|
|
1306
|
+
handle_size: 8,
|
|
1307
|
+
handle_fill: "#ffffff",
|
|
1308
|
+
handle_stroke: "#2563eb",
|
|
1309
|
+
endpoint_dot_radius: 5,
|
|
1310
|
+
selection_outline_width: 2,
|
|
1311
|
+
measurement_color: "#ff3a30"
|
|
1312
|
+
};
|
|
1313
|
+
//#endregion
|
|
1314
|
+
//#region src/core/editor.ts
|
|
1315
|
+
const PROVIDER_ID = "svg-editor";
|
|
1316
|
+
/** Max characters in a synthesized display label before truncation. */
|
|
1317
|
+
const DISPLAY_LABEL_MAX_LEN = 40;
|
|
1318
|
+
function createSvgEditor(opts) {
|
|
1319
|
+
const doc = new SvgDocument(opts.svg);
|
|
1320
|
+
const history = new HistoryImpl();
|
|
1321
|
+
const defs = create_defs(doc);
|
|
1322
|
+
let selection = [];
|
|
1323
|
+
let scope = null;
|
|
1324
|
+
let mode = "select";
|
|
1325
|
+
let version = 0;
|
|
1326
|
+
/** Document-edit counter — only bumps on actual mutations, not selection. */
|
|
1327
|
+
let doc_version = 0;
|
|
1328
|
+
/** doc_version at the last load()/serialize(); compared to derive `dirty`. */
|
|
1329
|
+
let baseline_doc_version = 0;
|
|
1330
|
+
let style = {
|
|
1331
|
+
...DEFAULT_STYLE,
|
|
1332
|
+
...opts.style ?? {}
|
|
1333
|
+
};
|
|
1334
|
+
const providers = opts.providers ?? {};
|
|
1335
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1336
|
+
let attached_surface = null;
|
|
1337
|
+
const modes = ["select", "edit-content"];
|
|
1338
|
+
function snapshot() {
|
|
1339
|
+
return Object.freeze({
|
|
1340
|
+
selection,
|
|
1341
|
+
scope,
|
|
1342
|
+
mode,
|
|
1343
|
+
dirty: doc_version !== baseline_doc_version,
|
|
1344
|
+
can_undo: history.stack.canUndo,
|
|
1345
|
+
can_redo: history.stack.canRedo,
|
|
1346
|
+
version,
|
|
1347
|
+
structure_version: doc.structure_version
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
function emit() {
|
|
1351
|
+
version++;
|
|
1352
|
+
const s = snapshot();
|
|
1353
|
+
for (const fn of listeners) fn(s);
|
|
1354
|
+
}
|
|
1355
|
+
history.on("onChange", () => emit());
|
|
1356
|
+
history.on("onUndo", () => emit());
|
|
1357
|
+
history.on("onRedo", () => emit());
|
|
1358
|
+
doc.on_change(() => {
|
|
1359
|
+
doc_version++;
|
|
1360
|
+
});
|
|
1361
|
+
function subscribe(fn) {
|
|
1362
|
+
listeners.add(fn);
|
|
1363
|
+
return () => {
|
|
1364
|
+
listeners.delete(fn);
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
function subscribe_with_selector(selector, fn, equals = Object.is) {
|
|
1368
|
+
let prev = selector(snapshot());
|
|
1369
|
+
return subscribe((state) => {
|
|
1370
|
+
const next = selector(state);
|
|
1371
|
+
if (!equals(prev, next)) {
|
|
1372
|
+
const p = prev;
|
|
1373
|
+
prev = next;
|
|
1374
|
+
fn(next, p);
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
function set_selection(next) {
|
|
1379
|
+
selection = Object.freeze([...next]);
|
|
1380
|
+
emit();
|
|
1381
|
+
}
|
|
1382
|
+
function select(target, opts) {
|
|
1383
|
+
const ids = typeof target === "string" ? [target] : [...target];
|
|
1384
|
+
if (opts?.additive) {
|
|
1385
|
+
const merged = new Set(selection);
|
|
1386
|
+
for (const id of ids) merged.add(id);
|
|
1387
|
+
set_selection([...merged]);
|
|
1388
|
+
} else set_selection(ids);
|
|
1389
|
+
}
|
|
1390
|
+
function deselect() {
|
|
1391
|
+
set_selection([]);
|
|
1392
|
+
}
|
|
1393
|
+
function enter_scope(group) {
|
|
1394
|
+
scope = group;
|
|
1395
|
+
emit();
|
|
1396
|
+
}
|
|
1397
|
+
function exit_scope() {
|
|
1398
|
+
if (scope === null) return;
|
|
1399
|
+
const parent = doc.parent_of(scope);
|
|
1400
|
+
scope = parent && parent !== doc.root ? parent : null;
|
|
1401
|
+
emit();
|
|
1402
|
+
}
|
|
1403
|
+
function set_mode(next) {
|
|
1404
|
+
if (mode === next) return;
|
|
1405
|
+
mode = next;
|
|
1406
|
+
emit();
|
|
1407
|
+
}
|
|
1408
|
+
function node_properties(id, names) {
|
|
1409
|
+
const out = {};
|
|
1410
|
+
for (const name of names) out[name] = read_property(doc, id, name);
|
|
1411
|
+
return out;
|
|
1412
|
+
}
|
|
1413
|
+
function node_paint(id, channel) {
|
|
1414
|
+
const { declared, provenance } = resolve_declared(doc, id, channel);
|
|
1415
|
+
return {
|
|
1416
|
+
declared,
|
|
1417
|
+
computed: parse_paint(declared),
|
|
1418
|
+
provenance
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
function write_property(id, name, value) {
|
|
1422
|
+
if (choose_write_carrier(doc, id, name) === "inline_style") doc.set_style(id, name, value);
|
|
1423
|
+
else doc.set_attr(id, name, value);
|
|
1424
|
+
}
|
|
1425
|
+
function set_property(name, value) {
|
|
1426
|
+
if (selection.length === 0) return;
|
|
1427
|
+
const before = [];
|
|
1428
|
+
for (const id of selection) before.push({
|
|
1429
|
+
id,
|
|
1430
|
+
attr: doc.get_attr(id, name),
|
|
1431
|
+
style: doc.get_style(id, name)
|
|
1432
|
+
});
|
|
1433
|
+
const targets = [...selection];
|
|
1434
|
+
const apply = () => {
|
|
1435
|
+
for (const id of targets) write_property(id, name, value);
|
|
1436
|
+
emit();
|
|
1437
|
+
};
|
|
1438
|
+
const revert = () => {
|
|
1439
|
+
for (const b of before) {
|
|
1440
|
+
if (b.style !== null) doc.set_style(b.id, name, b.style);
|
|
1441
|
+
else doc.set_style(b.id, name, null);
|
|
1442
|
+
doc.set_attr(b.id, name, b.attr);
|
|
1443
|
+
}
|
|
1444
|
+
emit();
|
|
1445
|
+
};
|
|
1446
|
+
apply();
|
|
1447
|
+
history.atomic(`set ${name}`, (tx) => {
|
|
1448
|
+
tx.push({
|
|
1449
|
+
providerId: PROVIDER_ID,
|
|
1450
|
+
apply,
|
|
1451
|
+
revert
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
function preview_property(name) {
|
|
1456
|
+
const before = [];
|
|
1457
|
+
for (const id of selection) before.push({
|
|
1458
|
+
id,
|
|
1459
|
+
attr: doc.get_attr(id, name),
|
|
1460
|
+
style: doc.get_style(id, name)
|
|
1461
|
+
});
|
|
1462
|
+
const preview = history.preview(`change ${name}`);
|
|
1463
|
+
return {
|
|
1464
|
+
update(value) {
|
|
1465
|
+
preview.set({
|
|
1466
|
+
providerId: PROVIDER_ID,
|
|
1467
|
+
apply: () => {
|
|
1468
|
+
for (const id of selection) write_property(id, name, value);
|
|
1469
|
+
emit();
|
|
1470
|
+
},
|
|
1471
|
+
revert: () => {
|
|
1472
|
+
for (const b of before) {
|
|
1473
|
+
if (b.style !== null) doc.set_style(b.id, name, b.style);
|
|
1474
|
+
else doc.set_style(b.id, name, null);
|
|
1475
|
+
doc.set_attr(b.id, name, b.attr);
|
|
1476
|
+
}
|
|
1477
|
+
emit();
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
},
|
|
1481
|
+
commit: () => preview.commit(),
|
|
1482
|
+
discard: () => preview.discard()
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
function set_paint(channel, paint) {
|
|
1486
|
+
if (selection.length === 0) return;
|
|
1487
|
+
set_property(channel, serialize_paint(paint));
|
|
1488
|
+
}
|
|
1489
|
+
function preview_paint(channel) {
|
|
1490
|
+
const session = preview_property(channel);
|
|
1491
|
+
return {
|
|
1492
|
+
update: (paint) => session.update(serialize_paint(paint)),
|
|
1493
|
+
commit: () => session.commit(),
|
|
1494
|
+
discard: () => session.discard()
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
function set_paint_from_gradient(channel, definition, _opts) {
|
|
1498
|
+
const gradient_id = defs.gradients.upsert(definition);
|
|
1499
|
+
set_paint(channel, {
|
|
1500
|
+
kind: "ref",
|
|
1501
|
+
id: gradient_id
|
|
1502
|
+
});
|
|
1503
|
+
return { gradient_id };
|
|
1504
|
+
}
|
|
1505
|
+
function translate(delta) {
|
|
1506
|
+
if (selection.length === 0) return;
|
|
1507
|
+
if (delta.dx === 0 && delta.dy === 0) return;
|
|
1508
|
+
const baselines = selection.map((id) => ({
|
|
1509
|
+
id,
|
|
1510
|
+
baseline: capture_translate_baseline(doc, id)
|
|
1511
|
+
}));
|
|
1512
|
+
const apply = () => {
|
|
1513
|
+
for (const { id, baseline } of baselines) apply_translate(doc, id, baseline, delta.dx, delta.dy);
|
|
1514
|
+
emit();
|
|
1515
|
+
};
|
|
1516
|
+
const revert = () => {
|
|
1517
|
+
for (const { id, baseline } of baselines) apply_translate(doc, id, baseline, 0, 0);
|
|
1518
|
+
emit();
|
|
1519
|
+
};
|
|
1520
|
+
apply();
|
|
1521
|
+
history.atomic("translate", (tx) => {
|
|
1522
|
+
tx.push({
|
|
1523
|
+
providerId: PROVIDER_ID,
|
|
1524
|
+
apply,
|
|
1525
|
+
revert
|
|
1526
|
+
});
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
function reorder(direction) {
|
|
1530
|
+
if (selection.length !== 1) return;
|
|
1531
|
+
const target = selection[0];
|
|
1532
|
+
const parent = doc.parent_of(target);
|
|
1533
|
+
if (parent === null) return;
|
|
1534
|
+
const siblings = doc.element_children_of(parent);
|
|
1535
|
+
const i = siblings.indexOf(target);
|
|
1536
|
+
if (i < 0) return;
|
|
1537
|
+
const original_before = siblings[i + 1] ?? null;
|
|
1538
|
+
let new_before;
|
|
1539
|
+
switch (direction) {
|
|
1540
|
+
case "bring_forward":
|
|
1541
|
+
if (i >= siblings.length - 1) return;
|
|
1542
|
+
new_before = siblings[i + 2] ?? null;
|
|
1543
|
+
break;
|
|
1544
|
+
case "send_backward":
|
|
1545
|
+
if (i <= 0) return;
|
|
1546
|
+
new_before = siblings[i - 1];
|
|
1547
|
+
break;
|
|
1548
|
+
case "bring_to_front":
|
|
1549
|
+
if (i === siblings.length - 1) return;
|
|
1550
|
+
new_before = null;
|
|
1551
|
+
break;
|
|
1552
|
+
case "send_to_back":
|
|
1553
|
+
if (i === 0) return;
|
|
1554
|
+
new_before = siblings[0];
|
|
1555
|
+
break;
|
|
1556
|
+
}
|
|
1557
|
+
const apply = () => {
|
|
1558
|
+
doc.insert(target, parent, new_before);
|
|
1559
|
+
emit();
|
|
1560
|
+
};
|
|
1561
|
+
const revert = () => {
|
|
1562
|
+
doc.insert(target, parent, original_before);
|
|
1563
|
+
emit();
|
|
1564
|
+
};
|
|
1565
|
+
apply();
|
|
1566
|
+
history.atomic(`reorder: ${direction}`, (tx) => {
|
|
1567
|
+
tx.push({
|
|
1568
|
+
providerId: PROVIDER_ID,
|
|
1569
|
+
apply,
|
|
1570
|
+
revert
|
|
1571
|
+
});
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
function remove() {
|
|
1575
|
+
if (selection.length !== 1) return;
|
|
1576
|
+
const target = selection[0];
|
|
1577
|
+
const parent = doc.parent_of(target);
|
|
1578
|
+
if (parent === null) return;
|
|
1579
|
+
const next_sibling = doc.next_element_sibling_of(target);
|
|
1580
|
+
const old_selection = selection;
|
|
1581
|
+
const apply = () => {
|
|
1582
|
+
doc.remove(target);
|
|
1583
|
+
set_selection([]);
|
|
1584
|
+
};
|
|
1585
|
+
const revert = () => {
|
|
1586
|
+
doc.insert(target, parent, next_sibling);
|
|
1587
|
+
set_selection(old_selection);
|
|
1588
|
+
};
|
|
1589
|
+
apply();
|
|
1590
|
+
history.atomic("remove", (tx) => {
|
|
1591
|
+
tx.push({
|
|
1592
|
+
providerId: PROVIDER_ID,
|
|
1593
|
+
apply,
|
|
1594
|
+
revert
|
|
1595
|
+
});
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
function set_text(value) {
|
|
1599
|
+
if (selection.length !== 1) return;
|
|
1600
|
+
const target = selection[0];
|
|
1601
|
+
if (doc.tag_of(target) !== "text") return;
|
|
1602
|
+
const original = doc.text_of(target);
|
|
1603
|
+
if (original === value) return;
|
|
1604
|
+
const apply = () => {
|
|
1605
|
+
doc.set_text(target, value);
|
|
1606
|
+
emit();
|
|
1607
|
+
};
|
|
1608
|
+
const revert = () => {
|
|
1609
|
+
doc.set_text(target, original);
|
|
1610
|
+
emit();
|
|
1611
|
+
};
|
|
1612
|
+
apply();
|
|
1613
|
+
history.atomic("edit text", (tx) => {
|
|
1614
|
+
tx.push({
|
|
1615
|
+
providerId: PROVIDER_ID,
|
|
1616
|
+
apply,
|
|
1617
|
+
revert
|
|
1618
|
+
});
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
let content_edit_driver = null;
|
|
1622
|
+
let computed_resolver = null;
|
|
1623
|
+
function dom_computed_property(id, name) {
|
|
1624
|
+
return computed_resolver?.computed_property(id, name) ?? null;
|
|
1625
|
+
}
|
|
1626
|
+
function dom_computed_paint(id, channel) {
|
|
1627
|
+
return computed_resolver?.computed_paint(id, channel) ?? null;
|
|
1628
|
+
}
|
|
1629
|
+
let current_surface_hover = null;
|
|
1630
|
+
let surface_hover_override = null;
|
|
1631
|
+
const surface_hover_listeners = /* @__PURE__ */ new Set();
|
|
1632
|
+
let surface_hover_override_driver = null;
|
|
1633
|
+
function notify_surface_hover() {
|
|
1634
|
+
for (const cb of surface_hover_listeners) cb();
|
|
1635
|
+
}
|
|
1636
|
+
function _set_current_surface_hover(id) {
|
|
1637
|
+
if (current_surface_hover === id) return;
|
|
1638
|
+
current_surface_hover = id;
|
|
1639
|
+
notify_surface_hover();
|
|
1640
|
+
}
|
|
1641
|
+
function enter_content_edit(target) {
|
|
1642
|
+
const id = target ?? (selection.length === 1 ? selection[0] : null);
|
|
1643
|
+
if (!id) return false;
|
|
1644
|
+
if (doc.tag_of(id) !== "text") return false;
|
|
1645
|
+
if (!content_edit_driver) return false;
|
|
1646
|
+
return content_edit_driver(id);
|
|
1647
|
+
}
|
|
1648
|
+
function load_svg(svg) {
|
|
1649
|
+
doc.load(svg);
|
|
1650
|
+
selection = [];
|
|
1651
|
+
scope = null;
|
|
1652
|
+
mode = "select";
|
|
1653
|
+
history.clear();
|
|
1654
|
+
baseline_doc_version = doc_version;
|
|
1655
|
+
emit();
|
|
1656
|
+
}
|
|
1657
|
+
function serialize_svg() {
|
|
1658
|
+
return doc.serialize();
|
|
1659
|
+
}
|
|
1660
|
+
function undo() {
|
|
1661
|
+
history.undo();
|
|
1662
|
+
}
|
|
1663
|
+
function redo() {
|
|
1664
|
+
history.redo();
|
|
1665
|
+
}
|
|
1666
|
+
const registry = new CommandRegistry();
|
|
1667
|
+
const keymap = new Keymap(registry);
|
|
1668
|
+
const commands = {
|
|
1669
|
+
select,
|
|
1670
|
+
deselect,
|
|
1671
|
+
enter_scope,
|
|
1672
|
+
exit_scope,
|
|
1673
|
+
set_mode,
|
|
1674
|
+
set_property,
|
|
1675
|
+
preview_property,
|
|
1676
|
+
set_paint,
|
|
1677
|
+
preview_paint,
|
|
1678
|
+
set_paint_from_gradient,
|
|
1679
|
+
translate,
|
|
1680
|
+
reorder,
|
|
1681
|
+
remove,
|
|
1682
|
+
set_text,
|
|
1683
|
+
load_svg,
|
|
1684
|
+
serialize_svg,
|
|
1685
|
+
undo,
|
|
1686
|
+
redo,
|
|
1687
|
+
register: (id, handler) => registry.register(id, handler),
|
|
1688
|
+
invoke: (id, args) => registry.invoke(id, args),
|
|
1689
|
+
has: (id) => registry.has(id)
|
|
1690
|
+
};
|
|
1691
|
+
function load(svg) {
|
|
1692
|
+
load_svg(svg);
|
|
1693
|
+
}
|
|
1694
|
+
function serialize() {
|
|
1695
|
+
return doc.serialize();
|
|
1696
|
+
}
|
|
1697
|
+
function reset() {
|
|
1698
|
+
history.clear();
|
|
1699
|
+
doc.reset_to_original();
|
|
1700
|
+
selection = [];
|
|
1701
|
+
scope = null;
|
|
1702
|
+
mode = "select";
|
|
1703
|
+
baseline_doc_version = doc_version;
|
|
1704
|
+
emit();
|
|
1705
|
+
}
|
|
1706
|
+
function attach(surface) {
|
|
1707
|
+
if (attached_surface) attached_surface.dispose();
|
|
1708
|
+
attached_surface = surface;
|
|
1709
|
+
return { detach() {
|
|
1710
|
+
if (attached_surface === surface) {
|
|
1711
|
+
attached_surface.dispose();
|
|
1712
|
+
attached_surface = null;
|
|
1713
|
+
}
|
|
1714
|
+
} };
|
|
1715
|
+
}
|
|
1716
|
+
function detach() {
|
|
1717
|
+
if (attached_surface) {
|
|
1718
|
+
attached_surface.dispose();
|
|
1719
|
+
attached_surface = null;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
function dispose() {
|
|
1723
|
+
detach();
|
|
1724
|
+
listeners.clear();
|
|
1725
|
+
}
|
|
1726
|
+
function set_style(partial) {
|
|
1727
|
+
style = {
|
|
1728
|
+
...style,
|
|
1729
|
+
...partial
|
|
1730
|
+
};
|
|
1731
|
+
emit();
|
|
1732
|
+
}
|
|
1733
|
+
const public_editor = {
|
|
1734
|
+
document: doc,
|
|
1735
|
+
get state() {
|
|
1736
|
+
return snapshot();
|
|
1737
|
+
},
|
|
1738
|
+
subscribe,
|
|
1739
|
+
subscribe_with_selector,
|
|
1740
|
+
node_properties,
|
|
1741
|
+
node_paint,
|
|
1742
|
+
dom_computed_property,
|
|
1743
|
+
dom_computed_paint,
|
|
1744
|
+
enter_content_edit,
|
|
1745
|
+
defs,
|
|
1746
|
+
commands,
|
|
1747
|
+
display_label(id, opts) {
|
|
1748
|
+
const tag = doc.tag_of(id);
|
|
1749
|
+
if (tag === "text") {
|
|
1750
|
+
const collapsed = doc.text_of(id).replace(/\s+/g, " ").trim();
|
|
1751
|
+
if (collapsed.length === 0) return "text";
|
|
1752
|
+
return collapsed.length > DISPLAY_LABEL_MAX_LEN ? `${collapsed.slice(0, DISPLAY_LABEL_MAX_LEN)}…` : collapsed;
|
|
1753
|
+
}
|
|
1754
|
+
const elem_id = doc.get_attr(id, "id");
|
|
1755
|
+
const head = opts?.tagLabel ? opts.tagLabel(tag) : tag;
|
|
1756
|
+
return elem_id && elem_id.length > 0 ? `${head} #${elem_id}` : head;
|
|
1757
|
+
},
|
|
1758
|
+
tree() {
|
|
1759
|
+
const map = /* @__PURE__ */ new Map();
|
|
1760
|
+
for (const id of doc.all_elements()) map.set(id, {
|
|
1761
|
+
id,
|
|
1762
|
+
tag: doc.tag_of(id),
|
|
1763
|
+
name: doc.get_attr(id, "id") ?? void 0,
|
|
1764
|
+
parent: doc.parent_of(id),
|
|
1765
|
+
children: doc.element_children_of(id)
|
|
1766
|
+
});
|
|
1767
|
+
return {
|
|
1768
|
+
root: doc.root,
|
|
1769
|
+
nodes: map
|
|
1770
|
+
};
|
|
1771
|
+
},
|
|
1772
|
+
surface_hover() {
|
|
1773
|
+
return current_surface_hover;
|
|
1774
|
+
},
|
|
1775
|
+
set_surface_hover_override(id) {
|
|
1776
|
+
if (surface_hover_override === id) return;
|
|
1777
|
+
surface_hover_override = id;
|
|
1778
|
+
if (surface_hover_override_driver) surface_hover_override_driver(id);
|
|
1779
|
+
},
|
|
1780
|
+
subscribe_surface_hover(cb) {
|
|
1781
|
+
surface_hover_listeners.add(cb);
|
|
1782
|
+
return () => {
|
|
1783
|
+
surface_hover_listeners.delete(cb);
|
|
1784
|
+
};
|
|
1785
|
+
},
|
|
1786
|
+
modes,
|
|
1787
|
+
get style() {
|
|
1788
|
+
return style;
|
|
1789
|
+
},
|
|
1790
|
+
set_style,
|
|
1791
|
+
load,
|
|
1792
|
+
serialize,
|
|
1793
|
+
reset,
|
|
1794
|
+
attach,
|
|
1795
|
+
detach,
|
|
1796
|
+
dispose,
|
|
1797
|
+
providers,
|
|
1798
|
+
_internal: {
|
|
1799
|
+
doc,
|
|
1800
|
+
set_content_edit_driver(fn) {
|
|
1801
|
+
content_edit_driver = fn;
|
|
1802
|
+
},
|
|
1803
|
+
set_surface_hover_override_driver(fn) {
|
|
1804
|
+
surface_hover_override_driver = fn;
|
|
1805
|
+
if (fn) fn(surface_hover_override);
|
|
1806
|
+
},
|
|
1807
|
+
push_surface_hover(id) {
|
|
1808
|
+
_set_current_surface_hover(id);
|
|
1809
|
+
},
|
|
1810
|
+
set_computed_resolver(fn) {
|
|
1811
|
+
computed_resolver = fn;
|
|
1812
|
+
}
|
|
1813
|
+
},
|
|
1814
|
+
keymap
|
|
1815
|
+
};
|
|
1816
|
+
registerDefaultCommands(registry, public_editor);
|
|
1817
|
+
applyDefaultBindings(keymap);
|
|
1818
|
+
return public_editor;
|
|
1819
|
+
}
|
|
1820
|
+
//#endregion
|
|
1821
|
+
export { DEFAULT_STYLE as n, createSvgEditor as t };
|