svedit 0.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -0
  3. package/dist/AnnotatedStringProperty.svelte +126 -0
  4. package/dist/AnnotatedStringProperty.svelte.d.ts +7 -0
  5. package/dist/AnnotatedStringProperty.svelte.d.ts.map +1 -0
  6. package/dist/CustomProperty.svelte +40 -0
  7. package/dist/CustomProperty.svelte.d.ts +8 -0
  8. package/dist/CustomProperty.svelte.d.ts.map +1 -0
  9. package/dist/Document.svelte.d.ts +191 -0
  10. package/dist/Document.svelte.d.ts.map +1 -0
  11. package/dist/Document.svelte.js +681 -0
  12. package/dist/Node.svelte +41 -0
  13. package/dist/Node.svelte.d.ts +7 -0
  14. package/dist/Node.svelte.d.ts.map +1 -0
  15. package/dist/NodeArrayProperty.svelte +45 -0
  16. package/dist/NodeArrayProperty.svelte.d.ts +7 -0
  17. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -0
  18. package/dist/Svedit.svelte +936 -0
  19. package/dist/Svedit.svelte.d.ts +12 -0
  20. package/dist/Svedit.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +198 -0
  22. package/dist/Transaction.svelte.d.ts.map +1 -0
  23. package/dist/Transaction.svelte.js +577 -0
  24. package/dist/UnknownNode.svelte +12 -0
  25. package/dist/UnknownNode.svelte.d.ts +12 -0
  26. package/dist/UnknownNode.svelte.d.ts.map +1 -0
  27. package/dist/commands.svelte.d.ts +5 -0
  28. package/dist/commands.svelte.d.ts.map +1 -0
  29. package/dist/commands.svelte.js +261 -0
  30. package/dist/index.d.ts +10 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +14 -0
  33. package/dist/types.d.ts +264 -0
  34. package/dist/util.d.ts +4 -0
  35. package/dist/util.d.ts.map +1 -0
  36. package/dist/util.js +78 -0
  37. package/package.json +65 -0
  38. package/src/lib/AnnotatedStringProperty.svelte +126 -0
  39. package/src/lib/CustomProperty.svelte +40 -0
  40. package/src/lib/Document.svelte.js +681 -0
  41. package/src/lib/Node.svelte +41 -0
  42. package/src/lib/NodeArrayProperty.svelte +45 -0
  43. package/src/lib/Svedit.svelte +936 -0
  44. package/src/lib/Transaction.svelte.js +577 -0
  45. package/src/lib/UnknownNode.svelte +12 -0
  46. package/src/lib/commands.svelte.js +261 -0
  47. package/src/lib/index.js +14 -0
  48. package/src/lib/types.d.ts +264 -0
  49. package/src/lib/util.js +78 -0
@@ -0,0 +1,936 @@
1
+ <script>
2
+ import { setContext } from 'svelte';
3
+ import { snake_to_pascal } from './util.js';
4
+ import { break_text_node, join_text_node, insert_default_node, select_all } from './commands.svelte.js';
5
+
6
+ /** @import { SveditProps, DocumentPath, Selection, TextSelection, NodeSelection, PropertySelection, NodeId } from './types.d.ts'; */
7
+ /** @type {SveditProps} */
8
+ let {
9
+ doc,
10
+ editable = false,
11
+ path,
12
+ class: css_class,
13
+ } = $props();
14
+
15
+ let canvas_ref;
16
+ let root_node = $derived(doc.get(path));
17
+ let Overlays = $derived(doc.config.system_components.Overlays);
18
+ let RootComponent = $derived(doc.config.node_components[snake_to_pascal(root_node.type)]);
19
+
20
+ /** Expose function so parent can call it */
21
+ export { focus_canvas };
22
+
23
+ setContext("svedit", {
24
+ get doc() {
25
+ return doc;
26
+ }
27
+ });
28
+
29
+ /**
30
+ * @param {InputEvent} event
31
+ */
32
+ function onbeforeinput(event) {
33
+ const inserted_char = event.data;
34
+
35
+ event.preventDefault();
36
+ if (inserted_char) {
37
+ const tr = doc.tr;
38
+ tr.insert_text(inserted_char);
39
+ doc.apply(tr);
40
+ }
41
+ }
42
+
43
+ // Map DOM selection to internal model
44
+ function onselectionchange() {
45
+ const dom_selection = window.getSelection();
46
+ if (!dom_selection.rangeCount) return;
47
+
48
+ // Only handle selection changes if selection is within the canvas
49
+ const range = dom_selection.getRangeAt(0);
50
+ if (!canvas_ref?.contains(range.commonAncestorContainer)) return;
51
+ let selection = __get_property_selection_from_dom() || __get_text_selection_from_dom() || __get_node_selection_from_dom();
52
+ if (selection) {
53
+ doc.selection = selection;
54
+ }
55
+ }
56
+
57
+
58
+ /**
59
+ * @param {NodeId[]} selected_node_ids
60
+ */
61
+ function prepare_copy_payload(selected_node_ids) {
62
+ const nodes = {};
63
+
64
+ // Get subgraph for each selected node using doc.traverse()
65
+ for (const node_id of selected_node_ids) {
66
+ const subgraph = doc.traverse(node_id);
67
+
68
+ // Add all nodes from this subgraph to our nodes collection
69
+ for (const node of subgraph) {
70
+ if (!nodes[node.id]) {
71
+ nodes[node.id] = node;
72
+ }
73
+ }
74
+ }
75
+
76
+ // Keep original IDs - we'll generate new ones during paste
77
+ return { nodes, main_nodes: selected_node_ids };
78
+ }
79
+
80
+
81
+ /**
82
+ * @param {ClipboardEvent} event
83
+ * @param {boolean} delete_selection - used by oncut()
84
+ */
85
+ function oncopy(event, delete_selection = false) {
86
+ // Only handle copy events if focus is within the canvas
87
+ if (!canvas_ref?.contains(document.activeElement)) return;
88
+
89
+ event.preventDefault();
90
+ event.stopPropagation();
91
+
92
+ let plain_text, html, json_data;
93
+
94
+ if (doc.selection?.type === 'text') {
95
+ plain_text = doc.get_selected_plain_text();
96
+ html = plain_text;
97
+ } else if (doc.selection?.type === 'node') {
98
+ const selected_nodes = doc.get_selected_nodes();
99
+ const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
100
+
101
+ json_data = { nodes, main_nodes };
102
+
103
+ console.log('Copy operation:', {
104
+ selected_nodes,
105
+ main_nodes,
106
+ total_nodes: Object.keys(nodes).length,
107
+ operation: delete_selection ? 'cut' : 'copy'
108
+ });
109
+ }
110
+
111
+ // Create a ClipboardItem with multiple formats
112
+ const data = {
113
+ 'text/plain': new Blob([plain_text], {type: 'text/plain'}),
114
+ 'text/html': new Blob([html], {type: 'text/html'}),
115
+ };
116
+
117
+ if (json_data) {
118
+ const json_blob = new Blob([JSON.stringify(json_data)], {type: 'application/json'});
119
+ data['web application/json'] = json_blob;
120
+ }
121
+ const clipboard_item_raw = new ClipboardItem(data);
122
+
123
+ // Write to clipboard
124
+ navigator.clipboard.write([clipboard_item_raw]).then(() => {
125
+ console.log('Data copied to clipboard successfully');
126
+ }).catch(err => {
127
+ console.error('Failed to copy data: ', err);
128
+ });
129
+
130
+ if (delete_selection) {
131
+ doc.apply(doc.tr.delete_selection());
132
+ }
133
+ }
134
+
135
+ function oncut(event) {
136
+ oncopy(event, true);
137
+ }
138
+
139
+ async function onpaste(event) {
140
+ // Only handle paste events if focus is within the canvas
141
+ if (!canvas_ref?.contains(document.activeElement)) return;
142
+
143
+ event.preventDefault();
144
+ const clipboardItems = await navigator.clipboard.read();
145
+
146
+ let pasted_json;
147
+
148
+ // Wrapping this in a try-catch as this API only works in Chrome. We fallback to
149
+ // plaintext copy and pasting for all other situations.
150
+ try {
151
+ const json_blob = await clipboardItems[0].getType('web application/json');
152
+ pasted_json = JSON.parse(await json_blob.text());
153
+ } catch {
154
+ pasted_json = undefined;
155
+ }
156
+
157
+ if (pasted_json) {
158
+ const tr = doc.tr;
159
+
160
+ // Handle new format: { nodes: {id -> node}, main_nodes: [id1, id2, ...] }
161
+ if (pasted_json.nodes && pasted_json.main_nodes) {
162
+ const { nodes, main_nodes } = pasted_json;
163
+
164
+ // Generate new IDs for all nodes during paste
165
+ const id_mapping = {};
166
+ for (const node_id in nodes) {
167
+ id_mapping[node_id] = doc.generate_id();
168
+ }
169
+
170
+ // Create nodes with new IDs and updated references
171
+ const updated_nodes = {};
172
+ for (const [old_id, node] of Object.entries(nodes)) {
173
+ const new_node = { ...node };
174
+ new_node.id = id_mapping[old_id];
175
+
176
+ // Update all property references to use new IDs
177
+ for (const [property, value] of Object.entries(new_node)) {
178
+ if (property === 'id' || property === 'type') continue;
179
+
180
+ const prop_type = doc.schema[node.type]?.[property]?.type;
181
+
182
+ if (prop_type === 'node_array' && Array.isArray(value)) {
183
+ new_node[property] = value.map(ref_id => id_mapping[ref_id] || ref_id);
184
+ } else if (prop_type === 'node' && typeof value === 'string') {
185
+ new_node[property] = id_mapping[value] || value;
186
+ }
187
+ }
188
+
189
+ updated_nodes[new_node.id] = new_node;
190
+ }
191
+
192
+ // Create all nodes first
193
+ for (const node of Object.values(updated_nodes)) {
194
+ tr.create(node);
195
+ }
196
+
197
+ // Then insert the main nodes at the current selection
198
+ const new_main_nodes = main_nodes.map(id => id_mapping[id]);
199
+ const main_node_objects = new_main_nodes.map(id => updated_nodes[id]);
200
+ tr.insert_nodes(main_node_objects);
201
+ }
202
+
203
+ doc.apply(tr);
204
+ } else {
205
+ const plain_text_blob = await clipboardItems[0].getType('text/plain');
206
+ // Convert the Blob to text
207
+ const plain_text = await plain_text_blob.text();
208
+ doc.apply(doc.tr.insert_text(plain_text));
209
+ }
210
+ }
211
+
212
+ function render_selection() {
213
+ const selection = /** @type {Selection} */ (doc.selection);
214
+ let prev_selection = __get_property_selection_from_dom() || __get_text_selection_from_dom() || __get_node_selection_from_dom();
215
+
216
+ if (!selection) {
217
+ // No model selection -> just leave things as they are'
218
+ let dom_selection = window.getSelection();
219
+ dom_selection.removeAllRanges();
220
+ return;
221
+ }
222
+
223
+ // NOTE: Skip rerender only when the selection is the same and the focus is already within the canvas
224
+ if (JSON.stringify(selection) === JSON.stringify(prev_selection) && canvas_ref?.contains(document.activeElement)) {
225
+ // Skip. No need to rerender.
226
+ return;
227
+ }
228
+
229
+ if (selection?.type === 'text') {
230
+ __render_text_selection();
231
+ } else if (selection?.type === 'node') {
232
+ __render_node_selection();
233
+ } else if (selection?.type === 'property') {
234
+ __render_property_selection();
235
+ } else {
236
+ console.log('unsupported selection', selection);
237
+ }
238
+ }
239
+
240
+ function focus_toolbar() {
241
+ // Find the first interactive element in the toolbar and focus it
242
+ const toolbar = document.querySelector('.editor-toolbar');
243
+ if (toolbar) {
244
+ const firstInteractive = /** @type {HTMLElement} */ (toolbar.querySelector('input, button, select, textarea'));
245
+ if (firstInteractive) {
246
+ firstInteractive.focus();
247
+ }
248
+ }
249
+ }
250
+
251
+ function focus_canvas() {
252
+ // We just render the selection (which will return focus to the canvas) implicitly
253
+ render_selection();
254
+ }
255
+
256
+ function onkeydown(e) {
257
+ // Only handle keyboard events if focus is within the canvas
258
+ if (!canvas_ref?.contains(document.activeElement)) return;
259
+
260
+ const selection = /** @type {any} */ (doc.selection);
261
+ const is_collapsed = selection?.anchor_offset === selection?.focus_offset;
262
+
263
+ // console.log('onkeydown', e.key);
264
+ if (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.selected_node) {
265
+ const node = doc.selected_node;
266
+ const layout_count = doc.config.node_layouts[node.type];
267
+
268
+ if (layout_count > 1 && node?.layout) {
269
+ const next_layout = (node.layout % layout_count) + 1;
270
+ console.log('layout / count / next_layout', node.layout, layout_count, next_layout);
271
+ const tr = doc.tr;
272
+ tr.set([doc.selected_node?.id, 'layout'], next_layout);
273
+ doc.apply(tr);
274
+ }
275
+ } else if (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.selected_node) {
276
+ const node = doc.selected_node;
277
+ const layout_count = doc.config.node_layouts[node.type];
278
+ if (layout_count > 1 && node?.layout) {
279
+ const prev_layout = ((node.layout - 2 + layout_count) % layout_count) + 1;
280
+ const tr = doc.tr;
281
+ tr.set([doc.selected_node?.id, 'layout'], prev_layout);
282
+ doc.apply(tr);
283
+ console.log('layout / count / prev_layout', node.layout, layout_count, prev_layout);
284
+ }
285
+ } else if (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.selected_node) {
286
+ const node = doc.selected_node;
287
+
288
+ if (doc.selection.type !== 'node') {
289
+ doc.select_parent();
290
+ }
291
+ const old_selection = { ...doc.selection };
292
+
293
+ const node_array_schema = doc.inspect(doc.selection.path);
294
+ // If we are not dealing with a node selection in a container, return
295
+ if (node_array_schema.type !== 'node_array') return;
296
+
297
+ const current_type_index = node_array_schema.node_types.indexOf(node.type);
298
+ const next_type_index = (current_type_index + 1) % node_array_schema.node_types.length;
299
+ const next_type = node_array_schema.node_types[next_type_index];
300
+ const tr = doc.tr;
301
+ doc.config.inserters[next_type](tr);
302
+ tr.set_selection(old_selection);
303
+ doc.apply(tr);
304
+
305
+
306
+ } else if (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.selected_node) {
307
+ const node = doc.selected_node;
308
+
309
+ if (doc.selection.type !== 'node') {
310
+ doc.select_parent();
311
+ }
312
+
313
+ // if (selection.type !== 'node') return;
314
+ const old_selection = { ...doc.selection };
315
+ const node_array_schema = doc.inspect(doc.selection.path);
316
+ // If we are not dealing with a node selection in a container, return
317
+ if (node_array_schema.type !== 'node_array') return;
318
+
319
+ const current_type_index = node_array_schema.node_types.indexOf(node.type);
320
+ // return (current_layout_number - 1 + layout_count) % layout_count;
321
+ const prev_type_index = (current_type_index - 1 + node_array_schema.node_types.length) % node_array_schema.node_types.length;
322
+ const prev_type = node_array_schema.node_types[prev_type_index];
323
+ const tr = doc.tr;
324
+ doc.config.inserters[prev_type](tr);
325
+ tr.set_selection(old_selection);
326
+ doc.apply(tr);
327
+
328
+ } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
329
+ const tr = doc.tr;
330
+ if (select_all(tr)) {
331
+ doc.apply(tr);
332
+ }
333
+ e.preventDefault();
334
+ e.stopPropagation();
335
+ } else if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
336
+ doc.undo();
337
+ e.preventDefault();
338
+ e.stopPropagation();
339
+ } else if (e.key === 'z' && (e.metaKey || e.ctrlKey) && e.shiftKey) {
340
+ doc.redo();
341
+ e.preventDefault();
342
+ e.stopPropagation();
343
+ } else if (e.key === 'Enter' && (e.shiftKey)) {
344
+ doc.apply(doc.tr.insert_text('\n'));
345
+ e.preventDefault();
346
+ e.stopPropagation();
347
+ } else if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
348
+ doc.apply(doc.tr.annotate_text('strong'));
349
+ e.preventDefault();
350
+ e.stopPropagation();
351
+ } else if (e.key === 'i' && (e.ctrlKey || e.metaKey)) {
352
+ doc.apply(doc.tr.annotate_text('emphasis'));
353
+ e.preventDefault();
354
+ e.stopPropagation();
355
+ } else if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
356
+ doc.apply(doc.tr.annotate_text('link', {
357
+ href: window.prompt('Enter the URL', 'https://example.com')
358
+ }));
359
+ e.preventDefault();
360
+ e.stopPropagation();
361
+ } else if (e.key === 'Backspace') {
362
+ if (selection?.type === 'property') {
363
+ // For property selections, clear the property value only if it's not already falsy
364
+ if (doc.get(selection.path)) {
365
+ const tr = doc.tr;
366
+ tr.set(selection.path, '');
367
+ doc.apply(tr);
368
+ }
369
+ } else {
370
+ if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === 0 && doc.selection?.focus_offset === 0) {
371
+ console.log('try to join with previous node');
372
+ const tr = doc.tr;
373
+ join_text_node(tr);
374
+ doc.apply(tr);
375
+ } else {
376
+ // TODO: Things are not yet properly composable, I think we need to
377
+ // realize delete_selection as a command as well.
378
+ // See https://prosemirror.net/docs/guide/#commands for inspiration.
379
+ // For other selections, use the normal delete behavior
380
+ doc.apply(doc.tr.delete_selection());
381
+ }
382
+ }
383
+
384
+ e.preventDefault();
385
+ e.stopPropagation();
386
+ } else if (e.key === 'Enter' && selection?.type === 'property') {
387
+ // Focus toolbar for property selections
388
+ focus_toolbar();
389
+ e.preventDefault();
390
+ e.stopPropagation();
391
+ } else if (e.key === 'Enter' && selection?.type === 'node') {
392
+ const span_length = Math.abs(selection.focus_offset - selection.anchor_offset);
393
+
394
+ if (is_collapsed) {
395
+ // Try to insert default node if there's only one allowed ref_type
396
+ const tr = doc.tr;
397
+ insert_default_node(tr);
398
+ doc.apply(tr);
399
+ // if (insert_default_node(tr)) {
400
+ // doc.apply(tr);
401
+ // } else {
402
+ // // Fall back to focusing toolbar when multiple types are available
403
+ // focus_toolbar();
404
+ // }
405
+ } else if (span_length === 1) {
406
+ focus_toolbar();
407
+ }
408
+ // Node selections with multiple nodes do nothing on Enter
409
+ e.preventDefault();
410
+ e.stopPropagation();
411
+ } else if (e.key === 'Enter' && selection?.type === 'text') {
412
+ const tr = doc.tr;
413
+ break_text_node(tr);
414
+ doc.apply(tr);
415
+ } else if (e.key === 'Escape' && selection) {
416
+ doc.select_parent();
417
+ e.preventDefault();
418
+ e.stopPropagation();
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Extracts a NodeSelection from the current DOM selection.
424
+ *
425
+ *
426
+ * @returns {NodeSelection | null} A NodeSelection object if the DOM selection
427
+ * represents a valid node selection, null otherwise
428
+ */
429
+ function __get_node_selection_from_dom() {
430
+ const dom_selection = window.getSelection();
431
+ if (dom_selection.rangeCount === 0) return null;
432
+
433
+ let focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
434
+ let anchor_node = /** @type {HTMLElement} */ (dom_selection.anchorNode);
435
+
436
+ // If focus_node or anchor_node not an element node (e.g. a text node), we need
437
+ // to use the parent element, so we can perform the closest() query on it.
438
+ if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
439
+ if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
440
+
441
+ // EDGE CASE: Let's first check if we are in a cursor trap for node cursors
442
+ let after_node_cursor_trap = focus_node && focus_node?.closest('[data-type="after-node-cursor-trap"]');
443
+ if (after_node_cursor_trap && focus_node === anchor_node) {
444
+ // Find the node that this cursor trap belongs to
445
+ let node = /** @type {HTMLElement} */ (after_node_cursor_trap.closest('[data-type="node"]'));
446
+ if (!node) {
447
+ console.log('No corresponding node found for after-node-cursor-trap');
448
+ return null;
449
+ }
450
+ const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
451
+ const node_index = parseInt(String(node_path.at(-1)), 10);
452
+
453
+ return {
454
+ type: "node",
455
+ path: node_path.slice(0, -1),
456
+ anchor_offset: node_index + 1,
457
+ focus_offset: node_index + 1,
458
+ }
459
+ }
460
+
461
+ // EDGE CASE: Let's check if we are in a position-zero-cursor-trap for node_arrays.
462
+ let position_zero_cursor_trap = focus_node.closest('[data-type="position-zero-cursor-trap"]');
463
+ if (position_zero_cursor_trap && focus_node === anchor_node) {
464
+ const node_array_el = /** @type {HTMLElement} */ (position_zero_cursor_trap.closest('[data-type="node_array"]'));
465
+ const node_array_path = node_array_el.dataset.path.split('.');
466
+ return {
467
+ type: 'node',
468
+ path: node_array_path,
469
+ anchor_offset: 0,
470
+ focus_offset: 0,
471
+ }
472
+ }
473
+
474
+ let focus_root = /** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
475
+ if (!focus_root) return null;
476
+
477
+ let anchor_root = /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
478
+ if (!anchor_root) return null;
479
+
480
+ if (!(focus_root && anchor_root)) {
481
+ return null;
482
+ }
483
+
484
+ let focus_root_path = focus_root.dataset.path.split('.');
485
+ let anchor_root_path = anchor_root.dataset.path.split('.');
486
+
487
+ // HACK: this works only for one level nesting - should be done recursively to work generally
488
+ if (focus_root_path.length > anchor_root_path.length) {
489
+ focus_root = focus_root.parentElement.closest('[data-path][data-type="node"]');
490
+ if (!focus_root) return null;
491
+ focus_root_path = focus_root.dataset.path.split('.');
492
+ } else if (anchor_root_path.length > focus_root_path.length) {
493
+ anchor_root = anchor_root.parentElement.closest('[data-path][data-type="node"]');
494
+ if (!anchor_root) return null;
495
+ anchor_root_path = anchor_root.dataset.path.split('.');
496
+ }
497
+
498
+ const is_same_node_array = focus_root_path.slice(0, -1).join('.') === anchor_root_path.slice(0, -1).join('.');
499
+ if (!is_same_node_array) {
500
+ console.log('invalid selection, not same node_array');
501
+ return null;
502
+ }
503
+
504
+ let anchor_offset = parseInt(anchor_root_path.at(-1));
505
+ let focus_offset = parseInt(focus_root_path.at(-1));
506
+
507
+ // Check if it's a backwards selection
508
+ const is_backwards = __is_dom_selection_backwards();
509
+ if (is_backwards) {
510
+ anchor_offset += 1;
511
+ } else {
512
+ focus_offset += 1;
513
+ }
514
+
515
+ return {
516
+ type: 'node',
517
+ path: anchor_root_path.slice(0, -1),
518
+ anchor_offset: anchor_offset,
519
+ focus_offset: focus_offset,
520
+ };
521
+ }
522
+
523
+
524
+ /**
525
+ * Extracts a PropertySelection from the current DOM selection.
526
+ *
527
+ *
528
+ * @returns {PropertySelection | null} A PropertySelection object if the DOM selection
529
+ * represents a valid property selection, null otherwise
530
+ */
531
+ function __get_property_selection_from_dom() {
532
+ const dom_selection = window.getSelection();
533
+ if (dom_selection.rangeCount === 0) return null;
534
+
535
+ let focus_root = /** @type {HTMLElement} */ (dom_selection.focusNode.parentElement?.closest('[data-path][data-type="property"]'));
536
+ if (!focus_root) return null;
537
+ let anchor_root = /** @type {HTMLElement} */ (dom_selection.anchorNode.parentElement?.closest('[data-path][data-type="property"]'));
538
+ if (!anchor_root) return null;
539
+
540
+ if (focus_root === anchor_root) {
541
+ return {
542
+ type: 'property',
543
+ path: focus_root.dataset.path.split('.')
544
+ }
545
+ }
546
+ return null;
547
+ }
548
+
549
+ /**
550
+ * Extracts a TextSelection from the current DOM selection.
551
+ *
552
+ *
553
+ * @returns {TextSelection | null} A TextSelection object if the DOM selection
554
+ * represents a valid text selection, null otherwise
555
+ */
556
+ function __get_text_selection_from_dom() {
557
+ const dom_selection = window.getSelection();
558
+ if (dom_selection.rangeCount === 0) return null;
559
+
560
+ const focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
561
+ let focus_root, anchor_root;
562
+
563
+ if (dom_selection.focusNode === dom_selection.anchorNode && focus_node.dataset?.type === 'text') {
564
+ // This is the case when the text node is empty (only a <br> is present)
565
+ focus_root = anchor_root = focus_node;
566
+ } else {
567
+ focus_root = /** @type {HTMLElement} */ (dom_selection.focusNode.parentElement?.closest('[data-path][data-type="text"]'));
568
+ if (!focus_root) return null;
569
+ anchor_root = /** @type {HTMLElement} */ (dom_selection.anchorNode.parentElement?.closest('[data-path][data-type="text"]'));
570
+ if (!anchor_root) return null;
571
+ }
572
+
573
+ if (focus_root !== anchor_root) {
574
+ return null;
575
+ }
576
+
577
+ const range = dom_selection.getRangeAt(0);
578
+ const path = focus_root.dataset.path.split('.');
579
+
580
+ if (!path) return null;
581
+
582
+ let anchorOffset = 0;
583
+ let focusOffset = 0;
584
+ let currentOffset = 0;
585
+
586
+ function processNode(node) {
587
+ if (node.nodeType === Node.TEXT_NODE) {
588
+ const nodeLength = node.length;
589
+
590
+ if (node === range.startContainer) {
591
+ anchorOffset = currentOffset + range.startOffset;
592
+ }
593
+ if (node === range.endContainer) {
594
+ focusOffset = currentOffset + range.endOffset;
595
+ }
596
+
597
+ currentOffset += nodeLength;
598
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
599
+ for (const childNode of node.childNodes) {
600
+ processNode(childNode);
601
+ }
602
+ }
603
+
604
+ return (focusOffset !== 0);
605
+ }
606
+
607
+ // Process nodes to find offsets
608
+ for (const childNode of focus_root.childNodes) {
609
+ if (processNode(childNode)) break;
610
+ }
611
+
612
+ // Check if it's a backward selection
613
+ const is_backward = dom_selection.anchorNode === range.endContainer &&
614
+ dom_selection.anchorOffset === range.endOffset;
615
+
616
+ // Swap offsets if it's a backward selection
617
+ if (is_backward) {
618
+ [anchorOffset, focusOffset] = [focusOffset, anchorOffset];
619
+ }
620
+
621
+ return {
622
+ type: 'text',
623
+ path,
624
+ anchor_offset: anchorOffset,
625
+ focus_offset: focusOffset
626
+ };
627
+ }
628
+
629
+ function __get_node_element(node_array_path, node_offset) {
630
+ const node_array_el = canvas_ref.querySelector(`[data-path="${node_array_path}"][data-type="node_array"]`);
631
+ if (!node_array_el) return null;
632
+
633
+ const node_elements = node_array_el.children;
634
+ if (node_elements.length === 0) return null;
635
+
636
+ return node_elements[node_offset];
637
+ }
638
+
639
+ function __render_node_selection() {
640
+ const selection = /** @type {NodeSelection} */ (doc.selection);
641
+ const node_array_path = selection.path.join('.');
642
+
643
+ let is_collapsed = selection.anchor_offset === selection.focus_offset;
644
+ let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
645
+
646
+ // We need to translate the cusor offset to node offsets now
647
+ let anchor_node_offset, focus_node_offset;
648
+
649
+ if (is_collapsed) {
650
+ anchor_node_offset = Math.max(0, selection.anchor_offset - 1);
651
+ focus_node_offset = Math.max(0, selection.focus_offset - 1);
652
+ } else if (is_backward) {
653
+ anchor_node_offset = selection.anchor_offset - 1;
654
+ focus_node_offset = selection.focus_offset;
655
+ } else {
656
+ anchor_node_offset = selection.anchor_offset;
657
+ focus_node_offset = selection.focus_offset - 1;
658
+ }
659
+
660
+ const anchor_node = __get_node_element(node_array_path, anchor_node_offset);
661
+ const focus_node = __get_node_element(node_array_path, focus_node_offset);
662
+
663
+ if (!anchor_node || !focus_node) return;
664
+ const dom_selection = window.getSelection();
665
+ const range = window.document.createRange();
666
+
667
+ if (is_collapsed) {
668
+ // Cursor position in between two nodes or at the very beginning/end of a node_array
669
+ // IMPORTANT: We need to look for direct children of anchor_node to find the right cursor trap.
670
+ const cursor_trap_el = anchor_node.querySelector(
671
+ selection.anchor_offset === 0 ? ':scope > .position-zero-cursor-trap' : ':scope > .after-node-cursor-trap'
672
+ );
673
+
674
+
675
+ range.setStart(cursor_trap_el, 1);
676
+ range.setEnd(cursor_trap_el, 1);
677
+ dom_selection.removeAllRanges();
678
+ dom_selection.addRange(range);
679
+ } else {
680
+ // Expanded selection (one or more nodes are fully selectd)
681
+ if (is_backward) {
682
+ // Use the last selectable you find
683
+ const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(-1);
684
+ // Use the first selectable you find
685
+ const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(0);
686
+
687
+ range.setStart(focus_node_selectable, 1);
688
+ range.setEnd(anchor_node_selectable, 1);
689
+ dom_selection.removeAllRanges();
690
+ dom_selection.addRange(range);
691
+ // Phew, this was a hard nut to crack. But with that code the direction can be reversed.
692
+ dom_selection.setBaseAndExtent(anchor_node_selectable, 1, focus_node_selectable, 1);
693
+
694
+ } else {
695
+ // Use the first selectable you find
696
+ const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(0);
697
+ // Use the last selectable you find
698
+ const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(-1);
699
+
700
+ range.setStart(anchor_node_selectable, 1);
701
+ range.setEnd(focus_node_selectable, 1);
702
+ dom_selection.removeAllRanges();
703
+ dom_selection.addRange(range);
704
+ }
705
+ }
706
+
707
+ // Ensure the node_array is focused
708
+ const node_array_el = canvas_ref.querySelector(`[data-path="${node_array_path}"][data-type="node_array"]`);
709
+ if (node_array_el) {
710
+ node_array_el.focus();
711
+ // Scroll the selection into view
712
+ setTimeout(() => {
713
+ (is_backward ? focus_node : anchor_node).scrollIntoView({ block: 'nearest', inline: 'nearest' });
714
+ }, 0);
715
+ } else {
716
+ console.log('no container element found!!');
717
+ }
718
+ }
719
+
720
+ function __render_property_selection() {
721
+ const selection = doc.selection;
722
+ // The element that holds the property
723
+ const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="property"]`);
724
+ const cursor_trap = el.querySelector('.cursor-trap');
725
+
726
+ const range = window.document.createRange();
727
+ const dom_selection = window.getSelection();
728
+
729
+ // Select the entire cursor trap element contents and collapse to start
730
+ range.selectNodeContents(cursor_trap);
731
+ range.collapse(true); // Collapse to start position
732
+ dom_selection.removeAllRanges();
733
+ dom_selection.addRange(range);
734
+ }
735
+
736
+ function __render_text_selection() {
737
+ const selection = /** @type {any} */ (doc.selection);
738
+ // The element that holds the annotated string
739
+ const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="text"]`);
740
+ const empty_text = doc.get(selection.path)[0].length === 0;
741
+
742
+ const range = window.document.createRange();
743
+ const dom_selection = window.getSelection();
744
+ let current_offset = 0;
745
+ /** @type {HTMLElement | Text} */
746
+ let anchor_node;
747
+ /** @type {HTMLElement | Text} */
748
+ let focus_node;
749
+ /** @type {number} */
750
+ let anchor_node_offset
751
+ /** @type {number} */
752
+ let focus_node_offset;
753
+ const is_backward = selection.anchor_offset > selection.focus_offset;
754
+ const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
755
+ const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
756
+
757
+
758
+ // Helper function to process each node
759
+ function process_node(node) {
760
+ if (node.nodeType === Node.TEXT_NODE) {
761
+ const node_length = node.length;
762
+
763
+ if (is_backward) {
764
+ if (!focus_node && current_offset + node_length >= start_offset) {
765
+ focus_node = node;
766
+ focus_node_offset = start_offset - current_offset;
767
+ }
768
+ } else {
769
+ if (!anchor_node && current_offset + node_length >= start_offset) {
770
+ anchor_node = node;
771
+ anchor_node_offset = start_offset - current_offset;
772
+ }
773
+ }
774
+
775
+ // Find end node
776
+ if (is_backward) {
777
+ if (!anchor_node && current_offset + node_length >= end_offset) {
778
+ anchor_node = node;
779
+ anchor_node_offset = end_offset - current_offset;
780
+ return true; // Stop iteration
781
+ }
782
+ } else {
783
+ if (!focus_node && current_offset + node_length >= end_offset) {
784
+ focus_node = node;
785
+ focus_node_offset = end_offset - current_offset;
786
+ return true; // Stop iteration
787
+ }
788
+ }
789
+
790
+ current_offset += node_length;
791
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
792
+ for (const child_node of node.childNodes) {
793
+ if (process_node(child_node)) return true; // Stop iteration if end found
794
+ }
795
+ }
796
+ return false; // Continue iteration
797
+ }
798
+
799
+ // EDGE CASE: When text is empty, we need to set a different DOM selection
800
+ if (start_offset === end_offset && start_offset === 0 && empty_text) {
801
+ // Markup for empty text looks like this `<div data-type="text"><br></div>`.
802
+ // And the correct cursor position is after the <br> element.
803
+ anchor_node = el;
804
+ anchor_node_offset = 1;
805
+ focus_node = el;
806
+ focus_node_offset = 1;
807
+ } else {
808
+ // DEFAULT CASE
809
+ for (const child_node of el.childNodes) {
810
+ if (process_node(child_node)) break;
811
+ }
812
+
813
+ // EDGE CASE: cursor at the end of an annotation
814
+ // TODO: It seems this edge case is handled elsewhere. We keep around the checker for a
815
+ // while and delete this branch, if we don't see any problems.
816
+ if (anchor_node && !focus_node && current_offset === end_offset) {
817
+ alert('EDGE CASE: cursor at the end of an annotation.');
818
+ // console.log('EDGE CASE: cursor at the end of an annotation');
819
+ // focus_node = /** @type {HTMLElement | Text} */ (anchor_node.nextSibling || anchor_node);
820
+ // focus_node_offset = focus_node === anchor_node ? anchor_node.length : 0;
821
+ }
822
+ }
823
+
824
+ // Set the range if both start and end were found
825
+ if (anchor_node && focus_node) {
826
+ // Always set range in document order (start to end)
827
+ if (is_backward) {
828
+ range.setStart(focus_node, focus_node_offset);
829
+ range.setEnd(anchor_node, anchor_node_offset);
830
+ } else {
831
+ range.setStart(anchor_node, anchor_node_offset);
832
+ range.setEnd(focus_node, focus_node_offset);
833
+ }
834
+
835
+ dom_selection.removeAllRanges();
836
+ if (is_backward) {
837
+ // For backward selections, collapse to end and extend to start
838
+ range.collapse(false); // collapse to end
839
+ dom_selection.addRange(range);
840
+ dom_selection.extend(focus_node, focus_node_offset);
841
+ } else {
842
+ dom_selection.addRange(range);
843
+ }
844
+ el.focus(); // needed?
845
+
846
+ // Scroll the selection into view
847
+ setTimeout(() => {
848
+ const selectedElement = dom_selection.focusNode.parentElement;
849
+ if (selectedElement) {
850
+ selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
851
+ }
852
+ }, 0);
853
+ }
854
+ }
855
+
856
+ // Utils
857
+ // --------------------------
858
+
859
+ function __is_dom_selection_backwards() {
860
+ const dom_selection = window.getSelection();
861
+
862
+ // If there's no dom_selection, return false
863
+ if (dom_selection.rangeCount === 0) return false;
864
+
865
+ // Get the range of the dom_selection
866
+ const range = dom_selection.getRangeAt(0);
867
+
868
+ if (range.collapsed) return false;
869
+
870
+ // Create a new range for comparison
871
+ const comparisonRange = range.cloneRange();
872
+
873
+ // Set the comparison range to start at the dom_selection's anchor and end at its focus
874
+ comparisonRange.setStart(dom_selection.anchorNode, dom_selection.anchorOffset);
875
+ comparisonRange.setEnd(dom_selection.focusNode, dom_selection.focusOffset);
876
+
877
+ // If the comparison range is collapsed, the selection is backwards
878
+ return comparisonRange.collapsed;
879
+ }
880
+
881
+ // Whenever the model selection changes, render the selection
882
+ $effect(() => {
883
+ render_selection();
884
+ });
885
+ </script>
886
+
887
+ <!--
888
+ TODO: We must get rid of the global handlers here, so Svedit doesn't conflict
889
+ with any app-specific event handling.
890
+ -->
891
+ <svelte:document
892
+ {onselectionchange}
893
+ {oncut}
894
+ {oncopy}
895
+ {onpaste}
896
+ />
897
+ <svelte:window {onkeydown} />
898
+
899
+ <!-- TODO: move oncut/copy/paste handlers inside .svedit -->
900
+ <div
901
+ class="svedit"
902
+ >
903
+ <div
904
+ class="svedit-canvas {css_class}"
905
+ class:hide-selection={doc.selection?.type === 'node'}
906
+ bind:this={canvas_ref}
907
+ {onbeforeinput}
908
+ contenteditable={editable ? 'true' : 'false'}
909
+ >
910
+ <RootComponent {path} />
911
+ </div>
912
+ <Overlays />
913
+ </div>
914
+
915
+ <style>
916
+ .svedit-canvas {
917
+ --layout-orientation: vertical;
918
+ caret-color: var(--editing-stroke-color);
919
+ caret-shape: block;
920
+ &:focus {
921
+ outline: none;
922
+ }
923
+ }
924
+
925
+ .svedit-canvas.hide-selection {
926
+ caret-color: transparent;
927
+ }
928
+
929
+ .svedit-canvas :global(::selection) {
930
+ background: var(--editing-fill-color);
931
+ }
932
+
933
+ .svedit-canvas.hide-selection :global(::selection) {
934
+ background: transparent;
935
+ }
936
+ </style>