svedit 0.3.0 → 0.4.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.
@@ -10,6 +10,8 @@
10
10
  editable = $bindable(false),
11
11
  path,
12
12
  class: css_class,
13
+ autocapitalize = "on",
14
+ spellcheck = "true",
13
15
  } = $props();
14
16
 
15
17
  let canvas_ref;
@@ -27,13 +29,26 @@
27
29
  // Detect Chrome on desktop (not mobile) - only available in browser
28
30
  let is_chrome_desktop = $state(false);
29
31
 
32
+ /**
33
+ * Detect if the current browser is on a mobile device
34
+ * @returns {boolean} true if mobile browser, false otherwise
35
+ */
36
+ function is_mobile_browser() {
37
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
38
+ return false;
39
+ }
40
+
41
+ const user_agent = navigator.userAgent;
42
+ return /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
43
+ ('ontouchstart' in window) ||
44
+ (navigator.maxTouchPoints > 0);
45
+ }
46
+
30
47
  $effect(() => {
31
48
  if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
32
49
  const user_agent = navigator.userAgent;
33
50
  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);
51
+ const is_mobile = is_mobile_browser();
37
52
  is_chrome_desktop = is_chrome && !is_mobile;
38
53
  }
39
54
  });
@@ -86,7 +101,7 @@
86
101
 
87
102
  function commit_input() {
88
103
  if (input_selection?.type !== 'text') return;
89
- const model_text = doc.get(input_selection.path)[0];
104
+ const model_text = doc.get(input_selection.path).text;
90
105
  const dom_text_element = document.querySelector(`[data-path="${input_selection.path.join('.')}"]`);
91
106
  const dom_text = dom_text_element.textContent;
92
107
  const op = diff_text(model_text, dom_text);
@@ -142,25 +157,98 @@
142
157
 
143
158
  // Only take input when in a valid text selection inside the canvas
144
159
  if (
145
- !(canvas_ref?.contains(document.activeElement)) ||
146
- (doc.selection?.type !== 'text')
160
+ !(canvas_ref?.contains(document.activeElement))
147
161
  ) {
148
162
  event.preventDefault();
149
163
  return;
150
164
  }
151
165
 
152
- if (event.inputType === 'formatBold') {
166
+ if (event.inputType === 'formatBold' && doc.selection?.type === 'text') {
153
167
  doc.apply(doc.tr.annotate_text('strong'));
154
168
  event.preventDefault();
155
169
  event.stopPropagation();
156
170
  }
157
171
 
158
- if (event.inputType === 'formatItalic') {
172
+ if (event.inputType === 'formatItalic' && doc.selection?.type === 'text') {
159
173
  doc.apply(doc.tr.annotate_text('emphasis'));
160
174
  event.preventDefault();
161
175
  event.stopPropagation();
162
176
  }
163
177
 
178
+ // NOTE: underline doesn't make much sense as a semantic annotation,
179
+ // so we rewire `cmd + u` to toggle highlights
180
+ if (event.inputType === 'formatUnderline' && doc.selection?.type === 'text') {
181
+ doc.apply(doc.tr.annotate_text('highlight'));
182
+ event.preventDefault();
183
+ event.stopPropagation();
184
+ }
185
+
186
+
187
+ if (event.inputType === 'deleteContentBackward') {
188
+ if (doc.selection?.type === 'property') {
189
+ // For property selections, clear the property value only if it's not already falsy
190
+ if (doc.get(doc.selection.path)) {
191
+ const tr = doc.tr;
192
+ tr.set(doc.selection.path, '');
193
+ doc.apply(tr);
194
+ }
195
+ } else {
196
+ if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === 0 && doc.selection?.focus_offset === 0) {
197
+ const tr = doc.tr;
198
+ join_text_node(tr);
199
+ doc.apply(tr);
200
+ } else {
201
+ doc.apply(doc.tr.delete_selection());
202
+ }
203
+ }
204
+
205
+ event.preventDefault();
206
+ event.stopPropagation();
207
+ }
208
+
209
+ if (event.inputType === 'deleteContentForward') {
210
+ // Forward delete: only handle collapsed text selections
211
+ if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === doc.selection?.focus_offset) {
212
+ const text_content = doc.get(doc.selection.path).text;
213
+ const text_length = get_char_length(text_content);
214
+
215
+ if (doc.selection.focus_offset < text_length) {
216
+ // There's a character after the cursor - select and delete it
217
+ const tr = doc.tr;
218
+ tr.set_selection({
219
+ type: 'text',
220
+ path: [...doc.selection.path],
221
+ anchor_offset: doc.selection.focus_offset,
222
+ focus_offset: doc.selection.focus_offset + 1
223
+ });
224
+ tr.delete_selection();
225
+ doc.apply(tr);
226
+ } else {
227
+ // At end of text - try to join with next text node
228
+ const tr = doc.tr;
229
+ const node_index = parseInt(String(doc.selection.path.at(-2)), 10);
230
+ const successor_node = doc.get([...doc.selection.path.slice(0, -2), node_index + 1]);
231
+
232
+ // Check if next node is a text node
233
+ if (successor_node && doc.kind(successor_node) === 'text') {
234
+ // Set selection to beginning of next text node
235
+ tr.set_selection({
236
+ type: 'text',
237
+ path: [...doc.selection.path.slice(0, -2), node_index + 1, 'content'],
238
+ anchor_offset: 0,
239
+ focus_offset: 0
240
+ });
241
+ // Use join_text_node to merge with previous
242
+ join_text_node(tr);
243
+ doc.apply(tr);
244
+ }
245
+ }
246
+ }
247
+
248
+ event.preventDefault();
249
+ event.stopPropagation();
250
+ }
251
+
164
252
 
165
253
  // We remember the "input selection", because in some mobile browsers (Samsung Android) the onbeforeinput event
166
254
  // is fired on a different selection than the input event.
@@ -302,9 +390,9 @@ ${fallback_html}`;
302
390
  for (const [prop_name, prop_value] of Object.entries(node)) {
303
391
  if (prop_name === 'id' || prop_name === 'type') continue;
304
392
 
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];
393
+ // Check if this is an annotated_text property (object with text property)
394
+ if (typeof prop_value === 'object' && prop_value !== null && typeof prop_value.text === 'string') {
395
+ const text_content = prop_value.text;
308
396
  if (text_content.trim()) {
309
397
  html += `<p>${text_content}</p>\n`;
310
398
  has_content = true;
@@ -313,7 +401,7 @@ ${fallback_html}`;
313
401
  }
314
402
 
315
403
  if (!has_content) {
316
- // Generic fallback for unknown node types with no annotated_string content
404
+ // Generic fallback for unknown node types with no annotated_text content
317
405
  html += `<div data-node-type="${node.type}">Content from ${node.type}</div>\n`;
318
406
  }
319
407
 
@@ -389,7 +477,7 @@ ${fallback_html}`;
389
477
 
390
478
  console.log('Copy operation:', {
391
479
  selected_nodes,
392
- main_nodes,
480
+ nodes,
393
481
  total_nodes: Object.keys(nodes).length,
394
482
  operation: delete_selection ? 'cut' : 'copy'
395
483
  });
@@ -453,8 +541,6 @@ ${fallback_html}`;
453
541
  pasted_json = undefined;
454
542
  }
455
543
 
456
-
457
-
458
544
  if (pasted_json) {
459
545
  const tr = doc.tr;
460
546
 
@@ -478,12 +564,18 @@ ${fallback_html}`;
478
564
  for (const [property, value] of Object.entries(new_node)) {
479
565
  if (property === 'id' || property === 'type') continue;
480
566
 
481
- const prop_type = doc.schema[node.type]?.[property]?.type;
567
+ const prop_type = doc.schema[node.type]?.properties?.[property]?.type;
482
568
 
483
569
  if (prop_type === 'node_array' && Array.isArray(value)) {
484
570
  new_node[property] = value.map(ref_id => id_mapping[ref_id] || ref_id);
485
571
  } else if (prop_type === 'node' && typeof value === 'string') {
486
572
  new_node[property] = id_mapping[value] || value;
573
+ } else if (prop_type === 'annotated_text') {
574
+ const annotations = value.annotations.map(annotation => {
575
+ const {start_offset, end_offset, node_id} = annotation;
576
+ return {start_offset, end_offset, node_id: id_mapping[node_id] || node_id};
577
+ });
578
+ new_node[property] = {text: value.text, annotations};
487
579
  }
488
580
  }
489
581
 
@@ -588,37 +680,39 @@ ${fallback_html}`;
588
680
  const is_collapsed = selection?.anchor_offset === selection?.focus_offset;
589
681
 
590
682
  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)
683
+ (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.layout_node) ||
684
+ (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
593
685
  ) {
594
- const node = doc.selected_node;
686
+ const node = doc.layout_node;
595
687
  const layout_count = doc.config.node_layouts[node.type];
596
688
 
597
689
  if (layout_count > 1 && node?.layout) {
598
690
  const next_layout = (node.layout % layout_count) + 1;
599
691
  console.log('layout / count / next_layout', node.layout, layout_count, next_layout);
600
692
  const tr = doc.tr;
601
- tr.set([doc.selected_node?.id, 'layout'], next_layout);
693
+ tr.set([doc.layout_node?.id, 'layout'], next_layout);
602
694
  doc.apply(tr);
603
695
  }
696
+ e.preventDefault();
604
697
  } 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)
698
+ (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.layout_node) ||
699
+ (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
607
700
  ) {
608
- const node = doc.selected_node;
701
+ const node = doc.layout_node;
609
702
  const layout_count = doc.config.node_layouts[node.type];
610
703
  if (layout_count > 1 && node?.layout) {
611
704
  const prev_layout = ((node.layout - 2 + layout_count) % layout_count) + 1;
612
705
  const tr = doc.tr;
613
- tr.set([doc.selected_node?.id, 'layout'], prev_layout);
706
+ tr.set([doc.layout_node?.id, 'layout'], prev_layout);
614
707
  doc.apply(tr);
615
708
  console.log('layout / count / prev_layout', node.layout, layout_count, prev_layout);
616
709
  }
710
+ e.preventDefault();
617
711
  } 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 )
712
+ (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.layout_node) ||
713
+ (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node )
620
714
  ) {
621
- const node = doc.selected_node;
715
+ const node = doc.layout_node;
622
716
 
623
717
  if (doc.selection.type !== 'node') {
624
718
  doc.select_parent();
@@ -636,11 +730,12 @@ ${fallback_html}`;
636
730
  doc.config.inserters[next_type](tr);
637
731
  tr.set_selection(old_selection);
638
732
  doc.apply(tr);
733
+ e.preventDefault();
639
734
  } 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)
735
+ (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.layout_node) ||
736
+ (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
642
737
  ) {
643
- const node = doc.selected_node;
738
+ const node = doc.layout_node;
644
739
 
645
740
  if (doc.selection.type !== 'node') {
646
741
  doc.select_parent();
@@ -660,6 +755,7 @@ ${fallback_html}`;
660
755
  doc.config.inserters[prev_type](tr);
661
756
  tr.set_selection(old_selection);
662
757
  doc.apply(tr);
758
+ e.preventDefault();
663
759
 
664
760
  } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
665
761
  const tr = doc.tr;
@@ -676,76 +772,32 @@ ${fallback_html}`;
676
772
  doc.redo();
677
773
  e.preventDefault();
678
774
  e.stopPropagation();
679
- } else if (e.key === 'Enter' && (e.shiftKey)) {
775
+ } else if (e.key === 'Enter' && (e.shiftKey) && !is_mobile_browser() && selection?.type === 'text' && doc.inspect(selection.path).allow_newlines) {
680
776
  doc.apply(doc.tr.insert_text('\n'));
681
777
  e.preventDefault();
682
778
  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
- }));
779
+ } else if (e.key === 'b' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
780
+ doc.apply(doc.tr.annotate_text('strong'));
687
781
  e.preventDefault();
688
782
  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
-
783
+ } else if (e.key === 'i' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
784
+ doc.apply(doc.tr.annotate_text('emphasis'));
711
785
  e.preventDefault();
712
786
  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
- }
787
+ } else if (e.key === 'u' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
788
+ doc.apply(doc.tr.annotate_text('highlight'));
789
+ e.preventDefault();
790
+ e.stopPropagation();
791
+ } else if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
792
+ const has_link = doc.active_annotation('link');
793
+ if (has_link) {
794
+ // Delete link
795
+ doc.apply(doc.tr.annotate_text('link'));
796
+ } else {
797
+ // Create link
798
+ const href = window.prompt('Enter the URL', 'https://example.com');
799
+ if (href) {
800
+ doc.apply(doc.tr.annotate_text('link', { href }));
749
801
  }
750
802
  }
751
803
 
@@ -1023,7 +1075,7 @@ ${fallback_html}`;
1023
1075
  // 2. Last child node is a <br> (the visual representation of that \n)
1024
1076
  // 3. Cursor is positioned at or near the end
1025
1077
  // And returns the correct text.length position instead.
1026
- const text_content = doc.get(path)[0];
1078
+ const text_content = doc.get(path).text;
1027
1079
  const text_length = get_char_length(text_content);
1028
1080
  if (text_length > 0) {
1029
1081
  const last_char = get_char_at(text_content, text_length - 1);
@@ -1208,7 +1260,7 @@ ${fallback_html}`;
1208
1260
  const selection = /** @type {any} */ (doc.selection);
1209
1261
  // The element that holds the annotated string
1210
1262
  const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="text"]`);
1211
- const empty_text = doc.get(selection.path)[0].length === 0;
1263
+ const empty_text = doc.get(selection.path).text.length === 0;
1212
1264
 
1213
1265
  const range = window.document.createRange();
1214
1266
  const dom_selection = window.getSelection();
@@ -1385,8 +1437,8 @@ ${fallback_html}`;
1385
1437
  {oncompositionstart}
1386
1438
  {oncompositionend}
1387
1439
  contenteditable={editable ? 'true' : 'false'}
1388
- autocapitalize="off"
1389
- spellcheck="true"
1440
+ {autocapitalize}
1441
+ {spellcheck}
1390
1442
  {...{
1391
1443
  // NOTE: Autocomplete and autocorrect make immense troubles
1392
1444
  // on Desktop Chrome, so we disable them only for Chrome desktop.
@@ -67,7 +67,7 @@ export default class Transaction {
67
67
  * @example
68
68
  * ```js
69
69
  * tr.set(["list_1", "list_items"], [1, 2, 3]);
70
- * tr.set(["page_1", "body", "0", "description"], ["Hello world", []]);
70
+ * tr.set(["page_1", "body", "0", "description"], {text: "Hello world", annotations: []});
71
71
  * ```
72
72
  */
73
73
  set (path, value) {
@@ -152,10 +152,10 @@ export default class Transaction {
152
152
  *
153
153
  * @param {Selection} selection - The new selection object
154
154
  * @returns {Transaction} This transaction instance for method chaining
155
- * @todo Check if selection is valid and throw error if not
155
+ * @throws {Error} Throws if the selection is invalid or out of bounds
156
156
  */
157
157
  set_selection(selection) {
158
- // TODO: Check if selection is valid and throw error if not
158
+ this.doc._validate_selection(selection);
159
159
  this.doc.selection = selection;
160
160
  return this;
161
161
  }
@@ -168,7 +168,7 @@ export default class Transaction {
168
168
  * when conflicting types are applied.
169
169
  *
170
170
  * @param {any} annotation_type - The type of annotation (e.g., 'link', 'bold', 'italic')
171
- * @param {any} annotation_data - Additional data for the annotation (e.g., href for links)
171
+ * @param {any} annotation_properties - Additional data for the annotation (e.g., href for links)
172
172
  * @returns {Transaction} This transaction instance for method chaining
173
173
  *
174
174
  * @example
@@ -180,64 +180,25 @@ export default class Transaction {
180
180
  * tr.annotate_text('emphasis', {});
181
181
  * ```
182
182
  */
183
- annotate_text(annotation_type, annotation_data) {
183
+ annotate_text(annotation_type, annotation_properties) {
184
184
  if (this.doc.selection.type !== 'text') return this;
185
185
 
186
186
  const { start, end } = this.doc.get_selection_range();
187
- const annotated_string = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
188
- const annotations = annotated_string[1];
189
- const existing_annotations = this.doc.active_annotation();
190
-
191
- // Special annotation type handling should probably be done in a separate function.
192
- // The goal is to keep the core logic simple and allow developer to extend and pick only what they need.
193
- // It could also be abstracted to not check for type (e.g. "link") but for a special attribute
194
- // e.g. "zero-range-updatable" for annotations that are updatable without a range selection change.
195
-
196
- // Special handling for links when there's no selection range
197
- // Links should be updatable by just clicking on them without a range selection
198
- if (annotation_type === 'link' && start === end && existing_annotations) {
199
-
200
- // Use findIndex for deep comparison of annotation properties (comparison of annotation properties rather than object reference via indexOf)
201
- const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
202
- anno[0] === existing_annotations[0] &&
203
- anno[1] === existing_annotations[1] &&
204
- anno[2] === existing_annotations[2]
205
- );
206
- // const index = annotations.indexOf(existing_annotations);
207
-
208
- if (index !== -1) {
209
- if (annotation_data.href === '') {
210
- // Remove the annotation if the href is empty
211
- annotations.splice(index, 1);
212
- } else {
213
- annotations[index] = [
214
- existing_annotations[0],
215
- existing_annotations[1],
216
- 'link',
217
- { ...existing_annotations[3], ...annotation_data }
218
- ];
219
- }
220
-
221
- this.set(this.doc.selection.path, annotated_string);
222
- return this;
223
- }
224
- }
225
-
226
- // Regular annotation handling
227
- if (start === end) {
228
- // For non-link annotations: You can not annotate text if the selection is collapsed.
229
- return this;
230
- }
187
+ const annotated_text = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
188
+ const annotations = annotated_text.annotations;
189
+ const existing_annotation = this.doc.active_annotation();
190
+ const existing_annotation_same_type = this.doc.active_annotation(annotation_type);
231
191
 
232
- if (existing_annotations) {
192
+ if (existing_annotation) {
233
193
  // If there's an existing annotation of the same type, remove it
234
- if (existing_annotations[2] === annotation_type) {
194
+ if (existing_annotation_same_type) {
235
195
  const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
236
- anno[0] === existing_annotations[0] &&
237
- anno[1] === existing_annotations[1] &&
238
- anno[2] === existing_annotations[2]
196
+ anno.start_offset === existing_annotation.start_offset &&
197
+ anno.end_offset === existing_annotation.end_offset
239
198
  );
240
199
  if (index !== -1) {
200
+ // Remove the annotation node from the graph
201
+ this.delete(annotations[index].node_id);
241
202
  annotations.splice(index, 1);
242
203
  }
243
204
  } else {
@@ -245,14 +206,23 @@ export default class Transaction {
245
206
  return this;
246
207
  }
247
208
  } else {
209
+ if (!this.doc.available_annotation_types.includes(annotation_type)) {
210
+ console.log(`Annotation type ${annotation_type} is not allowed here.`);
211
+ return this;
212
+ }
213
+
214
+ const new_annotation_node = {
215
+ id: this.doc.generate_id(),
216
+ type: annotation_type,
217
+ ...annotation_properties,
218
+ }
219
+ this.create(new_annotation_node);
248
220
  // If there's no existing annotation, add the new one
249
- annotations.push([start, end, annotation_type, annotation_data]);
221
+ annotations.push({start_offset: start, end_offset: end, node_id: new_annotation_node.id});
250
222
  }
251
223
 
252
- // Update the annotated string
253
- this.set(this.doc.selection.path, annotated_string);
254
- // Not needed anymore as doc.apply makes sure selection is rerendered
255
- // this.doc.selection = { ...this.doc.selection };
224
+ // Update the annotated text
225
+ this.set(this.doc.selection.path, annotated_text);
256
226
  return this;
257
227
  }
258
228
 
@@ -309,13 +279,15 @@ export default class Transaction {
309
279
  let text = structuredClone($state.snapshot(this.doc.get(path)));
310
280
 
311
281
  // Update the text content using character-based operations
312
- const original_text = text[0];
313
- text[0] = char_slice(original_text, 0, start) + char_slice(original_text, end, get_char_length(original_text));
282
+ const original_text = text.text;
283
+ text.text = char_slice(original_text, 0, start) + char_slice(original_text, end, get_char_length(original_text));
314
284
 
285
+ // To mark annotation nodes for deletion.
286
+ const _deleted_nodes = [];
315
287
  // Update annotation offsets for deletion
316
288
  const deletion_length = end - start;
317
- const new_annotations = text[1].map(/** @param {any} annotation */ (annotation) => {
318
- const [annotation_start, annotation_end, type, annotation_data] = annotation;
289
+ const new_annotations = text.annotations.map(/** @param {any} annotation */ (annotation) => {
290
+ const {start_offset: annotation_start, end_offset: annotation_end, node_id} = annotation;
319
291
 
320
292
  // Adjust start offset
321
293
  let new_start = annotation_start;
@@ -341,16 +313,24 @@ export default class Transaction {
341
313
 
342
314
  // Remove annotation if it becomes invalid (start >= end)
343
315
  if (new_start >= new_end) {
316
+ console.log('deleting annotation');
317
+ _deleted_nodes.push(node_id);
344
318
  return false;
345
319
  }
346
320
 
347
- return [new_start, new_end, type, annotation_data];
321
+ return {start_offset: new_start, end_offset: new_end, node_id};
348
322
  }).filter(Boolean);
349
323
 
350
- text[1] = new_annotations;
324
+ text.annotations = new_annotations;
351
325
 
352
326
  // Update the text in the entry (this implicitly records an op via this.set)
353
327
  this.set(path, text);
328
+
329
+ // Remove annotation nodes that were implicitly deleted.
330
+ for (const node_id of _deleted_nodes) {
331
+ this.delete(node_id);
332
+ }
333
+
354
334
  this.set_selection({
355
335
  type: 'text',
356
336
  path,
@@ -442,18 +422,18 @@ export default class Transaction {
442
422
  insert_text(replaced_text) {
443
423
  if (this.doc.selection.type !== 'text') return this;
444
424
 
445
- const annotated_string = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
425
+ const annotated_text = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
446
426
  const { start, end } = this.doc.get_selection_range();
447
427
 
448
428
  // Transform the plain text string using character-based operations
449
- const text = annotated_string[0];
450
- annotated_string[0] = char_slice(text, 0, start) + replaced_text + char_slice(text, end, get_char_length(text));
429
+ const text = annotated_text.text;
430
+ annotated_text.text = char_slice(text, 0, start) + replaced_text + char_slice(text, end, get_char_length(text));
451
431
 
452
- // Transform the annotations (annotated_string[1])
453
- // NOTE: Annotations are stored as [start_offset, end_offset, type]
432
+ // Transform the annotations (annotated_text.annotations)
433
+ // NOTE: Annotations are stored as {start_offset, end_offset, node_id}
454
434
  const delta = get_char_length(replaced_text) - (end - start);
455
- const new_annotations = annotated_string[1].map(/** @param {any} annotation */ (annotation) => {
456
- const [annotation_start, annotation_end, type, annotation_data] = annotation;
435
+ const new_annotations = annotated_text.annotations.map(/** @param {any} annotation */ (annotation) => {
436
+ const {start_offset: annotation_start, end_offset: annotation_end, node_id} = annotation;
457
437
 
458
438
  // Case 1: annotation is wrapped in start and end (remove it)
459
439
  if (start <= annotation_start && end >= annotation_end) {
@@ -462,7 +442,7 @@ export default class Transaction {
462
442
 
463
443
  // Case 2: text inserted before the annotation
464
444
  if (end <= annotation_start) {
465
- return [annotation_start + delta, annotation_end + delta, type, annotation_data];
445
+ return {start_offset: annotation_start + delta, end_offset: annotation_end + delta, node_id};
466
446
  }
467
447
 
468
448
  // Case 3: text inserted after the annotation
@@ -474,19 +454,19 @@ export default class Transaction {
474
454
  // NOTE: replaced_text will not be part of the annotation, we treat it the same as
475
455
  // a cursor right after the annotation
476
456
  if (start > annotation_start && start < annotation_end && end >= annotation_end) {
477
- return [annotation_start, start, type, annotation_data];
457
+ return {start_offset: annotation_start, end_offset: start, node_id};
478
458
  }
479
459
 
480
460
  // Case 5: text inserted inside an annotation
481
461
  if (start >= annotation_start && start <= annotation_end && end < annotation_end && end >= annotation_start) {
482
- return [annotation_start, annotation_end + delta, type, annotation_data];
462
+ return {start_offset: annotation_start, end_offset: annotation_end + delta, node_id};
483
463
  }
484
464
 
485
465
  // Case 6: annotation is partly selected towards left
486
466
  // NOTE: replaced_text will not be part of the annotation, we treat it the same as
487
467
  // a cursor right before the annotation
488
468
  if (start < annotation_start && end > annotation_start && end < annotation_end) {
489
- return [start + get_char_length(replaced_text), annotation_end + delta, type, annotation_data];
469
+ return {start_offset: start + get_char_length(replaced_text), end_offset: annotation_end + delta, node_id};
490
470
  }
491
471
 
492
472
  // Unhandled edge case:
@@ -496,7 +476,8 @@ export default class Transaction {
496
476
  // return annotation;
497
477
  }).filter(Boolean);
498
478
 
499
- this.set(this.doc.selection.path, [annotated_string[0], new_annotations]); // this will update the current state and create a history entry
479
+ annotated_text.annotations = new_annotations;
480
+ this.set(this.doc.selection.path, annotated_text); // this will update the current state and create a history entry
500
481
 
501
482
  // Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
502
483
  const new_selection = {
@@ -578,6 +559,8 @@ export default class Transaction {
578
559
  count += value.filter(id => id === target_node_id).length;
579
560
  } else if (prop_type === 'node' && value === target_node_id) {
580
561
  count += 1;
562
+ } else if (prop_type === 'annotated_text' && value && value.annotations) {
563
+ count += value.annotations.filter(annotation => annotation.node_id === target_node_id).length;
581
564
  }
582
565
  }
583
566
  }