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.
Files changed (93) hide show
  1. package/README.md +260 -82
  2. package/dist/Command.svelte.d.ts +54 -29
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +286 -283
  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 +40 -9
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +142 -18
  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 +60 -12
  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 +85 -116
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +543 -571
  32. package/dist/Svedit.svelte +243 -216
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  36. package/dist/TextProperty.svelte.d.ts +5 -0
  37. package/dist/TextProperty.svelte.d.ts.map +1 -0
  38. package/dist/Transaction.svelte.d.ts +101 -126
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +843 -827
  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 +109 -122
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +726 -698
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +2 -18
  50. package/dist/node_visibility.svelte.d.ts +132 -5
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +535 -642
  53. package/dist/paste_utils.d.ts +11 -43
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -149
  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 +126 -152
  59. package/dist/types.d.ts +329 -291
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +1 -0
  62. package/dist/utils.d.ts +79 -95
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +392 -305
  65. package/package.json +69 -67
  66. package/src/lib/{Command.svelte.js → Command.svelte.ts} +111 -41
  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 +40 -9
  70. package/src/lib/NodeArrayProperty.svelte +142 -18
  71. package/src/lib/NodeCaret.svelte +1 -1
  72. package/src/lib/NodeGap.svelte +60 -12
  73. package/src/lib/NodeGapMarkers.svelte +19 -10
  74. package/src/lib/NodeSelectionMarkers.svelte +20 -6
  75. package/src/lib/Session.svelte.ts +656 -0
  76. package/src/lib/Svedit.svelte +243 -216
  77. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  78. package/src/lib/Transaction.svelte.ts +1020 -0
  79. package/src/lib/UnknownNode.svelte +4 -3
  80. package/src/lib/doc_utils.ts +1120 -0
  81. package/src/lib/{index.js → index.ts} +48 -1
  82. package/src/lib/node_visibility.svelte.ts +658 -0
  83. package/src/lib/{paste_utils.js → paste_utils.ts} +51 -59
  84. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +44 -27
  85. package/src/lib/{types.d.ts → types.ts} +250 -103
  86. package/src/lib/utils.ts +660 -0
  87. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  88. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
  89. package/src/lib/Session.svelte.js +0 -575
  90. package/src/lib/Transaction.svelte.js +0 -846
  91. package/src/lib/doc_utils.js +0 -807
  92. package/src/lib/node_visibility.svelte.js +0 -718
  93. package/src/lib/utils.js +0 -465
@@ -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"}