svedit 0.3.0 → 0.5.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.
@@ -1,15 +1,27 @@
1
1
  <script>
2
2
  import { setContext } from 'svelte';
3
3
  import { snake_to_pascal, get_char_length, utf16_to_char_offset, char_to_utf16_offset, get_char_at, char_slice } from './util.js';
4
- import { break_text_node, join_text_node, insert_default_node, select_all } from './commands.svelte.js';
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
+ */
5
16
 
6
- /** @import { SveditProps, DocumentPath, Selection, TextSelection, NodeSelection, PropertySelection, NodeId } from './types.d.ts'; */
7
17
  /** @type {SveditProps} */
8
18
  let {
9
19
  doc,
10
20
  editable = $bindable(false),
11
21
  path,
12
22
  class: css_class,
23
+ autocapitalize = "on",
24
+ spellcheck = "true",
13
25
  } = $props();
14
26
 
15
27
  let canvas_ref;
@@ -19,21 +31,32 @@
19
31
 
20
32
  // Track temporary disabled onkeydown events (e.g. during character composition)
21
33
  let skip_onkeydown = false;
22
-
23
34
  let is_composing = $state(false);
24
-
25
35
  let input_selection = undefined;
26
36
 
27
37
  // Detect Chrome on desktop (not mobile) - only available in browser
28
38
  let is_chrome_desktop = $state(false);
29
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
+
30
55
  $effect(() => {
31
56
  if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
32
57
  const user_agent = navigator.userAgent;
33
58
  const is_chrome = user_agent.includes('Chrome') && !user_agent.includes('Edg');
34
- const is_mobile = /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
35
- ('ontouchstart' in window) ||
36
- (navigator.maxTouchPoints > 0);
59
+ const is_mobile = is_mobile_browser();
37
60
  is_chrome_desktop = is_chrome && !is_mobile;
38
61
  }
39
62
  });
@@ -86,7 +109,7 @@
86
109
 
87
110
  function commit_input() {
88
111
  if (input_selection?.type !== 'text') return;
89
- const model_text = doc.get(input_selection.path)[0];
112
+ const model_text = doc.get(input_selection.path).text;
90
113
  const dom_text_element = document.querySelector(`[data-path="${input_selection.path.join('.')}"]`);
91
114
  const dom_text = dom_text_element.textContent;
92
115
  const op = diff_text(model_text, dom_text);
@@ -142,25 +165,43 @@
142
165
 
143
166
  // Only take input when in a valid text selection inside the canvas
144
167
  if (
145
- !(canvas_ref?.contains(document.activeElement)) ||
146
- (doc.selection?.type !== 'text')
168
+ !(canvas_ref?.contains(document.activeElement))
147
169
  ) {
148
170
  event.preventDefault();
149
171
  return;
150
172
  }
151
173
 
152
- if (event.inputType === 'formatBold') {
174
+ if (event.inputType === 'formatBold' && doc.selection?.type === 'text') {
153
175
  doc.apply(doc.tr.annotate_text('strong'));
154
176
  event.preventDefault();
155
177
  event.stopPropagation();
156
178
  }
157
179
 
158
- if (event.inputType === 'formatItalic') {
180
+ if (event.inputType === 'formatItalic' && doc.selection?.type === 'text') {
159
181
  doc.apply(doc.tr.annotate_text('emphasis'));
160
182
  event.preventDefault();
161
183
  event.stopPropagation();
162
184
  }
163
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
+ }
164
205
 
165
206
  // We remember the "input selection", because in some mobile browsers (Samsung Android) the onbeforeinput event
166
207
  // is fired on a different selection than the input event.
@@ -295,29 +336,47 @@ ${fallback_html}`;
295
336
  * @param {Object} node - Node object
296
337
  * @returns {string} HTML representation
297
338
  */
298
- function default_node_exporter(node) {
339
+ function default_node_html_exporter(node, doc, html_exporters) {
299
340
  let html = '';
300
- let has_content = false;
341
+ const node_schema = doc.schema[node.type];
301
342
 
302
343
  for (const [prop_name, prop_value] of Object.entries(node)) {
303
344
  if (prop_name === 'id' || prop_name === 'type') continue;
304
-
305
- // Check if this is an annotated_string property (array with string as first element)
306
- if (Array.isArray(prop_value) && typeof prop_value[0] === 'string') {
307
- const text_content = prop_value[0];
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;
308
349
  if (text_content.trim()) {
309
- html += `<p>${text_content}</p>\n`;
310
- has_content = true;
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);
311
357
  }
312
358
  }
313
359
  }
314
360
 
315
- if (!has_content) {
316
- // Generic fallback for unknown node types with no annotated_string content
317
- html += `<div data-node-type="${node.type}">Content from ${node.type}</div>\n`;
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
+ }
318
377
  }
319
378
 
320
- return html;
379
+ return plain_text;
321
380
  }
322
381
 
323
382
  /**
@@ -333,14 +392,22 @@ ${fallback_html}`;
333
392
 
334
393
  if (html_exporters[node.type]) {
335
394
  // Use custom exporter for this node type
336
- html += html_exporters[node.type](node, doc);
395
+ html += html_exporters[node.type](node, doc, html_exporters);
337
396
  } else {
338
397
  // Use default exporter
339
- html += default_node_exporter(node);
398
+ html += default_node_html_exporter(node, doc, html_exporters);
340
399
  }
341
400
  }
401
+ return html;
402
+ }
342
403
 
343
- return html || '<p>Copied content</p>';
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();
344
411
  }
345
412
 
346
413
  /**
@@ -376,20 +443,29 @@ ${fallback_html}`;
376
443
  event.preventDefault();
377
444
  event.stopPropagation();
378
445
 
379
- let plain_text, html;
446
+ let plain_text, annotated_text, html;
380
447
 
381
448
  if (doc.selection?.type === 'text') {
382
449
  plain_text = doc.get_selected_plain_text();
383
- html = `<span style="white-space:pre-wrap;">${plain_text}</span>`;
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);
384
460
  } else if (doc.selection?.type === 'node') {
385
461
  const selected_nodes = doc.get_selected_nodes();
386
462
  const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
387
463
 
388
464
  const json_data = { nodes, main_nodes };
389
465
 
390
- console.log('Copy operation:', {
466
+ console.log('Node copy:', {
391
467
  selected_nodes,
392
- main_nodes,
468
+ nodes,
393
469
  total_nodes: Object.keys(nodes).length,
394
470
  operation: delete_selection ? 'cut' : 'copy'
395
471
  });
@@ -400,30 +476,30 @@ ${fallback_html}`;
400
476
 
401
477
  // Create HTML with embedded svedit data
402
478
  html = create_svedit_html_format(json_data, fallback_html);
403
-
404
- // Create plain text representation
405
- plain_text = selected_node_objects.map(node => {
406
- if (node.type === 'story') {
407
- return (node.title || 'Untitled Story') + (node.content ? '\n' + node.content : '');
408
- }
409
- return `Content from ${node.type}`;
410
- }).join('\n\n');
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);
411
493
  }
412
494
 
413
- // Create a ClipboardItem with multiple formats
414
- const data = {
415
- 'text/plain': new Blob([plain_text || ''], {type: 'text/plain'}),
416
- 'text/html': new Blob([html || ''], {type: 'text/html'}),
417
- };
418
-
419
- const clipboard_item_raw = new ClipboardItem(data);
420
-
421
- // Write to clipboard
422
- navigator.clipboard.write([clipboard_item_raw]).then(() => {
495
+ // Write to clipboard using event.clipboardData
496
+ try {
497
+ event.clipboardData?.setData('text/plain', plain_text || '');
498
+ event.clipboardData?.setData('text/html', html || '');
423
499
  console.log('Data copied to clipboard successfully');
424
- }).catch(err => {
500
+ } catch (err) {
425
501
  console.error('Failed to copy data: ', err);
426
- });
502
+ }
427
503
 
428
504
  if (delete_selection) {
429
505
  doc.apply(doc.tr.delete_selection());
@@ -434,83 +510,163 @@ ${fallback_html}`;
434
510
  oncopy(event, true);
435
511
  }
436
512
 
437
- async function onpaste(event) {
438
- // Only handle paste events if focus is within the canvas
439
- if (!canvas_ref?.contains(document.activeElement)) return;
440
-
441
- event.preventDefault();
442
- const clipboardItems = await navigator.clipboard.read();
443
513
 
444
- let pasted_json;
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;
445
523
 
446
- // First try to extract svedit data from HTML format
447
- try {
448
- const html_blob = await clipboardItems[0].getType('text/html');
449
- const html_content = await html_blob.text();
450
- pasted_json = extract_svedit_data_from_html(html_content);
451
- } catch (e) {
452
- console.log('No HTML format available or failed to extract svedit data:', e);
453
- pasted_json = undefined;
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);
454
529
  }
455
530
 
456
-
457
-
458
- if (pasted_json) {
459
- const tr = doc.tr;
460
-
461
- // Handle new format: { nodes: {id -> node}, main_nodes: [id1, id2, ...] }
462
- if (pasted_json.nodes && pasted_json.main_nodes) {
463
- const { nodes, main_nodes } = pasted_json;
464
-
465
- // Generate new IDs for all nodes during paste
466
- const id_mapping = {};
467
- for (const node_id in nodes) {
468
- id_mapping[node_id] = doc.generate_id();
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;
469
554
  }
555
+ } else {
556
+ const new_node_id = tr.build(node_id, nodes);
557
+ nodes_to_insert.push(new_node_id);
558
+ }
559
+ }
470
560
 
471
- // Create nodes with new IDs and updated references
472
- const updated_nodes = {};
473
- for (const [old_id, node] of Object.entries(nodes)) {
474
- const new_node = { ...node };
475
- new_node.id = id_mapping[old_id];
476
-
477
- // Update all property references to use new IDs
478
- for (const [property, value] of Object.entries(new_node)) {
479
- if (property === 'id' || property === 'type') continue;
480
-
481
- const prop_type = doc.schema[node.type]?.[property]?.type;
482
-
483
- if (prop_type === 'node_array' && Array.isArray(value)) {
484
- new_node[property] = value.map(ref_id => id_mapping[ref_id] || ref_id);
485
- } else if (prop_type === 'node' && typeof value === 'string') {
486
- new_node[property] = id_mapping[value] || value;
487
- }
488
- }
489
-
490
- updated_nodes[new_node.id] = new_node;
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);
491
570
  }
571
+ }
572
+ }
573
+ return false;
574
+ }
492
575
 
493
- // Create all nodes first
494
- for (const node of Object.values(updated_nodes)) {
495
- tr.create(node);
496
- }
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();
497
580
 
498
- // Then insert the main nodes at the current selection
499
- const new_main_nodes = main_nodes.map(id => id_mapping[id]);
500
- const main_node_objects = new_main_nodes.map(id => updated_nodes[id]);
501
- tr.insert_nodes(main_node_objects);
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
+ });
502
595
  }
596
+ }
503
597
 
504
- doc.apply(tr);
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;
505
604
  } else {
506
- // Fallback to plain text when no svedit data is found
605
+ // First try to extract svedit data from HTML format
507
606
  try {
508
- const plain_text_blob = await clipboardItems[0].getType('text/plain');
509
- const plain_text = await plain_text_blob.text();
510
- doc.apply(doc.tr.insert_text(plain_text));
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');
511
618
  } catch (e) {
512
619
  console.error('Failed to paste any content:', e);
513
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.');
514
670
  }
515
671
  }
516
672
 
@@ -588,47 +744,47 @@ ${fallback_html}`;
588
744
  const is_collapsed = selection?.anchor_offset === selection?.focus_offset;
589
745
 
590
746
  if (
591
- (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.selected_node) ||
592
- (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node)
747
+ (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.layout_node) ||
748
+ (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
593
749
  ) {
594
- const node = doc.selected_node;
750
+ const node = doc.layout_node;
595
751
  const layout_count = doc.config.node_layouts[node.type];
596
752
 
597
753
  if (layout_count > 1 && node?.layout) {
598
754
  const next_layout = (node.layout % layout_count) + 1;
599
755
  console.log('layout / count / next_layout', node.layout, layout_count, next_layout);
600
756
  const tr = doc.tr;
601
- tr.set([doc.selected_node?.id, 'layout'], next_layout);
757
+ tr.set([doc.layout_node?.id, 'layout'], next_layout);
602
758
  doc.apply(tr);
603
759
  }
760
+ e.preventDefault();
604
761
  } else if (
605
- (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.selected_node) ||
606
- (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node)
762
+ (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.layout_node) ||
763
+ (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
607
764
  ) {
608
- const node = doc.selected_node;
765
+ const node = doc.layout_node;
609
766
  const layout_count = doc.config.node_layouts[node.type];
767
+
610
768
  if (layout_count > 1 && node?.layout) {
611
769
  const prev_layout = ((node.layout - 2 + layout_count) % layout_count) + 1;
612
770
  const tr = doc.tr;
613
- tr.set([doc.selected_node?.id, 'layout'], prev_layout);
771
+ tr.set([doc.layout_node?.id, 'layout'], prev_layout);
614
772
  doc.apply(tr);
615
773
  console.log('layout / count / prev_layout', node.layout, layout_count, prev_layout);
616
774
  }
775
+ e.preventDefault();
617
776
  } else if (
618
- (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.selected_node) ||
619
- (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node )
777
+ (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.layout_node) ||
778
+ (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node )
620
779
  ) {
621
- const node = doc.selected_node;
622
-
780
+ const node = doc.layout_node;
623
781
  if (doc.selection.type !== 'node') {
624
782
  doc.select_parent();
625
783
  }
626
784
  const old_selection = { ...doc.selection };
627
-
628
785
  const node_array_schema = doc.inspect(doc.selection.path);
629
786
  // If we are not dealing with a node selection in a container, return
630
787
  if (node_array_schema.type !== 'node_array') return;
631
-
632
788
  const current_type_index = node_array_schema.node_types.indexOf(node.type);
633
789
  const next_type_index = (current_type_index + 1) % node_array_schema.node_types.length;
634
790
  const next_type = node_array_schema.node_types[next_type_index];
@@ -636,31 +792,27 @@ ${fallback_html}`;
636
792
  doc.config.inserters[next_type](tr);
637
793
  tr.set_selection(old_selection);
638
794
  doc.apply(tr);
795
+ e.preventDefault();
639
796
  } else if (
640
- (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.selected_node) ||
641
- (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node)
797
+ (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.layout_node) ||
798
+ (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
642
799
  ) {
643
- const node = doc.selected_node;
644
-
800
+ const node = doc.layout_node;
645
801
  if (doc.selection.type !== 'node') {
646
802
  doc.select_parent();
647
803
  }
648
-
649
- // if (selection.type !== 'node') return;
650
804
  const old_selection = { ...doc.selection };
651
805
  const node_array_schema = doc.inspect(doc.selection.path);
652
806
  // If we are not dealing with a node selection in a container, return
653
807
  if (node_array_schema.type !== 'node_array') return;
654
-
655
808
  const current_type_index = node_array_schema.node_types.indexOf(node.type);
656
- // return (current_layout_number - 1 + layout_count) % layout_count;
657
809
  const prev_type_index = (current_type_index - 1 + node_array_schema.node_types.length) % node_array_schema.node_types.length;
658
810
  const prev_type = node_array_schema.node_types[prev_type_index];
659
811
  const tr = doc.tr;
660
812
  doc.config.inserters[prev_type](tr);
661
813
  tr.set_selection(old_selection);
662
814
  doc.apply(tr);
663
-
815
+ e.preventDefault();
664
816
  } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
665
817
  const tr = doc.tr;
666
818
  if (select_all(tr)) {
@@ -676,76 +828,32 @@ ${fallback_html}`;
676
828
  doc.redo();
677
829
  e.preventDefault();
678
830
  e.stopPropagation();
679
- } else if (e.key === 'Enter' && (e.shiftKey)) {
831
+ } else if (e.key === 'Enter' && (e.shiftKey) && !is_mobile_browser() && selection?.type === 'text' && doc.inspect(selection.path).allow_newlines) {
680
832
  doc.apply(doc.tr.insert_text('\n'));
681
833
  e.preventDefault();
682
834
  e.stopPropagation();
683
- } else if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
684
- doc.apply(doc.tr.annotate_text('link', {
685
- href: window.prompt('Enter the URL', 'https://example.com')
686
- }));
835
+ } else if (e.key === 'b' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
836
+ doc.apply(doc.tr.annotate_text('strong'));
687
837
  e.preventDefault();
688
838
  e.stopPropagation();
689
- } else if (e.key === 'Backspace') {
690
- if (selection?.type === 'property') {
691
- // For property selections, clear the property value only if it's not already falsy
692
- if (doc.get(selection.path)) {
693
- const tr = doc.tr;
694
- tr.set(selection.path, '');
695
- doc.apply(tr);
696
- }
697
- } else {
698
- if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === 0 && doc.selection?.focus_offset === 0) {
699
- const tr = doc.tr;
700
- join_text_node(tr);
701
- doc.apply(tr);
702
- } else {
703
- // TODO: Things are not yet properly composable, I think we need to
704
- // realize delete_selection as a command as well.
705
- // See https://prosemirror.net/docs/guide/#commands for inspiration.
706
- // For other selections, use the normal delete behavior
707
- doc.apply(doc.tr.delete_selection());
708
- }
709
- }
710
-
839
+ } else if (e.key === 'i' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
840
+ doc.apply(doc.tr.annotate_text('emphasis'));
711
841
  e.preventDefault();
712
842
  e.stopPropagation();
713
- } else if (e.key === 'Delete') {
714
- // Forward delete: only handle collapsed text selections
715
- if (selection?.type === 'text' && is_collapsed) {
716
- const text_content = doc.get(selection.path)[0];
717
- const text_length = get_char_length(text_content);
718
-
719
- if (selection.focus_offset < text_length) {
720
- // There's a character after the cursor - select and delete it
721
- const tr = doc.tr;
722
- tr.set_selection({
723
- type: 'text',
724
- path: [...selection.path],
725
- anchor_offset: selection.focus_offset,
726
- focus_offset: selection.focus_offset + 1
727
- });
728
- tr.delete_selection();
729
- doc.apply(tr);
730
- } else {
731
- // At end of text - try to join with next text node
732
- const tr = doc.tr;
733
- const node_index = parseInt(selection.path.at(-2), 10);
734
- const successor_node = doc.get([...selection.path.slice(0, -2), node_index + 1]);
735
-
736
- // Check if next node is a text node
737
- if (successor_node && doc.kind(successor_node) === 'text') {
738
- // Set selection to beginning of next text node
739
- tr.set_selection({
740
- type: 'text',
741
- path: [...selection.path.slice(0, -2), node_index + 1, 'content'],
742
- anchor_offset: 0,
743
- focus_offset: 0
744
- });
745
- // Use join_text_node to merge with previous
746
- join_text_node(tr);
747
- doc.apply(tr);
748
- }
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 }));
749
857
  }
750
858
  }
751
859
 
@@ -760,16 +868,10 @@ ${fallback_html}`;
760
868
  const span_length = Math.abs(selection.focus_offset - selection.anchor_offset);
761
869
 
762
870
  if (is_collapsed) {
763
- // Try to insert default node if there's only one allowed ref_type
871
+ // Always insert default node on ENTER
764
872
  const tr = doc.tr;
765
873
  insert_default_node(tr);
766
874
  doc.apply(tr);
767
- // if (insert_default_node(tr)) {
768
- // doc.apply(tr);
769
- // } else {
770
- // // Fall back to focusing toolbar when multiple types are available
771
- // focus_toolbar();
772
- // }
773
875
  } else if (span_length === 1) {
774
876
  focus_toolbar();
775
877
  }
@@ -1023,7 +1125,7 @@ ${fallback_html}`;
1023
1125
  // 2. Last child node is a <br> (the visual representation of that \n)
1024
1126
  // 3. Cursor is positioned at or near the end
1025
1127
  // And returns the correct text.length position instead.
1026
- const text_content = doc.get(path)[0];
1128
+ const text_content = doc.get(path).text;
1027
1129
  const text_length = get_char_length(text_content);
1028
1130
  if (text_length > 0) {
1029
1131
  const last_char = get_char_at(text_content, text_length - 1);
@@ -1184,7 +1286,7 @@ ${fallback_html}`;
1184
1286
  (is_backward ? focus_node : anchor_node).scrollIntoView({ block: 'nearest', inline: 'nearest' });
1185
1287
  }, 0);
1186
1288
  } else {
1187
- console.log('no container element found!!');
1289
+ console.log('No container element found!');
1188
1290
  }
1189
1291
  }
1190
1292
 
@@ -1193,7 +1295,6 @@ ${fallback_html}`;
1193
1295
  // The element that holds the property
1194
1296
  const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="property"]`);
1195
1297
  const cursor_trap_selectable = el.querySelector('.svedit-selectable');
1196
-
1197
1298
  const range = window.document.createRange();
1198
1299
  const dom_selection = window.getSelection();
1199
1300
 
@@ -1202,13 +1303,21 @@ ${fallback_html}`;
1202
1303
  range.collapse(true); // Collapse to start position
1203
1304
  dom_selection.removeAllRanges();
1204
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);
1205
1314
  }
1206
1315
 
1207
1316
  function __render_text_selection() {
1208
1317
  const selection = /** @type {any} */ (doc.selection);
1209
1318
  // The element that holds the annotated string
1210
1319
  const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="text"]`);
1211
- const empty_text = doc.get(selection.path)[0].length === 0;
1320
+ const empty_text = doc.get(selection.path).text.length === 0;
1212
1321
 
1213
1322
  const range = window.document.createRange();
1214
1323
  const dom_selection = window.getSelection();
@@ -1261,7 +1370,6 @@ ${fallback_html}`;
1261
1370
  return true; // Stop iteration
1262
1371
  }
1263
1372
  }
1264
-
1265
1373
  current_offset += node_char_length;
1266
1374
  } else if (node.nodeType === Node.ELEMENT_NODE) {
1267
1375
  for (const child_node of node.childNodes) {
@@ -1284,16 +1392,6 @@ ${fallback_html}`;
1284
1392
  for (const child_node of el.childNodes) {
1285
1393
  if (process_node(child_node)) break;
1286
1394
  }
1287
-
1288
- // EDGE CASE: cursor at the end of an annotation
1289
- // TODO: It seems this edge case is handled elsewhere. We keep around the checker for a
1290
- // while and delete this branch, if we don't see any problems.
1291
- if (anchor_node && !focus_node && current_offset === end_offset) {
1292
- alert('EDGE CASE: cursor at the end of an annotation.');
1293
- // console.log('EDGE CASE: cursor at the end of an annotation');
1294
- // focus_node = /** @type {HTMLElement | Text} */ (anchor_node.nextSibling || anchor_node);
1295
- // focus_node_offset = focus_node === anchor_node ? anchor_node.length : 0;
1296
- }
1297
1395
  }
1298
1396
 
1299
1397
  // Set the range if both start and end were found
@@ -1379,14 +1477,15 @@ ${fallback_html}`;
1379
1477
  class="svedit-canvas {css_class}"
1380
1478
  class:hide-selection={doc.selection?.type === 'node'}
1381
1479
  class:node-cursor={doc.selection?.type === 'node' && doc.selection.anchor_offset === doc.selection.focus_offset}
1480
+ class:property-selection={doc.selection?.type === 'property'}
1382
1481
  bind:this={canvas_ref}
1383
1482
  {oninput}
1384
1483
  {onbeforeinput}
1385
1484
  {oncompositionstart}
1386
1485
  {oncompositionend}
1387
1486
  contenteditable={editable ? 'true' : 'false'}
1388
- autocapitalize="off"
1389
- spellcheck="true"
1487
+ {autocapitalize}
1488
+ {spellcheck}
1390
1489
  {...{
1391
1490
  // NOTE: Autocomplete and autocorrect make immense troubles
1392
1491
  // on Desktop Chrome, so we disable them only for Chrome desktop.
@@ -1423,7 +1522,7 @@ ${fallback_html}`;
1423
1522
  }
1424
1523
 
1425
1524
  /* When the cursor is in a cursor-trap we never want to see the caret */
1426
- .svedit-canvas.node-cursor {
1525
+ .svedit-canvas.node-cursor, .svedit-canvas.property-selection {
1427
1526
  caret-color: transparent;
1428
1527
  }
1429
1528