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.
Files changed (86) hide show
  1. package/README.md +33 -21
  2. package/dist/Command.svelte.d.ts +38 -30
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +271 -326
  5. package/dist/CustomProperty.svelte +3 -5
  6. package/dist/CustomProperty.svelte.d.ts +3 -6
  7. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  8. package/dist/KeyMapper.svelte.d.ts +19 -6
  9. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  10. package/dist/KeyMapper.svelte.js +95 -112
  11. package/dist/Node.svelte +16 -13
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +39 -22
  15. package/dist/NodeArrayProperty.svelte.d.ts +3 -5
  16. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  17. package/dist/NodeCaret.svelte +1 -1
  18. package/dist/NodeCaret.svelte.d.ts +6 -14
  19. package/dist/NodeCaret.svelte.d.ts.map +1 -1
  20. package/dist/NodeGap.svelte +12 -6
  21. package/dist/NodeGap.svelte.d.ts +7 -14
  22. package/dist/NodeGap.svelte.d.ts.map +1 -1
  23. package/dist/NodeGapMarkers.svelte +19 -10
  24. package/dist/NodeGapMarkers.svelte.d.ts +6 -11
  25. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  26. package/dist/NodeSelectionMarkers.svelte +20 -6
  27. package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
  28. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  29. package/dist/Session.svelte.d.ts +81 -129
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +542 -648
  32. package/dist/Svedit.svelte +144 -138
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/TextProperty.svelte +32 -18
  36. package/dist/TextProperty.svelte.d.ts +3 -5
  37. package/dist/TextProperty.svelte.d.ts.map +1 -1
  38. package/dist/Transaction.svelte.d.ts +64 -127
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +839 -1001
  41. package/dist/UnknownNode.svelte +4 -3
  42. package/dist/UnknownNode.svelte.d.ts +5 -9
  43. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  44. package/dist/doc_utils.d.ts +67 -155
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +619 -863
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -17
  50. package/dist/node_visibility.svelte.d.ts +110 -9
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +473 -544
  53. package/dist/paste_utils.d.ts +11 -44
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -152
  56. package/dist/transforms.svelte.d.ts +9 -7
  57. package/dist/transforms.svelte.d.ts.map +1 -1
  58. package/dist/transforms.svelte.js +125 -150
  59. package/dist/types.d.ts +277 -309
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +1 -0
  62. package/dist/utils.d.ts +40 -119
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +346 -473
  65. package/package.json +69 -70
  66. package/src/lib/{Command.svelte.js → Command.svelte.ts} +29 -17
  67. package/src/lib/CustomProperty.svelte +3 -5
  68. package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
  69. package/src/lib/Node.svelte +16 -13
  70. package/src/lib/NodeArrayProperty.svelte +39 -22
  71. package/src/lib/NodeCaret.svelte +1 -1
  72. package/src/lib/NodeGap.svelte +12 -6
  73. package/src/lib/NodeGapMarkers.svelte +19 -10
  74. package/src/lib/NodeSelectionMarkers.svelte +20 -6
  75. package/src/lib/{Session.svelte.js → Session.svelte.ts} +130 -127
  76. package/src/lib/Svedit.svelte +144 -138
  77. package/src/lib/TextProperty.svelte +32 -18
  78. package/src/lib/{Transaction.svelte.js → Transaction.svelte.ts} +139 -143
  79. package/src/lib/UnknownNode.svelte +4 -3
  80. package/src/lib/{doc_utils.js → doc_utils.ts} +247 -206
  81. package/src/lib/{index.js → index.ts} +47 -0
  82. package/src/lib/{node_visibility.svelte.js → node_visibility.svelte.ts} +68 -92
  83. package/src/lib/{paste_utils.js → paste_utils.ts} +47 -58
  84. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +39 -21
  85. package/src/lib/{types.d.ts → types.ts} +143 -66
  86. 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
- * @returns {boolean} true if the virtual keyboard is likely active, false otherwise
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
- if (typeof window === 'undefined' || typeof document === 'undefined') {
17
- return false;
18
- }
19
-
20
- const visual_viewport = window.visualViewport;
21
- if (!visual_viewport) {
22
- return false;
23
- }
24
-
25
- return visual_viewport.height < document.documentElement.clientHeight;
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
- if (typeof navigator === 'undefined') {
38
- return false;
39
- }
40
-
41
- const user_agent = navigator.userAgent;
42
- return /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent);
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
- if (typeof window === 'undefined' || typeof navigator === 'undefined') {
52
- return false;
53
- }
54
- const user_agent = navigator.userAgent;
55
- const is_chrome = user_agent.includes('Chrome') && !user_agent.includes('Edg');
56
- const is_mobile = is_mobile_browser();
57
- return is_chrome && !is_mobile;
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
- return [...SEGMENTER.segment(str)].length;
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
- const segments = [...SEGMENTER.segment(str)];
96
- return segments[index].segment;
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
- const segments = [...SEGMENTER.segment(str)];
116
- return segments
117
- .slice(start, end)
118
- .map((s) => s.segment)
119
- .join('');
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
- const segments = [...SEGMENTER.segment(str)];
138
- let char_offset = 0;
139
- let utf16_count = 0;
140
-
141
- for (const segment of segments) {
142
- if (utf16_count >= utf16_offset) break;
143
- utf16_count += segment.segment.length;
144
- if (utf16_count > utf16_offset) break;
145
- char_offset++;
146
- }
147
-
148
- return char_offset;
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
- const segments = [...SEGMENTER.segment(str)];
167
- let utf16_offset = 0;
168
-
169
- for (let i = 0; i < Math.min(char_offset, segments.length); i++) {
170
- utf16_offset += segments[i].segment.length;
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 an annotated text at the specified character position.
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
- const { content } = text_value;
196
-
197
- // Split the text using character-aware slicing
198
- const left = { content: char_slice(content, 0, at_position), marks: [], annotations: [] };
199
- const right = { content: char_slice(content, at_position), marks: [], annotations: [] };
200
-
201
- // Process marks and annotations with the same range logic
202
- for (const key of ['marks', 'annotations']) {
203
- for (const { start_offset, end_offset, node_id } of text_value[key] ?? []) {
204
- if (end_offset <= at_position) {
205
- // Range is entirely in the left part
206
- left[key].push({ start_offset, end_offset, node_id });
207
- } else if (start_offset >= at_position) {
208
- // Range is entirely in the right part - shift offsets
209
- right[key].push({
210
- start_offset: start_offset - at_position,
211
- end_offset: end_offset - at_position,
212
- node_id
213
- });
214
- } else {
215
- // Range spans the split point - split it
216
- left[key].push({ start_offset, end_offset: at_position, node_id });
217
- right[key].push({ start_offset: 0, end_offset: end_offset - at_position, node_id });
218
- }
219
- }
220
- }
221
-
222
- return [left, right];
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 annotated texts into a single annotated text.
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
- // Join the text content
242
- const joined = {
243
- content: first_text.content + second_text.content,
244
- marks: [],
245
- annotations: []
246
- };
247
-
248
- const offset = get_char_length(first_text.content);
249
-
250
- for (const key of ['marks', 'annotations']) {
251
- // Start with all ranges from the first text (unchanged)
252
- const joined_ranges = (first_text[key] ?? []).map((range) => ({ ...range }));
253
-
254
- // Add ranges from the second text, shifting their offsets
255
- for (const { start_offset, end_offset, node_id } of second_text[key] ?? []) {
256
- const shifted_range = {
257
- start_offset: start_offset + offset,
258
- end_offset: end_offset + offset,
259
- node_id
260
- };
261
-
262
- // Check if this range can be merged with the last range from the first text
263
- const last_range = joined_ranges[joined_ranges.length - 1];
264
- if (
265
- last_range &&
266
- last_range.end_offset === shifted_range.start_offset && // ranges are adjacent
267
- last_range.node_id === shifted_range.node_id
268
- ) {
269
- // same node_id
270
- // Merge by extending the end position of the last range
271
- last_range.end_offset = shifted_range.end_offset;
272
- } else {
273
- // Add as separate range
274
- joined_ranges.push(shifted_range);
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
- return str
295
- .split('_')
296
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
297
- .join('');
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
- return (
316
- typeof segment === 'string' &&
317
- PATH_STRING_SEGMENT_RE.test(segment) &&
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
- if (!is_path_string_segment_valid(segment)) {
331
- throw new Error(
332
- `${label} must start with a letter or underscore and contain only letters, numbers, underscores, or dashes. It must not contain "${PATH_SEPARATOR}".`
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
- return path
352
- .map((segment) => {
353
- if (typeof segment === 'number') {
354
- if (!Number.isInteger(segment) || segment < 0) {
355
- throw new Error(`Path index must be a non-negative integer: ${segment}`);
356
- }
357
- return String(segment);
358
- }
359
-
360
- assert_path_string_segment(segment);
361
- return segment;
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
- if (serialized_path === '') return [];
377
-
378
- return serialized_path.split(PATH_SEPARATOR).map((segment) => {
379
- if (segment === '') {
380
- throw new Error(`Invalid serialized path: ${serialized_path}`);
381
- }
382
-
383
- if (/^\d+$/.test(segment)) {
384
- if (!PATH_INDEX_SEGMENT_RE.test(segment)) {
385
- throw new Error(`Invalid serialized path index: ${segment}`);
386
- }
387
- return Number(segment);
388
- }
389
-
390
- assert_path_string_segment(segment, 'Serialized path segment');
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
- if (a.length !== b.length) return false;
404
- return a.every((segment, index) => segment === b[index]);
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
- * @param {string} node_id - The ID of the node to start traversing from
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
- const ids = [];
420
- const visited = {};
421
- const visit = (node) => {
422
- if (!node || visited[node.id]) {
423
- return;
424
- }
425
- visited[node.id] = true;
426
- for (const [property_name, value] of Object.entries(node)) {
427
- const property_definition = schema[node.type].properties[property_name];
428
-
429
- if (property_definition?.type === 'node_array') {
430
- for (const v of value?.nodes || []) {
431
- if (typeof v === 'string') {
432
- visit(nodes[v]);
433
- }
434
- }
435
- for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
436
- visit(nodes[range.node_id]);
437
- }
438
- } else if (property_definition?.type === 'node') {
439
- visit(nodes[value]);
440
- } else if (property_definition?.type === 'text') {
441
- for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
442
- visit(nodes[range.node_id]);
443
- }
444
- }
445
- }
446
- ids.push(node.id);
447
- };
448
- visit(nodes[node_id]);
449
- return ids;
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
- const json = [];
454
- const visited = {};
455
- const visit = (node) => {
456
- if (!node || visited[node.id]) {
457
- return;
458
- }
459
- visited[node.id] = true;
460
- for (const [property_name, value] of Object.entries(node)) {
461
- const property_definition = schema[node.type].properties[property_name];
462
-
463
- if (property_definition?.type === 'node_array') {
464
- const node_ids = value?.nodes || [];
465
-
466
- for (const v of node_ids) {
467
- if (typeof v === 'string') {
468
- visit(nodes[v]);
469
- }
470
- }
471
-
472
- for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
473
- visit(nodes[range.node_id]);
474
- }
475
- } else if (property_definition?.type === 'node') {
476
- visit(nodes[value]);
477
- } else if (property_definition?.type === 'text') {
478
- for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
479
- visit(nodes[range.node_id]);
480
- }
481
- }
482
- }
483
- // Finally add the node to the result.
484
- // Deep clone, to make sure nothing of the original document is referenced.
485
- json.push(structuredClone(node));
486
- };
487
- // Start with the root node (document_id)
488
- visit(nodes[node_id]);
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
- * @param {Selection} [selection] - The selection to extract the range from
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
- if (selection && selection.type !== 'property') {
500
- return {
501
- start_offset: Math.min(selection.anchor_offset, selection.focus_offset),
502
- end_offset: Math.max(selection.anchor_offset, selection.focus_offset)
503
- };
504
- } else {
505
- return null;
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
- if (selection && selection.type !== 'property') {
511
- return selection.anchor_offset === selection.focus_offset;
512
- } else {
513
- return false;
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 annotated text (character offsets) and annotated node arrays
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
- const removed_node_ids = [];
530
- const deletion_length = end - start;
531
-
532
- const next_ranges = ranges
533
- .map((range) => {
534
- if (range.end_offset <= start) return range;
535
-
536
- let start_offset = range.start_offset;
537
- if (range.start_offset >= end) {
538
- start_offset -= deletion_length;
539
- } else if (range.start_offset > start) {
540
- start_offset = start;
541
- }
542
-
543
- let end_offset = range.end_offset;
544
- if (range.end_offset >= end) {
545
- end_offset -= deletion_length;
546
- } else if (range.end_offset > start) {
547
- end_offset = start;
548
- }
549
-
550
- if (start_offset >= end_offset) {
551
- removed_node_ids.push(range.node_id);
552
- return null;
553
- }
554
-
555
- return { start_offset, end_offset, node_id: range.node_id };
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
- if (length === 0) return ranges;
575
-
576
- return ranges.map((range) => {
577
- if (range.end_offset <= offset) return range;
578
-
579
- if (range.start_offset < offset) {
580
- return {
581
- start_offset: range.start_offset,
582
- end_offset: range.end_offset + length,
583
- node_id: range.node_id
584
- };
585
- }
586
-
587
- return {
588
- start_offset: range.start_offset + length,
589
- end_offset: range.end_offset + length,
590
- node_id: range.node_id
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
- const sorted = [...ranges].sort(
604
- (a, b) => a.start_offset - b.start_offset || a.end_offset - b.end_offset
605
- );
606
-
607
- return sorted.every(
608
- (range, index) =>
609
- Number.isInteger(range.start_offset) &&
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
- /** @type {Array<{type: 'content' | 'mark' | 'selection_highlight', start_offset: number, end_offset: number, mark_index?: number, node_id?: string}>} */
628
- const fragments = [];
629
- let last_index = 0;
630
-
631
- // Merge marks with selection highlight and sort by start offset
632
- const ranges = [...marks, ...(selection_highlight_range ? [selection_highlight_range] : [])].sort(
633
- (a, b) => a.start_offset - b.start_offset
634
- );
635
-
636
- for (const range of ranges) {
637
- // Add content before this range
638
- if (range.start_offset > last_index) {
639
- fragments.push({
640
- type: 'content',
641
- start_offset: last_index,
642
- end_offset: range.start_offset
643
- });
644
- }
645
-
646
- if ('node_id' in range) {
647
- const mark = /** @type {import('./types.d.ts').Mark} */ (range);
648
- const mark_index =
649
- /** @type {import('./types.d.ts').Mark & { mark_index?: number }} */ (mark).mark_index ??
650
- marks.indexOf(mark);
651
- fragments.push({
652
- type: 'mark',
653
- start_offset: mark.start_offset,
654
- end_offset: mark.end_offset,
655
- node_id: mark.node_id,
656
- mark_index
657
- });
658
- } else {
659
- fragments.push({
660
- type: 'selection_highlight',
661
- start_offset: range.start_offset,
662
- end_offset: range.end_offset
663
- });
664
- }
665
-
666
- last_index = range.end_offset;
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
  }