svedit 0.5.0 → 0.6.1

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