svedit 0.12.0 → 0.14.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 (88) hide show
  1. package/README.md +35 -22
  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 +2 -2
  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 +36 -14
  24. package/dist/NodeGapMarkers.svelte.d.ts +6 -11
  25. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  26. package/dist/NodeSelectionMarkers.svelte +21 -7
  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 +146 -140
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/TextProperty.svelte +37 -23
  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/styles/svedit-colors.css +8 -5
  57. package/dist/transforms.svelte.d.ts +9 -7
  58. package/dist/transforms.svelte.d.ts.map +1 -1
  59. package/dist/transforms.svelte.js +125 -150
  60. package/dist/types.d.ts +294 -309
  61. package/dist/types.d.ts.map +1 -0
  62. package/dist/types.js +1 -0
  63. package/dist/utils.d.ts +40 -119
  64. package/dist/utils.d.ts.map +1 -1
  65. package/dist/utils.js +346 -473
  66. package/package.json +70 -70
  67. package/src/lib/{Command.svelte.js → Command.svelte.ts} +29 -17
  68. package/src/lib/CustomProperty.svelte +3 -5
  69. package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
  70. package/src/lib/Node.svelte +16 -13
  71. package/src/lib/NodeArrayProperty.svelte +39 -22
  72. package/src/lib/NodeCaret.svelte +2 -2
  73. package/src/lib/NodeGap.svelte +12 -6
  74. package/src/lib/NodeGapMarkers.svelte +36 -14
  75. package/src/lib/NodeSelectionMarkers.svelte +21 -7
  76. package/src/lib/{Session.svelte.js → Session.svelte.ts} +130 -127
  77. package/src/lib/Svedit.svelte +146 -140
  78. package/src/lib/TextProperty.svelte +37 -23
  79. package/src/lib/{Transaction.svelte.js → Transaction.svelte.ts} +139 -143
  80. package/src/lib/UnknownNode.svelte +4 -3
  81. package/src/lib/{doc_utils.js → doc_utils.ts} +247 -206
  82. package/src/lib/{index.js → index.ts} +48 -0
  83. package/src/lib/{node_visibility.svelte.js → node_visibility.svelte.ts} +68 -92
  84. package/src/lib/{paste_utils.js → paste_utils.ts} +47 -58
  85. package/src/lib/styles/svedit-colors.css +8 -5
  86. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +39 -21
  87. package/src/lib/{types.d.ts → types.ts} +161 -66
  88. package/src/lib/{utils.js → utils.ts} +110 -129
@@ -1,6 +1,13 @@
1
- /**
2
- * @import { Attachment, Mark, Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
3
- */
1
+ import type {
2
+ Attachment,
3
+ Mark,
4
+ Text,
5
+ SelectionRange,
6
+ Selection,
7
+ DocumentSchema,
8
+ DocumentNode,
9
+ DocumentPath
10
+ } from './types.js';
4
11
 
5
12
  const SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
6
13
 
@@ -8,21 +15,28 @@ const SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
8
15
  * Detect if the virtual keyboard is likely visible.
9
16
  *
10
17
  * 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
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.
14
22
  */
15
- export function is_virtual_keyboard_active() {
23
+ const VIRTUAL_KEYBOARD_MIN_HEIGHT = 50;
24
+
25
+ export function is_virtual_keyboard_active(): boolean {
16
26
  if (typeof window === 'undefined' || typeof document === 'undefined') {
17
27
  return false;
18
28
  }
19
29
 
30
+ if (!is_mobile_browser()) {
31
+ return false;
32
+ }
33
+
20
34
  const visual_viewport = window.visualViewport;
21
35
  if (!visual_viewport) {
22
36
  return false;
23
37
  }
24
38
 
25
- return visual_viewport.height < document.documentElement.clientHeight;
39
+ return visual_viewport.height < document.documentElement.clientHeight - VIRTUAL_KEYBOARD_MIN_HEIGHT;
26
40
  }
27
41
 
28
42
  /**
@@ -30,10 +44,8 @@ export function is_virtual_keyboard_active() {
30
44
  *
31
45
  * This uses the user agent only so touch-capable laptops are not treated as
32
46
  * mobile browsers.
33
- *
34
- * @returns {boolean} true if mobile browser, false otherwise
35
47
  */
36
- export function is_mobile_browser() {
48
+ export function is_mobile_browser(): boolean {
37
49
  if (typeof navigator === 'undefined') {
38
50
  return false;
39
51
  }
@@ -45,9 +57,8 @@ export function is_mobile_browser() {
45
57
  // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
46
58
  /**
47
59
  * Detect if the current browser is Chrome on desktop
48
- * @returns {boolean} true if Chrome desktop browser, false otherwise
49
60
  */
50
- export function is_chrome_desktop_browser() {
61
+ export function is_chrome_desktop_browser(): boolean {
51
62
  if (typeof window === 'undefined' || typeof navigator === 'undefined') {
52
63
  return false;
53
64
  }
@@ -63,15 +74,12 @@ export function is_chrome_desktop_browser() {
63
74
  * Uses Intl.Segmenter to count grapheme clusters rather than UTF-16 code units,
64
75
  * ensuring emojis and other complex Unicode sequences are counted as single characters.
65
76
  *
66
- * @param {string} str - The string to measure
67
- * @returns {number} The number of visual characters (grapheme clusters)
68
- *
69
77
  * @example
70
78
  * get_char_length('Hello') // Returns: 5
71
79
  * get_char_length('a😀b') // Returns: 3 (not 4)
72
80
  * get_char_length('👋🏽') // Returns: 1 (not 4 - skin tone modifier treated as single char)
73
81
  */
74
- export function get_char_length(str) {
82
+ export function get_char_length(str: string): number {
75
83
  return [...SEGMENTER.segment(str)].length;
76
84
  }
77
85
 
@@ -82,16 +90,12 @@ export function get_char_length(str) {
82
90
  * Uses Intl.Segmenter to access grapheme clusters rather than UTF-16 code units,
83
91
  * ensuring emojis and other complex Unicode sequences are treated as single characters.
84
92
  *
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
93
  * @example
90
94
  * get_char_at('Hello', 1) // Returns: 'e'
91
95
  * get_char_at('a😀b', 1) // Returns: '😀' (full emoji)
92
96
  * get_char_at('👋🏽', 0) // Returns: '👋🏽' (skin tone modifier included)
93
97
  */
94
- export function get_char_at(str, index) {
98
+ export function get_char_at(str: string, index: number): string {
95
99
  const segments = [...SEGMENTER.segment(str)];
96
100
  return segments[index].segment;
97
101
  }
@@ -102,16 +106,15 @@ export function get_char_at(str, index) {
102
106
  * Uses Intl.Segmenter to slice by grapheme clusters rather than UTF-16 code units,
103
107
  * ensuring emojis and other complex Unicode sequences remain intact.
104
108
  *
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
109
  * @example
111
110
  * char_slice('Hello 😀 World', 6, 8) // Returns: '😀 ' (emoji stays intact)
112
111
  * char_slice('a👋🏽b', 1, 2) // Returns: '👋🏽' (skin tone modifier included)
113
112
  */
114
- export function char_slice(str, start, end = undefined) {
113
+ export function char_slice(
114
+ str: string,
115
+ start: number,
116
+ end: number | undefined = undefined
117
+ ): string {
115
118
  const segments = [...SEGMENTER.segment(str)];
116
119
  return segments
117
120
  .slice(start, end)
@@ -125,15 +128,11 @@ export function char_slice(str, start, end = undefined) {
125
128
  * Converts DOM selection offsets (which use UTF-16 code units) to character-based
126
129
  * offsets (grapheme clusters) for consistent text manipulation.
127
130
  *
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
131
  * @example
133
132
  * // For string "a😀b" where 😀 uses 2 UTF-16 code units
134
133
  * utf16_to_char_offset("a😀b", 3) // Returns: 2 (position after emoji)
135
134
  */
136
- export function utf16_to_char_offset(str, utf16_offset) {
135
+ export function utf16_to_char_offset(str: string, utf16_offset: number): number {
137
136
  const segments = [...SEGMENTER.segment(str)];
138
137
  let char_offset = 0;
139
138
  let utf16_count = 0;
@@ -154,15 +153,11 @@ export function utf16_to_char_offset(str, utf16_offset) {
154
153
  * Converts character-based offsets (grapheme clusters) to DOM selection offsets
155
154
  * (UTF-16 code units) for proper DOM selection positioning.
156
155
  *
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
156
  * @example
162
157
  * // For string "a😀b" where 😀 uses 2 UTF-16 code units
163
158
  * char_to_utf16_offset("a😀b", 2) // Returns: 3 (UTF-16 position after emoji)
164
159
  */
165
- export function char_to_utf16_offset(str, char_offset) {
160
+ export function char_to_utf16_offset(str: string, char_offset: number): number {
166
161
  const segments = [...SEGMENTER.segment(str)];
167
162
  let utf16_offset = 0;
168
163
 
@@ -174,15 +169,11 @@ export function char_to_utf16_offset(str, char_offset) {
174
169
  }
175
170
 
176
171
  /**
177
- * Splits an annotated text at the specified character position.
172
+ * Splits a text value at the specified character position.
178
173
  *
179
174
  * Marks and annotations that span the split point will be divided
180
175
  * appropriately, with offsets adjusted for each resulting part.
181
176
  *
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
177
  * @example
187
178
  * split_text({content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}, 8)
188
179
  * // Returns:
@@ -191,15 +182,23 @@ export function char_to_utf16_offset(str, char_offset) {
191
182
  * // {content: "rld", marks: [{start_offset: 0, end_offset: 3, node_id: "strong"}], annotations: []}
192
183
  * // ]
193
184
  */
194
- export function split_text(text_value, at_position) {
185
+ export function split_text(text_value: Text, at_position: number): [Text, Text] {
195
186
  const { content } = text_value;
196
187
 
197
188
  // 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: [] };
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
+ };
200
199
 
201
200
  // Process marks and annotations with the same range logic
202
- for (const key of ['marks', 'annotations']) {
201
+ for (const key of ['marks', 'annotations'] as const) {
203
202
  for (const { start_offset, end_offset, node_id } of text_value[key] ?? []) {
204
203
  if (end_offset <= at_position) {
205
204
  // Range is entirely in the left part
@@ -223,23 +222,19 @@ export function split_text(text_value, at_position) {
223
222
  }
224
223
 
225
224
  /**
226
- * Joins two annotated texts into a single annotated text.
225
+ * Joins two text values into a single text value.
227
226
  *
228
227
  * Marks and annotations from the second text will have their offsets shifted
229
228
  * by the length of the first text. Adjacent ranges referencing the same node
230
229
  * will be merged.
231
230
  *
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
231
  * @example
237
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: []})
238
233
  * // Returns: {content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}
239
234
  */
240
- export function join_text(first_text, second_text) {
235
+ export function join_text(first_text: Text, second_text: Text): Text {
241
236
  // Join the text content
242
- const joined = {
237
+ const joined: Text = {
243
238
  content: first_text.content + second_text.content,
244
239
  marks: [],
245
240
  annotations: []
@@ -247,7 +242,7 @@ export function join_text(first_text, second_text) {
247
242
 
248
243
  const offset = get_char_length(first_text.content);
249
244
 
250
- for (const key of ['marks', 'annotations']) {
245
+ for (const key of ['marks', 'annotations'] as const) {
251
246
  // Start with all ranges from the first text (unchanged)
252
247
  const joined_ranges = (first_text[key] ?? []).map((range) => ({ ...range }));
253
248
 
@@ -284,13 +279,10 @@ export function join_text(first_text, second_text) {
284
279
  /**
285
280
  * Convert snake_case string to PascalCase
286
281
  *
287
- * @param {string} str - The snake_case string to convert
288
- * @returns {string} The converted PascalCase string
289
- *
290
282
  * @example
291
283
  * snake_to_pascal('list_item') // Returns: 'ListItem'
292
284
  */
293
- export function snake_to_pascal(str) {
285
+ export function snake_to_pascal(str: string): string {
294
286
  return str
295
287
  .split('_')
296
288
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
@@ -307,11 +299,8 @@ const PATH_INDEX_SEGMENT_RE = /^(0|[1-9]\d*)$/;
307
299
  * Path string segments include node ids and schema property names. Keeping
308
300
  * them restricted makes serialized paths reversible, valid as HTML ids, and
309
301
  * safe to use as CSS anchor-name suffixes.
310
- *
311
- * @param {string} segment
312
- * @returns {boolean}
313
302
  */
314
- export function is_path_string_segment_valid(segment) {
303
+ export function is_path_string_segment_valid(segment: string): boolean {
315
304
  return (
316
305
  typeof segment === 'string' &&
317
306
  PATH_STRING_SEGMENT_RE.test(segment) &&
@@ -321,12 +310,8 @@ export function is_path_string_segment_valid(segment) {
321
310
 
322
311
  /**
323
312
  * 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
313
  */
329
- export function assert_path_string_segment(segment, label = 'Path segment') {
314
+ export function assert_path_string_segment(segment: string, label = 'Path segment'): void {
330
315
  if (!is_path_string_segment_valid(segment)) {
331
316
  throw new Error(
332
317
  `${label} must start with a letter or underscore and contain only letters, numbers, underscores, or dashes. It must not contain "${PATH_SEPARATOR}".`
@@ -341,13 +326,10 @@ export function assert_path_string_segment(segment, label = 'Path segment') {
341
326
  * The result is reversible and safe to use as a data attribute value, HTML id,
342
327
  * or CSS anchor-name suffix.
343
328
  *
344
- * @param {Array<string | number>} path
345
- * @returns {string}
346
- *
347
329
  * @example
348
330
  * serialize_path(['page_1', 'body', 0, 'items']) // Returns: 'page_1__body__0__items'
349
331
  */
350
- export function serialize_path(path) {
332
+ export function serialize_path(path: DocumentPath): string {
351
333
  return path
352
334
  .map((segment) => {
353
335
  if (typeof segment === 'number') {
@@ -366,13 +348,10 @@ export function serialize_path(path) {
366
348
  /**
367
349
  * Deserialize a path string produced by serialize_path.
368
350
  *
369
- * @param {string} serialized_path
370
- * @returns {Array<string | number>}
371
- *
372
351
  * @example
373
352
  * deserialize_path('page_1__body__0__items') // Returns: ['page_1', 'body', 0, 'items']
374
353
  */
375
- export function deserialize_path(serialized_path) {
354
+ export function deserialize_path(serialized_path: string): DocumentPath {
376
355
  if (serialized_path === '') return [];
377
356
 
378
357
  return serialized_path.split(PATH_SEPARATOR).map((segment) => {
@@ -394,12 +373,8 @@ export function deserialize_path(serialized_path) {
394
373
 
395
374
  /**
396
375
  * 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
376
  */
402
- export function paths_equal(a, b) {
377
+ export function paths_equal(a: DocumentPath, b: DocumentPath): boolean {
403
378
  if (a.length !== b.length) return false;
404
379
  return a.every((segment, index) => segment === b[index]);
405
380
  }
@@ -410,15 +385,16 @@ export function paths_equal(a, b) {
410
385
  * reference bookkeeping) — traverse() deep-clones every visited node,
411
386
  * which is wasteful for id-only consumers.
412
387
  *
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)
388
+ * @returns Node ids in depth-first order (entry point last)
417
389
  */
418
- export function traverse_ids(node_id, schema, nodes) {
419
- const ids = [];
420
- const visited = {};
421
- const visit = (node) => {
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) => {
422
398
  if (!node || visited[node.id]) {
423
399
  return;
424
400
  }
@@ -449,10 +425,14 @@ export function traverse_ids(node_id, schema, nodes) {
449
425
  return ids;
450
426
  }
451
427
 
452
- export function traverse(node_id, schema, nodes) {
453
- const json = [];
454
- const visited = {};
455
- const visit = (node) => {
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) => {
456
436
  if (!node || visited[node.id]) {
457
437
  return;
458
438
  }
@@ -492,10 +472,9 @@ export function traverse(node_id, schema, nodes) {
492
472
  /**
493
473
  * Extracts the normalized range from a text or node selection.
494
474
  * 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
475
+ * Returns null if selection is null, undefined, or a property selection.
497
476
  */
498
- export function get_selection_range(selection) {
477
+ export function get_selection_range(selection?: Selection | null): SelectionRange | null {
499
478
  if (selection && selection.type !== 'property') {
500
479
  return {
501
480
  start_offset: Math.min(selection.anchor_offset, selection.focus_offset),
@@ -506,7 +485,7 @@ export function get_selection_range(selection) {
506
485
  }
507
486
  }
508
487
 
509
- export function is_selection_collapsed(selection) {
488
+ export function is_selection_collapsed(selection?: Selection | null): boolean {
510
489
  if (selection && selection.type !== 'property') {
511
490
  return selection.anchor_offset === selection.focus_offset;
512
491
  } else {
@@ -517,16 +496,15 @@ export function is_selection_collapsed(selection) {
517
496
  /**
518
497
  * Adjust ranges (marks or annotations) after deleting a sequence range.
519
498
  *
520
- * Works for both annotated text (character offsets) and annotated node arrays
499
+ * Works for both text values (character offsets) and node arrays
521
500
  * (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
501
  */
528
- export function adjust_ranges_for_deletion(ranges, start, end) {
529
- const removed_node_ids = [];
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[] = [];
530
508
  const deletion_length = end - start;
531
509
 
532
510
  const next_ranges = ranges
@@ -554,7 +532,7 @@ export function adjust_ranges_for_deletion(ranges, start, end) {
554
532
 
555
533
  return { start_offset, end_offset, node_id: range.node_id };
556
534
  })
557
- .filter(Boolean);
535
+ .filter((range): range is Attachment => range !== null);
558
536
 
559
537
  return { ranges: next_ranges, removed_node_ids };
560
538
  }
@@ -564,13 +542,12 @@ export function adjust_ranges_for_deletion(ranges, start, end) {
564
542
  *
565
543
  * Insertion inside a range extends it. Insertion exactly at either edge
566
544
  * 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
545
  */
573
- export function adjust_ranges_for_insertion(ranges, offset, length) {
546
+ export function adjust_ranges_for_insertion(
547
+ ranges: Array<Attachment>,
548
+ offset: number,
549
+ length: number
550
+ ): Array<Attachment> {
574
551
  if (length === 0) return ranges;
575
552
 
576
553
  return ranges.map((range) => {
@@ -594,12 +571,8 @@ export function adjust_ranges_for_insertion(ranges, offset, length) {
594
571
 
595
572
  /**
596
573
  * 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
574
  */
602
- export function are_ranges_exclusive(ranges, length = Infinity) {
575
+ export function are_ranges_exclusive(ranges: Array<Attachment>, length = Infinity): boolean {
603
576
  const sorted = [...ranges].sort(
604
577
  (a, b) => a.start_offset - b.start_offset || a.end_offset - b.end_offset
605
578
  );
@@ -615,17 +588,27 @@ export function are_ranges_exclusive(ranges, length = Infinity) {
615
588
  );
616
589
  }
617
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
+
618
603
  /**
619
604
  * 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
605
  */
626
- 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 = [];
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> = [];
629
612
  let last_index = 0;
630
613
 
631
614
  // Merge marks with selection highlight and sort by start offset
@@ -644,10 +627,8 @@ export function calculate_fragment_ranges(length, marks, selection_highlight_ran
644
627
  }
645
628
 
646
629
  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);
630
+ const mark = range as Mark;
631
+ const mark_index = (mark as Mark & { mark_index?: number }).mark_index ?? marks.indexOf(mark);
651
632
  fragments.push({
652
633
  type: 'mark',
653
634
  start_offset: mark.start_offset,