svedit 0.11.1 → 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 +260 -82
- package/dist/Command.svelte.d.ts +54 -29
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +286 -283
- 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 +40 -9
- package/dist/Node.svelte.d.ts +3 -5
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +142 -18
- 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 +60 -12
- 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 +85 -116
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +543 -571
- package/dist/Svedit.svelte +243 -216
- package/dist/Svedit.svelte.d.ts +30 -10
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
- package/dist/TextProperty.svelte.d.ts +5 -0
- package/dist/TextProperty.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.d.ts +101 -126
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +843 -827
- 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 +109 -122
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +726 -698
- package/dist/index.d.ts +20 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -18
- package/dist/node_visibility.svelte.d.ts +132 -5
- package/dist/node_visibility.svelte.d.ts.map +1 -1
- package/dist/node_visibility.svelte.js +535 -642
- package/dist/paste_utils.d.ts +11 -43
- package/dist/paste_utils.d.ts.map +1 -1
- package/dist/paste_utils.js +111 -149
- package/dist/transforms.svelte.d.ts +9 -7
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +126 -152
- package/dist/types.d.ts +329 -291
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +79 -95
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +392 -305
- package/package.json +69 -67
- package/src/lib/{Command.svelte.js → Command.svelte.ts} +111 -41
- package/src/lib/CustomProperty.svelte +3 -5
- package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
- package/src/lib/Node.svelte +40 -9
- package/src/lib/NodeArrayProperty.svelte +142 -18
- package/src/lib/NodeCaret.svelte +1 -1
- package/src/lib/NodeGap.svelte +60 -12
- package/src/lib/NodeGapMarkers.svelte +19 -10
- package/src/lib/NodeSelectionMarkers.svelte +20 -6
- package/src/lib/Session.svelte.ts +656 -0
- package/src/lib/Svedit.svelte +243 -216
- package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
- package/src/lib/Transaction.svelte.ts +1020 -0
- package/src/lib/UnknownNode.svelte +4 -3
- package/src/lib/doc_utils.ts +1120 -0
- package/src/lib/{index.js → index.ts} +48 -1
- package/src/lib/node_visibility.svelte.ts +658 -0
- package/src/lib/{paste_utils.js → paste_utils.ts} +51 -59
- package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +44 -27
- package/src/lib/{types.d.ts → types.ts} +250 -103
- package/src/lib/utils.ts +660 -0
- package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
- package/src/lib/Session.svelte.js +0 -575
- package/src/lib/Transaction.svelte.js +0 -846
- package/src/lib/doc_utils.js +0 -807
- package/src/lib/node_visibility.svelte.js +0 -718
- package/src/lib/utils.js +0 -465
package/dist/utils.js
CHANGED
|
@@ -1,80 +1,67 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @import { 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,259 +69,207 @@ 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
|
-
*
|
|
180
|
-
* with offsets adjusted for each resulting part.
|
|
181
|
-
*
|
|
182
|
-
* @param {AnnotatedText} text_with_annotations - 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]
|
|
143
|
+
* Marks and annotations that span the split point will be divided
|
|
144
|
+
* appropriately, with offsets adjusted for each resulting part.
|
|
185
145
|
*
|
|
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:
|
|
189
149
|
* // [
|
|
190
|
-
* // {
|
|
191
|
-
* // {
|
|
150
|
+
* // {content: "Hello wo", marks: [{start_offset: 6, end_offset: 8, node_id: "strong"}], annotations: []},
|
|
151
|
+
* // {content: "rld", marks: [{start_offset: 0, end_offset: 3, node_id: "strong"}], annotations: []}
|
|
192
152
|
* // ]
|
|
193
153
|
*/
|
|
194
|
-
export function
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
154
|
+
export function split_text(text_value, at_position) {
|
|
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];
|
|
229
190
|
}
|
|
230
|
-
|
|
231
191
|
/**
|
|
232
|
-
* Joins two
|
|
233
|
-
*
|
|
234
|
-
* Annotations from the second text will have their offsets shifted by the length
|
|
235
|
-
* of the first text. Adjacent annotations of the same type and data will be merged.
|
|
192
|
+
* Joins two text values into a single text value.
|
|
236
193
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
194
|
+
* Marks and annotations from the second text will have their offsets shifted
|
|
195
|
+
* by the length of the first text. Adjacent ranges referencing the same node
|
|
196
|
+
* will be merged.
|
|
240
197
|
*
|
|
241
198
|
* @example
|
|
242
|
-
*
|
|
243
|
-
* // Returns: {
|
|
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: []})
|
|
200
|
+
* // Returns: {content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}
|
|
244
201
|
*/
|
|
245
|
-
export function
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
return { text: joined_text, annotations: joined_annotations };
|
|
202
|
+
export function join_text(first_text, second_text) {
|
|
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;
|
|
283
237
|
}
|
|
284
|
-
|
|
285
238
|
/**
|
|
286
239
|
* Convert snake_case string to PascalCase
|
|
287
240
|
*
|
|
288
|
-
* @param {string} str - The snake_case string to convert
|
|
289
|
-
* @returns {string} The converted PascalCase string
|
|
290
|
-
*
|
|
291
241
|
* @example
|
|
292
242
|
* snake_to_pascal('list_item') // Returns: 'ListItem'
|
|
293
243
|
*/
|
|
294
244
|
export function snake_to_pascal(str) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
245
|
+
return str
|
|
246
|
+
.split('_')
|
|
247
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
248
|
+
.join('');
|
|
299
249
|
}
|
|
300
|
-
|
|
301
250
|
export const PATH_SEPARATOR = '__';
|
|
302
251
|
const PATH_STRING_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
303
252
|
const PATH_INDEX_SEGMENT_RE = /^(0|[1-9]\d*)$/;
|
|
304
|
-
|
|
305
253
|
/**
|
|
306
254
|
* Check whether a string can be used as a Svedit path segment.
|
|
307
255
|
*
|
|
308
256
|
* Path string segments include node ids and schema property names. Keeping
|
|
309
257
|
* them restricted makes serialized paths reversible, valid as HTML ids, and
|
|
310
258
|
* safe to use as CSS anchor-name suffixes.
|
|
311
|
-
*
|
|
312
|
-
* @param {string} segment
|
|
313
|
-
* @returns {boolean}
|
|
314
259
|
*/
|
|
315
260
|
export function is_path_string_segment_valid(segment) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
!segment.includes(PATH_SEPARATOR)
|
|
320
|
-
);
|
|
261
|
+
return (typeof segment === 'string' &&
|
|
262
|
+
PATH_STRING_SEGMENT_RE.test(segment) &&
|
|
263
|
+
!segment.includes(PATH_SEPARATOR));
|
|
321
264
|
}
|
|
322
|
-
|
|
323
265
|
/**
|
|
324
266
|
* Assert that a string can be used as a Svedit path segment.
|
|
325
|
-
*
|
|
326
|
-
* @param {string} segment
|
|
327
|
-
* @param {string} [label]
|
|
328
|
-
* @returns {void}
|
|
329
267
|
*/
|
|
330
268
|
export function assert_path_string_segment(segment, label = 'Path segment') {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
);
|
|
335
|
-
}
|
|
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
|
+
}
|
|
336
272
|
}
|
|
337
|
-
|
|
338
273
|
/**
|
|
339
274
|
* Serialize a document path while preserving whether each segment is a
|
|
340
275
|
* property/key string or an array index number.
|
|
@@ -342,124 +277,276 @@ export function assert_path_string_segment(segment, label = 'Path segment') {
|
|
|
342
277
|
* The result is reversible and safe to use as a data attribute value, HTML id,
|
|
343
278
|
* or CSS anchor-name suffix.
|
|
344
279
|
*
|
|
345
|
-
* @param {Array<string | number>} path
|
|
346
|
-
* @returns {string}
|
|
347
|
-
*
|
|
348
280
|
* @example
|
|
349
281
|
* serialize_path(['page_1', 'body', 0, 'items']) // Returns: 'page_1__body__0__items'
|
|
350
282
|
*/
|
|
351
283
|
export function serialize_path(path) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
.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);
|
|
365
296
|
}
|
|
366
|
-
|
|
367
297
|
/**
|
|
368
298
|
* Deserialize a path string produced by serialize_path.
|
|
369
299
|
*
|
|
370
|
-
* @param {string} serialized_path
|
|
371
|
-
* @returns {Array<string | number>}
|
|
372
|
-
*
|
|
373
300
|
* @example
|
|
374
301
|
* deserialize_path('page_1__body__0__items') // Returns: ['page_1', 'body', 0, 'items']
|
|
375
302
|
*/
|
|
376
303
|
export function deserialize_path(serialized_path) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return segment;
|
|
393
|
-
});
|
|
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
|
+
});
|
|
394
319
|
}
|
|
395
|
-
|
|
396
320
|
/**
|
|
397
321
|
* Compare two document paths segment-by-segment, preserving segment types.
|
|
398
|
-
*
|
|
399
|
-
* @param {Array<string | number>} a
|
|
400
|
-
* @param {Array<string | number>} b
|
|
401
|
-
* @returns {boolean}
|
|
402
322
|
*/
|
|
403
323
|
export function paths_equal(a, b) {
|
|
404
|
-
|
|
405
|
-
|
|
324
|
+
if (a.length !== b.length)
|
|
325
|
+
return false;
|
|
326
|
+
return a.every((segment, index) => segment === b[index]);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Like traverse, but returns only node ids in depth-first order and never
|
|
330
|
+
* clones nodes. Use this when only the reachable id set is needed (e.g.
|
|
331
|
+
* reference bookkeeping) — traverse() deep-clones every visited node,
|
|
332
|
+
* which is wasteful for id-only consumers.
|
|
333
|
+
*
|
|
334
|
+
* @returns Node ids in depth-first order (entry point last)
|
|
335
|
+
*/
|
|
336
|
+
export function traverse_ids(node_id, schema, nodes) {
|
|
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;
|
|
406
369
|
}
|
|
407
|
-
|
|
408
370
|
export function traverse(node_id, schema, nodes) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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;
|
|
440
407
|
}
|
|
441
|
-
|
|
442
408
|
/**
|
|
443
409
|
* Extracts the normalized range from a text or node selection.
|
|
444
410
|
* Returns start and end offsets in document order (start <= end), regardless of selection direction.
|
|
445
|
-
*
|
|
446
|
-
* @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.
|
|
447
412
|
*/
|
|
448
413
|
export function get_selection_range(selection) {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
+
}
|
|
457
423
|
}
|
|
458
|
-
|
|
459
424
|
export function is_selection_collapsed(selection) {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
425
|
+
if (selection && selection.type !== 'property') {
|
|
426
|
+
return selection.anchor_offset === selection.focus_offset;
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Adjust ranges (marks or annotations) after deleting a sequence range.
|
|
434
|
+
*
|
|
435
|
+
* Works for both text values (character offsets) and node arrays
|
|
436
|
+
* (node offsets).
|
|
437
|
+
*/
|
|
438
|
+
export function adjust_ranges_for_deletion(ranges, start, end) {
|
|
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 };
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Adjust ranges (marks or annotations) after inserting into a sequence.
|
|
470
|
+
*
|
|
471
|
+
* Insertion inside a range extends it. Insertion exactly at either edge
|
|
472
|
+
* stays outside the range.
|
|
473
|
+
*/
|
|
474
|
+
export function adjust_ranges_for_insertion(ranges, offset, length) {
|
|
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
|
+
});
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Check whether ranges are non-empty and mutually exclusive.
|
|
496
|
+
*/
|
|
497
|
+
export function are_ranges_exclusive(ranges, length = Infinity) {
|
|
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));
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Calculates abstract fragment ranges from a length and marks.
|
|
508
|
+
*/
|
|
509
|
+
export function calculate_fragment_ranges(length, marks, selection_highlight_range) {
|
|
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;
|
|
465
552
|
}
|