svedit 0.11.0 → 0.12.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 (59) hide show
  1. package/README.md +230 -64
  2. package/dist/Command.svelte.d.ts +21 -5
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +86 -30
  5. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  6. package/dist/KeyMapper.svelte.js +18 -6
  7. package/dist/Node.svelte +29 -1
  8. package/dist/Node.svelte.d.ts.map +1 -1
  9. package/dist/NodeArrayProperty.svelte +119 -12
  10. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  11. package/dist/NodeGap.svelte +48 -6
  12. package/dist/NodeGap.svelte.d.ts.map +1 -1
  13. package/dist/Session.svelte.d.ts +31 -14
  14. package/dist/Session.svelte.d.ts.map +1 -1
  15. package/dist/Session.svelte.js +155 -77
  16. package/dist/Svedit.svelte +110 -89
  17. package/dist/Svedit.svelte.d.ts.map +1 -1
  18. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  19. package/dist/TextProperty.svelte.d.ts +7 -0
  20. package/dist/TextProperty.svelte.d.ts.map +1 -0
  21. package/dist/Transaction.svelte.d.ts +78 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +412 -234
  24. package/dist/doc_utils.d.ts +96 -21
  25. package/dist/doc_utils.d.ts.map +1 -1
  26. package/dist/doc_utils.js +440 -168
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/node_visibility.svelte.d.ts +30 -4
  30. package/dist/node_visibility.svelte.d.ts.map +1 -1
  31. package/dist/node_visibility.svelte.js +327 -363
  32. package/dist/paste_utils.d.ts +3 -2
  33. package/dist/paste_utils.d.ts.map +1 -1
  34. package/dist/paste_utils.js +16 -13
  35. package/dist/transforms.svelte.d.ts.map +1 -1
  36. package/dist/transforms.svelte.js +5 -6
  37. package/dist/types.d.ts +112 -42
  38. package/dist/utils.d.ts +89 -13
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +320 -85
  41. package/package.json +4 -1
  42. package/src/lib/Command.svelte.js +86 -30
  43. package/src/lib/KeyMapper.svelte.js +18 -6
  44. package/src/lib/Node.svelte +29 -1
  45. package/src/lib/NodeArrayProperty.svelte +119 -12
  46. package/src/lib/NodeGap.svelte +48 -6
  47. package/src/lib/Session.svelte.js +155 -77
  48. package/src/lib/Svedit.svelte +110 -89
  49. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  50. package/src/lib/Transaction.svelte.js +412 -234
  51. package/src/lib/doc_utils.js +440 -168
  52. package/src/lib/index.js +1 -1
  53. package/src/lib/node_visibility.svelte.js +327 -363
  54. package/src/lib/paste_utils.js +16 -13
  55. package/src/lib/transforms.svelte.js +5 -6
  56. package/src/lib/types.d.ts +112 -42
  57. package/src/lib/utils.js +320 -85
  58. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  59. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
package/src/lib/utils.js CHANGED
@@ -1,24 +1,45 @@
1
1
  /**
2
- * @import { Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
2
+ * @import { Attachment, Mark, Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
3
3
  */
4
4
 
5
5
  const SEGMENTER = new Intl.Segmenter('en', { granularity: 'grapheme' });
6
6
 
7
7
  /**
8
- * Detect if the current browser is on a mobile device
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
+ *
9
34
  * @returns {boolean} true if mobile browser, false otherwise
10
35
  */
11
36
  export function is_mobile_browser() {
12
- if (typeof window === 'undefined' || typeof navigator === 'undefined') {
37
+ if (typeof navigator === 'undefined') {
13
38
  return false;
14
39
  }
15
40
 
16
41
  const user_agent = navigator.userAgent;
17
- return (
18
- /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
19
- 'ontouchstart' in window ||
20
- navigator.maxTouchPoints > 0
21
- );
42
+ return /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent);
22
43
  }
23
44
 
24
45
  // ‼️‼️‼️‼️‼️‼️ UNUSED UTILITY BELOW ‼️‼️‼️‼️‼️‼️
@@ -155,110 +176,109 @@ export function char_to_utf16_offset(str, char_offset) {
155
176
  /**
156
177
  * Splits an annotated text at the specified character position.
157
178
  *
158
- * Annotations that span the split point will be divided appropriately,
159
- * with offsets adjusted for each resulting part.
179
+ * Marks and annotations that span the split point will be divided
180
+ * appropriately, with offsets adjusted for each resulting part.
160
181
  *
161
- * @param {AnnotatedText} text_with_annotations - Annotated text object
182
+ * @param {AnnotatedText} text_value - Annotated text object
162
183
  * @param {number} at_position - Character position where to split (0-based)
163
184
  * @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
164
185
  *
165
186
  * @example
166
- * split_annotated_text({text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}, 8)
187
+ * split_text({content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}, 8)
167
188
  * // Returns:
168
189
  * // [
169
- * // {text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]},
170
- * // {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]}
190
+ * // {content: "Hello wo", marks: [{start_offset: 6, end_offset: 8, node_id: "strong"}], annotations: []},
191
+ * // {content: "rld", marks: [{start_offset: 0, end_offset: 3, node_id: "strong"}], annotations: []}
171
192
  * // ]
172
193
  */
173
- export function split_annotated_text(text_with_annotations, at_position) {
174
- const { text, annotations } = text_with_annotations;
194
+ export function split_text(text_value, at_position) {
195
+ const { content } = text_value;
175
196
 
176
197
  // Split the text using character-aware slicing
177
- const left_text = char_slice(text, 0, at_position);
178
- const right_text = char_slice(text, at_position);
179
-
180
- /** @type {Array<Annotation>} */
181
- const left_annotations = [];
182
- /** @type {Array<Annotation>} */
183
- const right_annotations = [];
184
-
185
- // Process each annotation
186
- for (const { start_offset, end_offset, node_id } of annotations) {
187
- if (end_offset <= at_position) {
188
- // Annotation is entirely in the left part
189
- left_annotations.push({ start_offset, end_offset, node_id });
190
- } else if (start_offset >= at_position) {
191
- // Annotation is entirely in the right part - shift offsets
192
- right_annotations.push({
193
- start_offset: start_offset - at_position,
194
- end_offset: end_offset - at_position,
195
- node_id
196
- });
197
- } else {
198
- // Annotation spans the split point - split it
199
- left_annotations.push({ start_offset, end_offset: at_position, node_id });
200
- right_annotations.push({ start_offset: 0, end_offset: end_offset - at_position, node_id });
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
+ }
201
219
  }
202
220
  }
203
221
 
204
- return [
205
- { text: left_text, annotations: left_annotations },
206
- { text: right_text, annotations: right_annotations }
207
- ];
222
+ return [left, right];
208
223
  }
209
224
 
210
225
  /**
211
226
  * Joins two annotated texts into a single annotated text.
212
227
  *
213
- * Annotations from the second text will have their offsets shifted by the length
214
- * of the first text. Adjacent annotations of the same type and data will be merged.
228
+ * Marks and annotations from the second text will have their offsets shifted
229
+ * by the length of the first text. Adjacent ranges referencing the same node
230
+ * will be merged.
215
231
  *
216
232
  * @param {AnnotatedText} first_text - First annotated text object
217
233
  * @param {AnnotatedText} second_text - Second annotated text object
218
234
  * @returns {AnnotatedText} Combined annotated text object
219
235
  *
220
236
  * @example
221
- * 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"}]})
222
- * // Returns: {text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}
237
+ * 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
+ * // Returns: {content: "Hello world", marks: [{start_offset: 6, end_offset: 11, node_id: "strong"}], annotations: []}
223
239
  */
224
- export function join_annotated_text(first_text, second_text) {
225
- const { text: first_text_content, annotations: first_annotations } = first_text;
226
- const { text: second_text_content, annotations: second_annotations } = second_text;
227
-
240
+ export function join_text(first_text, second_text) {
228
241
  // Join the text content
229
- const joined_text = first_text_content + second_text_content;
230
-
231
- // Start with all annotations from the first text (unchanged)
232
- /** @type {Array<Annotation>} */
233
- const joined_annotations = [...first_annotations];
234
-
235
- // Add annotations from the second text, shifting their offsets
236
- const offset = get_char_length(first_text_content);
237
- for (const { start_offset, end_offset, node_id } of second_annotations) {
238
- /** @type {Annotation} */
239
- const shifted_annotation = {
240
- start_offset: start_offset + offset,
241
- end_offset: end_offset + offset,
242
- node_id
243
- };
242
+ const joined = {
243
+ content: first_text.content + second_text.content,
244
+ marks: [],
245
+ annotations: []
246
+ };
244
247
 
245
- // Check if this annotation can be merged with the last annotation from first text
246
- const last_annotation = joined_annotations[joined_annotations.length - 1];
247
- if (
248
- last_annotation &&
249
- last_annotation.end_offset === shifted_annotation.start_offset && // annotations are adjacent
250
- last_annotation.node_id === shifted_annotation.node_id
251
- ) {
252
- // same node_id
253
- // Merge by extending the end position of the last annotation
254
- last_annotation.end_offset = shifted_annotation.end_offset;
255
- } else {
256
- // Add as separate annotation
257
- joined_annotations.push(shifted_annotation);
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
+ }
258
276
  }
277
+
278
+ joined[key] = joined_ranges;
259
279
  }
260
280
 
261
- return { text: joined_text, annotations: joined_annotations };
281
+ return joined;
262
282
  }
263
283
 
264
284
  /**
@@ -384,6 +404,51 @@ export function paths_equal(a, b) {
384
404
  return a.every((segment, index) => segment === b[index]);
385
405
  }
386
406
 
407
+ /**
408
+ * Like traverse, but returns only node ids in depth-first order and never
409
+ * clones nodes. Use this when only the reachable id set is needed (e.g.
410
+ * reference bookkeeping) — traverse() deep-clones every visited node,
411
+ * which is wasteful for id-only consumers.
412
+ *
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)
417
+ */
418
+ 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;
450
+ }
451
+
387
452
  export function traverse(node_id, schema, nodes) {
388
453
  const json = [];
389
454
  const visited = {};
@@ -396,16 +461,22 @@ export function traverse(node_id, schema, nodes) {
396
461
  const property_definition = schema[node.type].properties[property_name];
397
462
 
398
463
  if (property_definition?.type === 'node_array') {
399
- for (const v of value) {
464
+ const node_ids = value?.nodes || [];
465
+
466
+ for (const v of node_ids) {
400
467
  if (typeof v === 'string') {
401
468
  visit(nodes[v]);
402
469
  }
403
470
  }
471
+
472
+ for (const range of [...(value?.marks || []), ...(value?.annotations || [])]) {
473
+ visit(nodes[range.node_id]);
474
+ }
404
475
  } else if (property_definition?.type === 'node') {
405
476
  visit(nodes[value]);
406
- } else if (property_definition?.type === 'annotated_text') {
407
- for (const annotation of value.annotations) {
408
- visit(nodes[annotation.node_id]);
477
+ } else if (property_definition?.type === 'text') {
478
+ for (const range of [...(value.marks || []), ...(value.annotations || [])]) {
479
+ visit(nodes[range.node_id]);
409
480
  }
410
481
  }
411
482
  }
@@ -442,3 +513,167 @@ export function is_selection_collapsed(selection) {
442
513
  return false;
443
514
  }
444
515
  }
516
+
517
+ /**
518
+ * Adjust ranges (marks or annotations) after deleting a sequence range.
519
+ *
520
+ * Works for both annotated text (character offsets) and annotated node arrays
521
+ * (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
+ */
528
+ 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 };
560
+ }
561
+
562
+ /**
563
+ * Adjust ranges (marks or annotations) after inserting into a sequence.
564
+ *
565
+ * Insertion inside a range extends it. Insertion exactly at either edge
566
+ * 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
+ */
573
+ 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
+ });
593
+ }
594
+
595
+ /**
596
+ * 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
+ */
602
+ 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
+ );
616
+ }
617
+
618
+ /**
619
+ * 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
+ */
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 = [];
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;
679
+ }
@@ -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"}