svedit 0.11.0 → 0.12.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 (59) hide show
  1. package/README.md +230 -64
  2. package/dist/Command.svelte.d.ts +21 -5
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +86 -30
  5. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  6. package/dist/KeyMapper.svelte.js +18 -6
  7. package/dist/Node.svelte +29 -1
  8. package/dist/Node.svelte.d.ts.map +1 -1
  9. package/dist/NodeArrayProperty.svelte +119 -12
  10. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  11. package/dist/NodeGap.svelte +48 -6
  12. package/dist/NodeGap.svelte.d.ts.map +1 -1
  13. package/dist/Session.svelte.d.ts +31 -14
  14. package/dist/Session.svelte.d.ts.map +1 -1
  15. package/dist/Session.svelte.js +155 -77
  16. package/dist/Svedit.svelte +110 -89
  17. package/dist/Svedit.svelte.d.ts.map +1 -1
  18. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  19. package/dist/TextProperty.svelte.d.ts +7 -0
  20. package/dist/TextProperty.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +78 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +412 -234
  24. package/dist/doc_utils.d.ts +96 -21
  25. package/dist/doc_utils.d.ts.map +1 -1
  26. package/dist/doc_utils.js +440 -168
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/node_visibility.svelte.d.ts +30 -4
  30. package/dist/node_visibility.svelte.d.ts.map +1 -1
  31. package/dist/node_visibility.svelte.js +327 -363
  32. package/dist/paste_utils.d.ts +3 -2
  33. package/dist/paste_utils.d.ts.map +1 -1
  34. package/dist/paste_utils.js +16 -13
  35. package/dist/transforms.svelte.d.ts.map +1 -1
  36. package/dist/transforms.svelte.js +5 -6
  37. package/dist/types.d.ts +112 -42
  38. package/dist/utils.d.ts +89 -13
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +320 -85
  41. package/package.json +4 -1
  42. package/src/lib/Command.svelte.js +86 -30
  43. package/src/lib/KeyMapper.svelte.js +18 -6
  44. package/src/lib/Node.svelte +29 -1
  45. package/src/lib/NodeArrayProperty.svelte +119 -12
  46. package/src/lib/NodeGap.svelte +48 -6
  47. package/src/lib/Session.svelte.js +155 -77
  48. package/src/lib/Svedit.svelte +110 -89
  49. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  50. package/src/lib/Transaction.svelte.js +412 -234
  51. package/src/lib/doc_utils.js +440 -168
  52. package/src/lib/index.js +1 -1
  53. package/src/lib/node_visibility.svelte.js +327 -363
  54. package/src/lib/paste_utils.js +16 -13
  55. package/src/lib/transforms.svelte.js +5 -6
  56. package/src/lib/types.d.ts +112 -42
  57. package/src/lib/utils.js +320 -85
  58. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  59. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
@@ -118,7 +118,7 @@
118
118
  }
119
119
 
120
120
  // Test-only hook: expose the svedit context so test specs can read
121
- // near_map / edge_map for diagnostics. Gated on `import.meta.env.MODE`
121
+ // array_indices / edge_map for diagnostics. Gated on `import.meta.env.MODE`
122
122
  // — Vite replaces the literal so the comparison is constant-false in
123
123
  // production and the branch is dead-code-eliminated. Wrapped in an
124
124
  // effect so unmount clears the reference and tests don't leak state
@@ -185,21 +185,21 @@
185
185
  }
186
186
 
187
187
  if (event.inputType === 'formatBold' && session.selection?.type === 'text') {
188
- session.apply(session.tr.annotate_text('strong'));
188
+ session.apply(session.tr.toggle_mark('strong'));
189
189
  event.preventDefault();
190
190
  event.stopPropagation();
191
191
  }
192
192
 
193
193
  if (event.inputType === 'formatItalic' && session.selection?.type === 'text') {
194
- session.apply(session.tr.annotate_text('emphasis'));
194
+ session.apply(session.tr.toggle_mark('emphasis'));
195
195
  event.preventDefault();
196
196
  event.stopPropagation();
197
197
  }
198
198
 
199
- // NOTE: underline doesn't make much sense as a semantic annotation,
199
+ // NOTE: underline doesn't make much sense as a semantic mark,
200
200
  // so we rewire `cmd + u` to toggle highlights
201
201
  if (event.inputType === 'formatUnderline' && session.selection?.type === 'text') {
202
- session.apply(session.tr.annotate_text('highlight'));
202
+ session.apply(session.tr.toggle_mark('highlight'));
203
203
  event.preventDefault();
204
204
  event.stopPropagation();
205
205
  }
@@ -398,14 +398,14 @@ ${fallback_html}`;
398
398
  for (const [prop_name, prop_value] of Object.entries(node)) {
399
399
  if (prop_name === 'id' || prop_name === 'type') continue;
400
400
  const property_definition = node_schema.properties[prop_name];
401
- // Check if this is an annotated_text property (object with text property)
402
- if (property_definition.type === 'annotated_text') {
403
- const text_content = prop_value.text;
401
+ // Check if this is a text property.
402
+ if (property_definition.type === 'text') {
403
+ const text_content = prop_value.content;
404
404
  if (text_content.trim()) {
405
405
  html += `<p>${text_content}</p>`;
406
406
  }
407
407
  } else if (property_definition.type === 'node_array') {
408
- for (const child_id of prop_value) {
408
+ for (const child_id of prop_value.nodes) {
409
409
  const child = session.get(child_id);
410
410
  const child_exporter = html_exporters[child.type] || default_node_html_exporter;
411
411
  html += child_exporter(child, session, html_exporters);
@@ -421,13 +421,13 @@ ${fallback_html}`;
421
421
  for (const [prop_name, prop_value] of Object.entries(node)) {
422
422
  if (prop_name === 'id' || prop_name === 'type') continue;
423
423
 
424
- // Check if this is an annotated_text property (object with text property)
424
+ // Check if this is a text property value.
425
425
  if (
426
426
  typeof prop_value === 'object' &&
427
427
  prop_value !== null &&
428
- typeof prop_value.text === 'string'
428
+ typeof prop_value.content === 'string'
429
429
  ) {
430
- const text_content = prop_value.text;
430
+ const text_content = prop_value.content;
431
431
  if (text_content.trim()) {
432
432
  plain_text += `${text_content.trim()}\n\n`;
433
433
  }
@@ -468,28 +468,6 @@ ${fallback_html}`;
468
468
  return plain_text.trim();
469
469
  }
470
470
 
471
- /**
472
- * @param {NodeId[]} selected_node_ids
473
- */
474
- function prepare_copy_payload(selected_node_ids) {
475
- const nodes = {};
476
-
477
- // Get subgraph for each selected node using session.traverse()
478
- for (const node_id of selected_node_ids) {
479
- const subgraph = session.traverse(node_id);
480
-
481
- // Add all nodes from this subgraph to our nodes collection
482
- for (const node of subgraph) {
483
- if (!nodes[node.id]) {
484
- nodes[node.id] = node;
485
- }
486
- }
487
- }
488
-
489
- // Keep original IDs - we'll generate new ones during paste
490
- return { nodes, main_nodes: selected_node_ids };
491
- }
492
-
493
471
  /**
494
472
  * @param {ClipboardEvent} event
495
473
  * @param {boolean} delete_selection - used by oncut()
@@ -502,24 +480,23 @@ ${fallback_html}`;
502
480
  event.preventDefault();
503
481
  event.stopPropagation();
504
482
 
505
- let plain_text, annotated_text, html;
483
+ let plain_text, text, html;
506
484
 
507
485
  if (session.selection?.type === 'text') {
508
486
  plain_text = session.get_selected_plain_text();
509
- annotated_text = session.get_selected_annotated_text();
510
- const fallback_html = `<span>${annotated_text.text}</span>`;
487
+ text = session.get_selected_text();
488
+ const fallback_html = `<span>${text.content}</span>`;
511
489
 
512
490
  // console.log('Text copy:', {
513
- // annotated_text,
491
+ // text,
514
492
  // plain_text,
515
493
  // html
516
494
  // });
517
495
 
518
- html = create_svedit_html_format(annotated_text, fallback_html);
496
+ html = create_svedit_html_format(text, fallback_html);
519
497
  } else if (session.selection?.type === 'node') {
520
- const selected_nodes = session.get_selected_nodes();
521
- const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
522
- const json_data = { nodes, main_nodes };
498
+ const json_data = session.get_selected_annotated_nodes();
499
+ const { nodes, main_nodes } = json_data;
523
500
 
524
501
  // console.log('Node copy:', {
525
502
  // selected_nodes,
@@ -567,8 +544,6 @@ ${fallback_html}`;
567
544
  oncopy(event, true);
568
545
  }
569
546
 
570
-
571
-
572
547
  /**
573
548
  * @returns {NodeSelection|null}
574
549
  */
@@ -587,13 +562,13 @@ ${fallback_html}`;
587
562
 
588
563
  const node_array_path = [...path, preferred_property_name];
589
564
  const node_array = session.get(node_array_path);
590
- if (!Array.isArray(node_array)) return null;
565
+ if (!node_array || !Array.isArray(node_array.nodes)) return null;
591
566
 
592
567
  return {
593
568
  type: /** @type {const} */ ('node'),
594
569
  path: node_array_path,
595
- anchor_offset: node_array.length,
596
- focus_offset: node_array.length
570
+ anchor_offset: node_array.nodes.length,
571
+ focus_offset: node_array.nodes.length
597
572
  };
598
573
  }
599
574
 
@@ -639,7 +614,7 @@ ${fallback_html}`;
639
614
  * @returns {boolean} True if the paste operation was successful, false otherwise
640
615
  */
641
616
  function try_node_paste(pasted_json, selection) {
642
- const { nodes, main_nodes } = pasted_json || {};
617
+ const { nodes, main_nodes, marks = [], annotations = [] } = pasted_json || {};
643
618
  if (!nodes || !main_nodes?.length) return false;
644
619
 
645
620
  let tr = session.tr;
@@ -652,7 +627,10 @@ ${fallback_html}`;
652
627
  if (property_definition?.type !== 'node_array') return false;
653
628
 
654
629
  const default_text_node_type = get_default_text_node(property_definition, session.schema);
655
- const target_text_property_name = get_text_property_name(default_text_node_type, session.schema);
630
+ const target_text_property_name = get_text_property_name(
631
+ default_text_node_type,
632
+ session.schema
633
+ );
656
634
 
657
635
  const nodes_to_insert = [];
658
636
  let rejected = false;
@@ -674,7 +652,11 @@ ${fallback_html}`;
674
652
  the_node: {
675
653
  id: 'the_node',
676
654
  type: default_text_node_type,
677
- [target_text_property_name]: text_content || { text: '', annotations: [] }
655
+ [target_text_property_name]: text_content || {
656
+ content: '',
657
+ marks: [],
658
+ annotations: []
659
+ }
678
660
  }
679
661
  });
680
662
  nodes_to_insert.push(new_node_id);
@@ -689,7 +671,7 @@ ${fallback_html}`;
689
671
  }
690
672
 
691
673
  if (!rejected) {
692
- tr.insert_nodes(nodes_to_insert);
674
+ tr.insert_nodes(nodes_to_insert, marks, annotations, nodes);
693
675
  session.apply(tr);
694
676
  return true;
695
677
  }
@@ -766,7 +748,7 @@ ${fallback_html}`;
766
748
 
767
749
  if (session.selection?.type === 'text') {
768
750
  const property_definition = session.inspect(session.selection.path);
769
- if (property_definition?.type === 'annotated_text' && !property_definition.allow_newlines) {
751
+ if (property_definition?.type === 'text' && !property_definition.allow_newlines) {
770
752
  plain_text = normalize_plain_text_for_single_line_property(plain_text);
771
753
  }
772
754
 
@@ -777,7 +759,10 @@ ${fallback_html}`;
777
759
  const node_array_property_definition = session.inspect(
778
760
  session.selection.path.slice(0, -2)
779
761
  );
780
- const default_text_node_type = get_default_text_node(node_array_property_definition, session.schema);
762
+ const default_text_node_type = get_default_text_node(
763
+ node_array_property_definition,
764
+ session.schema
765
+ );
781
766
  const node_insert_caret = get_node_insert_caret_after_text_selection(session.selection);
782
767
  const plain_text_nodes_payload = create_plain_text_nodes_payload(
783
768
  plain_text_fragments,
@@ -796,7 +781,10 @@ ${fallback_html}`;
796
781
  const target_node_insert_caret = get_target_node_insert_caret(session.selection);
797
782
  if (target_node_insert_caret) {
798
783
  const node_array_property_definition = session.inspect(target_node_insert_caret.path);
799
- const default_text_node_type = get_default_text_node(node_array_property_definition, session.schema);
784
+ const default_text_node_type = get_default_text_node(
785
+ node_array_property_definition,
786
+ session.schema
787
+ );
800
788
  pasted_json = create_plain_text_nodes_payload(
801
789
  plain_text_fragments,
802
790
  default_text_node_type,
@@ -831,10 +819,15 @@ ${fallback_html}`;
831
819
  session.apply(session.tr.set(session.selection.path, pasted_json.value));
832
820
  }
833
821
  }
834
- } else if (session.selection?.type === 'text' && pasted_json?.text) {
822
+ } else if (session.selection?.type === 'text' && pasted_json?.content) {
835
823
  // Paste text at a text selection
836
824
  session.apply(
837
- session.tr.insert_text(pasted_json.text, pasted_json.annotations, pasted_json.nodes)
825
+ session.tr.insert_text(
826
+ pasted_json.content,
827
+ pasted_json.marks,
828
+ pasted_json.annotations,
829
+ pasted_json.nodes
830
+ )
838
831
  );
839
832
  } else if (
840
833
  session.selection?.type === 'text' &&
@@ -848,7 +841,12 @@ ${fallback_html}`;
848
841
  );
849
842
  if (text_property) {
850
843
  session.apply(
851
- session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
844
+ session.tr.insert_text(
845
+ text_property.content,
846
+ text_property.marks,
847
+ text_property.annotations,
848
+ pasted_json.nodes
849
+ )
852
850
  );
853
851
  }
854
852
  } else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
@@ -881,7 +879,7 @@ ${fallback_html}`;
881
879
  // reuses gap elements with shifted data-gap-offset), so for those
882
880
  // we always rerender to scroll the cursor into view.
883
881
  const is_empty_text_selection =
884
- selection.type === 'text' && session.get(selection.path).text.length === 0;
882
+ selection.type === 'text' && session.get(selection.path).content.length === 0;
885
883
  if (dom_driven && !is_empty_text_selection) {
886
884
  const prev_selection = __get_selection_from_dom();
887
885
  if (
@@ -985,7 +983,7 @@ ${fallback_html}`;
985
983
  const empty_placeholder_path = deserialize_path(focus_empty_placeholder.dataset.path);
986
984
  const array_path = empty_placeholder_path.slice(0, -1);
987
985
  const node_array = session.get(array_path);
988
- if (Array.isArray(node_array) && node_array.length === 0) {
986
+ if (node_array?.nodes?.length === 0) {
989
987
  return {
990
988
  type: 'node',
991
989
  path: array_path,
@@ -1210,7 +1208,7 @@ ${fallback_html}`;
1210
1208
 
1211
1209
  // EDGE CASE 1B: Caret after trailing <br> at end of text
1212
1210
  //
1213
- // AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
1211
+ // TextProperty renders a trailing <br> for non-empty or non-focused text.
1214
1212
  // When the user places their caret after this <br>, focusNode is the container
1215
1213
  // element (not a text node), and normal processing would return position 0.
1216
1214
  // We detect this and return the current DOM text length instead.
@@ -1336,7 +1334,10 @@ ${fallback_html}`;
1336
1334
  if (!anchor_sel || !focus_sel) return;
1337
1335
 
1338
1336
  if (is_backward) {
1339
- dom_selection.removeAllRanges();
1337
+ // setBaseAndExtent replaces the current selection, so no
1338
+ // removeAllRanges is needed — every selection-API call forces a
1339
+ // synchronous layout when the DOM is dirty, and this runs right
1340
+ // after the per-change reconcile on every apply.
1340
1341
  dom_selection.setBaseAndExtent(anchor_sel, 1, focus_sel, 1);
1341
1342
  } else {
1342
1343
  range.setStart(anchor_sel, 1);
@@ -1352,28 +1353,49 @@ ${fallback_html}`;
1352
1353
  // — so an unconditional scroll would yank the viewport on each of
1353
1354
  // them. cursor_offset is a gap offset and the gap has no box of
1354
1355
  // its own, so visibility is judged from the nodes flanking it.
1355
- setTimeout(() => {
1356
- // Collapsed: anchor === focus, so focus is the gap offset.
1357
- // Range: cursor sits at the focus end (anchor when backward).
1358
- const cursor_offset = is_backward ? selection.anchor_offset : selection.focus_offset;
1359
- const array_length = session.get(node_array_path).length;
1356
+ const node_array = session.get(node_array_path);
1357
+ if (!node_array?.nodes) return;
1358
+
1359
+ // Collapsed: anchor === focus, so focus is the gap offset.
1360
+ // Range: cursor sits at the focus end (anchor when backward).
1361
+ const cursor_offset = is_backward ? selection.anchor_offset : selection.focus_offset;
1362
+ const array_length = node_array.nodes.length;
1363
+
1364
+ // node_before is null at offset 0: cursor_offset - 1 would be -1,
1365
+ // and serialize_path rejects a negative index.
1366
+ const node_before =
1367
+ cursor_offset > 0 ? __get_node_element(node_array_path, cursor_offset - 1) : null;
1368
+ const node_after = __get_node_element(node_array_path, cursor_offset);
1369
+
1370
+ let scroll_target = null;
1371
+ let scroll_array_to_start = false;
1372
+ let scroll_array_to_end = false;
1373
+
1374
+ if (cursor_offset === 0) {
1375
+ scroll_array_to_start = true;
1376
+ } else if (cursor_offset >= array_length) {
1377
+ scroll_array_to_end = true;
1378
+ } else {
1379
+ // A range selection's selected node IS node_before; scrolling
1380
+ // node_after would reveal the following node and leave the
1381
+ // selection itself off-screen. For a collapsed caret node_after's
1382
+ // leading edge is the cursor, so that stays the right target.
1383
+ scroll_target = is_collapsed ? node_after : node_before;
1384
+ }
1360
1385
 
1386
+ setTimeout(() => {
1361
1387
  // If either node flanking the cursor is already (even
1362
1388
  // partially) on screen, the cursor is visible — keep the
1363
- // viewport stable and scroll nothing. node_before is null at
1364
- // offset 0: cursor_offset - 1 would be -1, and serialize_path
1365
- // rejects a negative index.
1366
- const node_before =
1367
- cursor_offset > 0 ? __get_node_element(node_array_path, cursor_offset - 1) : null;
1368
- const node_after = __get_node_element(node_array_path, cursor_offset);
1389
+ // viewport stable and scroll nothing. Do this layout read in the
1390
+ // deferred callback, after Svelte/browser layout has settled.
1369
1391
  if (__intersects_viewport(node_before) || __intersects_viewport(node_after)) return;
1370
1392
 
1371
- if (cursor_offset === 0) {
1393
+ if (scroll_array_to_start) {
1372
1394
  node_array_el.scrollLeft = 0;
1373
1395
  node_array_el.scrollTop = 0;
1374
1396
  return;
1375
1397
  }
1376
- if (cursor_offset >= array_length) {
1398
+ if (scroll_array_to_end) {
1377
1399
  const max_left = Math.max(0, node_array_el.scrollWidth - node_array_el.clientWidth);
1378
1400
  const max_top = Math.max(0, node_array_el.scrollHeight - node_array_el.clientHeight);
1379
1401
  node_array_el.scrollLeft = max_left;
@@ -1383,12 +1405,7 @@ ${fallback_html}`;
1383
1405
  }
1384
1406
  return;
1385
1407
  }
1386
- // A range selection's selected node IS node_before; scrolling
1387
- // node_after would reveal the following node and leave the
1388
- // selection itself off-screen. For a collapsed caret node_after's
1389
- // leading edge is the cursor, so that stays the right target.
1390
- const target = is_collapsed ? node_after : node_before;
1391
- target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1408
+ scroll_target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1392
1409
  }, 0);
1393
1410
  }
1394
1411
 
@@ -1421,7 +1438,7 @@ ${fallback_html}`;
1421
1438
  const el = canvas_el.querySelector(
1422
1439
  `[data-path="${serialize_path(selection.path)}"][data-type="text"]`
1423
1440
  );
1424
- const empty_text = session.get(selection.path).text.length === 0;
1441
+ const empty_text = session.get(selection.path).content.length === 0;
1425
1442
  const dom_selection = window.getSelection();
1426
1443
  let current_offset = 0;
1427
1444
  /** @type {HTMLElement | Text} */
@@ -1498,8 +1515,10 @@ ${fallback_html}`;
1498
1515
 
1499
1516
  // Set the range if both start and end were found
1500
1517
  if (anchor_node && focus_node) {
1501
- dom_selection.removeAllRanges();
1502
- // NOTE: Only using setBaseAndExtent() will preserve selection direction
1518
+ // NOTE: Only using setBaseAndExtent() will preserve selection direction.
1519
+ // It also replaces the current selection, so no removeAllRanges is
1520
+ // needed — every selection-API call forces a synchronous layout when
1521
+ // the DOM is dirty, and this runs on every apply.
1503
1522
  dom_selection.setBaseAndExtent(
1504
1523
  anchor_node,
1505
1524
  anchor_node_offset,
@@ -1507,11 +1526,13 @@ ${fallback_html}`;
1507
1526
  focus_node_offset
1508
1527
  );
1509
1528
 
1510
- // Scroll the selection into view
1529
+ // Scroll the rendered selection into view. Capture the target now:
1530
+ // the browser selection is live and may be cleared before this
1531
+ // deferred callback runs, e.g. when app UI focuses an external input.
1532
+ const selected_element = focus_node.parentElement;
1511
1533
  setTimeout(() => {
1512
- const selectedElement = dom_selection.focusNode.parentElement;
1513
- if (selectedElement) {
1514
- selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1534
+ if (selected_element?.isConnected) {
1535
+ selected_element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
1515
1536
  }
1516
1537
  }, 0);
1517
1538
  }
@@ -1528,7 +1549,7 @@ ${fallback_html}`;
1528
1549
  );
1529
1550
  if (!text_el) return;
1530
1551
 
1531
- const model_text = session.get(selection.path).text;
1552
+ const model_text = session.get(selection.path).content;
1532
1553
  if ((text_el.textContent ?? '') === model_text) return;
1533
1554
 
1534
1555
  let current_offset = 0;
@@ -1625,7 +1646,7 @@ ${fallback_html}`;
1625
1646
  -->
1626
1647
  <div
1627
1648
  class="svedit-canvas {css_class}"
1628
- class:hide-selection={session.selection?.type === 'node'}
1649
+ class:hide-selection={editable && session.selection?.type === 'node'}
1629
1650
  class:node-caret={session.selection?.type === 'node' &&
1630
1651
  session.selection.anchor_offset === session.selection.focus_offset}
1631
1652
  class:property-selection={session.selection?.type === 'property'}
@@ -1 +1 @@
1
- {"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AA0mDA;;eAAqD;iCA9jD1C,cAAc"}
1
+ {"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AA+nDA;;eAAqD;iCAnlD1C,cAAc"}
@@ -5,14 +5,15 @@
5
5
  get_char_length,
6
6
  paths_equal,
7
7
  serialize_path,
8
- get_selection_range
8
+ get_selection_range,
9
+ calculate_fragment_ranges
9
10
  } from './utils.js';
10
11
 
11
- /** @import { AnnotatedTextPropertyProps, Annotation, Fragment, SelectionRange } from './types.d.ts'; */
12
+ /** @import { TextPropertyProps, Mark, Fragment, SelectionRange } from './types.d.ts'; */
12
13
 
13
14
  const svedit = getContext('svedit');
14
15
 
15
- /** @type {AnnotatedTextPropertyProps} */
16
+ /** @type {TextPropertyProps} */
16
17
  let { path, class: css_class, placeholder = '', tag = 'div', style = '', ...rest } = $props();
17
18
 
18
19
  let path_str = $derived(serialize_path(path));
@@ -23,16 +24,17 @@
23
24
  );
24
25
  });
25
26
 
26
- let plain_text = $derived(svedit.session.get(path).text);
27
- let is_empty = $derived(
28
- get_char_length(plain_text) === 0 && !(svedit.is_composing && is_focused)
29
- );
27
+ let plain_text = $derived(svedit.session.get(path).content);
28
+ // A string has zero grapheme clusters iff it has zero code units, so a
29
+ // plain length check avoids a full Intl.Segmenter pass (this derived
30
+ // re-runs for every text property on every document change).
31
+ let is_empty = $derived(plain_text.length === 0 && !(svedit.is_composing && is_focused));
30
32
 
31
33
  let is_collapsed = $derived(
32
34
  is_focused && svedit.session.selection?.anchor_offset == svedit.session.selection?.focus_offset
33
35
  );
34
36
 
35
- // Get selection highlight range if not inside an annotation
37
+ // Get selection highlight range if it does not touch marks.
36
38
  // Only render selection highlight when canvas is NOT focused.
37
39
  // This avoids DOM mutations (splitting text nodes for highlight spans)
38
40
  // while the user is actively selecting, which would cause selection
@@ -41,7 +43,7 @@
41
43
  if (svedit.canvas_focused) return null;
42
44
  if (is_collapsed) return null;
43
45
  if (!is_focused) return null;
44
- if (svedit.session.active_annotation()) return null;
46
+ if (svedit.session.selected_marks.length > 0) return null;
45
47
  const sel = svedit.session.selection;
46
48
  if (!sel || sel.type !== 'text') return null;
47
49
  return get_selection_range(sel);
@@ -49,61 +51,58 @@
49
51
 
50
52
  let fragments = $derived(
51
53
  get_fragments(
52
- svedit.session.get(path).text,
53
- svedit.session.get(path).annotations,
54
+ svedit.session.get(path).content,
55
+ svedit.session.get(path).marks,
54
56
  selection_highlight_range
55
57
  )
56
58
  );
57
59
 
58
60
  /**
59
- * Converts text with annotations into renderable fragments for display.
61
+ * Converts text with marks into renderable fragments for display.
62
+ * Annotations never fragment the text; they are data-only.
60
63
  * @param {string} text - The plain text content
61
- * @param {Array<Annotation>} annotations - Array of annotations
64
+ * @param {Array<Mark>} marks - Marks to render in-place
62
65
  * @param {SelectionRange} [selection_highlight_range] - Optional selection highlight range
63
66
  * @returns {Array<Fragment>} Array of fragments
64
67
  */
65
- function get_fragments(text, annotations, selection_highlight_range) {
68
+ function get_fragments(text, marks, selection_highlight_range) {
69
+ // Fast path: no marks and no selection highlight means the whole text
70
+ // is a single content fragment. This runs for EVERY text property on
71
+ // EVERY document change, so skipping the two full Intl.Segmenter passes
72
+ // (get_char_length + char_slice over the whole text) matters at scale.
73
+ if (marks.length === 0 && !selection_highlight_range) {
74
+ return text.length > 0 ? [text] : [];
75
+ }
76
+
77
+ const ranges = calculate_fragment_ranges(
78
+ get_char_length(text),
79
+ marks,
80
+ selection_highlight_range
81
+ );
66
82
  /** @type {Array<Fragment>} */
67
83
  let fragments = [];
68
- let last_index = 0;
69
-
70
- // Merge annotations with selection highlight and sort by start offset
71
- const ranges = [
72
- ...annotations,
73
- ...(selection_highlight_range ? [selection_highlight_range] : [])
74
- ].sort((a, b) => a.start_offset - b.start_offset);
75
84
 
76
85
  for (const range of ranges) {
77
- // Add text before this range
78
- if (range.start_offset > last_index) {
79
- fragments.push(char_slice(text, last_index, range.start_offset));
80
- }
81
-
82
86
  const content = char_slice(text, range.start_offset, range.end_offset);
83
87
 
84
- if ('node_id' in range) {
88
+ if (range.type === 'mark') {
85
89
  const node = svedit.session.get(range.node_id);
86
- if (!node) throw new Error(`Node not found for annotation ${range.node_id}`);
90
+ if (!node) throw new Error(`Node not found for mark ${range.node_id}`);
87
91
 
88
92
  fragments.push({
89
- type: 'annotation',
93
+ type: 'mark',
90
94
  node,
91
95
  content,
92
- annotation_index: annotations.indexOf(/** @type {Annotation} */ (range))
96
+ mark_index: range.mark_index
93
97
  });
94
- } else {
98
+ } else if (range.type === 'selection_highlight') {
95
99
  fragments.push({
96
100
  type: 'selection_highlight',
97
101
  content
98
102
  });
103
+ } else {
104
+ fragments.push(content);
99
105
  }
100
-
101
- last_index = range.end_offset;
102
- }
103
-
104
- // Add any remaining text
105
- if (last_index < get_char_length(text)) {
106
- fragments.push(char_slice(text, last_index));
107
106
  }
108
107
 
109
108
  return fragments;
@@ -130,12 +129,14 @@
130
129
  {#if typeof fragment === 'string'}{fragment}{:else if fragment.type === 'selection_highlight'}<span
131
130
  class="selection-highlight"
132
131
  style="anchor-name: --selection-highlight;">{fragment.content}</span
133
- >{:else if fragment.type === 'annotation'}
134
- {@const AnnotationComponent = svedit.session.config.node_components[fragment.node.type]}
135
- <AnnotationComponent
136
- path={[...path, 'annotations', fragment.annotation_index, 'node_id']}
137
- content={fragment.content}
138
- />
132
+ >{:else if fragment.type === 'mark'}
133
+ {@const MarkComponent = svedit.session.config.node_components[fragment.node.type]}
134
+ {#if MarkComponent}
135
+ <MarkComponent
136
+ path={[...path, 'marks', fragment.mark_index, 'node_id']}
137
+ content={fragment.content}
138
+ />
139
+ {:else}<span class="mark-{fragment.node.type}">{fragment.content}</span>{/if}
139
140
  {/if}
140
141
  {/each}<!--
141
142
  -->{#if !is_focused || !is_empty}<br />{/if}
@@ -0,0 +1,7 @@
1
+ export default TextProperty;
2
+ type TextProperty = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<TextPropertyProps>): void;
5
+ };
6
+ declare const TextProperty: import("svelte").Component<import("./types.d.ts").TextPropertyProps, {}, "">;
7
+ //# sourceMappingURL=TextProperty.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/TextProperty.svelte.js"],"names":[],"mappings":";;;;;AAyIA,yGAA2D"}