svedit 0.2.0 → 0.3.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.
@@ -7,6 +7,6 @@ type Svedit = {
7
7
  };
8
8
  declare const Svedit: import("svelte").Component<SveditProps, {
9
9
  focus_canvas: () => void;
10
- }, "">;
10
+ }, "editable">;
11
11
  import type { SveditProps } from './types.d.ts';
12
12
  //# sourceMappingURL=Svedit.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AA66BA;;OAAqD;iCAj6BgE,cAAc"}
1
+ {"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AAg3CA;;eAAqD;iCAp2CgE,cAAc"}
@@ -1,6 +1,3 @@
1
- /**
2
- * @import { NodeId, SerializedNode, Selection, DocumentPath } from './types.js'
3
- */
4
1
  /**
5
2
  * Transaction class for managing atomic document operations with undo/redo support.
6
3
  *
@@ -1 +1 @@
1
- {"version":3,"file":"Transaction.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Transaction.svelte.js"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;;;;;;;;;;GAcG;AACH;IACE;;;;OAIG;IACH,iBAFW,GAAG,EAWb;IARC,SAAc;IAEd,oCAAoC;IACpC,KADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACnB;IACb,oCAAoC;IACpC,aADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACX;IAErB,sBAA0C;IAG5C;;;;;OAKG;IACH,UAHW,YAAY,GACV,GAAG,CAIf;IAED;;;;OAIG;IACH,iBAFa,SAAS,CAIrB;IAED;;;;;;;;;;;;;;OAcG;IACH,UATW,YAAY,SACZ,GAAG,QA2Bb;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAbW,GAAG,GACD,WAAW,CAyBvB;IAED;;;;;;;;;;;;OAYG;IACH,WARW,GAAG,GACD,WAAW,CAcvB;IAED;;;;;;OAMG;IACH,yBAJW,SAAS,GACP,WAAW,CAOvB;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,+BAbW,GAAG,mBACH,GAAG,GACD,WAAW,CAqFvB;IAED;;;;;;;;;OASG;IACH,oBAFa,WAAW,CA8FvB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,oBAbW,cAAc,EAAE,GACd,WAAW,CAqDvB;IAED;;;;;;;;;;;;;;;OAeG;IACH,2BAVW,MAAM,GACJ,WAAW,CAsEvB;IAED;;;;;;;;OAQG;IACH,2CA+BC;IAED;;;;;;;;;;OAUG;IACH,4CAqBC;CACF;kCA/jBmE,YAAY;+BAAZ,YAAY;oCAAZ,YAAY"}
1
+ {"version":3,"file":"Transaction.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Transaction.svelte.js"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;GAcG;AACH;IACE;;;;OAIG;IACH,iBAFW,GAAG,EAWb;IARC,SAAc;IAEd,oCAAoC;IACpC,KADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACnB;IACb,oCAAoC;IACpC,aADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACX;IAErB,sBAA0C;IAG5C;;;;;OAKG;IACH,UAHW,YAAY,GACV,GAAG,CAIf;IAED;;;;OAIG;IACH,iBAFa,SAAS,CAIrB;IAED;;;;;;;;;;;;;;OAcG;IACH,UATW,YAAY,SACZ,GAAG,QA2Bb;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAbW,GAAG,GACD,WAAW,CAyBvB;IAED;;;;;;;;;;;;OAYG;IACH,WARW,GAAG,GACD,WAAW,CAcvB;IAED;;;;;;OAMG;IACH,yBAJW,SAAS,GACP,WAAW,CAOvB;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,+BAbW,GAAG,mBACH,GAAG,GACD,WAAW,CAqFvB;IAED;;;;;;;;;OASG;IACH,oBAFa,WAAW,CA+FvB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,oBAbW,cAAc,EAAE,GACd,WAAW,CAqDvB;IAED;;;;;;;;;;;;;;;OAeG;IACH,2BAVW,MAAM,GACJ,WAAW,CA6EvB;IAED;;;;;;;;OAQG;IACH,2CA+BC;IAED;;;;;;;;;;OAUG;IACH,4CAqBC;CACF;kCAzkBmE,YAAY;+BAAZ,YAAY;oCAAZ,YAAY"}
@@ -2,6 +2,8 @@
2
2
  * @import { NodeId, SerializedNode, Selection, DocumentPath } from './types.js'
3
3
  */
4
4
 
5
+ import { get_char_length, char_slice } from './util.js';
6
+
5
7
  /**
6
8
  * Transaction class for managing atomic document operations with undo/redo support.
7
9
  *
@@ -306,8 +308,9 @@ export default class Transaction {
306
308
  const path = this.doc.selection.path;
307
309
  let text = structuredClone($state.snapshot(this.doc.get(path)));
308
310
 
309
- // Update the text content
310
- text[0] = text[0].slice(0, start) + text[0].slice(end);
311
+ // Update the text content using character-based operations
312
+ const original_text = text[0];
313
+ text[0] = char_slice(original_text, 0, start) + char_slice(original_text, end, get_char_length(original_text));
311
314
 
312
315
  // Update annotation offsets for deletion
313
316
  const deletion_length = end - start;
@@ -442,12 +445,13 @@ export default class Transaction {
442
445
  const annotated_string = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
443
446
  const { start, end } = this.doc.get_selection_range();
444
447
 
445
- // Transform the plain text string.
446
- annotated_string[0] = annotated_string[0].slice(0, start) + replaced_text + annotated_string[0].slice(end);
448
+ // Transform the plain text string using character-based operations
449
+ const text = annotated_string[0];
450
+ annotated_string[0] = char_slice(text, 0, start) + replaced_text + char_slice(text, end, get_char_length(text));
447
451
 
448
452
  // Transform the annotations (annotated_string[1])
449
453
  // NOTE: Annotations are stored as [start_offset, end_offset, type]
450
- const delta = replaced_text.length - (end - start);
454
+ const delta = get_char_length(replaced_text) - (end - start);
451
455
  const new_annotations = annotated_string[1].map(/** @param {any} annotation */ (annotation) => {
452
456
  const [annotation_start, annotation_end, type, annotation_data] = annotation;
453
457
 
@@ -469,16 +473,22 @@ export default class Transaction {
469
473
  // Case 4: annotation is partly selected towards right
470
474
  // NOTE: replaced_text will not be part of the annotation, we treat it the same as
471
475
  // a cursor right after the annotation
472
- if (start > annotation_start && start < annotation_end && end === annotation_end) {
476
+ if (start > annotation_start && start < annotation_end && end >= annotation_end) {
473
477
  return [annotation_start, start, type, annotation_data];
474
478
  }
475
479
 
476
480
  // Case 5: text inserted inside an annotation
477
- // NOTE: This also covers the case when a annotation is partly selected towards left
478
481
  if (start >= annotation_start && start <= annotation_end && end < annotation_end && end >= annotation_start) {
479
482
  return [annotation_start, annotation_end + delta, type, annotation_data];
480
483
  }
481
484
 
485
+ // Case 6: annotation is partly selected towards left
486
+ // NOTE: replaced_text will not be part of the annotation, we treat it the same as
487
+ // a cursor right before the annotation
488
+ if (start < annotation_start && end > annotation_start && end < annotation_end) {
489
+ return [start + get_char_length(replaced_text), annotation_end + delta, type, annotation_data];
490
+ }
491
+
482
492
  // Unhandled edge case:
483
493
  console.error('annotation could not be transformed: ', annotation);
484
494
  throw new Error('Case for transforming annotation not covered');
@@ -492,8 +502,8 @@ export default class Transaction {
492
502
  const new_selection = {
493
503
  type: 'text',
494
504
  path: this.doc.selection.path,
495
- anchor_offset: start + replaced_text.length,
496
- focus_offset: start + replaced_text.length,
505
+ anchor_offset: start + get_char_length(replaced_text),
506
+ focus_offset: start + get_char_length(replaced_text),
497
507
  };
498
508
  this.doc.selection = new_selection;
499
509
  return this;
@@ -1 +1 @@
1
- {"version":3,"file":"commands.svelte.d.ts","sourceRoot":"","sources":["../src/lib/commands.svelte.js"],"names":[],"mappings":"AAGA,kDAoDC;AAED,iDA6DC;AAED,sDA6BC;AAED,6CA6GC"}
1
+ {"version":3,"file":"commands.svelte.d.ts","sourceRoot":"","sources":["../src/lib/commands.svelte.js"],"names":[],"mappings":"AAGA,kDAqDC;AAED,iDA8DC;AAED,sDA6BC;AAED,6CA6GC"}
@@ -1,4 +1,4 @@
1
- import { split_annotated_string, join_annotated_string } from './util.js';
1
+ import { split_annotated_string, join_annotated_string, get_char_length } from './util.js';
2
2
  import { get_default_node_type } from './Document.svelte.js';
3
3
 
4
4
  export function break_text_node(tr) {
@@ -53,6 +53,7 @@ export function break_text_node(tr) {
53
53
  tr.set_selection(node_insert_position);
54
54
 
55
55
  doc.config.inserters[target_node_type](tr, right_text);
56
+ return true;
56
57
  }
57
58
 
58
59
  export function join_text_node(tr) {
@@ -112,10 +113,11 @@ export function join_text_node(tr) {
112
113
  tr.set_selection({
113
114
  type: 'text',
114
115
  path: [...previous_text_path, 'content'],
115
- anchor_offset: predecessor_node.content[0].length,
116
- focus_offset: predecessor_node.content[0].length,
116
+ anchor_offset: get_char_length(predecessor_node.content[0]),
117
+ focus_offset: get_char_length(predecessor_node.content[0]),
117
118
  });
118
119
  tr.set([predecessor_node.id, 'content'], joined_text);
120
+ return true;
119
121
  }
120
122
 
121
123
  export function insert_default_node(tr) {
@@ -159,7 +161,7 @@ export function select_all(tr) {
159
161
 
160
162
  if (selection.type === 'text') {
161
163
  const text_content = doc.get(selection.path);
162
- const text_length = text_content[0].length;
164
+ const text_length = get_char_length(text_content[0]);
163
165
 
164
166
  // Check if all text is already selected
165
167
  const is_all_text_selected =
package/dist/util.d.ts CHANGED
@@ -1,4 +1,126 @@
1
- export function split_annotated_string(text_with_annotations: any, at_position: any): any[][];
2
- export function join_annotated_string(first_text: any, second_text: any): any[];
3
- export function snake_to_pascal(str: any): any;
1
+ /**
2
+ * @import { Annotation } from './types.d.ts';
3
+ */
4
+ /**
5
+ * Get the actual character length (accounting for multi-byte characters)
6
+ *
7
+ * Uses Intl.Segmenter to count grapheme clusters rather than UTF-16 code units,
8
+ * ensuring emojis and other complex Unicode sequences are counted as single characters.
9
+ *
10
+ * @param {string} str - The string to measure
11
+ * @returns {number} The number of visual characters (grapheme clusters)
12
+ *
13
+ * @example
14
+ * get_char_length('Hello') // Returns: 5
15
+ * get_char_length('a😀b') // Returns: 3 (not 4)
16
+ * get_char_length('👋🏽') // Returns: 1 (not 4 - skin tone modifier treated as single char)
17
+ */
18
+ export function get_char_length(str: string): number;
19
+ /**
20
+ * Get a single character at the specified position (accounting for multi-byte characters)
21
+ *
22
+ * Uses Intl.Segmenter to access grapheme clusters rather than UTF-16 code units,
23
+ * ensuring emojis and other complex Unicode sequences are treated as single characters.
24
+ *
25
+ * @param {string} str - The string to access
26
+ * @param {number} index - Character position (0-based)
27
+ * @returns {string} The character at the specified position, or empty string if index is out of bounds
28
+ *
29
+ * @example
30
+ * get_char_at('Hello', 1) // Returns: 'e'
31
+ * get_char_at('a😀b', 1) // Returns: '😀' (full emoji)
32
+ * get_char_at('👋🏽', 0) // Returns: '👋🏽' (skin tone modifier included)
33
+ */
34
+ export function get_char_at(str: string, index: number): string;
35
+ /**
36
+ * Slice string by character positions (accounting for multi-byte characters)
37
+ *
38
+ * Uses Intl.Segmenter to slice by grapheme clusters rather than UTF-16 code units,
39
+ * ensuring emojis and other complex Unicode sequences remain intact.
40
+ *
41
+ * @param {string} str - The string to slice
42
+ * @param {number} start - Starting character position (inclusive)
43
+ * @param {number} [end] - Ending character position (exclusive). If undefined, slices to end
44
+ * @returns {string} The sliced string with complete characters
45
+ *
46
+ * @example
47
+ * char_slice('Hello 😀 World', 6, 8) // Returns: '😀 ' (emoji stays intact)
48
+ * char_slice('a👋🏽b', 1, 2) // Returns: '👋🏽' (skin tone modifier included)
49
+ */
50
+ export function char_slice(str: string, start: number, end?: number): string;
51
+ /**
52
+ * Convert UTF-16 code unit offset to grapheme cluster offset within a string
53
+ *
54
+ * Converts DOM selection offsets (which use UTF-16 code units) to character-based
55
+ * offsets (grapheme clusters) for consistent text manipulation.
56
+ *
57
+ * @param {string} str - The string to convert offsets within
58
+ * @param {number} utf16_offset - The UTF-16 code unit offset from DOM
59
+ * @returns {number} The corresponding grapheme cluster offset
60
+ *
61
+ * @example
62
+ * // For string "a😀b" where 😀 uses 2 UTF-16 code units
63
+ * utf16_to_char_offset("a😀b", 3) // Returns: 2 (position after emoji)
64
+ */
65
+ export function utf16_to_char_offset(str: string, utf16_offset: number): number;
66
+ /**
67
+ * Convert grapheme cluster offset to UTF-16 code unit offset within a string
68
+ *
69
+ * Converts character-based offsets (grapheme clusters) to DOM selection offsets
70
+ * (UTF-16 code units) for proper DOM selection positioning.
71
+ *
72
+ * @param {string} str - The string to convert offsets within
73
+ * @param {number} char_offset - The grapheme cluster offset
74
+ * @returns {number} The corresponding UTF-16 code unit offset for DOM operations
75
+ *
76
+ * @example
77
+ * // For string "a😀b" where 😀 uses 2 UTF-16 code units
78
+ * char_to_utf16_offset("a😀b", 2) // Returns: 3 (UTF-16 position after emoji)
79
+ */
80
+ export function char_to_utf16_offset(str: string, char_offset: number): number;
81
+ /**
82
+ * Splits an annotated string at the specified character position.
83
+ *
84
+ * Annotations that span the split point will be divided appropriately,
85
+ * with offsets adjusted for each resulting part.
86
+ *
87
+ * @param {[string, Array<Annotation>]} text_with_annotations - Tuple of [text, annotations] where annotations follow the Annotation type: [start, end, type, data?]
88
+ * @param {number} at_position - Character position where to split (0-based)
89
+ * @returns {[[string, Array<Annotation>], [string, Array<Annotation>]]} Tuple of [left_part, right_part] where each part is [text, annotations]
90
+ *
91
+ * @example
92
+ * split_annotated_string(["Hello world", [[6,11, "strong"]]], 8)
93
+ * // Returns:
94
+ * // [
95
+ * // ["Hello wo", [[6,8, "strong"]]]
96
+ * // ["rld", [[0,3, "strong"]]]
97
+ * // ]
98
+ */
99
+ export function split_annotated_string(text_with_annotations: [string, Array<Annotation>], at_position: number): [[string, Array<Annotation>], [string, Array<Annotation>]];
100
+ /**
101
+ * Joins two annotated strings into a single annotated string.
102
+ *
103
+ * Annotations from the second text will have their offsets shifted by the length
104
+ * of the first text. Adjacent annotations of the same type and data will be merged.
105
+ *
106
+ * @param {[string, Array<Annotation>]} first_text - First annotated string tuple where annotations follow the Annotation type: [start, end, type, data?]
107
+ * @param {[string, Array<Annotation>]} second_text - Second annotated string tuple where annotations follow the Annotation type: [start, end, type, data?]
108
+ * @returns {[string, Array<Annotation>]} Combined annotated string tuple [text, annotations]
109
+ *
110
+ * @example
111
+ * join_annotated_string(["Hello wo", [[6,8, "strong"]]], ["rld", [[0,3, "strong"]]])
112
+ * // Returns: ["Hello world", [[6,11, "strong"]]]
113
+ */
114
+ export function join_annotated_string(first_text: [string, Array<Annotation>], second_text: [string, Array<Annotation>]): [string, Array<Annotation>];
115
+ /**
116
+ * Convert snake_case string to PascalCase
117
+ *
118
+ * @param {string} str - The snake_case string to convert
119
+ * @returns {string} The converted PascalCase string
120
+ *
121
+ * @example
122
+ * snake_to_pascal('list_item') // Returns: 'ListItem'
123
+ */
124
+ export function snake_to_pascal(str: string): string;
125
+ import type { Annotation } from './types.d.ts';
4
126
  //# sourceMappingURL=util.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/lib/util.js"],"names":[],"mappings":"AAMA,8FA6BC;AAKD,gFA8BC;AAED,+CAKC"}
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/lib/util.js"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;;;;;;;;;;;GAaG;AACH,qCARW,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;;GAcG;AACH,iCATW,MAAM,SACN,MAAM,GACJ,MAAM,CAWlB;AAED;;;;;;;;;;;;;;GAcG;AACH,gCATW,MAAM,SACN,MAAM,QACN,MAAM,GACJ,MAAM,CAUlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,gBACN,MAAM,GACJ,MAAM,CAoBlB;AAED;;;;;;;;;;;;;GAaG;AACH,0CARW,MAAM,eACN,MAAM,GACJ,MAAM,CAgBlB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,8DAZW,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC,eAC3B,MAAM,GACJ,CAAC,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAyCtE;AAGD;;;;;;;;;;;;;GAaG;AACH,kDARW,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC,eAC3B,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC,GACzB,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,CAAC,CAAC,CAsCvC;AAED;;;;;;;;GAQG;AACH,qCANW,MAAM,GACJ,MAAM,CAUlB;gCA1O8B,cAAc"}
package/dist/util.js CHANGED
@@ -1,17 +1,152 @@
1
- // split_annotated_string(["Hello world", [[6,11, "strong"]]], 8)
2
- // =>
3
- // [
4
- // ["Hello wo", [[6,8, "strong"]]]
5
- // ["rld", [[0,3, "strong"]]]
6
- // ]
1
+ /**
2
+ * @import { Annotation } from './types.d.ts';
3
+ */
4
+
5
+ /**
6
+ * Get the actual character length (accounting for multi-byte characters)
7
+ *
8
+ * Uses Intl.Segmenter to count grapheme clusters rather than UTF-16 code units,
9
+ * ensuring emojis and other complex Unicode sequences are counted as single characters.
10
+ *
11
+ * @param {string} str - The string to measure
12
+ * @returns {number} The number of visual characters (grapheme clusters)
13
+ *
14
+ * @example
15
+ * get_char_length('Hello') // Returns: 5
16
+ * get_char_length('a😀b') // Returns: 3 (not 4)
17
+ * get_char_length('👋🏽') // Returns: 1 (not 4 - skin tone modifier treated as single char)
18
+ */
19
+ export function get_char_length(str) {
20
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
21
+ return [...segmenter.segment(str)].length;
22
+ }
23
+
24
+ /**
25
+ * Get a single character at the specified position (accounting for multi-byte characters)
26
+ *
27
+ * Uses Intl.Segmenter to access grapheme clusters rather than UTF-16 code units,
28
+ * ensuring emojis and other complex Unicode sequences are treated as single characters.
29
+ *
30
+ * @param {string} str - The string to access
31
+ * @param {number} index - Character position (0-based)
32
+ * @returns {string} The character at the specified position, or empty string if index is out of bounds
33
+ *
34
+ * @example
35
+ * get_char_at('Hello', 1) // Returns: 'e'
36
+ * get_char_at('a😀b', 1) // Returns: '😀' (full emoji)
37
+ * get_char_at('👋🏽', 0) // Returns: '👋🏽' (skin tone modifier included)
38
+ */
39
+ export function get_char_at(str, index) {
40
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
41
+ const segments = [...segmenter.segment(str)];
42
+ return segments[index].segment;
43
+ }
44
+
45
+ /**
46
+ * Slice string by character positions (accounting for multi-byte characters)
47
+ *
48
+ * Uses Intl.Segmenter to slice by grapheme clusters rather than UTF-16 code units,
49
+ * ensuring emojis and other complex Unicode sequences remain intact.
50
+ *
51
+ * @param {string} str - The string to slice
52
+ * @param {number} start - Starting character position (inclusive)
53
+ * @param {number} [end] - Ending character position (exclusive). If undefined, slices to end
54
+ * @returns {string} The sliced string with complete characters
55
+ *
56
+ * @example
57
+ * char_slice('Hello 😀 World', 6, 8) // Returns: '😀 ' (emoji stays intact)
58
+ * char_slice('a👋🏽b', 1, 2) // Returns: '👋🏽' (skin tone modifier included)
59
+ */
60
+ export function char_slice(str, start, end = undefined) {
61
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
62
+ const segments = [...segmenter.segment(str)];
63
+ return segments.slice(start, end).map(s => s.segment).join('');
64
+ }
65
+
66
+ /**
67
+ * Convert UTF-16 code unit offset to grapheme cluster offset within a string
68
+ *
69
+ * Converts DOM selection offsets (which use UTF-16 code units) to character-based
70
+ * offsets (grapheme clusters) for consistent text manipulation.
71
+ *
72
+ * @param {string} str - The string to convert offsets within
73
+ * @param {number} utf16_offset - The UTF-16 code unit offset from DOM
74
+ * @returns {number} The corresponding grapheme cluster offset
75
+ *
76
+ * @example
77
+ * // For string "a😀b" where 😀 uses 2 UTF-16 code units
78
+ * utf16_to_char_offset("a😀b", 3) // Returns: 2 (position after emoji)
79
+ */
80
+ export function utf16_to_char_offset(str, utf16_offset) {
81
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
82
+ const segments = [...segmenter.segment(str)];
83
+ let char_offset = 0;
84
+ let utf16_count = 0;
85
+
86
+ for (const segment of segments) {
87
+ if (utf16_count >= utf16_offset) break;
88
+ utf16_count += segment.segment.length;
89
+ if (utf16_count > utf16_offset) break;
90
+ char_offset++;
91
+ }
92
+
93
+ return char_offset;
94
+ }
95
+
96
+ /**
97
+ * Convert grapheme cluster offset to UTF-16 code unit offset within a string
98
+ *
99
+ * Converts character-based offsets (grapheme clusters) to DOM selection offsets
100
+ * (UTF-16 code units) for proper DOM selection positioning.
101
+ *
102
+ * @param {string} str - The string to convert offsets within
103
+ * @param {number} char_offset - The grapheme cluster offset
104
+ * @returns {number} The corresponding UTF-16 code unit offset for DOM operations
105
+ *
106
+ * @example
107
+ * // For string "a😀b" where 😀 uses 2 UTF-16 code units
108
+ * char_to_utf16_offset("a😀b", 2) // Returns: 3 (UTF-16 position after emoji)
109
+ */
110
+ export function char_to_utf16_offset(str, char_offset) {
111
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
112
+ const segments = [...segmenter.segment(str)];
113
+ let utf16_offset = 0;
114
+
115
+ for (let i = 0; i < Math.min(char_offset, segments.length); i++) {
116
+ utf16_offset += segments[i].segment.length;
117
+ }
118
+
119
+ return utf16_offset;
120
+ }
121
+
122
+ /**
123
+ * Splits an annotated string at the specified character position.
124
+ *
125
+ * Annotations that span the split point will be divided appropriately,
126
+ * with offsets adjusted for each resulting part.
127
+ *
128
+ * @param {[string, Array<Annotation>]} text_with_annotations - Tuple of [text, annotations] where annotations follow the Annotation type: [start, end, type, data?]
129
+ * @param {number} at_position - Character position where to split (0-based)
130
+ * @returns {[[string, Array<Annotation>], [string, Array<Annotation>]]} Tuple of [left_part, right_part] where each part is [text, annotations]
131
+ *
132
+ * @example
133
+ * split_annotated_string(["Hello world", [[6,11, "strong"]]], 8)
134
+ * // Returns:
135
+ * // [
136
+ * // ["Hello wo", [[6,8, "strong"]]]
137
+ * // ["rld", [[0,3, "strong"]]]
138
+ * // ]
139
+ */
7
140
  export function split_annotated_string(text_with_annotations, at_position) {
8
141
  const [text, annotations] = text_with_annotations;
9
142
 
10
- // Split the text
11
- const left_text = text.slice(0, at_position);
12
- const right_text = text.slice(at_position);
143
+ // Split the text using character-aware slicing
144
+ const left_text = char_slice(text, 0, at_position);
145
+ const right_text = char_slice(text, at_position);
13
146
 
147
+ /** @type {Array<Annotation>} */
14
148
  const left_annotations = [];
149
+ /** @type {Array<Annotation>} */
15
150
  const right_annotations = [];
16
151
 
17
152
  // Process each annotation
@@ -36,8 +171,20 @@ export function split_annotated_string(text_with_annotations, at_position) {
36
171
  }
37
172
 
38
173
 
39
- // join_annotated_string(["Hello wo", [[6,8, "strong"]]], ["rld", [[0,3, "strong"]]])
40
- // => ["Hello world", [[6,11, "strong"]]]
174
+ /**
175
+ * Joins two annotated strings into a single annotated string.
176
+ *
177
+ * Annotations from the second text will have their offsets shifted by the length
178
+ * of the first text. Adjacent annotations of the same type and data will be merged.
179
+ *
180
+ * @param {[string, Array<Annotation>]} first_text - First annotated string tuple where annotations follow the Annotation type: [start, end, type, data?]
181
+ * @param {[string, Array<Annotation>]} second_text - Second annotated string tuple where annotations follow the Annotation type: [start, end, type, data?]
182
+ * @returns {[string, Array<Annotation>]} Combined annotated string tuple [text, annotations]
183
+ *
184
+ * @example
185
+ * join_annotated_string(["Hello wo", [[6,8, "strong"]]], ["rld", [[0,3, "strong"]]])
186
+ * // Returns: ["Hello world", [[6,11, "strong"]]]
187
+ */
41
188
  export function join_annotated_string(first_text, second_text) {
42
189
  const [first_text_content, first_annotations] = first_text;
43
190
  const [second_text_content, second_annotations] = second_text;
@@ -46,11 +193,13 @@ export function join_annotated_string(first_text, second_text) {
46
193
  const joined_text = first_text_content + second_text_content;
47
194
 
48
195
  // Start with all annotations from the first text (unchanged)
196
+ /** @type {Array<Annotation>} */
49
197
  const joined_annotations = [...first_annotations];
50
198
 
51
199
  // Add annotations from the second text, shifting their offsets
52
- const offset = first_text_content.length;
200
+ const offset = get_char_length(first_text_content);
53
201
  for (const [start, end, type, data] of second_annotations) {
202
+ /** @type {Annotation} */
54
203
  const shifted_annotation = [start + offset, end + offset, type, data];
55
204
 
56
205
  // Check if this annotation can be merged with the last annotation from first text
@@ -70,6 +219,15 @@ export function join_annotated_string(first_text, second_text) {
70
219
  return [joined_text, joined_annotations];
71
220
  }
72
221
 
222
+ /**
223
+ * Convert snake_case string to PascalCase
224
+ *
225
+ * @param {string} str - The snake_case string to convert
226
+ * @returns {string} The converted PascalCase string
227
+ *
228
+ * @example
229
+ * snake_to_pascal('list_item') // Returns: 'ListItem'
230
+ */
73
231
  export function snake_to_pascal(str) {
74
232
  return str
75
233
  .split('_')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -1,7 +1,8 @@
1
1
  <script>
2
2
  import { getContext } from 'svelte';
3
+ import { char_slice, get_char_length } from './util.js';
3
4
 
4
- /** @import { AnnotatedStringPropertyProps, AnnotationFragment } from './types.d.ts'; */
5
+ /** @import { AnnotatedStringPropertyProps, Annotation, AnnotationFragment } from './types.d.ts'; */
5
6
 
6
7
  const svedit = getContext('svedit');
7
8
 
@@ -19,7 +20,7 @@
19
20
  /**
20
21
  * Converts text with annotations into renderable fragments for display.
21
22
  * @param {string} text - The plain text content
22
- * @param {Array<[number, number, string, ...any]>} annotations - Array of annotations where each is [start_offset, end_offset, type, options?] (minimum 3 elements)
23
+ * @param {Array<Annotation>} annotations - Array of annotations where each is [start_offset, end_offset, type, options?] (minimum 3 elements)
23
24
  * @returns {Array<string|AnnotationFragment>} Array of fragments - strings for plain text, AnnotationFragment objects for annotated content
24
25
  */
25
26
  function render_annotated_string(text, annotations) {
@@ -27,16 +28,16 @@
27
28
  let last_index = 0;
28
29
 
29
30
  // Sort annotations by start_offset
30
- const sorted_annotations = $state.snapshot(annotations).sort((a, b) => a[0] - b[0]);
31
+ const sorted_annotations = [...annotations].sort((a, b) => a[0] - b[0]);
31
32
 
32
33
  for (let [index, annotation] of sorted_annotations.entries()) {
33
- // Add text before the annotation
34
+ // Add text before the annotation using character-aware slicing
34
35
  if (annotation[0] > last_index) {
35
- fragments.push(text.slice(last_index, annotation[0]));
36
+ fragments.push(char_slice(text, last_index, annotation[0]));
36
37
  }
37
38
 
38
- // Add the annotated string
39
- const annotated_content = text.slice(annotation[0], annotation[1]);
39
+ // Add the annotated string using character-aware slicing
40
+ const annotated_content = char_slice(text, annotation[0], annotation[1]);
40
41
  fragments.push({
41
42
  type: annotation[2],
42
43
  content: annotated_content,
@@ -47,9 +48,9 @@
47
48
  last_index = annotation[1];
48
49
  }
49
50
 
50
- // Add any remaining text after the last annotation
51
- if (last_index < text.length) {
52
- fragments.push(text.slice(last_index));
51
+ // Add any remaining text after the last annotation using character-aware slicing
52
+ if (last_index < get_char_length(text)) {
53
+ fragments.push(char_slice(text, last_index));
53
54
  }
54
55
 
55
56
  return fragments;
@@ -66,35 +67,37 @@
66
67
  }
67
68
  </script>
68
69
 
69
- <!-- ATTENTION: The comments are needed to prevent unwanted text nodes with whitespace. -->
70
- <div
71
- data-type="text"
72
- data-path={path.join('.')}
73
- style="anchor-name: --{path.join('-')};"
74
- class="text svedit-selectable {css_class}"
75
- class:empty={plain_text.length === 0}
76
- class:focused={is_focused}
77
- placeholder={placeholder}
78
- >
79
- {#each fragments as fragment, index (index)}
80
- {#if typeof fragment === 'string'}<!--
81
- -->{fragment}<!--
82
- -->{:else if fragment.type === 'emphasis'}<!--
83
- --><em>{fragment.content}</em><!--
84
- -->{:else if fragment.type === 'strong'}<!--
85
- --><strong>{fragment.content}</strong><!--
86
- -->{:else if fragment.type === 'link'}<!--
87
- --><a
88
- onclick={handle_link_click}
89
- style="anchor-name: --{path.join('-') + '-' + fragment.annotation_index};"
90
- href={fragment.data.href}
91
- target={fragment.data.target || '_self'}>{fragment.content}</a><!--
92
- -->{:else}<!--
93
- -->{fragment.content}<!--
94
- -->{/if}
95
- {/each}<!--
96
- --><br>
97
- </div>
70
+ {#key plain_text}
71
+ <!-- ATTENTION: The comments are needed to prevent unwanted text nodes with whitespace. -->
72
+ <div
73
+ data-type="text"
74
+ data-path={path.join('.')}
75
+ style="anchor-name: --{path.join('-')};"
76
+ class="text svedit-selectable {css_class}"
77
+ class:empty={get_char_length(plain_text) === 0 && !(svedit.is_composing && is_focused)}
78
+ class:focused={is_focused}
79
+ placeholder={placeholder}
80
+ >
81
+ {#each fragments as fragment, index (index)}
82
+ {#if typeof fragment === 'string'}<!--
83
+ -->{fragment}<!--
84
+ -->{:else if fragment.type === 'emphasis'}<!--
85
+ --><em>{fragment.content}</em><!--
86
+ -->{:else if fragment.type === 'strong'}<!--
87
+ --><strong>{fragment.content}</strong><!--
88
+ -->{:else if fragment.type === 'link'}<!--
89
+ --><a
90
+ onclick={handle_link_click}
91
+ style="anchor-name: --{path.join('-') + '-' + fragment.annotation_index};"
92
+ href={fragment.data.href}
93
+ target={fragment.data.target || '_self'}>{fragment.content}</a><!--
94
+ -->{:else}<!--
95
+ -->{fragment.content}<!--
96
+ -->{/if}
97
+ {/each}<!--
98
+ --><br>
99
+ </div>
100
+ {/key}
98
101
 
99
102
  <style>
100
103
  .text {