svedit 0.5.0 → 0.6.0
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 +761 -120
- package/dist/AnnotatedTextProperty.svelte +122 -75
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.d.ts +118 -0
- package/dist/Command.svelte.d.ts.map +1 -0
- package/dist/Command.svelte.js +314 -0
- package/dist/CustomProperty.svelte +31 -42
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.d.ts +36 -0
- package/dist/KeyMapper.svelte.d.ts.map +1 -0
- package/dist/KeyMapper.svelte.js +141 -0
- package/dist/Node.svelte +33 -35
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +40 -37
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/{Document.svelte.d.ts → Session.svelte.d.ts} +76 -83
- package/dist/Session.svelte.d.ts.map +1 -0
- package/dist/Session.svelte.js +594 -0
- package/dist/Svedit.svelte +1352 -1512
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +114 -40
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +845 -661
- package/dist/UnknownNode.svelte +6 -6
- package/dist/UnknownNode.svelte.d.ts.map +1 -1
- package/dist/doc_utils.d.ts +127 -0
- package/dist/doc_utils.d.ts.map +1 -0
- package/dist/doc_utils.js +482 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +20 -4
- package/dist/{commands.svelte.d.ts → transforms.svelte.d.ts} +1 -2
- package/dist/transforms.svelte.d.ts.map +1 -0
- package/dist/transforms.svelte.js +155 -0
- package/dist/types.d.ts +256 -110
- package/dist/{util.d.ts → utils.d.ts} +22 -2
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +338 -0
- package/package.json +20 -18
- package/src/lib/AnnotatedTextProperty.svelte +122 -75
- package/src/lib/Command.svelte.js +314 -0
- package/src/lib/CustomProperty.svelte +31 -42
- package/src/lib/KeyMapper.svelte.js +141 -0
- package/src/lib/Node.svelte +33 -35
- package/src/lib/NodeArrayProperty.svelte +40 -37
- package/src/lib/Session.svelte.js +594 -0
- package/src/lib/Svedit.svelte +1352 -1512
- package/src/lib/Transaction.svelte.js +845 -661
- package/src/lib/UnknownNode.svelte +6 -6
- package/src/lib/doc_utils.js +482 -0
- package/src/lib/index.js +20 -4
- package/src/lib/transforms.svelte.js +155 -0
- package/src/lib/types.d.ts +256 -110
- package/src/lib/utils.js +338 -0
- package/dist/Document.svelte.d.ts.map +0 -1
- package/dist/Document.svelte.js +0 -833
- package/dist/commands.svelte.d.ts.map +0 -1
- package/dist/commands.svelte.js +0 -266
- package/dist/util.d.ts.map +0 -1
- package/dist/util.js +0 -269
- package/src/lib/Document.svelte.js +0 -833
- package/src/lib/commands.svelte.js +0 -266
- package/src/lib/util.js +0 -269
package/src/lib/Svedit.svelte
CHANGED
|
@@ -1,1536 +1,1376 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
let selection = __get_property_selection_from_dom() || __get_text_selection_from_dom() || __get_node_selection_from_dom();
|
|
287
|
-
if (selection) {
|
|
288
|
-
doc.selection = selection;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Creates HTML clipboard format with embedded svedit data
|
|
294
|
-
* @param {Object} json_data - The svedit data to embed
|
|
295
|
-
* @param {string} fallback_html - HTML for cross-app compatibility
|
|
296
|
-
* @returns {string} HTML with embedded svedit data
|
|
297
|
-
*/
|
|
298
|
-
function create_svedit_html_format(json_data, fallback_html) {
|
|
299
|
-
// Use encodeURIComponent to handle Unicode, then base64 encode
|
|
300
|
-
const json_string = JSON.stringify(json_data);
|
|
301
|
-
const encoded_data = btoa(encodeURIComponent(json_string));
|
|
302
|
-
|
|
303
|
-
return `<meta charset="utf-8">
|
|
2
|
+
import { getContext, setContext } from 'svelte';
|
|
3
|
+
import {
|
|
4
|
+
snake_to_pascal,
|
|
5
|
+
get_char_length,
|
|
6
|
+
utf16_to_char_offset,
|
|
7
|
+
char_to_utf16_offset,
|
|
8
|
+
get_char_at
|
|
9
|
+
} from './utils.js';
|
|
10
|
+
|
|
11
|
+
/** @import {
|
|
12
|
+
* SveditProps,
|
|
13
|
+
* DocumentPath,
|
|
14
|
+
* Selection,
|
|
15
|
+
* TextSelection,
|
|
16
|
+
* NodeSelection,
|
|
17
|
+
* PropertySelection,
|
|
18
|
+
* NodeId
|
|
19
|
+
* } from './types.d.ts';
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** @type {SveditProps} */
|
|
23
|
+
let {
|
|
24
|
+
session,
|
|
25
|
+
editable = $bindable(false),
|
|
26
|
+
path,
|
|
27
|
+
class: css_class,
|
|
28
|
+
autocapitalize = 'on',
|
|
29
|
+
spellcheck = 'true'
|
|
30
|
+
} = $props();
|
|
31
|
+
|
|
32
|
+
let canvas_el;
|
|
33
|
+
let root_node = $derived(session.get(path));
|
|
34
|
+
let Overlays = $derived(session.config.system_components.Overlays);
|
|
35
|
+
let RootComponent = $derived(session.config.node_components[snake_to_pascal(root_node.type)]);
|
|
36
|
+
|
|
37
|
+
let is_composing = $state(false);
|
|
38
|
+
let before_composition_selection = null;
|
|
39
|
+
|
|
40
|
+
// let is_mobile = $derived(is_mobile_browser());
|
|
41
|
+
// let is_chrome_desktop = $derived(is_chrome_desktop_browser());
|
|
42
|
+
|
|
43
|
+
/** Expose function so parent can call it */
|
|
44
|
+
export { focus_canvas };
|
|
45
|
+
|
|
46
|
+
const context = {
|
|
47
|
+
get session() {
|
|
48
|
+
return session;
|
|
49
|
+
},
|
|
50
|
+
get editable() {
|
|
51
|
+
return editable;
|
|
52
|
+
},
|
|
53
|
+
set editable(value) {
|
|
54
|
+
editable = value;
|
|
55
|
+
},
|
|
56
|
+
get is_composing() {
|
|
57
|
+
return is_composing;
|
|
58
|
+
},
|
|
59
|
+
get canvas_el() {
|
|
60
|
+
return canvas_el;
|
|
61
|
+
},
|
|
62
|
+
focus_canvas
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
setContext('svedit', context);
|
|
66
|
+
|
|
67
|
+
// Get KeyMapper from context (may be undefined if not provided)
|
|
68
|
+
const key_mapper = getContext('key_mapper');
|
|
69
|
+
|
|
70
|
+
// Initialize commands and keymap on the session
|
|
71
|
+
session.initialize_commands(context);
|
|
72
|
+
|
|
73
|
+
// Handle focus - push session's keymap onto stack
|
|
74
|
+
function handle_canvas_focus() {
|
|
75
|
+
key_mapper?.push_scope(session.keymap);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Handle blur - pop document's keymap from stack
|
|
79
|
+
function handle_canvas_blur() {
|
|
80
|
+
key_mapper?.pop_scope();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {InputEvent} event
|
|
85
|
+
*/
|
|
86
|
+
async function onbeforeinput(event) {
|
|
87
|
+
// console.log(`onbeforeinput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
|
|
88
|
+
|
|
89
|
+
if (event.inputType === 'historyUndo' && is_composing) {
|
|
90
|
+
// Let the historyundo event pass through (when triggered from within oncompositionend)
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Sometimes the part that should be replaced is not the same as the current DOM selection
|
|
95
|
+
// that's why we look into event.getTargetRanges()[0] if it exists.
|
|
96
|
+
let target_selection;
|
|
97
|
+
if (event.getTargetRanges?.()?.[0]) {
|
|
98
|
+
target_selection = __get_text_selection_from_dom(event.getTargetRanges()[0]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// While composing, Svedit does nothing and lets the oncompositionend
|
|
102
|
+
// event handle the final replacement.
|
|
103
|
+
if (event.isComposing) {
|
|
104
|
+
// NOTE: We only capture the initial selection right after the composition started
|
|
105
|
+
// We're not interested in the target selections during the composition.
|
|
106
|
+
if (!before_composition_selection) {
|
|
107
|
+
before_composition_selection = target_selection;
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// NOTE: in cases we can't reliably map event.getTargetRanges()[0] to a session selection,
|
|
113
|
+
// the original session.selection is used.
|
|
114
|
+
if (target_selection) {
|
|
115
|
+
session.selection = target_selection;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Only take input when in a valid text selection inside the canvas
|
|
119
|
+
if (!canvas_el?.contains(document.activeElement)) {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (event.inputType === 'formatBold' && session.selection?.type === 'text') {
|
|
125
|
+
session.apply(session.tr.annotate_text('strong'));
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
event.stopPropagation();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (event.inputType === 'formatItalic' && session.selection?.type === 'text') {
|
|
131
|
+
session.apply(session.tr.annotate_text('emphasis'));
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
event.stopPropagation();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// NOTE: underline doesn't make much sense as a semantic annotation,
|
|
137
|
+
// so we rewire `cmd + u` to toggle highlights
|
|
138
|
+
if (event.inputType === 'formatUnderline' && session.selection?.type === 'text') {
|
|
139
|
+
session.apply(session.tr.annotate_text('highlight'));
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
['deleteContentBackward', 'deleteWordBackward', 'deleteContent'].includes(event.inputType)
|
|
146
|
+
) {
|
|
147
|
+
session.apply(session.tr.delete_selection('backward'));
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
event.stopPropagation();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (['deleteContentForward', 'deleteWordForward'].includes(event.inputType)) {
|
|
154
|
+
session.apply(session.tr.delete_selection('forward'));
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
event.stopPropagation();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// For now I reject drag+drop text movements.
|
|
161
|
+
// TODO: If I want to support those, I need to handle them in such a way that
|
|
162
|
+
// you can drag from one text property to another too.
|
|
163
|
+
if (event.inputType === 'deleteByDrag' || event.inputType === 'insertFromDrop') {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Insert the character, unless there is none.
|
|
169
|
+
let inserted_text = event.data;
|
|
170
|
+
|
|
171
|
+
// Sometimes (e.g. for replacements) the inserted_text is available via
|
|
172
|
+
// event.dataTransfer, not event.data
|
|
173
|
+
if (!inserted_text && event.dataTransfer) {
|
|
174
|
+
inserted_text = event.dataTransfer?.getData('text/plain');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Skip, if there's no inserted_text at all
|
|
178
|
+
if (!inserted_text) {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const tr = session.tr;
|
|
184
|
+
tr.insert_text(inserted_text);
|
|
185
|
+
session.apply(tr, { batch: true });
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Handles composition start events for input methods like dead keys
|
|
191
|
+
* This occurs when user starts typing a composed character (e.g., backtick for accents)
|
|
192
|
+
* @param {CompositionEvent} event
|
|
193
|
+
*/
|
|
194
|
+
function oncompositionstart(event) {
|
|
195
|
+
console.log('DEBUG: oncompositionstart', event.data);
|
|
196
|
+
if (session.selection.type !== 'text') {
|
|
197
|
+
// Remove all ranges - completely clears the selection
|
|
198
|
+
window.getSelection()?.removeAllRanges();
|
|
199
|
+
|
|
200
|
+
// Restore
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
render_selection();
|
|
203
|
+
}, 0);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Disable keydown event handling during composition. This way, you can confirm
|
|
208
|
+
// a diacritic (a->ä) with ENTER without causing a line break.
|
|
209
|
+
key_mapper.skip_onkeydown = true;
|
|
210
|
+
is_composing = true;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Handles composition end events for input methods like dead keys
|
|
216
|
+
* This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
|
|
217
|
+
*/
|
|
218
|
+
function oncompositionend(event) {
|
|
219
|
+
console.log('DEBUG: oncompositionend, insert:', event.data, event);
|
|
220
|
+
if (!canvas_el?.contains(document.activeElement)) return;
|
|
221
|
+
if (canvas_el?.contains(document.activeElement) && session.selection?.type === 'text') {
|
|
222
|
+
// We need to remember the user's selection, as it might have changed in the process
|
|
223
|
+
// of finishing a composition. For instance, the user might have selected a different
|
|
224
|
+
// part of the text while composing.
|
|
225
|
+
const user_selection = __get_selection_from_dom();
|
|
226
|
+
|
|
227
|
+
// HACK: In order to restore the DOM state from before composition, we just run contenteditable's
|
|
228
|
+
// native undo command. Then the DOM will be in sync again with the editor's internal state.
|
|
229
|
+
document.execCommand('undo', false, null);
|
|
230
|
+
|
|
231
|
+
// Set the selection to where the user initiated the composition, make changes, and apply.
|
|
232
|
+
// NOTE: We need to check for valid selection here, as there is a rare race condition
|
|
233
|
+
// where the user had no text selection at the start of composition.
|
|
234
|
+
if (before_composition_selection) {
|
|
235
|
+
session.selection = before_composition_selection;
|
|
236
|
+
console.log('event.data', event.data);
|
|
237
|
+
const tr = session.tr;
|
|
238
|
+
tr.insert_text(event.data);
|
|
239
|
+
session.apply(tr);
|
|
240
|
+
// Recover user selection after composition. This assumes that document positions of natively
|
|
241
|
+
// modified DOM (before transaction applied) are equal to the positions after the transaction.
|
|
242
|
+
session.selection = user_selection;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// NOTE: We need a little timeout to nudge Safari into not handling the
|
|
246
|
+
// ENTER press when confirming a diacritic
|
|
247
|
+
setTimeout(() => {
|
|
248
|
+
key_mapper.skip_onkeydown = false;
|
|
249
|
+
is_composing = false;
|
|
250
|
+
}, 100);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Reset before_composition_selection, so we are ready for capturing the starting selection
|
|
254
|
+
// of the next composition.
|
|
255
|
+
before_composition_selection = null;
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Map DOM selection to internal model
|
|
260
|
+
function onselectionchange() {
|
|
261
|
+
if (!editable) return;
|
|
262
|
+
const dom_selection = window.getSelection();
|
|
263
|
+
if (!dom_selection.rangeCount) return;
|
|
264
|
+
|
|
265
|
+
// Only handle selection changes if selection is within the canvas
|
|
266
|
+
const range = dom_selection.getRangeAt(0);
|
|
267
|
+
if (!canvas_el?.contains(range.commonAncestorContainer)) return;
|
|
268
|
+
let selection = __get_selection_from_dom();
|
|
269
|
+
if (selection) {
|
|
270
|
+
session.selection = selection;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Creates HTML clipboard format with embedded svedit data
|
|
276
|
+
* @param {Object} json_data - The svedit data to embed
|
|
277
|
+
* @param {string} fallback_html - HTML for cross-app compatibility
|
|
278
|
+
* @returns {string} HTML with embedded svedit data
|
|
279
|
+
*/
|
|
280
|
+
function create_svedit_html_format(json_data, fallback_html) {
|
|
281
|
+
// Use encodeURIComponent to handle Unicode, then base64 encode
|
|
282
|
+
const json_string = JSON.stringify(json_data);
|
|
283
|
+
const encoded_data = btoa(encodeURIComponent(json_string));
|
|
284
|
+
|
|
285
|
+
return `<meta charset="utf-8">
|
|
304
286
|
<div>
|
|
305
287
|
<span data-svedit="${encoded_data}"></span>
|
|
306
288
|
</div>
|
|
307
289
|
${fallback_html}`;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
const range = window.document.createRange();
|
|
1323
|
-
const dom_selection = window.getSelection();
|
|
1324
|
-
let current_offset = 0;
|
|
1325
|
-
/** @type {HTMLElement | Text} */
|
|
1326
|
-
let anchor_node;
|
|
1327
|
-
/** @type {HTMLElement | Text} */
|
|
1328
|
-
let focus_node;
|
|
1329
|
-
/** @type {number} */
|
|
1330
|
-
let anchor_node_offset
|
|
1331
|
-
/** @type {number} */
|
|
1332
|
-
let focus_node_offset;
|
|
1333
|
-
const is_backward = selection.anchor_offset > selection.focus_offset;
|
|
1334
|
-
const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
|
|
1335
|
-
const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
|
|
1336
|
-
|
|
1337
|
-
// Helper function to process each node
|
|
1338
|
-
function process_node(node) {
|
|
1339
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
1340
|
-
const node_text = node.textContent;
|
|
1341
|
-
const node_char_length = get_char_length(node_text);
|
|
1342
|
-
|
|
1343
|
-
if (is_backward) {
|
|
1344
|
-
if (!focus_node && current_offset + node_char_length >= start_offset) {
|
|
1345
|
-
focus_node = node;
|
|
1346
|
-
const char_offset = start_offset - current_offset;
|
|
1347
|
-
focus_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
1348
|
-
}
|
|
1349
|
-
} else {
|
|
1350
|
-
if (!anchor_node && current_offset + node_char_length >= start_offset) {
|
|
1351
|
-
anchor_node = node;
|
|
1352
|
-
const char_offset = start_offset - current_offset;
|
|
1353
|
-
anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
// Find end node
|
|
1358
|
-
if (is_backward) {
|
|
1359
|
-
if (!anchor_node && current_offset + node_char_length >= end_offset) {
|
|
1360
|
-
anchor_node = node;
|
|
1361
|
-
const char_offset = end_offset - current_offset;
|
|
1362
|
-
anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
1363
|
-
return true; // Stop iteration
|
|
1364
|
-
}
|
|
1365
|
-
} else {
|
|
1366
|
-
if (!focus_node && current_offset + node_char_length >= end_offset) {
|
|
1367
|
-
focus_node = node;
|
|
1368
|
-
const char_offset = end_offset - current_offset;
|
|
1369
|
-
focus_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
1370
|
-
return true; // Stop iteration
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
current_offset += node_char_length;
|
|
1374
|
-
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1375
|
-
for (const child_node of node.childNodes) {
|
|
1376
|
-
if (process_node(child_node)) return true; // Stop iteration if end found
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
return false; // Continue iteration
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
// EDGE CASE: When text is empty, we need to set a different DOM selection
|
|
1383
|
-
if (start_offset === end_offset && start_offset === 0 && empty_text) {
|
|
1384
|
-
// Markup for empty text looks like this `<div data-type="text"><br></div>`.
|
|
1385
|
-
// And the correct cursor position is after the <br> element.
|
|
1386
|
-
anchor_node = el;
|
|
1387
|
-
anchor_node_offset = 1;
|
|
1388
|
-
focus_node = el;
|
|
1389
|
-
focus_node_offset = 1;
|
|
1390
|
-
} else {
|
|
1391
|
-
// DEFAULT CASE
|
|
1392
|
-
for (const child_node of el.childNodes) {
|
|
1393
|
-
if (process_node(child_node)) break;
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
// Set the range if both start and end were found
|
|
1398
|
-
if (anchor_node && focus_node) {
|
|
1399
|
-
// Always set range in document order (start to end)
|
|
1400
|
-
if (is_backward) {
|
|
1401
|
-
range.setStart(focus_node, focus_node_offset);
|
|
1402
|
-
range.setEnd(anchor_node, anchor_node_offset);
|
|
1403
|
-
} else {
|
|
1404
|
-
range.setStart(anchor_node, anchor_node_offset);
|
|
1405
|
-
range.setEnd(focus_node, focus_node_offset);
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
dom_selection.removeAllRanges();
|
|
1409
|
-
if (is_backward) {
|
|
1410
|
-
// For backward selections, collapse to end and extend to start
|
|
1411
|
-
range.collapse(false); // collapse to end
|
|
1412
|
-
dom_selection.addRange(range);
|
|
1413
|
-
dom_selection.extend(focus_node, focus_node_offset);
|
|
1414
|
-
} else {
|
|
1415
|
-
dom_selection.addRange(range);
|
|
1416
|
-
}
|
|
1417
|
-
el.focus(); // needed?
|
|
1418
|
-
|
|
1419
|
-
// Scroll the selection into view
|
|
1420
|
-
setTimeout(() => {
|
|
1421
|
-
const selectedElement = dom_selection.focusNode.parentElement;
|
|
1422
|
-
if (selectedElement) {
|
|
1423
|
-
selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1424
|
-
}
|
|
1425
|
-
}, 0);
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
// Utils
|
|
1430
|
-
// --------------------------
|
|
1431
|
-
|
|
1432
|
-
function __is_dom_selection_backwards() {
|
|
1433
|
-
const dom_selection = window.getSelection();
|
|
1434
|
-
|
|
1435
|
-
// If there's no dom_selection, return false
|
|
1436
|
-
if (dom_selection.rangeCount === 0) return false;
|
|
1437
|
-
|
|
1438
|
-
// Get the range of the dom_selection
|
|
1439
|
-
const range = dom_selection.getRangeAt(0);
|
|
1440
|
-
|
|
1441
|
-
if (range.collapsed) return false;
|
|
1442
|
-
|
|
1443
|
-
// Create a new range for comparison
|
|
1444
|
-
const comparisonRange = range.cloneRange();
|
|
1445
|
-
|
|
1446
|
-
// Set the comparison range to start at the dom_selection's anchor and end at its focus
|
|
1447
|
-
comparisonRange.setStart(dom_selection.anchorNode, dom_selection.anchorOffset);
|
|
1448
|
-
comparisonRange.setEnd(dom_selection.focusNode, dom_selection.focusOffset);
|
|
1449
|
-
|
|
1450
|
-
// If the comparison range is collapsed, the selection is backwards
|
|
1451
|
-
return comparisonRange.collapsed;
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// Whenever the model selection changes, render the selection
|
|
1455
|
-
$effect(() => {
|
|
1456
|
-
render_selection();
|
|
1457
|
-
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Extracts svedit data from HTML clipboard format
|
|
294
|
+
* @param {string} html - HTML content from clipboard
|
|
295
|
+
* @returns {Object|null} Parsed svedit data or null if not found
|
|
296
|
+
*/
|
|
297
|
+
function extract_svedit_data_from_html(html) {
|
|
298
|
+
const svedit_regex = /data-svedit="([^"]+)"/;
|
|
299
|
+
const match = html.match(svedit_regex);
|
|
300
|
+
|
|
301
|
+
if (match && match[1]) {
|
|
302
|
+
try {
|
|
303
|
+
// Decode base64, then decode URI component to handle Unicode
|
|
304
|
+
const base64_decoded = atob(match[1]);
|
|
305
|
+
const decoded_data = decodeURIComponent(base64_decoded);
|
|
306
|
+
return JSON.parse(decoded_data);
|
|
307
|
+
} catch (e) {
|
|
308
|
+
console.warn('Failed to decode svedit data from HTML:', e);
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Default node exporter for nodes without specific exporters
|
|
318
|
+
* @param {Object} node - Node object
|
|
319
|
+
* @returns {string} HTML representation
|
|
320
|
+
*/
|
|
321
|
+
function default_node_html_exporter(node, session, html_exporters) {
|
|
322
|
+
let html = '';
|
|
323
|
+
const node_schema = session.schema[node.type];
|
|
324
|
+
|
|
325
|
+
for (const [prop_name, prop_value] of Object.entries(node)) {
|
|
326
|
+
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
327
|
+
const property_definition = node_schema.properties[prop_name];
|
|
328
|
+
// Check if this is an annotated_text property (object with text property)
|
|
329
|
+
if (property_definition.type === 'annotated_text') {
|
|
330
|
+
const text_content = prop_value.text;
|
|
331
|
+
if (text_content.trim()) {
|
|
332
|
+
html += `<p>${text_content}</p>`;
|
|
333
|
+
}
|
|
334
|
+
} else if (property_definition.type === 'node_array') {
|
|
335
|
+
for (const child_id of prop_value) {
|
|
336
|
+
const child = session.get(child_id);
|
|
337
|
+
const child_exporter = html_exporters[child.type] || default_node_html_exporter;
|
|
338
|
+
html += child_exporter(child, session, html_exporters);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return html;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function default_node_plain_text_exporter(node) {
|
|
346
|
+
let plain_text = '';
|
|
347
|
+
|
|
348
|
+
for (const [prop_name, prop_value] of Object.entries(node)) {
|
|
349
|
+
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
350
|
+
|
|
351
|
+
// Check if this is an annotated_text property (object with text property)
|
|
352
|
+
if (
|
|
353
|
+
typeof prop_value === 'object' &&
|
|
354
|
+
prop_value !== null &&
|
|
355
|
+
typeof prop_value.text === 'string'
|
|
356
|
+
) {
|
|
357
|
+
const text_content = prop_value.text;
|
|
358
|
+
if (text_content.trim()) {
|
|
359
|
+
plain_text += `${text_content.trim()}\n\n`;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return plain_text;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Exports nodes to HTML using document config exporters
|
|
369
|
+
* @param {Object[]} nodes - Array of node objects
|
|
370
|
+
* @returns {string} HTML representation
|
|
371
|
+
*/
|
|
372
|
+
function export_html(nodes) {
|
|
373
|
+
let html = '';
|
|
374
|
+
|
|
375
|
+
for (const node of nodes) {
|
|
376
|
+
const html_exporters = session.config.html_exporters || {};
|
|
377
|
+
|
|
378
|
+
if (html_exporters[node.type]) {
|
|
379
|
+
// Use custom exporter for this node type
|
|
380
|
+
html += html_exporters[node.type](node, session, html_exporters);
|
|
381
|
+
} else {
|
|
382
|
+
// Use default exporter
|
|
383
|
+
html += default_node_html_exporter(node, session, html_exporters);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return html;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function export_plain_text(nodes) {
|
|
390
|
+
let plain_text = '';
|
|
391
|
+
|
|
392
|
+
for (const node of nodes) {
|
|
393
|
+
plain_text += default_node_plain_text_exporter(node);
|
|
394
|
+
}
|
|
395
|
+
return plain_text.trim();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* @param {NodeId[]} selected_node_ids
|
|
400
|
+
*/
|
|
401
|
+
function prepare_copy_payload(selected_node_ids) {
|
|
402
|
+
const nodes = {};
|
|
403
|
+
|
|
404
|
+
// Get subgraph for each selected node using session.traverse()
|
|
405
|
+
for (const node_id of selected_node_ids) {
|
|
406
|
+
const subgraph = session.traverse(node_id);
|
|
407
|
+
|
|
408
|
+
// Add all nodes from this subgraph to our nodes collection
|
|
409
|
+
for (const node of subgraph) {
|
|
410
|
+
if (!nodes[node.id]) {
|
|
411
|
+
nodes[node.id] = node;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Keep original IDs - we'll generate new ones during paste
|
|
417
|
+
return { nodes, main_nodes: selected_node_ids };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* @param {ClipboardEvent} event
|
|
422
|
+
* @param {boolean} delete_selection - used by oncut()
|
|
423
|
+
*/
|
|
424
|
+
function oncopy(event, delete_selection = false) {
|
|
425
|
+
// Only handle copy events if focus is within the canvas
|
|
426
|
+
if (!canvas_el?.contains(document.activeElement)) return;
|
|
427
|
+
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
event.stopPropagation();
|
|
430
|
+
|
|
431
|
+
let plain_text, annotated_text, html;
|
|
432
|
+
|
|
433
|
+
if (session.selection?.type === 'text') {
|
|
434
|
+
plain_text = session.get_selected_plain_text();
|
|
435
|
+
annotated_text = session.get_selected_annotated_text();
|
|
436
|
+
const fallback_html = `<span>${annotated_text.text}</span>`;
|
|
437
|
+
|
|
438
|
+
console.log('Text copy:', {
|
|
439
|
+
annotated_text,
|
|
440
|
+
plain_text,
|
|
441
|
+
html
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
html = create_svedit_html_format(annotated_text, fallback_html);
|
|
445
|
+
} else if (session.selection?.type === 'node') {
|
|
446
|
+
const selected_nodes = session.get_selected_nodes();
|
|
447
|
+
const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
|
|
448
|
+
|
|
449
|
+
const json_data = { nodes, main_nodes };
|
|
450
|
+
|
|
451
|
+
console.log('Node copy:', {
|
|
452
|
+
selected_nodes,
|
|
453
|
+
nodes,
|
|
454
|
+
total_nodes: Object.keys(nodes).length,
|
|
455
|
+
operation: delete_selection ? 'cut' : 'copy'
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Generate fallback HTML for cross-app compatibility
|
|
459
|
+
const selected_node_objects = main_nodes.map((id) => nodes[id]);
|
|
460
|
+
const fallback_html = export_html(selected_node_objects);
|
|
461
|
+
|
|
462
|
+
// Create HTML with embedded svedit data
|
|
463
|
+
html = create_svedit_html_format(json_data, fallback_html);
|
|
464
|
+
// Generate plain text representation
|
|
465
|
+
plain_text = export_plain_text(selected_node_objects);
|
|
466
|
+
} else if (session.selection?.type === 'property') {
|
|
467
|
+
const property_definition = session.inspect(session.selection.path);
|
|
468
|
+
const value = session.get(session.selection.path);
|
|
469
|
+
const json_data = {
|
|
470
|
+
kind: 'property',
|
|
471
|
+
name: property_definition.name,
|
|
472
|
+
type: property_definition.type,
|
|
473
|
+
value
|
|
474
|
+
};
|
|
475
|
+
console.log('Property copy:', json_data);
|
|
476
|
+
html = create_svedit_html_format(json_data, `<span>${value}</span>`);
|
|
477
|
+
plain_text = String(value);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Write to clipboard using event.clipboardData
|
|
481
|
+
try {
|
|
482
|
+
event.clipboardData?.setData('text/plain', plain_text || '');
|
|
483
|
+
event.clipboardData?.setData('text/html', html || '');
|
|
484
|
+
console.log('Data copied to clipboard successfully');
|
|
485
|
+
} catch (err) {
|
|
486
|
+
console.error('Failed to copy data: ', err);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (delete_selection) {
|
|
490
|
+
session.apply(session.tr.delete_selection());
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function oncut(event) {
|
|
495
|
+
oncopy(event, true);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Attempts to paste JSON data as a node at the current selection.
|
|
500
|
+
*
|
|
501
|
+
* @param {string|object} pasted_json - The JSON data to paste, either as a string or parsed object
|
|
502
|
+
* @param {Selection} [selection] - Optional selection (node cursor) where the payload should be pasted
|
|
503
|
+
* @returns {boolean} True if the paste operation was successful, false otherwise
|
|
504
|
+
*/
|
|
505
|
+
function try_node_paste(pasted_json, selection) {
|
|
506
|
+
const { nodes, main_nodes } = pasted_json;
|
|
507
|
+
|
|
508
|
+
// NOTE: At this point, nodes contains a subgraph from the copy
|
|
509
|
+
// with original ids.
|
|
510
|
+
let tr = session.tr;
|
|
511
|
+
if (selection) {
|
|
512
|
+
tr.set_selection(selection);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// We can safely assume we're dealing with a node_array property
|
|
516
|
+
const property_definition = session.inspect(tr.selection.path);
|
|
517
|
+
const first_compatible_text_node_type = property_definition.node_types.find(
|
|
518
|
+
(type) => session.kind({ type }) === 'text'
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
const nodes_to_insert = [];
|
|
522
|
+
let rejected = false;
|
|
523
|
+
for (const node_id of main_nodes) {
|
|
524
|
+
const node = nodes[node_id];
|
|
525
|
+
if (!property_definition.node_types.includes(node.type)) {
|
|
526
|
+
// Incompatible node type detected
|
|
527
|
+
if (session.kind(node) === 'text' && first_compatible_text_node_type) {
|
|
528
|
+
const new_node_id = tr.build('the_node', {
|
|
529
|
+
the_node: {
|
|
530
|
+
id: 'the_node',
|
|
531
|
+
type: first_compatible_text_node_type,
|
|
532
|
+
content: node.content
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
nodes_to_insert.push(new_node_id);
|
|
536
|
+
} else {
|
|
537
|
+
console.log(
|
|
538
|
+
`rejected ${node.type}. Only ${property_definition.node_types.join(', ')} allowed.`
|
|
539
|
+
);
|
|
540
|
+
rejected = true;
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
const new_node_id = tr.build(node_id, nodes);
|
|
545
|
+
nodes_to_insert.push(new_node_id);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (!rejected) {
|
|
550
|
+
tr.insert_nodes(nodes_to_insert);
|
|
551
|
+
session.apply(tr);
|
|
552
|
+
return true;
|
|
553
|
+
} else {
|
|
554
|
+
if (tr.selection.path.length >= 2) {
|
|
555
|
+
const next_node_insert_cursor = session.get_next_node_insert_cursor(tr.selection);
|
|
556
|
+
if (next_node_insert_cursor) {
|
|
557
|
+
try_node_paste(pasted_json, next_node_insert_cursor);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function onpaste(event) {
|
|
565
|
+
// Only handle paste events if focus is within the canvas
|
|
566
|
+
if (!canvas_el?.contains(document.activeElement)) return;
|
|
567
|
+
event.preventDefault();
|
|
568
|
+
|
|
569
|
+
let plain_text,
|
|
570
|
+
pasted_json,
|
|
571
|
+
pasted_images = [];
|
|
572
|
+
|
|
573
|
+
// NOTE: For some reason, await navigator.clipboard.read()
|
|
574
|
+
const clipboard_items = event.clipboardData?.items || [];
|
|
575
|
+
for (const item of clipboard_items || []) {
|
|
576
|
+
if (item.type.startsWith('image/')) {
|
|
577
|
+
const blob = item.getAsFile();
|
|
578
|
+
const data_url = URL.createObjectURL(blob);
|
|
579
|
+
pasted_images.push({
|
|
580
|
+
blob,
|
|
581
|
+
data_url,
|
|
582
|
+
type: item.type,
|
|
583
|
+
size: blob.size
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (pasted_images.length > 0) {
|
|
589
|
+
pasted_json = await session.config.handle_image_paste(session, pasted_images);
|
|
590
|
+
console.log('pasted_json_after_image_paste', pasted_json);
|
|
591
|
+
// NOTE: If no pasted_json is returned from the custom handler, we assume that content creation has been
|
|
592
|
+
// handled inside handle_image_paste already.
|
|
593
|
+
if (!pasted_json) return;
|
|
594
|
+
} else {
|
|
595
|
+
// First try to extract svedit data from HTML format
|
|
596
|
+
try {
|
|
597
|
+
const html_content = event.clipboardData?.getData('text/html');
|
|
598
|
+
if (html_content) {
|
|
599
|
+
pasted_json = extract_svedit_data_from_html(html_content);
|
|
600
|
+
}
|
|
601
|
+
} catch (e) {
|
|
602
|
+
console.log('No HTML format available or failed to extract svedit data:', e);
|
|
603
|
+
pasted_json = undefined;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
plain_text = event.clipboardData?.getData('text/plain');
|
|
608
|
+
} catch (e) {
|
|
609
|
+
console.error('Failed to paste any content:', e);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Try to contruct a node payload from plain text when applicable
|
|
613
|
+
if (!pasted_json && typeof plain_text === 'string') {
|
|
614
|
+
const plain_text_fragments = plain_text
|
|
615
|
+
.split('\n\n')
|
|
616
|
+
.map((fragment) => fragment.trim())
|
|
617
|
+
.filter(Boolean);
|
|
618
|
+
if (plain_text_fragments.length > 1) {
|
|
619
|
+
pasted_json = {
|
|
620
|
+
main_nodes: [],
|
|
621
|
+
nodes: []
|
|
622
|
+
};
|
|
623
|
+
for (let i = 0; i < plain_text_fragments.length; i++) {
|
|
624
|
+
const fragment = plain_text_fragments[i];
|
|
625
|
+
pasted_json.nodes['fragment_' + i] = {
|
|
626
|
+
id: 'fragment_' + i,
|
|
627
|
+
type: 'text',
|
|
628
|
+
content: {
|
|
629
|
+
text: fragment,
|
|
630
|
+
annotations: []
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
pasted_json.main_nodes.push('fragment_' + i);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
console.log('plain_text', plain_text);
|
|
640
|
+
console.log('pasted_json', pasted_json);
|
|
641
|
+
|
|
642
|
+
if (pasted_json?.main_nodes && session.selection?.type === 'node') {
|
|
643
|
+
// Paste nodes at a node selection
|
|
644
|
+
try_node_paste(pasted_json);
|
|
645
|
+
} else if (pasted_json?.kind === 'property' && session.selection?.type === 'property') {
|
|
646
|
+
const property_definition = session.inspect(session.selection.path);
|
|
647
|
+
if (property_definition.type === pasted_json.type) {
|
|
648
|
+
if (property_definition.type === 'node') {
|
|
649
|
+
const tr = session.tr;
|
|
650
|
+
const new_id = tr.build('some_new_node_id', {
|
|
651
|
+
some_new_node_id: {
|
|
652
|
+
...pasted_json.value,
|
|
653
|
+
id: 'some_new_node_id'
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
tr.set(session.selection.path, new_id);
|
|
657
|
+
session.apply(tr);
|
|
658
|
+
} else {
|
|
659
|
+
// we assume that we have a value type for the property (string, number)
|
|
660
|
+
session.apply(session.tr.set(session.selection.path, pasted_json.value));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
} else if (session.selection?.type === 'text' && pasted_json?.text) {
|
|
664
|
+
// Paste text at a text selection
|
|
665
|
+
session.apply(
|
|
666
|
+
session.tr.insert_text(pasted_json.text, pasted_json.annotations, pasted_json.nodes)
|
|
667
|
+
);
|
|
668
|
+
} else if (
|
|
669
|
+
session.selection?.type === 'text' &&
|
|
670
|
+
pasted_json?.main_nodes?.length === 1 &&
|
|
671
|
+
session.kind(pasted_json?.nodes[pasted_json.main_nodes[0]]) === 'text'
|
|
672
|
+
) {
|
|
673
|
+
// Paste a single text node, at a text cursor
|
|
674
|
+
const text_property = pasted_json.nodes[pasted_json.main_nodes[0]].content;
|
|
675
|
+
session.apply(
|
|
676
|
+
session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
|
|
677
|
+
);
|
|
678
|
+
} else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
|
|
679
|
+
// Paste nodes at a text or property selection by finding the next valid insert cursor
|
|
680
|
+
const next_node_insert_cursor = session.get_next_node_insert_cursor(session.selection);
|
|
681
|
+
try_node_paste(pasted_json, next_node_insert_cursor);
|
|
682
|
+
} else if (typeof plain_text === 'string') {
|
|
683
|
+
// External paste: Fallback to plain text when no svedit data is found
|
|
684
|
+
session.apply(session.tr.insert_text(plain_text.trim()));
|
|
685
|
+
} else {
|
|
686
|
+
console.log('Could not paste.');
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function render_selection() {
|
|
691
|
+
const selection = /** @type {Selection} */ (session.selection);
|
|
692
|
+
let prev_selection =
|
|
693
|
+
__get_property_selection_from_dom() ||
|
|
694
|
+
__get_text_selection_from_dom() ||
|
|
695
|
+
__get_node_selection_from_dom();
|
|
696
|
+
|
|
697
|
+
if (!selection) {
|
|
698
|
+
// No model selection -> just leave things as they are'
|
|
699
|
+
let dom_selection = window.getSelection();
|
|
700
|
+
dom_selection.removeAllRanges();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// NOTE: Skip rerender only when the selection is the same and the focus is already within the canvas
|
|
705
|
+
if (
|
|
706
|
+
JSON.stringify(selection) === JSON.stringify(prev_selection) &&
|
|
707
|
+
canvas_el?.contains(document.activeElement)
|
|
708
|
+
) {
|
|
709
|
+
// Skip. No need to rerender.
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (selection?.type === 'text') {
|
|
714
|
+
__render_text_selection();
|
|
715
|
+
} else if (selection?.type === 'node') {
|
|
716
|
+
__render_node_selection();
|
|
717
|
+
} else if (selection?.type === 'property') {
|
|
718
|
+
__render_property_selection();
|
|
719
|
+
} else {
|
|
720
|
+
console.log('unsupported selection', $state.snapshot(selection));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function focus_canvas() {
|
|
725
|
+
// We just render the selection (which will return focus to the canvas) implicitly
|
|
726
|
+
render_selection();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Extracts a NodeSelection from the current DOM selection.
|
|
731
|
+
*
|
|
732
|
+
*
|
|
733
|
+
* @returns {NodeSelection | null} A NodeSelection object if the DOM selection
|
|
734
|
+
* represents a valid node selection, null otherwise
|
|
735
|
+
*/
|
|
736
|
+
function __get_node_selection_from_dom() {
|
|
737
|
+
const dom_selection = window.getSelection();
|
|
738
|
+
if (dom_selection.rangeCount === 0) return null;
|
|
739
|
+
|
|
740
|
+
let focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
|
|
741
|
+
let anchor_node = /** @type {HTMLElement} */ (dom_selection.anchorNode);
|
|
742
|
+
|
|
743
|
+
// If focus_node or anchor_node not an element node (e.g. a text node), we need
|
|
744
|
+
// to use the parent element, so we can perform the closest() query on it.
|
|
745
|
+
if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
|
|
746
|
+
if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
|
|
747
|
+
|
|
748
|
+
// EDGE CASE: Let's first check if we are in a cursor trap for node cursors
|
|
749
|
+
let after_node_cursor_trap =
|
|
750
|
+
focus_node && focus_node?.closest('[data-type="after-node-cursor-trap"]');
|
|
751
|
+
if (after_node_cursor_trap && focus_node === anchor_node) {
|
|
752
|
+
// Find the node that this cursor trap belongs to
|
|
753
|
+
let node = /** @type {HTMLElement} */ (after_node_cursor_trap.closest('[data-type="node"]'));
|
|
754
|
+
if (!node) {
|
|
755
|
+
console.log('No corresponding node found for after-node-cursor-trap');
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
|
|
759
|
+
const node_index = parseInt(String(node_path.at(-1)), 10);
|
|
760
|
+
|
|
761
|
+
return {
|
|
762
|
+
type: 'node',
|
|
763
|
+
path: node_path.slice(0, -1),
|
|
764
|
+
anchor_offset: node_index + 1,
|
|
765
|
+
focus_offset: node_index + 1
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// EDGE CASE: Let's check if we are in a position-zero-cursor-trap for node_arrays.
|
|
770
|
+
let position_zero_cursor_trap = focus_node.closest('[data-type="position-zero-cursor-trap"]');
|
|
771
|
+
if (position_zero_cursor_trap && focus_node === anchor_node) {
|
|
772
|
+
const node_array_el = /** @type {HTMLElement} */ (
|
|
773
|
+
position_zero_cursor_trap.closest('[data-type="node_array"]')
|
|
774
|
+
);
|
|
775
|
+
const node_array_path = node_array_el.dataset.path.split('.');
|
|
776
|
+
return {
|
|
777
|
+
type: 'node',
|
|
778
|
+
path: node_array_path,
|
|
779
|
+
anchor_offset: 0,
|
|
780
|
+
focus_offset: 0
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
let focus_root = /** @type {HTMLElement} */ (
|
|
785
|
+
focus_node.closest('[data-path][data-type="node"]')
|
|
786
|
+
);
|
|
787
|
+
if (!focus_root) return null;
|
|
788
|
+
|
|
789
|
+
let anchor_root = /** @type {HTMLElement} */ (
|
|
790
|
+
anchor_node.closest('[data-path][data-type="node"]')
|
|
791
|
+
);
|
|
792
|
+
if (!anchor_root) return null;
|
|
793
|
+
|
|
794
|
+
if (!(focus_root && anchor_root)) {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
let focus_root_path = focus_root.dataset.path.split('.');
|
|
799
|
+
let anchor_root_path = anchor_root.dataset.path.split('.');
|
|
800
|
+
let focus_node_depth = focus_root_path.length;
|
|
801
|
+
let anchor_node_depth = anchor_root_path.length;
|
|
802
|
+
|
|
803
|
+
// HACK: this works only for one level nesting - should be done recursively to work generally
|
|
804
|
+
if (focus_root_path.length > anchor_root_path.length) {
|
|
805
|
+
focus_root = focus_root.parentElement.closest('[data-path][data-type="node"]');
|
|
806
|
+
if (!focus_root) return null;
|
|
807
|
+
focus_root_path = focus_root.dataset.path.split('.');
|
|
808
|
+
} else if (anchor_root_path.length > focus_root_path.length) {
|
|
809
|
+
anchor_root = anchor_root.parentElement.closest('[data-path][data-type="node"]');
|
|
810
|
+
if (!anchor_root) return null;
|
|
811
|
+
anchor_root_path = anchor_root.dataset.path.split('.');
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const is_same_node_array =
|
|
815
|
+
focus_root_path.slice(0, -1).join('.') === anchor_root_path.slice(0, -1).join('.');
|
|
816
|
+
if (!is_same_node_array) {
|
|
817
|
+
console.log('invalid selection, not same node_array');
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
let anchor_offset = parseInt(anchor_root_path.at(-1));
|
|
822
|
+
let focus_offset = parseInt(focus_root_path.at(-1));
|
|
823
|
+
|
|
824
|
+
// Check if it's a backwards selection
|
|
825
|
+
const is_backwards = __is_dom_selection_backwards();
|
|
826
|
+
if (is_backwards) {
|
|
827
|
+
anchor_offset += 1;
|
|
828
|
+
} else {
|
|
829
|
+
focus_offset += 1;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// EDGE CASE: Exclude first node when anchor_node is an afer-node-cursor-trap
|
|
833
|
+
// in a non-collapsed forward selection
|
|
834
|
+
if (
|
|
835
|
+
anchor_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
|
|
836
|
+
!is_backwards &&
|
|
837
|
+
anchor_offset !== focus_offset
|
|
838
|
+
) {
|
|
839
|
+
anchor_offset += 1;
|
|
840
|
+
}
|
|
841
|
+
// EDGE CASE: Exclude first node when focus_node is an afer-node-cursor-trap
|
|
842
|
+
// in a non-collapsed backward selection
|
|
843
|
+
else if (
|
|
844
|
+
focus_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
|
|
845
|
+
is_backwards &&
|
|
846
|
+
anchor_offset !== focus_offset &&
|
|
847
|
+
// EDGE CASE: Only do correction when drag started from a deeper or equally deep anchor node
|
|
848
|
+
anchor_node_depth >= focus_node_depth
|
|
849
|
+
) {
|
|
850
|
+
focus_offset += 1;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return {
|
|
854
|
+
type: 'node',
|
|
855
|
+
path: anchor_root_path.slice(0, -1),
|
|
856
|
+
anchor_offset: anchor_offset,
|
|
857
|
+
focus_offset: focus_offset
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Extracts a PropertySelection from the current DOM selection.
|
|
863
|
+
*
|
|
864
|
+
*
|
|
865
|
+
* @returns {PropertySelection | null} A PropertySelection object if the DOM selection
|
|
866
|
+
* represents a valid property selection, null otherwise
|
|
867
|
+
*/
|
|
868
|
+
function __get_property_selection_from_dom() {
|
|
869
|
+
const dom_selection = window.getSelection();
|
|
870
|
+
if (dom_selection.rangeCount === 0) return null;
|
|
871
|
+
|
|
872
|
+
let focus_root = /** @type {HTMLElement} */ (
|
|
873
|
+
dom_selection.focusNode.parentElement?.closest('[data-path][data-type="property"]')
|
|
874
|
+
);
|
|
875
|
+
if (!focus_root) return null;
|
|
876
|
+
let anchor_root = /** @type {HTMLElement} */ (
|
|
877
|
+
dom_selection.anchorNode.parentElement?.closest('[data-path][data-type="property"]')
|
|
878
|
+
);
|
|
879
|
+
if (!anchor_root) return null;
|
|
880
|
+
|
|
881
|
+
if (focus_root === anchor_root) {
|
|
882
|
+
return {
|
|
883
|
+
type: 'property',
|
|
884
|
+
path: focus_root.dataset.path.split('.')
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function __get_selection_from_dom() {
|
|
891
|
+
return (
|
|
892
|
+
__get_property_selection_from_dom() ||
|
|
893
|
+
__get_text_selection_from_dom() ||
|
|
894
|
+
__get_node_selection_from_dom()
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Extracts a TextSelection from the current DOM selection.
|
|
900
|
+
*
|
|
901
|
+
*
|
|
902
|
+
* @returns {TextSelection | null} A TextSelection object if the DOM selection
|
|
903
|
+
* represents a valid text selection, null otherwise
|
|
904
|
+
*/
|
|
905
|
+
function __get_text_selection_from_dom(range = null) {
|
|
906
|
+
let dom_selection;
|
|
907
|
+
let focus_node, anchor_node;
|
|
908
|
+
let focus_offset_in_node; // anchor_offset_in_node;
|
|
909
|
+
|
|
910
|
+
if (range) {
|
|
911
|
+
// When range is provided, use it directly
|
|
912
|
+
focus_node = range.endContainer;
|
|
913
|
+
anchor_node = range.startContainer;
|
|
914
|
+
focus_offset_in_node = range.endOffset;
|
|
915
|
+
// anchor_offset_in_node = range.startOffset;
|
|
916
|
+
} else {
|
|
917
|
+
// Otherwise get from window selection
|
|
918
|
+
dom_selection = window.getSelection();
|
|
919
|
+
if (dom_selection.rangeCount === 0) return null;
|
|
920
|
+
focus_node = dom_selection.focusNode;
|
|
921
|
+
anchor_node = dom_selection.anchorNode;
|
|
922
|
+
focus_offset_in_node = dom_selection.focusOffset;
|
|
923
|
+
// anchor_offset_in_node = dom_selection.anchorOffset;
|
|
924
|
+
range = dom_selection.getRangeAt(0);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
let focus_root, anchor_root;
|
|
928
|
+
|
|
929
|
+
if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
|
|
930
|
+
// EDGE CASE 1: Either text node is empty (only a <br> is present), or cursor is after a <br> at the very end of the text node
|
|
931
|
+
focus_root = anchor_root = focus_node;
|
|
932
|
+
} else {
|
|
933
|
+
focus_root = /** @type {HTMLElement} */ (
|
|
934
|
+
focus_node.parentElement?.closest('[data-path][data-type="text"]')
|
|
935
|
+
);
|
|
936
|
+
if (!focus_root) return null;
|
|
937
|
+
anchor_root = /** @type {HTMLElement} */ (
|
|
938
|
+
anchor_node.parentElement?.closest('[data-path][data-type="text"]')
|
|
939
|
+
);
|
|
940
|
+
if (!anchor_root) return null;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (focus_root !== anchor_root) {
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const path = focus_root.dataset.path.split('.');
|
|
948
|
+
|
|
949
|
+
if (!path) return null;
|
|
950
|
+
|
|
951
|
+
// EDGE CASE 1B: Cursor after trailing <br> at end of text
|
|
952
|
+
const text_content = session.get(path).text;
|
|
953
|
+
const text_length = get_char_length(text_content);
|
|
954
|
+
if (text_length > 0) {
|
|
955
|
+
const last_char = get_char_at(text_content, text_length - 1);
|
|
956
|
+
if (
|
|
957
|
+
focus_node === anchor_node &&
|
|
958
|
+
focus_node === focus_root &&
|
|
959
|
+
focus_root.dataset?.type === 'text' &&
|
|
960
|
+
!focus_root.classList.contains('empty') &&
|
|
961
|
+
last_char === '\n'
|
|
962
|
+
) {
|
|
963
|
+
const child_nodes = focus_root.childNodes;
|
|
964
|
+
|
|
965
|
+
if (
|
|
966
|
+
child_nodes.length > 0 &&
|
|
967
|
+
child_nodes[child_nodes.length - 1].nodeName === 'BR' &&
|
|
968
|
+
focus_offset_in_node >= child_nodes.length - 2
|
|
969
|
+
) {
|
|
970
|
+
return {
|
|
971
|
+
type: 'text',
|
|
972
|
+
path,
|
|
973
|
+
anchor_offset: text_length,
|
|
974
|
+
focus_offset: text_length
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
let anchor_offset = 0;
|
|
981
|
+
let focus_offset = 0;
|
|
982
|
+
let current_offset = 0;
|
|
983
|
+
|
|
984
|
+
function processNode(node) {
|
|
985
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
986
|
+
const nodeText = node.textContent;
|
|
987
|
+
const nodeCharLength = get_char_length(nodeText);
|
|
988
|
+
if (node === range.startContainer) {
|
|
989
|
+
// Convert UTF-16 offset to character offset
|
|
990
|
+
const char_start_offset = utf16_to_char_offset(nodeText, range.startOffset);
|
|
991
|
+
anchor_offset = current_offset + char_start_offset;
|
|
992
|
+
}
|
|
993
|
+
if (node === range.endContainer) {
|
|
994
|
+
// Convert UTF-16 offset to character offset
|
|
995
|
+
const char_end_offset = utf16_to_char_offset(nodeText, range.endOffset);
|
|
996
|
+
focus_offset = current_offset + char_end_offset;
|
|
997
|
+
}
|
|
998
|
+
current_offset += nodeCharLength;
|
|
999
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1000
|
+
for (const childNode of node.childNodes) {
|
|
1001
|
+
processNode(childNode);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return focus_offset !== 0;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Process nodes to find offsets
|
|
1008
|
+
for (const childNode of focus_root.childNodes) {
|
|
1009
|
+
if (processNode(childNode)) break;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Check if it's a backward selection
|
|
1013
|
+
// When range is provided, we can't detect backward selection from the range alone
|
|
1014
|
+
// since ranges are always normalized (start before end)
|
|
1015
|
+
const is_backward = dom_selection
|
|
1016
|
+
? dom_selection.anchorNode === range.endContainer &&
|
|
1017
|
+
dom_selection.anchorOffset === range.endOffset
|
|
1018
|
+
: false;
|
|
1019
|
+
|
|
1020
|
+
// Swap offsets if it's a backward selection
|
|
1021
|
+
if (is_backward) {
|
|
1022
|
+
[anchor_offset, focus_offset] = [focus_offset, anchor_offset];
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return {
|
|
1026
|
+
type: 'text',
|
|
1027
|
+
path,
|
|
1028
|
+
anchor_offset: anchor_offset,
|
|
1029
|
+
focus_offset: focus_offset
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function __get_node_element(node_array_path, node_offset) {
|
|
1034
|
+
const node_array_el = canvas_el.querySelector(
|
|
1035
|
+
`[data-path="${node_array_path}"][data-type="node_array"]`
|
|
1036
|
+
);
|
|
1037
|
+
if (!node_array_el) return null;
|
|
1038
|
+
|
|
1039
|
+
const node_elements = node_array_el.children;
|
|
1040
|
+
if (node_elements.length === 0) return null;
|
|
1041
|
+
|
|
1042
|
+
return node_elements[node_offset];
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function __render_node_selection() {
|
|
1046
|
+
const selection = /** @type {NodeSelection} */ (session.selection);
|
|
1047
|
+
const node_array_path = selection.path.join('.');
|
|
1048
|
+
let is_collapsed = selection.anchor_offset === selection.focus_offset;
|
|
1049
|
+
let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
|
|
1050
|
+
|
|
1051
|
+
// We need to translate the cusor offset to node offsets now
|
|
1052
|
+
let anchor_node_offset, focus_node_offset;
|
|
1053
|
+
|
|
1054
|
+
if (is_collapsed) {
|
|
1055
|
+
anchor_node_offset = Math.max(0, selection.anchor_offset - 1);
|
|
1056
|
+
focus_node_offset = Math.max(0, selection.focus_offset - 1);
|
|
1057
|
+
} else if (is_backward) {
|
|
1058
|
+
anchor_node_offset = selection.anchor_offset - 1;
|
|
1059
|
+
focus_node_offset = selection.focus_offset;
|
|
1060
|
+
} else {
|
|
1061
|
+
anchor_node_offset = selection.anchor_offset;
|
|
1062
|
+
focus_node_offset = selection.focus_offset - 1;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const anchor_node = __get_node_element(node_array_path, anchor_node_offset);
|
|
1066
|
+
const focus_node = __get_node_element(node_array_path, focus_node_offset);
|
|
1067
|
+
|
|
1068
|
+
if (!anchor_node || !focus_node) return;
|
|
1069
|
+
const dom_selection = window.getSelection();
|
|
1070
|
+
const range = window.document.createRange();
|
|
1071
|
+
|
|
1072
|
+
if (is_collapsed) {
|
|
1073
|
+
// Cursor position in between two nodes or at the very beginning/end of a node_array
|
|
1074
|
+
// IMPORTANT: We need to look for direct children of anchor_node to find the right cursor trap.
|
|
1075
|
+
const cursor_trap_el = anchor_node.querySelector(
|
|
1076
|
+
selection.anchor_offset === 0
|
|
1077
|
+
? ':scope > .position-zero-cursor-trap'
|
|
1078
|
+
: ':scope > .after-node-cursor-trap'
|
|
1079
|
+
);
|
|
1080
|
+
|
|
1081
|
+
range.setStart(cursor_trap_el, 1);
|
|
1082
|
+
range.setEnd(cursor_trap_el, 1);
|
|
1083
|
+
dom_selection.removeAllRanges();
|
|
1084
|
+
dom_selection.addRange(range);
|
|
1085
|
+
} else {
|
|
1086
|
+
// Expanded selection (one or more nodes are fully selectd)
|
|
1087
|
+
if (is_backward) {
|
|
1088
|
+
// Use the last selectable you find
|
|
1089
|
+
const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(
|
|
1090
|
+
-1
|
|
1091
|
+
);
|
|
1092
|
+
// Use the first selectable you find
|
|
1093
|
+
const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(0);
|
|
1094
|
+
|
|
1095
|
+
range.setStart(focus_node_selectable, 1);
|
|
1096
|
+
range.setEnd(anchor_node_selectable, 1);
|
|
1097
|
+
dom_selection.removeAllRanges();
|
|
1098
|
+
dom_selection.addRange(range);
|
|
1099
|
+
// Phew, this was a hard nut to crack. But with that code the direction can be reversed.
|
|
1100
|
+
dom_selection.setBaseAndExtent(anchor_node_selectable, 1, focus_node_selectable, 1);
|
|
1101
|
+
} else {
|
|
1102
|
+
// Use the first selectable you find
|
|
1103
|
+
const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(
|
|
1104
|
+
0
|
|
1105
|
+
);
|
|
1106
|
+
// Use the last selectable you find
|
|
1107
|
+
const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(-1);
|
|
1108
|
+
|
|
1109
|
+
range.setStart(anchor_node_selectable, 1);
|
|
1110
|
+
range.setEnd(focus_node_selectable, 1);
|
|
1111
|
+
dom_selection.removeAllRanges();
|
|
1112
|
+
dom_selection.addRange(range);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Ensure the node_array is focused
|
|
1117
|
+
const node_array_el = canvas_el.querySelector(
|
|
1118
|
+
`[data-path="${node_array_path}"][data-type="node_array"]`
|
|
1119
|
+
);
|
|
1120
|
+
if (node_array_el) {
|
|
1121
|
+
node_array_el.focus();
|
|
1122
|
+
// Scroll the selection into view
|
|
1123
|
+
setTimeout(() => {
|
|
1124
|
+
(is_backward ? focus_node : anchor_node).scrollIntoView({
|
|
1125
|
+
block: 'nearest',
|
|
1126
|
+
inline: 'nearest'
|
|
1127
|
+
});
|
|
1128
|
+
}, 0);
|
|
1129
|
+
} else {
|
|
1130
|
+
console.log('No container element found!');
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function __render_property_selection() {
|
|
1135
|
+
const selection = session.selection;
|
|
1136
|
+
// The element that holds the property
|
|
1137
|
+
const el = canvas_el.querySelector(
|
|
1138
|
+
`[data-path="${selection.path.join('.')}"][data-type="property"]`
|
|
1139
|
+
);
|
|
1140
|
+
const cursor_trap_selectable = el.querySelector('.svedit-selectable');
|
|
1141
|
+
const range = window.document.createRange();
|
|
1142
|
+
const dom_selection = window.getSelection();
|
|
1143
|
+
|
|
1144
|
+
// Select the entire cursor trap element contents and collapse to start
|
|
1145
|
+
range.selectNodeContents(cursor_trap_selectable);
|
|
1146
|
+
range.collapse(true); // Collapse to start position
|
|
1147
|
+
dom_selection.removeAllRanges();
|
|
1148
|
+
dom_selection.addRange(range);
|
|
1149
|
+
|
|
1150
|
+
// Scroll the selection into view
|
|
1151
|
+
setTimeout(() => {
|
|
1152
|
+
const selectedElement = dom_selection.focusNode.parentElement;
|
|
1153
|
+
if (selectedElement) {
|
|
1154
|
+
selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1155
|
+
}
|
|
1156
|
+
}, 0);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function __render_text_selection() {
|
|
1160
|
+
const selection = /** @type {any} */ (session.selection);
|
|
1161
|
+
// The element that holds the annotated string
|
|
1162
|
+
const el = canvas_el.querySelector(
|
|
1163
|
+
`[data-path="${selection.path.join('.')}"][data-type="text"]`
|
|
1164
|
+
);
|
|
1165
|
+
const empty_text = session.get(selection.path).text.length === 0;
|
|
1166
|
+
|
|
1167
|
+
const range = window.document.createRange();
|
|
1168
|
+
const dom_selection = window.getSelection();
|
|
1169
|
+
let current_offset = 0;
|
|
1170
|
+
/** @type {HTMLElement | Text} */
|
|
1171
|
+
let anchor_node;
|
|
1172
|
+
/** @type {HTMLElement | Text} */
|
|
1173
|
+
let focus_node;
|
|
1174
|
+
/** @type {number} */
|
|
1175
|
+
let anchor_node_offset;
|
|
1176
|
+
/** @type {number} */
|
|
1177
|
+
let focus_node_offset;
|
|
1178
|
+
const is_backward = selection.anchor_offset > selection.focus_offset;
|
|
1179
|
+
const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
|
|
1180
|
+
const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
|
|
1181
|
+
|
|
1182
|
+
// Helper function to process each node
|
|
1183
|
+
function process_node(node) {
|
|
1184
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1185
|
+
const node_text = node.textContent;
|
|
1186
|
+
const node_char_length = get_char_length(node_text);
|
|
1187
|
+
|
|
1188
|
+
if (is_backward) {
|
|
1189
|
+
if (!focus_node && current_offset + node_char_length >= start_offset) {
|
|
1190
|
+
focus_node = node;
|
|
1191
|
+
const char_offset = start_offset - current_offset;
|
|
1192
|
+
focus_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
1193
|
+
}
|
|
1194
|
+
} else {
|
|
1195
|
+
if (!anchor_node && current_offset + node_char_length >= start_offset) {
|
|
1196
|
+
anchor_node = node;
|
|
1197
|
+
const char_offset = start_offset - current_offset;
|
|
1198
|
+
anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Find end node
|
|
1203
|
+
if (is_backward) {
|
|
1204
|
+
if (!anchor_node && current_offset + node_char_length >= end_offset) {
|
|
1205
|
+
anchor_node = node;
|
|
1206
|
+
const char_offset = end_offset - current_offset;
|
|
1207
|
+
anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
1208
|
+
return true; // Stop iteration
|
|
1209
|
+
}
|
|
1210
|
+
} else {
|
|
1211
|
+
if (!focus_node && current_offset + node_char_length >= end_offset) {
|
|
1212
|
+
focus_node = node;
|
|
1213
|
+
const char_offset = end_offset - current_offset;
|
|
1214
|
+
focus_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
1215
|
+
return true; // Stop iteration
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
current_offset += node_char_length;
|
|
1219
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1220
|
+
for (const child_node of node.childNodes) {
|
|
1221
|
+
if (process_node(child_node)) return true; // Stop iteration if end found
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return false; // Continue iteration
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// EDGE CASE: When text is empty, we need to set a different DOM selection
|
|
1228
|
+
if (start_offset === end_offset && start_offset === 0 && empty_text) {
|
|
1229
|
+
// Markup for empty text looks like this `<div data-type="text"><br></div>`.
|
|
1230
|
+
// And the correct cursor position is after the <br> element.
|
|
1231
|
+
anchor_node = el;
|
|
1232
|
+
anchor_node_offset = 1;
|
|
1233
|
+
focus_node = el;
|
|
1234
|
+
focus_node_offset = 1;
|
|
1235
|
+
} else {
|
|
1236
|
+
// DEFAULT CASE
|
|
1237
|
+
for (const child_node of el.childNodes) {
|
|
1238
|
+
if (process_node(child_node)) break;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Set the range if both start and end were found
|
|
1243
|
+
if (anchor_node && focus_node) {
|
|
1244
|
+
// Always set range in document order (start to end)
|
|
1245
|
+
if (is_backward) {
|
|
1246
|
+
range.setStart(focus_node, focus_node_offset);
|
|
1247
|
+
range.setEnd(anchor_node, anchor_node_offset);
|
|
1248
|
+
} else {
|
|
1249
|
+
range.setStart(anchor_node, anchor_node_offset);
|
|
1250
|
+
range.setEnd(focus_node, focus_node_offset);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
dom_selection.removeAllRanges();
|
|
1254
|
+
if (is_backward) {
|
|
1255
|
+
// For backward selections, collapse to end and extend to start
|
|
1256
|
+
range.collapse(false); // collapse to end
|
|
1257
|
+
dom_selection.addRange(range);
|
|
1258
|
+
dom_selection.extend(focus_node, focus_node_offset);
|
|
1259
|
+
} else {
|
|
1260
|
+
dom_selection.addRange(range);
|
|
1261
|
+
}
|
|
1262
|
+
el.focus(); // needed?
|
|
1263
|
+
|
|
1264
|
+
// Scroll the selection into view
|
|
1265
|
+
setTimeout(() => {
|
|
1266
|
+
const selectedElement = dom_selection.focusNode.parentElement;
|
|
1267
|
+
if (selectedElement) {
|
|
1268
|
+
selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1269
|
+
}
|
|
1270
|
+
}, 0);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Utils
|
|
1275
|
+
// --------------------------
|
|
1276
|
+
|
|
1277
|
+
function __is_dom_selection_backwards() {
|
|
1278
|
+
const dom_selection = window.getSelection();
|
|
1279
|
+
|
|
1280
|
+
// If there's no dom_selection, return false
|
|
1281
|
+
if (dom_selection.rangeCount === 0) return false;
|
|
1282
|
+
|
|
1283
|
+
// Get the range of the dom_selection
|
|
1284
|
+
const range = dom_selection.getRangeAt(0);
|
|
1285
|
+
|
|
1286
|
+
if (range.collapsed) return false;
|
|
1287
|
+
|
|
1288
|
+
// Create a new range for comparison
|
|
1289
|
+
const comparisonRange = range.cloneRange();
|
|
1290
|
+
|
|
1291
|
+
// Set the comparison range to start at the dom_selection's anchor and end at its focus
|
|
1292
|
+
comparisonRange.setStart(dom_selection.anchorNode, dom_selection.anchorOffset);
|
|
1293
|
+
comparisonRange.setEnd(dom_selection.focusNode, dom_selection.focusOffset);
|
|
1294
|
+
|
|
1295
|
+
// If the comparison range is collapsed, the selection is backwards
|
|
1296
|
+
return comparisonRange.collapsed;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Whenever the model selection changes, render the selection
|
|
1300
|
+
$effect(() => {
|
|
1301
|
+
render_selection();
|
|
1302
|
+
});
|
|
1458
1303
|
</script>
|
|
1459
1304
|
|
|
1460
1305
|
<!--
|
|
1461
1306
|
TODO: We must get rid of the global handlers here, so Svedit doesn't conflict
|
|
1462
1307
|
with any app-specific event handling.
|
|
1463
1308
|
-->
|
|
1464
|
-
<svelte:document
|
|
1465
|
-
{onselectionchange}
|
|
1466
|
-
{oncut}
|
|
1467
|
-
{oncopy}
|
|
1468
|
-
{onpaste}
|
|
1469
|
-
/>
|
|
1470
|
-
<svelte:window {onkeydown} />
|
|
1309
|
+
<svelte:document {onselectionchange} {oncut} {oncopy} {onpaste} />
|
|
1471
1310
|
|
|
1472
1311
|
<!-- TODO: move oncut/copy/paste handlers inside .svedit -->
|
|
1473
|
-
<div
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1312
|
+
<div class="svedit">
|
|
1313
|
+
<div
|
|
1314
|
+
class="svedit-canvas {css_class}"
|
|
1315
|
+
class:hide-selection={session.selection?.type === 'node'}
|
|
1316
|
+
class:node-cursor={session.selection?.type === 'node' &&
|
|
1317
|
+
session.selection.anchor_offset === session.selection.focus_offset}
|
|
1318
|
+
class:property-selection={session.selection?.type === 'property'}
|
|
1319
|
+
bind:this={canvas_el}
|
|
1320
|
+
{onbeforeinput}
|
|
1321
|
+
{oncompositionstart}
|
|
1322
|
+
{oncompositionend}
|
|
1323
|
+
onfocus={handle_canvas_focus}
|
|
1324
|
+
onblur={handle_canvas_blur}
|
|
1325
|
+
contenteditable={editable ? 'true' : 'false'}
|
|
1326
|
+
{autocapitalize}
|
|
1327
|
+
{spellcheck}
|
|
1328
|
+
{...{
|
|
1329
|
+
// NOTE: Autocomplete and autocorrect make immense troubles
|
|
1330
|
+
// on Desktop Chrome, so we disable them only for Chrome desktop.
|
|
1331
|
+
// Additionally, OSX-native auto-complete also breaks, because
|
|
1332
|
+
// I'm using a keyed block that always wipes the DOM of a text node
|
|
1333
|
+
// on every change.
|
|
1334
|
+
// autocomplete: is_chrome_desktop ? "off" : "on",
|
|
1335
|
+
// autocorrect: is_chrome_desktop ? "off" : "on"
|
|
1336
|
+
}}
|
|
1337
|
+
>
|
|
1338
|
+
<RootComponent {path} />
|
|
1339
|
+
</div>
|
|
1340
|
+
<Overlays />
|
|
1502
1341
|
</div>
|
|
1503
1342
|
|
|
1504
1343
|
<style>
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1344
|
+
.svedit-canvas {
|
|
1345
|
+
--layout-orientation: vertical;
|
|
1346
|
+
caret-color: var(--editing-stroke-color);
|
|
1347
|
+
caret-shape: block;
|
|
1348
|
+
&:focus {
|
|
1349
|
+
outline: none;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1513
1352
|
|
|
1514
1353
|
.svedit-canvas :global(::selection) {
|
|
1515
1354
|
background: var(--editing-fill-color);
|
|
1516
1355
|
}
|
|
1517
1356
|
|
|
1518
1357
|
@media not (pointer: coarse) {
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
/* When the cursor is in a cursor-trap we never want to see the caret */
|
|
1525
|
-
.svedit-canvas.node-cursor, .svedit-canvas.property-selection {
|
|
1526
|
-
caret-color: transparent;
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
@media not (pointer: coarse) {
|
|
1530
|
-
@supports (anchor-name: --test) {
|
|
1531
|
-
.svedit-canvas.hide-selection :global(::selection) {
|
|
1532
|
-
background: transparent;
|
|
1533
|
-
}
|
|
1358
|
+
.svedit-canvas.hide-selection {
|
|
1359
|
+
caret-color: transparent;
|
|
1534
1360
|
}
|
|
1535
|
-
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/* When the cursor is in a cursor-trap we never want to see the caret */
|
|
1364
|
+
.svedit-canvas.node-cursor,
|
|
1365
|
+
.svedit-canvas.property-selection {
|
|
1366
|
+
caret-color: transparent;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
@media not (pointer: coarse) {
|
|
1370
|
+
@supports (anchor-name: --test) {
|
|
1371
|
+
.svedit-canvas.hide-selection :global(::selection) {
|
|
1372
|
+
background: transparent;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1536
1376
|
</style>
|