svedit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +761 -120
  2. package/dist/AnnotatedTextProperty.svelte +122 -75
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +118 -0
  5. package/dist/Command.svelte.d.ts.map +1 -0
  6. package/dist/Command.svelte.js +314 -0
  7. package/dist/CustomProperty.svelte +31 -42
  8. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  9. package/dist/KeyMapper.svelte.d.ts +36 -0
  10. package/dist/KeyMapper.svelte.d.ts.map +1 -0
  11. package/dist/KeyMapper.svelte.js +141 -0
  12. package/dist/Node.svelte +33 -35
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +40 -37
  15. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  16. package/dist/{Document.svelte.d.ts → Session.svelte.d.ts} +76 -83
  17. package/dist/Session.svelte.d.ts.map +1 -0
  18. package/dist/Session.svelte.js +594 -0
  19. package/dist/Svedit.svelte +1352 -1512
  20. package/dist/Svedit.svelte.d.ts.map +1 -1
  21. package/dist/Transaction.svelte.d.ts +114 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +845 -661
  24. package/dist/UnknownNode.svelte +6 -6
  25. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  26. package/dist/doc_utils.d.ts +127 -0
  27. package/dist/doc_utils.d.ts.map +1 -0
  28. package/dist/doc_utils.js +482 -0
  29. package/dist/index.d.ts +7 -3
  30. package/dist/index.js +20 -4
  31. package/dist/{commands.svelte.d.ts → transforms.svelte.d.ts} +1 -2
  32. package/dist/transforms.svelte.d.ts.map +1 -0
  33. package/dist/transforms.svelte.js +155 -0
  34. package/dist/types.d.ts +256 -110
  35. package/dist/{util.d.ts → utils.d.ts} +22 -2
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +338 -0
  38. package/package.json +20 -18
  39. package/src/lib/AnnotatedTextProperty.svelte +122 -75
  40. package/src/lib/Command.svelte.js +314 -0
  41. package/src/lib/CustomProperty.svelte +31 -42
  42. package/src/lib/KeyMapper.svelte.js +141 -0
  43. package/src/lib/Node.svelte +33 -35
  44. package/src/lib/NodeArrayProperty.svelte +40 -37
  45. package/src/lib/Session.svelte.js +594 -0
  46. package/src/lib/Svedit.svelte +1352 -1512
  47. package/src/lib/Transaction.svelte.js +845 -661
  48. package/src/lib/UnknownNode.svelte +6 -6
  49. package/src/lib/doc_utils.js +482 -0
  50. package/src/lib/index.js +20 -4
  51. package/src/lib/transforms.svelte.js +155 -0
  52. package/src/lib/types.d.ts +256 -110
  53. package/src/lib/utils.js +338 -0
  54. package/dist/Document.svelte.d.ts.map +0 -1
  55. package/dist/Document.svelte.js +0 -833
  56. package/dist/commands.svelte.d.ts.map +0 -1
  57. package/dist/commands.svelte.js +0 -266
  58. package/dist/util.d.ts.map +0 -1
  59. package/dist/util.js +0 -269
  60. package/src/lib/Document.svelte.js +0 -833
  61. package/src/lib/commands.svelte.js +0 -266
  62. package/src/lib/util.js +0 -269
@@ -1 +0,0 @@
1
- {"version":3,"file":"commands.svelte.d.ts","sourceRoot":"","sources":["../src/lib/commands.svelte.js"],"names":[],"mappings":"AAGA,kDAqDC;AAED,iDAsEC;AAED,sDAwBC;AAED,6CA6GC"}
@@ -1,266 +0,0 @@
1
- import { split_annotated_text, join_annotated_text, get_char_length } from './util.js';
2
- import { get_default_node_type } from './Document.svelte.js';
3
-
4
- export function break_text_node(tr) {
5
- const doc = tr.doc;
6
- // Keep a reference of the original selection (before any transforms are applied)
7
- const selection = doc.selection;
8
- // First we need to ensure we have a text selection
9
- if (selection.type !== 'text') return false;
10
-
11
- // Next, we need to determine if the enclosing node is a pure text node (e.g. paragraph),
12
- // which is wrapped inside a node_array (e.g. page.body)
13
-
14
- // Owner of the text property (e.g. paragraph)
15
- const node = doc.get(selection.path.slice(0, -1));
16
- if (doc.kind(node) !== 'text') return false;
17
- const is_inside_node_array = doc.inspect(selection.path.slice(0, -2))?.type === 'node_array';
18
- // console.log('is_inside_node_array', is_inside_node_array);
19
- if (!is_inside_node_array) return false; // Do nothing if we're not inside a node_array
20
- const node_array_prop = selection.path.at(-3);
21
- // console.log('node_array_prop', node_array_prop);
22
- // Get the node that owns the node_array property (e.g. a page.body)
23
- const node_array_node = doc.get(selection.path.slice(0, -3));
24
- // console.log('node_array_node', $state.snapshot(node_array_node));
25
-
26
- // Delete selection unless collapsed
27
- if (selection.anchor_offset !== selection.focus_offset) {
28
- tr.delete_selection();
29
- }
30
-
31
- const split_at_position = tr.doc.selection.anchor_offset;
32
- const content = tr.doc.get(selection.path);
33
- const [left_text, right_text] = split_annotated_text(content, split_at_position);
34
-
35
- tr.set([node.id, 'content'], left_text);
36
-
37
- const node_insert_position = {
38
- type: 'node',
39
- path: tr.doc.selection.path.slice(0, -2),
40
- anchor_offset: parseInt(tr.doc.selection.path.at(-2), 10) + 1,
41
- focus_offset: parseInt(tr.doc.selection.path.at(-2), 10) + 1,
42
- };
43
-
44
- // TODO: Only use default_node_type when cursor is at the end of
45
- const node_array_property_definition = doc.schema[node_array_node.type].properties[node_array_prop];
46
- const target_node_type = get_default_node_type(node_array_property_definition);
47
-
48
- if (!target_node_type) {
49
- console.warn('Cannot determine target node type for break_text_node - no default_ref_type and multiple node_types');
50
- return false;
51
- }
52
-
53
- tr.set_selection(node_insert_position);
54
-
55
- doc.config.inserters[target_node_type](tr, right_text);
56
- return true;
57
- }
58
-
59
- export function join_text_node(tr) {
60
- const doc = tr.doc;
61
- // Keep a reference of the original selection (before any transforms are applied)
62
- const selection = doc.selection;
63
- // First we need to ensure we have a text selection
64
- if (selection.type !== 'text') return false;
65
-
66
- const node = doc.get(selection.path.slice(0, -1));
67
- if (doc.kind(node) !== 'text') return false;
68
- const is_inside_node_array = doc.inspect(selection.path.slice(0, -2))?.type === 'node_array';
69
- // console.log('is_inside_node_array', is_inside_node_array);
70
- if (!is_inside_node_array) return false; // Do nothing if we're not inside a node_array
71
-
72
- const node_index = parseInt(doc.selection.path.at(-2), 10);
73
-
74
- // Determine if we can join with the previous node
75
- let can_join = false;
76
- let predecessor_node = null;
77
-
78
- if (node_index > 0) {
79
- const previous_text_path = [...doc.selection.path.slice(0, -2), node_index - 1];
80
- predecessor_node = doc.get(previous_text_path);
81
- can_join = doc.kind(predecessor_node) === 'text';
82
- }
83
-
84
- // Special behavior: if we can't join and current node is empty, delete it
85
- if (!can_join && node.content.text === '') {
86
- tr.set_selection({
87
- type: 'node',
88
- path: doc.selection.path.slice(0, -2),
89
- anchor_offset: node_index,
90
- focus_offset: node_index + 1,
91
- });
92
- tr.delete_selection();
93
- return true;
94
- }
95
-
96
- // If we can't join for any reason, return false
97
- if (!can_join) {
98
- return false;
99
- }
100
-
101
- // Normal joining logic - both nodes are text nodes
102
- const previous_text_path = [...doc.selection.path.slice(0, -2), node_index - 1];
103
- const joined_text = join_annotated_text(predecessor_node.content, node.content);
104
-
105
- // Calculate cursor position based on original predecessor content length
106
- const cursor_position = get_char_length(predecessor_node.content.text);
107
-
108
- // First set the joined content on the predecessor node (preserves annotations)
109
- tr.set([predecessor_node.id, 'content'], joined_text);
110
-
111
- // Then delete the current node
112
- tr.set_selection({
113
- type: 'node',
114
- path: doc.selection.path.slice(0, -2),
115
- anchor_offset: node_index,
116
- focus_offset: node_index + 1,
117
- });
118
-
119
- tr.delete_selection();
120
-
121
- // Finally set the cursor position at the join point using the pre-calculated position
122
- tr.set_selection({
123
- type: 'text',
124
- path: [...previous_text_path, 'content'],
125
- anchor_offset: cursor_position,
126
- focus_offset: cursor_position,
127
- });
128
- return true;
129
- }
130
-
131
- export function insert_default_node(tr) {
132
- const doc = tr.doc;
133
- const selection = doc.selection;
134
-
135
- // Only work with collapsed node selections
136
- if (selection?.type !== 'node' || selection.anchor_offset !== selection.focus_offset) {
137
- return false;
138
- }
139
-
140
- const path = selection.path;
141
- const node_array_node = doc.get(path.slice(0, -1));
142
- const property_name = path.at(-1);
143
-
144
- // Get the definition for this property
145
- const property_definition = doc.schema[node_array_node.type].properties[property_name];
146
- const default_type = get_default_node_type(property_definition);
147
-
148
- // Use the inserter function if available
149
- if (doc.config?.inserters?.[default_type]) {
150
- doc.config.inserters[default_type](tr);
151
- return true;
152
- } else {
153
- throw new Error(`No inserter function available for default node type '${default_type}'`);
154
- }
155
- }
156
-
157
- export function select_all(tr) {
158
- const doc = tr.doc;
159
- const selection = doc.selection;
160
-
161
- if (!selection) {
162
- return false;
163
- }
164
-
165
- if (selection.type === 'text') {
166
- const text_content = doc.get(selection.path);
167
- const text_length = get_char_length(text_content.text);
168
-
169
- // Check if all text is already selected
170
- const is_all_text_selected =
171
- Math.min(selection.anchor_offset, selection.focus_offset) === 0 &&
172
- Math.max(selection.anchor_offset, selection.focus_offset) === text_length;
173
-
174
- if (!is_all_text_selected) {
175
- // Select all text in the current text node
176
- tr.set_selection({
177
- type: 'text',
178
- path: selection.path,
179
- anchor_offset: 0,
180
- focus_offset: text_length
181
- });
182
- return true;
183
- } else {
184
- // All text is selected, move up to select the containing node
185
- const node_path = selection.path.slice(0, -1); // Remove the property name (e.g., 'content')
186
-
187
- // Check if we have enough path segments and if we're inside a node_array
188
- if (node_path.length >= 2) {
189
- const is_inside_node_array = doc.inspect(node_path.slice(0, -1))?.type === 'node_array';
190
-
191
- if (is_inside_node_array) {
192
- const node_index = parseInt(node_path.at(-1));
193
- tr.set_selection({
194
- type: 'node',
195
- path: node_path.slice(0, -1),
196
- anchor_offset: node_index,
197
- focus_offset: node_index + 1
198
- });
199
- return true;
200
- }
201
- }
202
- // Stop expanding - text is not in a selectable node_array
203
- }
204
- } else if (selection.type === 'node') {
205
- const node_array_path = selection.path;
206
- const node_array = doc.get(node_array_path);
207
-
208
- // Check if the entire node_array is already selected
209
- const is_entire_node_array_selected =
210
- Math.min(selection.anchor_offset, selection.focus_offset) === 0 &&
211
- Math.max(selection.anchor_offset, selection.focus_offset) === node_array.length;
212
-
213
- if (!is_entire_node_array_selected) {
214
- // Select the entire node_array
215
- tr.set_selection({
216
- type: 'node',
217
- path: node_array_path,
218
- anchor_offset: 0,
219
- focus_offset: node_array.length
220
- });
221
- return true;
222
- } else {
223
- // Entire node_array is selected, try to move up to parent node_array
224
- const parent_path = node_array_path.slice(0, -1);
225
-
226
- // Check if we have enough path segments and if parent is a valid node_array
227
- if (parent_path.length >= 2) {
228
- const is_parent_node_array = doc.inspect(parent_path.slice(0, -1))?.type === 'node_array';
229
-
230
- if (is_parent_node_array) {
231
- const parent_node_index = parseInt(parent_path.at(-1));
232
- tr.set_selection({
233
- type: 'node',
234
- path: parent_path.slice(0, -1),
235
- anchor_offset: parent_node_index,
236
- focus_offset: parent_node_index + 1
237
- });
238
- return true;
239
- }
240
- }
241
- // Stop expanding - we've reached the top level
242
- }
243
- } else if (selection.type === 'property') {
244
- // For property selections, select the containing node
245
- const node_path = selection.path.slice(0, -1);
246
-
247
- // Check if we have enough path segments and if we're inside a node_array
248
- if (node_path.length >= 2) {
249
- const is_inside_node_array = doc.inspect(node_path.slice(0, -1))?.type === 'node_array';
250
-
251
- if (is_inside_node_array) {
252
- const node_index = parseInt(node_path.at(-1));
253
- tr.set_selection({
254
- type: 'node',
255
- path: node_path.slice(0, -1),
256
- anchor_offset: node_index,
257
- focus_offset: node_index + 1
258
- });
259
- return true;
260
- }
261
- }
262
- // Stop expanding - property is not in a selectable node_array
263
- }
264
-
265
- return false;
266
- }
@@ -1 +0,0 @@
1
- {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/lib/util.js"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;;;;;;;;;GAaG;AACH,qCARW,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;;GAcG;AACH,iCATW,MAAM,SACN,MAAM,GACJ,MAAM,CAWlB;AAED;;;;;;;;;;;;;;GAcG;AACH,gCATW,MAAM,SACN,MAAM,QACN,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,gBACN,MAAM,GACJ,MAAM,CAoBlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,eACN,MAAM,GACJ,MAAM,CAgBlB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,4DAZW,aAAa,eACb,MAAM,GACJ,CAAC,aAAa,EAAE,aAAa,CAAC,CAyC1C;AAGD;;;;;;;;;;;;;GAaG;AACH,gDARW,aAAa,eACb,aAAa,GACX,aAAa,CAqCzB;AAED;;;;;;;;GAQG;AACH,qCANW,MAAM,GACJ,MAAM,CAUlB;AAED,uEAgCC;mCA3Q6C,cAAc"}
package/dist/util.js DELETED
@@ -1,269 +0,0 @@
1
- /**
2
- * @import { Annotation, AnnotatedText } from './types.d.ts';
3
- */
4
-
5
- /**
6
- * Get the actual character length (accounting for multi-byte characters)
7
- *
8
- * Uses Intl.Segmenter to count grapheme clusters rather than UTF-16 code units,
9
- * ensuring emojis and other complex Unicode sequences are counted as single characters.
10
- *
11
- * @param {string} str - The string to measure
12
- * @returns {number} The number of visual characters (grapheme clusters)
13
- *
14
- * @example
15
- * get_char_length('Hello') // Returns: 5
16
- * get_char_length('a😀b') // Returns: 3 (not 4)
17
- * get_char_length('👋🏽') // Returns: 1 (not 4 - skin tone modifier treated as single char)
18
- */
19
- export function get_char_length(str) {
20
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
21
- return [...segmenter.segment(str)].length;
22
- }
23
-
24
- /**
25
- * Get a single character at the specified position (accounting for multi-byte characters)
26
- *
27
- * Uses Intl.Segmenter to access grapheme clusters rather than UTF-16 code units,
28
- * ensuring emojis and other complex Unicode sequences are treated as single characters.
29
- *
30
- * @param {string} str - The string to access
31
- * @param {number} index - Character position (0-based)
32
- * @returns {string} The character at the specified position, or empty string if index is out of bounds
33
- *
34
- * @example
35
- * get_char_at('Hello', 1) // Returns: 'e'
36
- * get_char_at('a😀b', 1) // Returns: '😀' (full emoji)
37
- * get_char_at('👋🏽', 0) // Returns: '👋🏽' (skin tone modifier included)
38
- */
39
- export function get_char_at(str, index) {
40
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
41
- const segments = [...segmenter.segment(str)];
42
- return segments[index].segment;
43
- }
44
-
45
- /**
46
- * Slice string by character positions (accounting for multi-byte characters)
47
- *
48
- * Uses Intl.Segmenter to slice by grapheme clusters rather than UTF-16 code units,
49
- * ensuring emojis and other complex Unicode sequences remain intact.
50
- *
51
- * @param {string} str - The string to slice
52
- * @param {number} start - Starting character position (inclusive)
53
- * @param {number} [end] - Ending character position (exclusive). If undefined, slices to end
54
- * @returns {string} The sliced string with complete characters
55
- *
56
- * @example
57
- * char_slice('Hello 😀 World', 6, 8) // Returns: '😀 ' (emoji stays intact)
58
- * char_slice('a👋🏽b', 1, 2) // Returns: '👋🏽' (skin tone modifier included)
59
- */
60
- export function char_slice(str, start, end = undefined) {
61
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
62
- const segments = [...segmenter.segment(str)];
63
- return segments.slice(start, end).map(s => s.segment).join('');
64
- }
65
-
66
- /**
67
- * Convert UTF-16 code unit offset to grapheme cluster offset within a string
68
- *
69
- * Converts DOM selection offsets (which use UTF-16 code units) to character-based
70
- * offsets (grapheme clusters) for consistent text manipulation.
71
- *
72
- * @param {string} str - The string to convert offsets within
73
- * @param {number} utf16_offset - The UTF-16 code unit offset from DOM
74
- * @returns {number} The corresponding grapheme cluster offset
75
- *
76
- * @example
77
- * // For string "a😀b" where 😀 uses 2 UTF-16 code units
78
- * utf16_to_char_offset("a😀b", 3) // Returns: 2 (position after emoji)
79
- */
80
- export function utf16_to_char_offset(str, utf16_offset) {
81
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
82
- const segments = [...segmenter.segment(str)];
83
- let char_offset = 0;
84
- let utf16_count = 0;
85
-
86
- for (const segment of segments) {
87
- if (utf16_count >= utf16_offset) break;
88
- utf16_count += segment.segment.length;
89
- if (utf16_count > utf16_offset) break;
90
- char_offset++;
91
- }
92
-
93
- return char_offset;
94
- }
95
-
96
- /**
97
- * Convert grapheme cluster offset to UTF-16 code unit offset within a string
98
- *
99
- * Converts character-based offsets (grapheme clusters) to DOM selection offsets
100
- * (UTF-16 code units) for proper DOM selection positioning.
101
- *
102
- * @param {string} str - The string to convert offsets within
103
- * @param {number} char_offset - The grapheme cluster offset
104
- * @returns {number} The corresponding UTF-16 code unit offset for DOM operations
105
- *
106
- * @example
107
- * // For string "a😀b" where 😀 uses 2 UTF-16 code units
108
- * char_to_utf16_offset("a😀b", 2) // Returns: 3 (UTF-16 position after emoji)
109
- */
110
- export function char_to_utf16_offset(str, char_offset) {
111
- const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
112
- const segments = [...segmenter.segment(str)];
113
- let utf16_offset = 0;
114
-
115
- for (let i = 0; i < Math.min(char_offset, segments.length); i++) {
116
- utf16_offset += segments[i].segment.length;
117
- }
118
-
119
- return utf16_offset;
120
- }
121
-
122
- /**
123
- * Splits an annotated text at the specified character position.
124
- *
125
- * Annotations that span the split point will be divided appropriately,
126
- * with offsets adjusted for each resulting part.
127
- *
128
- * @param {AnnotatedText} text_with_annotations - Annotated text object
129
- * @param {number} at_position - Character position where to split (0-based)
130
- * @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
131
- *
132
- * @example
133
- * split_annotated_text({text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}, 8)
134
- * // Returns:
135
- * // [
136
- * // {text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]},
137
- * // {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]}
138
- * // ]
139
- */
140
- export function split_annotated_text(text_with_annotations, at_position) {
141
- const {text, annotations} = text_with_annotations;
142
-
143
- // Split the text using character-aware slicing
144
- const left_text = char_slice(text, 0, at_position);
145
- const right_text = char_slice(text, at_position);
146
-
147
- /** @type {Array<Annotation>} */
148
- const left_annotations = [];
149
- /** @type {Array<Annotation>} */
150
- const right_annotations = [];
151
-
152
- // Process each annotation
153
- for (const {start_offset, end_offset, node_id} of annotations) {
154
- if (end_offset <= at_position) {
155
- // Annotation is entirely in the left part
156
- left_annotations.push({start_offset, end_offset, node_id});
157
- } else if (start_offset >= at_position) {
158
- // Annotation is entirely in the right part - shift offsets
159
- right_annotations.push({start_offset: start_offset - at_position, end_offset: end_offset - at_position, node_id});
160
- } else {
161
- // Annotation spans the split point - split it
162
- left_annotations.push({start_offset, end_offset: at_position, node_id});
163
- right_annotations.push({start_offset: 0, end_offset: end_offset - at_position, node_id});
164
- }
165
- }
166
-
167
- return [
168
- {text: left_text, annotations: left_annotations},
169
- {text: right_text, annotations: right_annotations}
170
- ];
171
- }
172
-
173
-
174
- /**
175
- * Joins two annotated texts into a single annotated text.
176
- *
177
- * Annotations from the second text will have their offsets shifted by the length
178
- * of the first text. Adjacent annotations of the same type and data will be merged.
179
- *
180
- * @param {AnnotatedText} first_text - First annotated text object
181
- * @param {AnnotatedText} second_text - Second annotated text object
182
- * @returns {AnnotatedText} Combined annotated text object
183
- *
184
- * @example
185
- * join_annotated_text({text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]}, {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]})
186
- * // Returns: {text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}
187
- */
188
- export function join_annotated_text(first_text, second_text) {
189
- const {text: first_text_content, annotations: first_annotations} = first_text;
190
- const {text: second_text_content, annotations: second_annotations} = second_text;
191
-
192
- // Join the text content
193
- const joined_text = first_text_content + second_text_content;
194
-
195
- // Start with all annotations from the first text (unchanged)
196
- /** @type {Array<Annotation>} */
197
- const joined_annotations = [...first_annotations];
198
-
199
- // Add annotations from the second text, shifting their offsets
200
- const offset = get_char_length(first_text_content);
201
- for (const {start_offset, end_offset, node_id} of second_annotations) {
202
- /** @type {Annotation} */
203
- const shifted_annotation = {start_offset: start_offset + offset, end_offset: end_offset + offset, node_id};
204
-
205
- // Check if this annotation can be merged with the last annotation from first text
206
- const last_annotation = joined_annotations[joined_annotations.length - 1];
207
- if (last_annotation &&
208
- last_annotation.end_offset === shifted_annotation.start_offset && // annotations are adjacent
209
- last_annotation.node_id === shifted_annotation.node_id) { // same node_id
210
- // Merge by extending the end position of the last annotation
211
- last_annotation.end_offset = shifted_annotation.end_offset;
212
- } else {
213
- // Add as separate annotation
214
- joined_annotations.push(shifted_annotation);
215
- }
216
- }
217
-
218
- return {text: joined_text, annotations: joined_annotations};
219
- }
220
-
221
- /**
222
- * Convert snake_case string to PascalCase
223
- *
224
- * @param {string} str - The snake_case string to convert
225
- * @returns {string} The converted PascalCase string
226
- *
227
- * @example
228
- * snake_to_pascal('list_item') // Returns: 'ListItem'
229
- */
230
- export function snake_to_pascal(str) {
231
- return str
232
- .split('_')
233
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
234
- .join('');
235
- }
236
-
237
- export function traverse(node_id, schema, nodes) {
238
- const json = [];
239
- const visited = {};
240
- const visit = (node) => {
241
- if (!node || visited[node.id]) {
242
- return;
243
- }
244
- visited[node.id] = true;
245
- for (const [property_name, value] of Object.entries(node)) {
246
- const property_definition = schema[node.type].properties[property_name];
247
-
248
- if (property_definition?.type === 'node_array') {
249
- for (const v of value) {
250
- if (typeof v === 'string') {
251
- visit(nodes[v]);
252
- }
253
- }
254
- } else if (property_definition?.type === 'node') {
255
- visit(nodes[value]);
256
- } else if (property_definition?.type === 'annotated_text') {
257
- for (const annotation of value.annotations) {
258
- visit(nodes[annotation.node_id]);
259
- }
260
- }
261
- }
262
- // Finally add the node to the result.
263
- // Deep clone, to make sure nothing of the original document is referenced.
264
- json.push(structuredClone(node));
265
- }
266
- // Start with the root node (document_id)
267
- visit(nodes[node_id]);
268
- return json;
269
- }