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.
- package/README.md +2 -0
- package/dist/AnnotatedStringProperty.svelte +42 -39
- package/dist/AnnotatedStringProperty.svelte.d.ts.map +1 -1
- package/dist/CustomProperty.svelte +20 -12
- package/dist/CustomProperty.svelte.d.ts +1 -2
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/Document.svelte.d.ts +1 -1
- package/dist/Document.svelte.d.ts.map +1 -1
- package/dist/Document.svelte.js +5 -2
- package/dist/Node.svelte +18 -14
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +1 -1
- package/dist/Svedit.svelte +543 -76
- package/dist/Svedit.svelte.d.ts +1 -1
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +0 -3
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +19 -9
- package/dist/commands.svelte.d.ts.map +1 -1
- package/dist/commands.svelte.js +6 -4
- package/dist/util.d.ts +125 -3
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +170 -12
- package/package.json +1 -1
- package/src/lib/AnnotatedStringProperty.svelte +42 -39
- package/src/lib/CustomProperty.svelte +20 -12
- package/src/lib/Document.svelte.js +5 -2
- package/src/lib/Node.svelte +18 -14
- package/src/lib/NodeArrayProperty.svelte +1 -1
- package/src/lib/Svedit.svelte +543 -76
- package/src/lib/Transaction.svelte.js +19 -9
- package/src/lib/commands.svelte.js +6 -4
- package/src/lib/util.js +170 -12
package/dist/Svedit.svelte.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AAg3CA;;eAAqD;iCAp2CgE,cAAc"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Transaction.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Transaction.svelte.js"],"names":[],"mappings":"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
496
|
-
focus_offset: start + replaced_text
|
|
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,
|
|
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"}
|
package/dist/commands.svelte.js
CHANGED
|
@@ -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]
|
|
116
|
-
focus_offset: predecessor_node.content[0]
|
|
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]
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
package/dist/util.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/lib/util.js"],"names":[],"mappings":"
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
12
|
-
const right_text = text
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
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,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<
|
|
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 =
|
|
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
|
|
36
|
+
fragments.push(char_slice(text, last_index, annotation[0]));
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
// Add the annotated string
|
|
39
|
-
const annotated_content = text
|
|
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
|
|
52
|
-
fragments.push(text
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
-->{
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 {
|