svedit 0.2.0 → 0.4.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 (43) hide show
  1. package/README.md +41 -21
  2. package/dist/AnnotatedTextProperty.svelte +117 -0
  3. package/dist/AnnotatedTextProperty.svelte.d.ts +7 -0
  4. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -0
  5. package/dist/CustomProperty.svelte +20 -12
  6. package/dist/CustomProperty.svelte.d.ts +1 -2
  7. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  8. package/dist/Document.svelte.d.ts +40 -17
  9. package/dist/Document.svelte.d.ts.map +1 -1
  10. package/dist/Document.svelte.js +192 -77
  11. package/dist/Node.svelte +18 -14
  12. package/dist/Node.svelte.d.ts.map +1 -1
  13. package/dist/NodeArrayProperty.svelte +1 -1
  14. package/dist/Svedit.svelte +628 -109
  15. package/dist/Svedit.svelte.d.ts +1 -1
  16. package/dist/Svedit.svelte.d.ts.map +1 -1
  17. package/dist/Transaction.svelte.d.ts +4 -7
  18. package/dist/Transaction.svelte.d.ts.map +1 -1
  19. package/dist/Transaction.svelte.js +74 -81
  20. package/dist/commands.svelte.d.ts.map +1 -1
  21. package/dist/commands.svelte.js +20 -10
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/types.d.ts +66 -21
  25. package/dist/util.d.ts +125 -3
  26. package/dist/util.d.ts.map +1 -1
  27. package/dist/util.js +191 -34
  28. package/package.json +1 -1
  29. package/src/lib/AnnotatedTextProperty.svelte +117 -0
  30. package/src/lib/CustomProperty.svelte +20 -12
  31. package/src/lib/Document.svelte.js +192 -77
  32. package/src/lib/Node.svelte +18 -14
  33. package/src/lib/NodeArrayProperty.svelte +1 -1
  34. package/src/lib/Svedit.svelte +628 -109
  35. package/src/lib/Transaction.svelte.js +74 -81
  36. package/src/lib/commands.svelte.js +20 -10
  37. package/src/lib/index.js +1 -1
  38. package/src/lib/types.d.ts +66 -21
  39. package/src/lib/util.js +191 -34
  40. package/dist/AnnotatedStringProperty.svelte +0 -126
  41. package/dist/AnnotatedStringProperty.svelte.d.ts +0 -7
  42. package/dist/AnnotatedStringProperty.svelte.d.ts.map +0 -1
  43. package/src/lib/AnnotatedStringProperty.svelte +0 -126
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, AnnotatedText } 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 text 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 {AnnotatedText} text_with_annotations - Annotated text object
88
+ * @param {number} at_position - Character position where to split (0-based)
89
+ * @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
90
+ *
91
+ * @example
92
+ * split_annotated_text({text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}, 8)
93
+ * // Returns:
94
+ * // [
95
+ * // {text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]},
96
+ * // {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]}
97
+ * // ]
98
+ */
99
+ export function split_annotated_text(text_with_annotations: AnnotatedText, at_position: number): [AnnotatedText, AnnotatedText];
100
+ /**
101
+ * Joins two annotated texts into a single annotated text.
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 {AnnotatedText} first_text - First annotated text object
107
+ * @param {AnnotatedText} second_text - Second annotated text object
108
+ * @returns {AnnotatedText} Combined annotated text object
109
+ *
110
+ * @example
111
+ * 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"}]})
112
+ * // Returns: {text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}
113
+ */
114
+ export function join_annotated_text(first_text: AnnotatedText, second_text: AnnotatedText): AnnotatedText;
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 { AnnotatedText } 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,4DAZW,aAAa,eACb,MAAM,GACJ,CAAC,aAAa,EAAE,aAAa,CAAC,CAyC1C;AAGD;;;;;;;;;;;;;GAaG;AACH,gDARW,aAAa,eACb,aAAa,GACX,aAAa,CAqCzB;AAED;;;;;;;;GAQG;AACH,qCANW,MAAM,GACJ,MAAM,CAUlB;mCAzO6C,cAAc"}
package/dist/util.js CHANGED
@@ -1,75 +1,232 @@
1
- // split_annotated_string(["Hello world", [[6,11, "strong"]]], 8)
2
- // =>
3
- // [
4
- // ["Hello wo", [[6,8, "strong"]]]
5
- // ["rld", [[0,3, "strong"]]]
6
- // ]
7
- export function split_annotated_string(text_with_annotations, at_position) {
8
- const [text, annotations] = text_with_annotations;
9
-
10
- // Split the text
11
- const left_text = text.slice(0, at_position);
12
- const right_text = text.slice(at_position);
1
+ /**
2
+ * @import { Annotation, AnnotatedText } from './types.d.ts';
3
+ */
13
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 text 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 {AnnotatedText} text_with_annotations - Annotated text object
129
+ * @param {number} at_position - Character position where to split (0-based)
130
+ * @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
131
+ *
132
+ * @example
133
+ * split_annotated_text({text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}, 8)
134
+ * // Returns:
135
+ * // [
136
+ * // {text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]},
137
+ * // {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]}
138
+ * // ]
139
+ */
140
+ export function split_annotated_text(text_with_annotations, at_position) {
141
+ const {text, annotations} = text_with_annotations;
142
+
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);
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
18
- for (const [start, end, type, data] of annotations) {
19
- if (end <= at_position) {
153
+ for (const {start_offset, end_offset, node_id} of annotations) {
154
+ if (end_offset <= at_position) {
20
155
  // Annotation is entirely in the left part
21
- left_annotations.push([start, end, type, data]);
22
- } else if (start >= at_position) {
156
+ left_annotations.push({start_offset, end_offset, node_id});
157
+ } else if (start_offset >= at_position) {
23
158
  // Annotation is entirely in the right part - shift offsets
24
- right_annotations.push([start - at_position, end - at_position, type, data]);
159
+ right_annotations.push({start_offset: start_offset - at_position, end_offset: end_offset - at_position, node_id});
25
160
  } else {
26
161
  // Annotation spans the split point - split it
27
- left_annotations.push([start, at_position, type, data]);
28
- right_annotations.push([0, end - at_position, type, data]);
162
+ left_annotations.push({start_offset, end_offset: at_position, node_id});
163
+ right_annotations.push({start_offset: 0, end_offset: end_offset - at_position, node_id});
29
164
  }
30
165
  }
31
166
 
32
167
  return [
33
- [left_text, left_annotations],
34
- [right_text, right_annotations]
168
+ {text: left_text, annotations: left_annotations},
169
+ {text: right_text, annotations: right_annotations}
35
170
  ];
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"]]]
41
- export function join_annotated_string(first_text, second_text) {
42
- const [first_text_content, first_annotations] = first_text;
43
- const [second_text_content, second_annotations] = second_text;
174
+ /**
175
+ * Joins two annotated texts into a single annotated text.
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 {AnnotatedText} first_text - First annotated text object
181
+ * @param {AnnotatedText} second_text - Second annotated text object
182
+ * @returns {AnnotatedText} Combined annotated text object
183
+ *
184
+ * @example
185
+ * 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"}]})
186
+ * // Returns: {text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}
187
+ */
188
+ export function join_annotated_text(first_text, second_text) {
189
+ const {text: first_text_content, annotations: first_annotations} = first_text;
190
+ const {text: second_text_content, annotations: second_annotations} = second_text;
44
191
 
45
192
  // Join the text content
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;
53
- for (const [start, end, type, data] of second_annotations) {
54
- const shifted_annotation = [start + offset, end + offset, type, data];
200
+ const offset = get_char_length(first_text_content);
201
+ for (const {start_offset, end_offset, node_id} of second_annotations) {
202
+ /** @type {Annotation} */
203
+ const shifted_annotation = {start_offset: start_offset + offset, end_offset: end_offset + offset, node_id};
55
204
 
56
205
  // Check if this annotation can be merged with the last annotation from first text
57
206
  const last_annotation = joined_annotations[joined_annotations.length - 1];
58
207
  if (last_annotation &&
59
- last_annotation[1] === shifted_annotation[0] && // annotations are adjacent
60
- last_annotation[2] === shifted_annotation[2] && // same type
61
- JSON.stringify(last_annotation[3]) === JSON.stringify(shifted_annotation[3])) { // same data
208
+ last_annotation.end_offset === shifted_annotation.start_offset && // annotations are adjacent
209
+ last_annotation.node_id === shifted_annotation.node_id) { // same node_id
62
210
  // Merge by extending the end position of the last annotation
63
- last_annotation[1] = shifted_annotation[1];
211
+ last_annotation.end_offset = shifted_annotation.end_offset;
64
212
  } else {
65
213
  // Add as separate annotation
66
214
  joined_annotations.push(shifted_annotation);
67
215
  }
68
216
  }
69
217
 
70
- return [joined_text, joined_annotations];
218
+ return {text: joined_text, annotations: joined_annotations};
71
219
  }
72
220
 
221
+ /**
222
+ * Convert snake_case string to PascalCase
223
+ *
224
+ * @param {string} str - The snake_case string to convert
225
+ * @returns {string} The converted PascalCase string
226
+ *
227
+ * @example
228
+ * snake_to_pascal('list_item') // Returns: 'ListItem'
229
+ */
73
230
  export function snake_to_pascal(str) {
74
231
  return str
75
232
  .split('_')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -0,0 +1,117 @@
1
+ <script>
2
+ import { getContext } from 'svelte';
3
+ import { char_slice, get_char_length, snake_to_pascal } from './util.js';
4
+
5
+ /** @import { AnnotatedTextPropertyProps, Annotation, AnnotationFragment } from './types.d.ts'; */
6
+
7
+ const svedit = getContext('svedit');
8
+
9
+ /** @type {AnnotatedTextPropertyProps} */
10
+ let {
11
+ path,
12
+ class: css_class,
13
+ placeholder = '',
14
+ } = $props();
15
+
16
+ let is_focused = $derived.by(() => {
17
+ return svedit.doc.selection?.type === 'text' && path.join('.') === svedit.doc.selection?.path.join('.');
18
+ });
19
+
20
+ /**
21
+ * Converts text with annotations into renderable fragments for display.
22
+ * @param {string} text - The plain text content
23
+ * @param {Array<Annotation>} annotations - Array of annotations where each is {start_offset, end_offset, node_id}
24
+ * @returns {Array<string|AnnotationFragment>} Array of fragments - strings for plain text, AnnotationFragment objects for annotated content
25
+ */
26
+ function get_fragments(text, annotations) {
27
+ let fragments = [];
28
+ let last_index = 0;
29
+
30
+ // Sort annotations by start_offset
31
+ const sorted_annotations = [...annotations].sort((a, b) => a.start_offset - b.start_offset);
32
+
33
+ for (let [, annotation] of sorted_annotations.entries()) {
34
+ // Add text before the annotation using character-aware slicing
35
+ if (annotation.start_offset > last_index) {
36
+ fragments.push(char_slice(text, last_index, annotation.start_offset));
37
+ }
38
+
39
+ // Add the annotated string using character-aware slicing
40
+ const annotated_content = char_slice(text, annotation.start_offset, annotation.end_offset);
41
+ const node = svedit.doc.get(annotation.node_id);
42
+ if (!node) throw new Error(`Node not found for annotation ${annotation.node_id}`);
43
+
44
+ fragments.push({
45
+ node,
46
+ content: annotated_content,
47
+ // NOTE: We need to provide the original index here, because the source data
48
+ // is the address space.
49
+ annotation_index: annotations.indexOf(annotation),
50
+ });
51
+
52
+ last_index = annotation.end_offset;
53
+ }
54
+
55
+ // Add any remaining text after the last annotation using character-aware slicing
56
+ if (last_index < get_char_length(text)) {
57
+ fragments.push(char_slice(text, last_index));
58
+ }
59
+
60
+ return fragments;
61
+ }
62
+
63
+ let fragments = $derived(get_fragments(svedit.doc.get(path).text, svedit.doc.get(path).annotations));
64
+ let plain_text = $derived(svedit.doc.get(path).text);
65
+
66
+ </script>
67
+
68
+ {#key plain_text}
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={get_char_length(plain_text) === 0 && !(svedit.is_composing && is_focused)}
76
+ class:focused={is_focused}
77
+ placeholder={placeholder}
78
+ >
79
+ {#each fragments as fragment, index (index)}
80
+ {#if typeof fragment === 'string'}{fragment}{:else}
81
+ {@const AnnotationComponent = svedit.doc.config.node_components[snake_to_pascal(fragment.node.type)]}
82
+ <AnnotationComponent path={[...path, 'annotations', fragment.annotation_index, 'node_id']} content={fragment.content} />
83
+ {/if}
84
+ {/each}<!--
85
+ --><br>
86
+ </div>
87
+ {/key}
88
+
89
+ <style>
90
+ .text {
91
+ white-space: pre-wrap;
92
+ overflow-wrap: anywhere;
93
+ box-sizing: content-box;
94
+
95
+ &.heading1 {
96
+ text-wrap: var(--text-wrap);
97
+ }
98
+ }
99
+
100
+ [placeholder].empty::before {
101
+ content: attr(placeholder);
102
+ pointer-events: none;
103
+ color: color-mix(in oklch, currentcolor 50%, transparent);
104
+ }
105
+
106
+ /* We can safely hide the <br> element when the placeholder is empty and focused. */
107
+ /* No longer the cursor will be rendered after the placeholder when focused. */
108
+ /* ATTENTION: This hack has the side effect that in Safari Desktop the cursor sometimes disappears. */
109
+ [placeholder].empty.focused br {
110
+ display:none;
111
+ }
112
+
113
+ /*.text.focused {
114
+ background: none;
115
+ outline: 1px solid var(--editing-stroke-color);
116
+ }*/
117
+ </style>
@@ -1,6 +1,10 @@
1
1
  <script>
2
+ import { getContext } from 'svelte';
3
+
2
4
  /** @import { CustomPropertyProps } from './types.d.ts'; */
3
5
 
6
+ const svedit = getContext('svedit');
7
+
4
8
  /** @type {CustomPropertyProps} */
5
9
  let {
6
10
  path,
@@ -9,19 +13,23 @@
9
13
  } = $props();
10
14
  </script>
11
15
 
12
- <!-- NOTE: It's super crucial that there is no extra whitespace, to not introduce additional cursor positions in contenteditable -->
13
- <div
14
- class="property {css_class}"
15
- data-type="property"
16
- data-path={path.join('.')}
17
- style="anchor-name: --{path.join('-')};"
18
- >
19
- <div class="cursor-trap">
20
- <div class="svedit-selectable"><br/></div>
21
- </div>
16
+ {#if svedit.editable}
17
+ <!-- NOTE: It's super crucial that there is no extra whitespace, to not introduce additional cursor positions in contenteditable -->
18
+ <div
19
+ class="property {css_class}"
20
+ data-type="property"
21
+ data-path={path.join('.')}
22
+ style="anchor-name: --{path.join('-')};"
23
+ >
24
+ <div class="cursor-trap">
25
+ <div class="svedit-selectable"><br/></div>
26
+ </div>
22
27
 
23
- <div class="property-content" contenteditable="false">{@render children()}</div>
24
- </div>
28
+ <div class="property-content" contenteditable="false">{@render children()}</div>
29
+ </div>
30
+ {:else}
31
+ {@render children()}
32
+ {/if}
25
33
 
26
34
  <style>
27
35
  .property {