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
package/src/lib/utils.js DELETED
@@ -1,465 +0,0 @@
1
- /**
2
- * @import { Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
3
- */
4
-
5
- const SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
6
-
7
- /**
8
- * Detect if the virtual keyboard is likely visible.
9
- *
10
- * 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
14
- */
15
- 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;
26
- }
27
-
28
- /**
29
- * Detect if the current browser is likely on a mobile device.
30
- *
31
- * This uses the user agent only so touch-capable laptops are not treated as
32
- * mobile browsers.
33
- *
34
- * @returns {boolean} true if mobile browser, false otherwise
35
- */
36
- 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);
43
- }
44
-
45
- // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
46
- /**
47
- * Detect if the current browser is Chrome on desktop
48
- * @returns {boolean} true if Chrome desktop browser, false otherwise
49
- */
50
- 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;
58
- }
59
-
60
- /**
61
- * Get the actual character length (accounting for multi-byte characters)
62
- *
63
- * Uses Intl.Segmenter to count grapheme clusters rather than UTF-16 code units,
64
- * ensuring emojis and other complex Unicode sequences are counted as single characters.
65
- *
66
- * @param {string} str - The string to measure
67
- * @returns {number} The number of visual characters (grapheme clusters)
68
- *
69
- * @example
70
- * get_char_length('Hello') // Returns: 5
71
- * get_char_length('a😀b') // Returns: 3 (not 4)
72
- * get_char_length('👋🏽') // Returns: 1 (not 4 - skin tone modifier treated as single char)
73
- */
74
- export function get_char_length(str) {
75
- return [...SEGMENTER.segment(str)].length;
76
- }
77
-
78
- // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
79
- /**
80
- * Get a single character at the specified position (accounting for multi-byte characters)
81
- *
82
- * Uses Intl.Segmenter to access grapheme clusters rather than UTF-16 code units,
83
- * ensuring emojis and other complex Unicode sequences are treated as single characters.
84
- *
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
- * @example
90
- * get_char_at('Hello', 1) // Returns: 'e'
91
- * get_char_at('a😀b', 1) // Returns: '😀' (full emoji)
92
- * get_char_at('👋🏽', 0) // Returns: '👋🏽' (skin tone modifier included)
93
- */
94
- export function get_char_at(str, index) {
95
- const segments = [...SEGMENTER.segment(str)];
96
- return segments[index].segment;
97
- }
98
-
99
- /**
100
- * Slice string by character positions (accounting for multi-byte characters)
101
- *
102
- * Uses Intl.Segmenter to slice by grapheme clusters rather than UTF-16 code units,
103
- * ensuring emojis and other complex Unicode sequences remain intact.
104
- *
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
- * @example
111
- * char_slice('Hello 😀 World', 6, 8) // Returns: '😀 ' (emoji stays intact)
112
- * char_slice('a👋🏽b', 1, 2) // Returns: '👋🏽' (skin tone modifier included)
113
- */
114
- 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('');
120
- }
121
-
122
- /**
123
- * Convert UTF-16 code unit offset to grapheme cluster offset within a string
124
- *
125
- * Converts DOM selection offsets (which use UTF-16 code units) to character-based
126
- * offsets (grapheme clusters) for consistent text manipulation.
127
- *
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
- * @example
133
- * // For string "a😀b" where 😀 uses 2 UTF-16 code units
134
- * utf16_to_char_offset("a😀b", 3) // Returns: 2 (position after emoji)
135
- */
136
- 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;
149
- }
150
-
151
- /**
152
- * Convert grapheme cluster offset to UTF-16 code unit offset within a string
153
- *
154
- * Converts character-based offsets (grapheme clusters) to DOM selection offsets
155
- * (UTF-16 code units) for proper DOM selection positioning.
156
- *
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
- * @example
162
- * // For string "a😀b" where 😀 uses 2 UTF-16 code units
163
- * char_to_utf16_offset("a😀b", 2) // Returns: 3 (UTF-16 position after emoji)
164
- */
165
- 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;
174
- }
175
-
176
- /**
177
- * Splits an annotated text at the specified character position.
178
- *
179
- * Annotations that span the split point will be divided appropriately,
180
- * with offsets adjusted for each resulting part.
181
- *
182
- * @param {AnnotatedText} text_with_annotations - Annotated text object
183
- * @param {number} at_position - Character position where to split (0-based)
184
- * @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
185
- *
186
- * @example
187
- * split_annotated_text({text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}, 8)
188
- * // Returns:
189
- * // [
190
- * // {text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]},
191
- * // {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]}
192
- * // ]
193
- */
194
- export function split_annotated_text(text_with_annotations, at_position) {
195
- const { text, annotations } = text_with_annotations;
196
-
197
- // Split the text using character-aware slicing
198
- const left_text = char_slice(text, 0, at_position);
199
- const right_text = char_slice(text, at_position);
200
-
201
- /** @type {Array<Annotation>} */
202
- const left_annotations = [];
203
- /** @type {Array<Annotation>} */
204
- const right_annotations = [];
205
-
206
- // Process each annotation
207
- for (const { start_offset, end_offset, node_id } of annotations) {
208
- if (end_offset <= at_position) {
209
- // Annotation is entirely in the left part
210
- left_annotations.push({ start_offset, end_offset, node_id });
211
- } else if (start_offset >= at_position) {
212
- // Annotation is entirely in the right part - shift offsets
213
- right_annotations.push({
214
- start_offset: start_offset - at_position,
215
- end_offset: end_offset - at_position,
216
- node_id
217
- });
218
- } else {
219
- // Annotation spans the split point - split it
220
- left_annotations.push({ start_offset, end_offset: at_position, node_id });
221
- right_annotations.push({ start_offset: 0, end_offset: end_offset - at_position, node_id });
222
- }
223
- }
224
-
225
- return [
226
- { text: left_text, annotations: left_annotations },
227
- { text: right_text, annotations: right_annotations }
228
- ];
229
- }
230
-
231
- /**
232
- * Joins two annotated texts into a single annotated text.
233
- *
234
- * Annotations from the second text will have their offsets shifted by the length
235
- * of the first text. Adjacent annotations of the same type and data will be merged.
236
- *
237
- * @param {AnnotatedText} first_text - First annotated text object
238
- * @param {AnnotatedText} second_text - Second annotated text object
239
- * @returns {AnnotatedText} Combined annotated text object
240
- *
241
- * @example
242
- * join_annotated_text({text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]}, {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]})
243
- * // Returns: {text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}
244
- */
245
- export function join_annotated_text(first_text, second_text) {
246
- const { text: first_text_content, annotations: first_annotations } = first_text;
247
- const { text: second_text_content, annotations: second_annotations } = second_text;
248
-
249
- // Join the text content
250
- const joined_text = first_text_content + second_text_content;
251
-
252
- // Start with all annotations from the first text (unchanged)
253
- /** @type {Array<Annotation>} */
254
- const joined_annotations = [...first_annotations];
255
-
256
- // Add annotations from the second text, shifting their offsets
257
- const offset = get_char_length(first_text_content);
258
- for (const { start_offset, end_offset, node_id } of second_annotations) {
259
- /** @type {Annotation} */
260
- const shifted_annotation = {
261
- start_offset: start_offset + offset,
262
- end_offset: end_offset + offset,
263
- node_id
264
- };
265
-
266
- // Check if this annotation can be merged with the last annotation from first text
267
- const last_annotation = joined_annotations[joined_annotations.length - 1];
268
- if (
269
- last_annotation &&
270
- last_annotation.end_offset === shifted_annotation.start_offset && // annotations are adjacent
271
- last_annotation.node_id === shifted_annotation.node_id
272
- ) {
273
- // same node_id
274
- // Merge by extending the end position of the last annotation
275
- last_annotation.end_offset = shifted_annotation.end_offset;
276
- } else {
277
- // Add as separate annotation
278
- joined_annotations.push(shifted_annotation);
279
- }
280
- }
281
-
282
- return { text: joined_text, annotations: joined_annotations };
283
- }
284
-
285
- /**
286
- * Convert snake_case string to PascalCase
287
- *
288
- * @param {string} str - The snake_case string to convert
289
- * @returns {string} The converted PascalCase string
290
- *
291
- * @example
292
- * snake_to_pascal('list_item') // Returns: 'ListItem'
293
- */
294
- export function snake_to_pascal(str) {
295
- return str
296
- .split('_')
297
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
298
- .join('');
299
- }
300
-
301
- export const PATH_SEPARATOR = '__';
302
- const PATH_STRING_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
303
- const PATH_INDEX_SEGMENT_RE = /^(0|[1-9]\d*)$/;
304
-
305
- /**
306
- * Check whether a string can be used as a Svedit path segment.
307
- *
308
- * Path string segments include node ids and schema property names. Keeping
309
- * them restricted makes serialized paths reversible, valid as HTML ids, and
310
- * safe to use as CSS anchor-name suffixes.
311
- *
312
- * @param {string} segment
313
- * @returns {boolean}
314
- */
315
- export function is_path_string_segment_valid(segment) {
316
- return (
317
- typeof segment === 'string' &&
318
- PATH_STRING_SEGMENT_RE.test(segment) &&
319
- !segment.includes(PATH_SEPARATOR)
320
- );
321
- }
322
-
323
- /**
324
- * Assert that a string can be used as a Svedit path segment.
325
- *
326
- * @param {string} segment
327
- * @param {string} [label]
328
- * @returns {void}
329
- */
330
- export function assert_path_string_segment(segment, label = 'Path segment') {
331
- if (!is_path_string_segment_valid(segment)) {
332
- throw new Error(
333
- `${label} must start with a letter or underscore and contain only letters, numbers, underscores, or dashes. It must not contain "${PATH_SEPARATOR}".`
334
- );
335
- }
336
- }
337
-
338
- /**
339
- * Serialize a document path while preserving whether each segment is a
340
- * property/key string or an array index number.
341
- *
342
- * The result is reversible and safe to use as a data attribute value, HTML id,
343
- * or CSS anchor-name suffix.
344
- *
345
- * @param {Array<string | number>} path
346
- * @returns {string}
347
- *
348
- * @example
349
- * serialize_path(['page_1', 'body', 0, 'items']) // Returns: 'page_1__body__0__items'
350
- */
351
- export function serialize_path(path) {
352
- return path
353
- .map((segment) => {
354
- if (typeof segment === 'number') {
355
- if (!Number.isInteger(segment) || segment < 0) {
356
- throw new Error(`Path index must be a non-negative integer: ${segment}`);
357
- }
358
- return String(segment);
359
- }
360
-
361
- assert_path_string_segment(segment);
362
- return segment;
363
- })
364
- .join(PATH_SEPARATOR);
365
- }
366
-
367
- /**
368
- * Deserialize a path string produced by serialize_path.
369
- *
370
- * @param {string} serialized_path
371
- * @returns {Array<string | number>}
372
- *
373
- * @example
374
- * deserialize_path('page_1__body__0__items') // Returns: ['page_1', 'body', 0, 'items']
375
- */
376
- export function deserialize_path(serialized_path) {
377
- if (serialized_path === '') return [];
378
-
379
- return serialized_path.split(PATH_SEPARATOR).map((segment) => {
380
- if (segment === '') {
381
- throw new Error(`Invalid serialized path: ${serialized_path}`);
382
- }
383
-
384
- if (/^\d+$/.test(segment)) {
385
- if (!PATH_INDEX_SEGMENT_RE.test(segment)) {
386
- throw new Error(`Invalid serialized path index: ${segment}`);
387
- }
388
- return Number(segment);
389
- }
390
-
391
- assert_path_string_segment(segment, 'Serialized path segment');
392
- return segment;
393
- });
394
- }
395
-
396
- /**
397
- * Compare two document paths segment-by-segment, preserving segment types.
398
- *
399
- * @param {Array<string | number>} a
400
- * @param {Array<string | number>} b
401
- * @returns {boolean}
402
- */
403
- export function paths_equal(a, b) {
404
- if (a.length !== b.length) return false;
405
- return a.every((segment, index) => segment === b[index]);
406
- }
407
-
408
- export function traverse(node_id, schema, nodes) {
409
- const json = [];
410
- const visited = {};
411
- const visit = (node) => {
412
- if (!node || visited[node.id]) {
413
- return;
414
- }
415
- visited[node.id] = true;
416
- for (const [property_name, value] of Object.entries(node)) {
417
- const property_definition = schema[node.type].properties[property_name];
418
-
419
- if (property_definition?.type === 'node_array') {
420
- for (const v of value) {
421
- if (typeof v === 'string') {
422
- visit(nodes[v]);
423
- }
424
- }
425
- } else if (property_definition?.type === 'node') {
426
- visit(nodes[value]);
427
- } else if (property_definition?.type === 'annotated_text') {
428
- for (const annotation of value.annotations) {
429
- visit(nodes[annotation.node_id]);
430
- }
431
- }
432
- }
433
- // Finally add the node to the result.
434
- // Deep clone, to make sure nothing of the original document is referenced.
435
- json.push(structuredClone(node));
436
- };
437
- // Start with the root node (document_id)
438
- visit(nodes[node_id]);
439
- return json;
440
- }
441
-
442
- /**
443
- * Extracts the normalized range from a text or node selection.
444
- * Returns start and end offsets in document order (start <= end), regardless of selection direction.
445
- * @param {Selection} [selection] - The selection to extract the range from
446
- * @returns {SelectionRange | null} The normalized selection range, or null if selection is null, undefined, or a property selection
447
- */
448
- export function get_selection_range(selection) {
449
- if (selection && selection.type !== 'property') {
450
- return {
451
- start_offset: Math.min(selection.anchor_offset, selection.focus_offset),
452
- end_offset: Math.max(selection.anchor_offset, selection.focus_offset)
453
- };
454
- } else {
455
- return null;
456
- }
457
- }
458
-
459
- export function is_selection_collapsed(selection) {
460
- if (selection && selection.type !== 'property') {
461
- return selection.anchor_offset === selection.focus_offset;
462
- } else {
463
- return false;
464
- }
465
- }