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