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