svedit 0.12.0 → 0.13.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.
- package/README.md +33 -21
- package/dist/Command.svelte.d.ts +38 -30
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +271 -326
- package/dist/CustomProperty.svelte +3 -5
- package/dist/CustomProperty.svelte.d.ts +3 -6
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.d.ts +19 -6
- package/dist/KeyMapper.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.js +95 -112
- package/dist/Node.svelte +16 -13
- package/dist/Node.svelte.d.ts +3 -5
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +39 -22
- package/dist/NodeArrayProperty.svelte.d.ts +3 -5
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeCaret.svelte +1 -1
- package/dist/NodeCaret.svelte.d.ts +6 -14
- package/dist/NodeCaret.svelte.d.ts.map +1 -1
- package/dist/NodeGap.svelte +12 -6
- package/dist/NodeGap.svelte.d.ts +7 -14
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/NodeGapMarkers.svelte +19 -10
- package/dist/NodeGapMarkers.svelte.d.ts +6 -11
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
- package/dist/NodeSelectionMarkers.svelte +20 -6
- package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
- package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.d.ts +81 -129
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +542 -648
- package/dist/Svedit.svelte +144 -138
- package/dist/Svedit.svelte.d.ts +30 -10
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/TextProperty.svelte +32 -18
- package/dist/TextProperty.svelte.d.ts +3 -5
- package/dist/TextProperty.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +64 -127
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +839 -1001
- package/dist/UnknownNode.svelte +4 -3
- package/dist/UnknownNode.svelte.d.ts +5 -9
- package/dist/UnknownNode.svelte.d.ts.map +1 -1
- package/dist/doc_utils.d.ts +67 -155
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +619 -863
- package/dist/index.d.ts +20 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -17
- package/dist/node_visibility.svelte.d.ts +110 -9
- package/dist/node_visibility.svelte.d.ts.map +1 -1
- package/dist/node_visibility.svelte.js +473 -544
- package/dist/paste_utils.d.ts +11 -44
- package/dist/paste_utils.d.ts.map +1 -1
- package/dist/paste_utils.js +111 -152
- package/dist/transforms.svelte.d.ts +9 -7
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +125 -150
- package/dist/types.d.ts +277 -309
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +40 -119
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +346 -473
- package/package.json +69 -70
- package/src/lib/{Command.svelte.js → Command.svelte.ts} +29 -17
- package/src/lib/CustomProperty.svelte +3 -5
- package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
- package/src/lib/Node.svelte +16 -13
- package/src/lib/NodeArrayProperty.svelte +39 -22
- package/src/lib/NodeCaret.svelte +1 -1
- package/src/lib/NodeGap.svelte +12 -6
- package/src/lib/NodeGapMarkers.svelte +19 -10
- package/src/lib/NodeSelectionMarkers.svelte +20 -6
- package/src/lib/{Session.svelte.js → Session.svelte.ts} +130 -127
- package/src/lib/Svedit.svelte +144 -138
- package/src/lib/TextProperty.svelte +32 -18
- package/src/lib/{Transaction.svelte.js → Transaction.svelte.ts} +139 -143
- package/src/lib/UnknownNode.svelte +4 -3
- package/src/lib/{doc_utils.js → doc_utils.ts} +247 -206
- package/src/lib/{index.js → index.ts} +47 -0
- package/src/lib/{node_visibility.svelte.js → node_visibility.svelte.ts} +68 -92
- package/src/lib/{paste_utils.js → paste_utils.ts} +47 -58
- package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +39 -21
- package/src/lib/{types.d.ts → types.ts} +143 -66
- package/src/lib/{utils.js → utils.ts} +110 -129
package/dist/utils.js
CHANGED
|
@@ -1,80 +1,67 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @import { Attachment, Mark, Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
1
|
const SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
6
|
-
|
|
7
2
|
/**
|
|
8
3
|
* Detect if the virtual keyboard is likely visible.
|
|
9
4
|
*
|
|
10
5
|
* This is a heuristic based on the visual viewport becoming smaller than the
|
|
11
|
-
* layout viewport.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
6
|
+
* layout viewport. A tolerance is required because clientHeight is a rounded
|
|
7
|
+
* integer while visualViewport.height is fractional (e.g. on Firefox with
|
|
8
|
+
* HiDPI scaling), so a strict comparison can misfire on desktop. A real
|
|
9
|
+
* virtual keyboard shrinks the viewport by far more than the tolerance.
|
|
14
10
|
*/
|
|
11
|
+
const VIRTUAL_KEYBOARD_MIN_HEIGHT = 50;
|
|
15
12
|
export function is_virtual_keyboard_active() {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (!is_mobile_browser()) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const visual_viewport = window.visualViewport;
|
|
20
|
+
if (!visual_viewport) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return visual_viewport.height < document.documentElement.clientHeight - VIRTUAL_KEYBOARD_MIN_HEIGHT;
|
|
26
24
|
}
|
|
27
|
-
|
|
28
25
|
/**
|
|
29
26
|
* Detect if the current browser is likely on a mobile device.
|
|
30
27
|
*
|
|
31
28
|
* This uses the user agent only so touch-capable laptops are not treated as
|
|
32
29
|
* mobile browsers.
|
|
33
|
-
*
|
|
34
|
-
* @returns {boolean} true if mobile browser, false otherwise
|
|
35
30
|
*/
|
|
36
31
|
export function is_mobile_browser() {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent);
|
|
32
|
+
if (typeof navigator === 'undefined') {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const user_agent = navigator.userAgent;
|
|
36
|
+
return /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent);
|
|
43
37
|
}
|
|
44
|
-
|
|
45
38
|
// ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
|
|
46
39
|
/**
|
|
47
40
|
* Detect if the current browser is Chrome on desktop
|
|
48
|
-
* @returns {boolean} true if Chrome desktop browser, false otherwise
|
|
49
41
|
*/
|
|
50
42
|
export function is_chrome_desktop_browser() {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
43
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const user_agent = navigator.userAgent;
|
|
47
|
+
const is_chrome = user_agent.includes('Chrome') && !user_agent.includes('Edg');
|
|
48
|
+
const is_mobile = is_mobile_browser();
|
|
49
|
+
return is_chrome && !is_mobile;
|
|
58
50
|
}
|
|
59
|
-
|
|
60
51
|
/**
|
|
61
52
|
* Get the actual character length (accounting for multi-byte characters)
|
|
62
53
|
*
|
|
63
54
|
* Uses Intl.Segmenter to count grapheme clusters rather than UTF-16 code units,
|
|
64
55
|
* ensuring emojis and other complex Unicode sequences are counted as single characters.
|
|
65
56
|
*
|
|
66
|
-
* @param {string} str - The string to measure
|
|
67
|
-
* @returns {number} The number of visual characters (grapheme clusters)
|
|
68
|
-
*
|
|
69
57
|
* @example
|
|
70
58
|
* get_char_length('Hello') // Returns: 5
|
|
71
59
|
* get_char_length('a😀b') // Returns: 3 (not 4)
|
|
72
60
|
* get_char_length('👋🏽') // Returns: 1 (not 4 - skin tone modifier treated as single char)
|
|
73
61
|
*/
|
|
74
62
|
export function get_char_length(str) {
|
|
75
|
-
|
|
63
|
+
return [...SEGMENTER.segment(str)].length;
|
|
76
64
|
}
|
|
77
|
-
|
|
78
65
|
// ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
|
|
79
66
|
/**
|
|
80
67
|
* Get a single character at the specified position (accounting for multi-byte characters)
|
|
@@ -82,107 +69,80 @@ export function get_char_length(str) {
|
|
|
82
69
|
* Uses Intl.Segmenter to access grapheme clusters rather than UTF-16 code units,
|
|
83
70
|
* ensuring emojis and other complex Unicode sequences are treated as single characters.
|
|
84
71
|
*
|
|
85
|
-
* @param {string} str - The string to access
|
|
86
|
-
* @param {number} index - Character position (0-based)
|
|
87
|
-
* @returns {string} The character at the specified position, or empty string if index is out of bounds
|
|
88
|
-
*
|
|
89
72
|
* @example
|
|
90
73
|
* get_char_at('Hello', 1) // Returns: 'e'
|
|
91
74
|
* get_char_at('a😀b', 1) // Returns: '😀' (full emoji)
|
|
92
75
|
* get_char_at('👋🏽', 0) // Returns: '👋🏽' (skin tone modifier included)
|
|
93
76
|
*/
|
|
94
77
|
export function get_char_at(str, index) {
|
|
95
|
-
|
|
96
|
-
|
|
78
|
+
const segments = [...SEGMENTER.segment(str)];
|
|
79
|
+
return segments[index].segment;
|
|
97
80
|
}
|
|
98
|
-
|
|
99
81
|
/**
|
|
100
82
|
* Slice string by character positions (accounting for multi-byte characters)
|
|
101
83
|
*
|
|
102
84
|
* Uses Intl.Segmenter to slice by grapheme clusters rather than UTF-16 code units,
|
|
103
85
|
* ensuring emojis and other complex Unicode sequences remain intact.
|
|
104
86
|
*
|
|
105
|
-
* @param {string} str - The string to slice
|
|
106
|
-
* @param {number} start - Starting character position (inclusive)
|
|
107
|
-
* @param {number} [end] - Ending character position (exclusive). If undefined, slices to end
|
|
108
|
-
* @returns {string} The sliced string with complete characters
|
|
109
|
-
*
|
|
110
87
|
* @example
|
|
111
88
|
* char_slice('Hello 😀 World', 6, 8) // Returns: '😀 ' (emoji stays intact)
|
|
112
89
|
* char_slice('a👋🏽b', 1, 2) // Returns: '👋🏽' (skin tone modifier included)
|
|
113
90
|
*/
|
|
114
91
|
export function char_slice(str, start, end = undefined) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
92
|
+
const segments = [...SEGMENTER.segment(str)];
|
|
93
|
+
return segments
|
|
94
|
+
.slice(start, end)
|
|
95
|
+
.map((s) => s.segment)
|
|
96
|
+
.join('');
|
|
120
97
|
}
|
|
121
|
-
|
|
122
98
|
/**
|
|
123
99
|
* Convert UTF-16 code unit offset to grapheme cluster offset within a string
|
|
124
100
|
*
|
|
125
101
|
* Converts DOM selection offsets (which use UTF-16 code units) to character-based
|
|
126
102
|
* offsets (grapheme clusters) for consistent text manipulation.
|
|
127
103
|
*
|
|
128
|
-
* @param {string} str - The string to convert offsets within
|
|
129
|
-
* @param {number} utf16_offset - The UTF-16 code unit offset from DOM
|
|
130
|
-
* @returns {number} The corresponding grapheme cluster offset
|
|
131
|
-
*
|
|
132
104
|
* @example
|
|
133
105
|
* // For string "a😀b" where 😀 uses 2 UTF-16 code units
|
|
134
106
|
* utf16_to_char_offset("a😀b", 3) // Returns: 2 (position after emoji)
|
|
135
107
|
*/
|
|
136
108
|
export function utf16_to_char_offset(str, utf16_offset) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
109
|
+
const segments = [...SEGMENTER.segment(str)];
|
|
110
|
+
let char_offset = 0;
|
|
111
|
+
let utf16_count = 0;
|
|
112
|
+
for (const segment of segments) {
|
|
113
|
+
if (utf16_count >= utf16_offset)
|
|
114
|
+
break;
|
|
115
|
+
utf16_count += segment.segment.length;
|
|
116
|
+
if (utf16_count > utf16_offset)
|
|
117
|
+
break;
|
|
118
|
+
char_offset++;
|
|
119
|
+
}
|
|
120
|
+
return char_offset;
|
|
149
121
|
}
|
|
150
|
-
|
|
151
122
|
/**
|
|
152
123
|
* Convert grapheme cluster offset to UTF-16 code unit offset within a string
|
|
153
124
|
*
|
|
154
125
|
* Converts character-based offsets (grapheme clusters) to DOM selection offsets
|
|
155
126
|
* (UTF-16 code units) for proper DOM selection positioning.
|
|
156
127
|
*
|
|
157
|
-
* @param {string} str - The string to convert offsets within
|
|
158
|
-
* @param {number} char_offset - The grapheme cluster offset
|
|
159
|
-
* @returns {number} The corresponding UTF-16 code unit offset for DOM operations
|
|
160
|
-
*
|
|
161
128
|
* @example
|
|
162
129
|
* // For string "a😀b" where 😀 uses 2 UTF-16 code units
|
|
163
130
|
* char_to_utf16_offset("a😀b", 2) // Returns: 3 (UTF-16 position after emoji)
|
|
164
131
|
*/
|
|
165
132
|
export function char_to_utf16_offset(str, char_offset) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return utf16_offset;
|
|
133
|
+
const segments = [...SEGMENTER.segment(str)];
|
|
134
|
+
let utf16_offset = 0;
|
|
135
|
+
for (let i = 0; i < Math.min(char_offset, segments.length); i++) {
|
|
136
|
+
utf16_offset += segments[i].segment.length;
|
|
137
|
+
}
|
|
138
|
+
return utf16_offset;
|
|
174
139
|
}
|
|
175
|
-
|
|
176
140
|
/**
|
|
177
|
-
* Splits
|
|
141
|
+
* Splits a text value at the specified character position.
|
|
178
142
|
*
|
|
179
143
|
* Marks and annotations that span the split point will be divided
|
|
180
144
|
* appropriately, with offsets adjusted for each resulting part.
|
|
181
145
|
*
|
|
182
|
-
* @param {AnnotatedText} text_value - Annotated text object
|
|
183
|
-
* @param {number} at_position - Character position where to split (0-based)
|
|
184
|
-
* @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
|
|
185
|
-
*
|
|
186
146
|
* @example
|
|
187
147
|
* split_text({content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}, 8)
|
|
188
148
|
* // Returns:
|
|
@@ -192,148 +152,124 @@ export function char_to_utf16_offset(str, char_offset) {
|
|
|
192
152
|
* // ]
|
|
193
153
|
*/
|
|
194
154
|
export function split_text(text_value, at_position) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
155
|
+
const { content } = text_value;
|
|
156
|
+
// Split the text using character-aware slicing
|
|
157
|
+
const left = {
|
|
158
|
+
content: char_slice(content, 0, at_position),
|
|
159
|
+
marks: [],
|
|
160
|
+
annotations: []
|
|
161
|
+
};
|
|
162
|
+
const right = {
|
|
163
|
+
content: char_slice(content, at_position),
|
|
164
|
+
marks: [],
|
|
165
|
+
annotations: []
|
|
166
|
+
};
|
|
167
|
+
// Process marks and annotations with the same range logic
|
|
168
|
+
for (const key of ['marks', 'annotations']) {
|
|
169
|
+
for (const { start_offset, end_offset, node_id } of text_value[key] ?? []) {
|
|
170
|
+
if (end_offset <= at_position) {
|
|
171
|
+
// Range is entirely in the left part
|
|
172
|
+
left[key].push({ start_offset, end_offset, node_id });
|
|
173
|
+
}
|
|
174
|
+
else if (start_offset >= at_position) {
|
|
175
|
+
// Range is entirely in the right part - shift offsets
|
|
176
|
+
right[key].push({
|
|
177
|
+
start_offset: start_offset - at_position,
|
|
178
|
+
end_offset: end_offset - at_position,
|
|
179
|
+
node_id
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// Range spans the split point - split it
|
|
184
|
+
left[key].push({ start_offset, end_offset: at_position, node_id });
|
|
185
|
+
right[key].push({ start_offset: 0, end_offset: end_offset - at_position, node_id });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return [left, right];
|
|
223
190
|
}
|
|
224
|
-
|
|
225
191
|
/**
|
|
226
|
-
* Joins two
|
|
192
|
+
* Joins two text values into a single text value.
|
|
227
193
|
*
|
|
228
194
|
* Marks and annotations from the second text will have their offsets shifted
|
|
229
195
|
* by the length of the first text. Adjacent ranges referencing the same node
|
|
230
196
|
* will be merged.
|
|
231
197
|
*
|
|
232
|
-
* @param {AnnotatedText} first_text - First annotated text object
|
|
233
|
-
* @param {AnnotatedText} second_text - Second annotated text object
|
|
234
|
-
* @returns {AnnotatedText} Combined annotated text object
|
|
235
|
-
*
|
|
236
198
|
* @example
|
|
237
199
|
* join_text({content: "Hello wo", marks: [{start_offset: 6, end_offset: 8, node_id: "strong"}], annotations: []}, {content: "rld", marks: [{start_offset: 0, end_offset: 3, node_id: "strong"}], annotations: []})
|
|
238
200
|
* // Returns: {content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}
|
|
239
201
|
*/
|
|
240
202
|
export function join_text(first_text, second_text) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
joined[key] = joined_ranges;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return joined;
|
|
203
|
+
// Join the text content
|
|
204
|
+
const joined = {
|
|
205
|
+
content: first_text.content + second_text.content,
|
|
206
|
+
marks: [],
|
|
207
|
+
annotations: []
|
|
208
|
+
};
|
|
209
|
+
const offset = get_char_length(first_text.content);
|
|
210
|
+
for (const key of ['marks', 'annotations']) {
|
|
211
|
+
// Start with all ranges from the first text (unchanged)
|
|
212
|
+
const joined_ranges = (first_text[key] ?? []).map((range) => ({ ...range }));
|
|
213
|
+
// Add ranges from the second text, shifting their offsets
|
|
214
|
+
for (const { start_offset, end_offset, node_id } of second_text[key] ?? []) {
|
|
215
|
+
const shifted_range = {
|
|
216
|
+
start_offset: start_offset + offset,
|
|
217
|
+
end_offset: end_offset + offset,
|
|
218
|
+
node_id
|
|
219
|
+
};
|
|
220
|
+
// Check if this range can be merged with the last range from the first text
|
|
221
|
+
const last_range = joined_ranges[joined_ranges.length - 1];
|
|
222
|
+
if (last_range &&
|
|
223
|
+
last_range.end_offset === shifted_range.start_offset && // ranges are adjacent
|
|
224
|
+
last_range.node_id === shifted_range.node_id) {
|
|
225
|
+
// same node_id
|
|
226
|
+
// Merge by extending the end position of the last range
|
|
227
|
+
last_range.end_offset = shifted_range.end_offset;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// Add as separate range
|
|
231
|
+
joined_ranges.push(shifted_range);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
joined[key] = joined_ranges;
|
|
235
|
+
}
|
|
236
|
+
return joined;
|
|
282
237
|
}
|
|
283
|
-
|
|
284
238
|
/**
|
|
285
239
|
* Convert snake_case string to PascalCase
|
|
286
240
|
*
|
|
287
|
-
* @param {string} str - The snake_case string to convert
|
|
288
|
-
* @returns {string} The converted PascalCase string
|
|
289
|
-
*
|
|
290
241
|
* @example
|
|
291
242
|
* snake_to_pascal('list_item') // Returns: 'ListItem'
|
|
292
243
|
*/
|
|
293
244
|
export function snake_to_pascal(str) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
245
|
+
return str
|
|
246
|
+
.split('_')
|
|
247
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
248
|
+
.join('');
|
|
298
249
|
}
|
|
299
|
-
|
|
300
250
|
export const PATH_SEPARATOR = '__';
|
|
301
251
|
const PATH_STRING_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
302
252
|
const PATH_INDEX_SEGMENT_RE = /^(0|[1-9]\d*)$/;
|
|
303
|
-
|
|
304
253
|
/**
|
|
305
254
|
* Check whether a string can be used as a Svedit path segment.
|
|
306
255
|
*
|
|
307
256
|
* Path string segments include node ids and schema property names. Keeping
|
|
308
257
|
* them restricted makes serialized paths reversible, valid as HTML ids, and
|
|
309
258
|
* safe to use as CSS anchor-name suffixes.
|
|
310
|
-
*
|
|
311
|
-
* @param {string} segment
|
|
312
|
-
* @returns {boolean}
|
|
313
259
|
*/
|
|
314
260
|
export function is_path_string_segment_valid(segment) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
!segment.includes(PATH_SEPARATOR)
|
|
319
|
-
);
|
|
261
|
+
return (typeof segment === 'string' &&
|
|
262
|
+
PATH_STRING_SEGMENT_RE.test(segment) &&
|
|
263
|
+
!segment.includes(PATH_SEPARATOR));
|
|
320
264
|
}
|
|
321
|
-
|
|
322
265
|
/**
|
|
323
266
|
* Assert that a string can be used as a Svedit path segment.
|
|
324
|
-
*
|
|
325
|
-
* @param {string} segment
|
|
326
|
-
* @param {string} [label]
|
|
327
|
-
* @returns {void}
|
|
328
267
|
*/
|
|
329
268
|
export function assert_path_string_segment(segment, label = 'Path segment') {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
);
|
|
334
|
-
}
|
|
269
|
+
if (!is_path_string_segment_valid(segment)) {
|
|
270
|
+
throw new Error(`${label} must start with a letter or underscore and contain only letters, numbers, underscores, or dashes. It must not contain "${PATH_SEPARATOR}".`);
|
|
271
|
+
}
|
|
335
272
|
}
|
|
336
|
-
|
|
337
273
|
/**
|
|
338
274
|
* Serialize a document path while preserving whether each segment is a
|
|
339
275
|
* property/key string or an array index number.
|
|
@@ -341,339 +277,276 @@ export function assert_path_string_segment(segment, label = 'Path segment') {
|
|
|
341
277
|
* The result is reversible and safe to use as a data attribute value, HTML id,
|
|
342
278
|
* or CSS anchor-name suffix.
|
|
343
279
|
*
|
|
344
|
-
* @param {Array<string | number>} path
|
|
345
|
-
* @returns {string}
|
|
346
|
-
*
|
|
347
280
|
* @example
|
|
348
281
|
* serialize_path(['page_1', 'body', 0, 'items']) // Returns: 'page_1__body__0__items'
|
|
349
282
|
*/
|
|
350
283
|
export function serialize_path(path) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
.join(PATH_SEPARATOR);
|
|
284
|
+
return path
|
|
285
|
+
.map((segment) => {
|
|
286
|
+
if (typeof segment === 'number') {
|
|
287
|
+
if (!Number.isInteger(segment) || segment < 0) {
|
|
288
|
+
throw new Error(`Path index must be a non-negative integer: ${segment}`);
|
|
289
|
+
}
|
|
290
|
+
return String(segment);
|
|
291
|
+
}
|
|
292
|
+
assert_path_string_segment(segment);
|
|
293
|
+
return segment;
|
|
294
|
+
})
|
|
295
|
+
.join(PATH_SEPARATOR);
|
|
364
296
|
}
|
|
365
|
-
|
|
366
297
|
/**
|
|
367
298
|
* Deserialize a path string produced by serialize_path.
|
|
368
299
|
*
|
|
369
|
-
* @param {string} serialized_path
|
|
370
|
-
* @returns {Array<string | number>}
|
|
371
|
-
*
|
|
372
300
|
* @example
|
|
373
301
|
* deserialize_path('page_1__body__0__items') // Returns: ['page_1', 'body', 0, 'items']
|
|
374
302
|
*/
|
|
375
303
|
export function deserialize_path(serialized_path) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return segment;
|
|
392
|
-
});
|
|
304
|
+
if (serialized_path === '')
|
|
305
|
+
return [];
|
|
306
|
+
return serialized_path.split(PATH_SEPARATOR).map((segment) => {
|
|
307
|
+
if (segment === '') {
|
|
308
|
+
throw new Error(`Invalid serialized path: ${serialized_path}`);
|
|
309
|
+
}
|
|
310
|
+
if (/^\d+$/.test(segment)) {
|
|
311
|
+
if (!PATH_INDEX_SEGMENT_RE.test(segment)) {
|
|
312
|
+
throw new Error(`Invalid serialized path index: ${segment}`);
|
|
313
|
+
}
|
|
314
|
+
return Number(segment);
|
|
315
|
+
}
|
|
316
|
+
assert_path_string_segment(segment, 'Serialized path segment');
|
|
317
|
+
return segment;
|
|
318
|
+
});
|
|
393
319
|
}
|
|
394
|
-
|
|
395
320
|
/**
|
|
396
321
|
* Compare two document paths segment-by-segment, preserving segment types.
|
|
397
|
-
*
|
|
398
|
-
* @param {Array<string | number>} a
|
|
399
|
-
* @param {Array<string | number>} b
|
|
400
|
-
* @returns {boolean}
|
|
401
322
|
*/
|
|
402
323
|
export function paths_equal(a, b) {
|
|
403
|
-
|
|
404
|
-
|
|
324
|
+
if (a.length !== b.length)
|
|
325
|
+
return false;
|
|
326
|
+
return a.every((segment, index) => segment === b[index]);
|
|
405
327
|
}
|
|
406
|
-
|
|
407
328
|
/**
|
|
408
329
|
* Like traverse, but returns only node ids in depth-first order and never
|
|
409
330
|
* clones nodes. Use this when only the reachable id set is needed (e.g.
|
|
410
331
|
* reference bookkeeping) — traverse() deep-clones every visited node,
|
|
411
332
|
* which is wasteful for id-only consumers.
|
|
412
333
|
*
|
|
413
|
-
* @
|
|
414
|
-
* @param {object} schema - The document schema
|
|
415
|
-
* @param {Record<string, any>} nodes - All document nodes
|
|
416
|
-
* @returns {string[]} Node ids in depth-first order (entry point last)
|
|
334
|
+
* @returns Node ids in depth-first order (entry point last)
|
|
417
335
|
*/
|
|
418
336
|
export function traverse_ids(node_id, schema, nodes) {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
337
|
+
const ids = [];
|
|
338
|
+
const visited = {};
|
|
339
|
+
const visit = (node) => {
|
|
340
|
+
if (!node || visited[node.id]) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
visited[node.id] = true;
|
|
344
|
+
for (const [property_name, value] of Object.entries(node)) {
|
|
345
|
+
const property_definition = schema[node.type].properties[property_name];
|
|
346
|
+
if (property_definition?.type === 'node_array') {
|
|
347
|
+
for (const v of value?.nodes || []) {
|
|
348
|
+
if (typeof v === 'string') {
|
|
349
|
+
visit(nodes[v]);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
|
|
353
|
+
visit(nodes[range.node_id]);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else if (property_definition?.type === 'node') {
|
|
357
|
+
visit(nodes[value]);
|
|
358
|
+
}
|
|
359
|
+
else if (property_definition?.type === 'text') {
|
|
360
|
+
for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
|
|
361
|
+
visit(nodes[range.node_id]);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
ids.push(node.id);
|
|
366
|
+
};
|
|
367
|
+
visit(nodes[node_id]);
|
|
368
|
+
return ids;
|
|
450
369
|
}
|
|
451
|
-
|
|
452
370
|
export function traverse(node_id, schema, nodes) {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
return json;
|
|
371
|
+
const json = [];
|
|
372
|
+
const visited = {};
|
|
373
|
+
const visit = (node) => {
|
|
374
|
+
if (!node || visited[node.id]) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
visited[node.id] = true;
|
|
378
|
+
for (const [property_name, value] of Object.entries(node)) {
|
|
379
|
+
const property_definition = schema[node.type].properties[property_name];
|
|
380
|
+
if (property_definition?.type === 'node_array') {
|
|
381
|
+
const node_ids = value?.nodes || [];
|
|
382
|
+
for (const v of node_ids) {
|
|
383
|
+
if (typeof v === 'string') {
|
|
384
|
+
visit(nodes[v]);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
|
|
388
|
+
visit(nodes[range.node_id]);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (property_definition?.type === 'node') {
|
|
392
|
+
visit(nodes[value]);
|
|
393
|
+
}
|
|
394
|
+
else if (property_definition?.type === 'text') {
|
|
395
|
+
for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
|
|
396
|
+
visit(nodes[range.node_id]);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// Finally add the node to the result.
|
|
401
|
+
// Deep clone, to make sure nothing of the original document is referenced.
|
|
402
|
+
json.push(structuredClone(node));
|
|
403
|
+
};
|
|
404
|
+
// Start with the root node (document_id)
|
|
405
|
+
visit(nodes[node_id]);
|
|
406
|
+
return json;
|
|
490
407
|
}
|
|
491
|
-
|
|
492
408
|
/**
|
|
493
409
|
* Extracts the normalized range from a text or node selection.
|
|
494
410
|
* Returns start and end offsets in document order (start <= end), regardless of selection direction.
|
|
495
|
-
*
|
|
496
|
-
* @returns {SelectionRange | null} The normalized selection range, or null if selection is null, undefined, or a property selection
|
|
411
|
+
* Returns null if selection is null, undefined, or a property selection.
|
|
497
412
|
*/
|
|
498
413
|
export function get_selection_range(selection) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
414
|
+
if (selection && selection.type !== 'property') {
|
|
415
|
+
return {
|
|
416
|
+
start_offset: Math.min(selection.anchor_offset, selection.focus_offset),
|
|
417
|
+
end_offset: Math.max(selection.anchor_offset, selection.focus_offset)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
507
423
|
}
|
|
508
|
-
|
|
509
424
|
export function is_selection_collapsed(selection) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
425
|
+
if (selection && selection.type !== 'property') {
|
|
426
|
+
return selection.anchor_offset === selection.focus_offset;
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
515
431
|
}
|
|
516
|
-
|
|
517
432
|
/**
|
|
518
433
|
* Adjust ranges (marks or annotations) after deleting a sequence range.
|
|
519
434
|
*
|
|
520
|
-
* Works for both
|
|
435
|
+
* Works for both text values (character offsets) and node arrays
|
|
521
436
|
* (node offsets).
|
|
522
|
-
*
|
|
523
|
-
* @param {Array<import('./types.d.ts').Attachment>} ranges
|
|
524
|
-
* @param {number} start
|
|
525
|
-
* @param {number} end
|
|
526
|
-
* @returns {{ranges: Array<import('./types.d.ts').Attachment>, removed_node_ids: string[]}}
|
|
527
437
|
*/
|
|
528
438
|
export function adjust_ranges_for_deletion(ranges, start, end) {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
.filter(Boolean);
|
|
558
|
-
|
|
559
|
-
return { ranges: next_ranges, removed_node_ids };
|
|
439
|
+
const removed_node_ids = [];
|
|
440
|
+
const deletion_length = end - start;
|
|
441
|
+
const next_ranges = ranges
|
|
442
|
+
.map((range) => {
|
|
443
|
+
if (range.end_offset <= start)
|
|
444
|
+
return range;
|
|
445
|
+
let start_offset = range.start_offset;
|
|
446
|
+
if (range.start_offset >= end) {
|
|
447
|
+
start_offset -= deletion_length;
|
|
448
|
+
}
|
|
449
|
+
else if (range.start_offset > start) {
|
|
450
|
+
start_offset = start;
|
|
451
|
+
}
|
|
452
|
+
let end_offset = range.end_offset;
|
|
453
|
+
if (range.end_offset >= end) {
|
|
454
|
+
end_offset -= deletion_length;
|
|
455
|
+
}
|
|
456
|
+
else if (range.end_offset > start) {
|
|
457
|
+
end_offset = start;
|
|
458
|
+
}
|
|
459
|
+
if (start_offset >= end_offset) {
|
|
460
|
+
removed_node_ids.push(range.node_id);
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
return { start_offset, end_offset, node_id: range.node_id };
|
|
464
|
+
})
|
|
465
|
+
.filter((range) => range !== null);
|
|
466
|
+
return { ranges: next_ranges, removed_node_ids };
|
|
560
467
|
}
|
|
561
|
-
|
|
562
468
|
/**
|
|
563
469
|
* Adjust ranges (marks or annotations) after inserting into a sequence.
|
|
564
470
|
*
|
|
565
471
|
* Insertion inside a range extends it. Insertion exactly at either edge
|
|
566
472
|
* stays outside the range.
|
|
567
|
-
*
|
|
568
|
-
* @param {Array<import('./types.d.ts').Attachment>} ranges
|
|
569
|
-
* @param {number} offset
|
|
570
|
-
* @param {number} length
|
|
571
|
-
* @returns {Array<import('./types.d.ts').Attachment>}
|
|
572
473
|
*/
|
|
573
474
|
export function adjust_ranges_for_insertion(ranges, offset, length) {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
});
|
|
475
|
+
if (length === 0)
|
|
476
|
+
return ranges;
|
|
477
|
+
return ranges.map((range) => {
|
|
478
|
+
if (range.end_offset <= offset)
|
|
479
|
+
return range;
|
|
480
|
+
if (range.start_offset < offset) {
|
|
481
|
+
return {
|
|
482
|
+
start_offset: range.start_offset,
|
|
483
|
+
end_offset: range.end_offset + length,
|
|
484
|
+
node_id: range.node_id
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
start_offset: range.start_offset + length,
|
|
489
|
+
end_offset: range.end_offset + length,
|
|
490
|
+
node_id: range.node_id
|
|
491
|
+
};
|
|
492
|
+
});
|
|
593
493
|
}
|
|
594
|
-
|
|
595
494
|
/**
|
|
596
495
|
* Check whether ranges are non-empty and mutually exclusive.
|
|
597
|
-
*
|
|
598
|
-
* @param {Array<import('./types.d.ts').Attachment>} ranges
|
|
599
|
-
* @param {number} [length]
|
|
600
|
-
* @returns {boolean}
|
|
601
496
|
*/
|
|
602
497
|
export function are_ranges_exclusive(ranges, length = Infinity) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
Number.isInteger(range.end_offset) &&
|
|
611
|
-
range.start_offset >= 0 &&
|
|
612
|
-
range.start_offset < range.end_offset &&
|
|
613
|
-
range.end_offset <= length &&
|
|
614
|
-
(index === 0 || range.start_offset >= sorted[index - 1].end_offset)
|
|
615
|
-
);
|
|
498
|
+
const sorted = [...ranges].sort((a, b) => a.start_offset - b.start_offset || a.end_offset - b.end_offset);
|
|
499
|
+
return sorted.every((range, index) => Number.isInteger(range.start_offset) &&
|
|
500
|
+
Number.isInteger(range.end_offset) &&
|
|
501
|
+
range.start_offset >= 0 &&
|
|
502
|
+
range.start_offset < range.end_offset &&
|
|
503
|
+
range.end_offset <= length &&
|
|
504
|
+
(index === 0 || range.start_offset >= sorted[index - 1].end_offset));
|
|
616
505
|
}
|
|
617
|
-
|
|
618
506
|
/**
|
|
619
507
|
* Calculates abstract fragment ranges from a length and marks.
|
|
620
|
-
*
|
|
621
|
-
* @param {number} length - Length of the sequence (e.g. text length or array length)
|
|
622
|
-
* @param {Array<import('./types.d.ts').Mark>} marks - Array of marks
|
|
623
|
-
* @param {import('./types.d.ts').SelectionRange} [selection_highlight_range] - Optional selection highlight range
|
|
624
|
-
* @returns {Array<{type: 'content' | 'mark' | 'selection_highlight', start_offset: number, end_offset: number, mark_index?: number, node_id?: string}>}
|
|
625
508
|
*/
|
|
626
509
|
export function calculate_fragment_ranges(length, marks, selection_highlight_range) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
// Add any remaining content
|
|
670
|
-
if (last_index < length) {
|
|
671
|
-
fragments.push({
|
|
672
|
-
type: 'content',
|
|
673
|
-
start_offset: last_index,
|
|
674
|
-
end_offset: length
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
return fragments;
|
|
510
|
+
const fragments = [];
|
|
511
|
+
let last_index = 0;
|
|
512
|
+
// Merge marks with selection highlight and sort by start offset
|
|
513
|
+
const ranges = [...marks, ...(selection_highlight_range ? [selection_highlight_range] : [])].sort((a, b) => a.start_offset - b.start_offset);
|
|
514
|
+
for (const range of ranges) {
|
|
515
|
+
// Add content before this range
|
|
516
|
+
if (range.start_offset > last_index) {
|
|
517
|
+
fragments.push({
|
|
518
|
+
type: 'content',
|
|
519
|
+
start_offset: last_index,
|
|
520
|
+
end_offset: range.start_offset
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if ('node_id' in range) {
|
|
524
|
+
const mark = range;
|
|
525
|
+
const mark_index = mark.mark_index ?? marks.indexOf(mark);
|
|
526
|
+
fragments.push({
|
|
527
|
+
type: 'mark',
|
|
528
|
+
start_offset: mark.start_offset,
|
|
529
|
+
end_offset: mark.end_offset,
|
|
530
|
+
node_id: mark.node_id,
|
|
531
|
+
mark_index
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
fragments.push({
|
|
536
|
+
type: 'selection_highlight',
|
|
537
|
+
start_offset: range.start_offset,
|
|
538
|
+
end_offset: range.end_offset
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
last_index = range.end_offset;
|
|
542
|
+
}
|
|
543
|
+
// Add any remaining content
|
|
544
|
+
if (last_index < length) {
|
|
545
|
+
fragments.push({
|
|
546
|
+
type: 'content',
|
|
547
|
+
start_offset: last_index,
|
|
548
|
+
end_offset: length
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
return fragments;
|
|
679
552
|
}
|