svedit 0.5.0 → 0.6.0

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