svedit 0.5.0 → 0.6.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 (62) hide show
  1. package/README.md +761 -120
  2. package/dist/AnnotatedTextProperty.svelte +122 -75
  3. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.d.ts +118 -0
  5. package/dist/Command.svelte.d.ts.map +1 -0
  6. package/dist/Command.svelte.js +314 -0
  7. package/dist/CustomProperty.svelte +31 -42
  8. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  9. package/dist/KeyMapper.svelte.d.ts +36 -0
  10. package/dist/KeyMapper.svelte.d.ts.map +1 -0
  11. package/dist/KeyMapper.svelte.js +141 -0
  12. package/dist/Node.svelte +33 -35
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +40 -37
  15. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  16. package/dist/{Document.svelte.d.ts → Session.svelte.d.ts} +76 -83
  17. package/dist/Session.svelte.d.ts.map +1 -0
  18. package/dist/Session.svelte.js +594 -0
  19. package/dist/Svedit.svelte +1352 -1512
  20. package/dist/Svedit.svelte.d.ts.map +1 -1
  21. package/dist/Transaction.svelte.d.ts +114 -40
  22. package/dist/Transaction.svelte.d.ts.map +1 -1
  23. package/dist/Transaction.svelte.js +845 -661
  24. package/dist/UnknownNode.svelte +6 -6
  25. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  26. package/dist/doc_utils.d.ts +127 -0
  27. package/dist/doc_utils.d.ts.map +1 -0
  28. package/dist/doc_utils.js +482 -0
  29. package/dist/index.d.ts +7 -3
  30. package/dist/index.js +20 -4
  31. package/dist/{commands.svelte.d.ts → transforms.svelte.d.ts} +1 -2
  32. package/dist/transforms.svelte.d.ts.map +1 -0
  33. package/dist/transforms.svelte.js +155 -0
  34. package/dist/types.d.ts +256 -110
  35. package/dist/{util.d.ts → utils.d.ts} +22 -2
  36. package/dist/utils.d.ts.map +1 -0
  37. package/dist/utils.js +338 -0
  38. package/package.json +20 -18
  39. package/src/lib/AnnotatedTextProperty.svelte +122 -75
  40. package/src/lib/Command.svelte.js +314 -0
  41. package/src/lib/CustomProperty.svelte +31 -42
  42. package/src/lib/KeyMapper.svelte.js +141 -0
  43. package/src/lib/Node.svelte +33 -35
  44. package/src/lib/NodeArrayProperty.svelte +40 -37
  45. package/src/lib/Session.svelte.js +594 -0
  46. package/src/lib/Svedit.svelte +1352 -1512
  47. package/src/lib/Transaction.svelte.js +845 -661
  48. package/src/lib/UnknownNode.svelte +6 -6
  49. package/src/lib/doc_utils.js +482 -0
  50. package/src/lib/index.js +20 -4
  51. package/src/lib/transforms.svelte.js +155 -0
  52. package/src/lib/types.d.ts +256 -110
  53. package/src/lib/utils.js +338 -0
  54. package/dist/Document.svelte.d.ts.map +0 -1
  55. package/dist/Document.svelte.js +0 -833
  56. package/dist/commands.svelte.d.ts.map +0 -1
  57. package/dist/commands.svelte.js +0 -266
  58. package/dist/util.d.ts.map +0 -1
  59. package/dist/util.js +0 -269
  60. package/src/lib/Document.svelte.js +0 -833
  61. package/src/lib/commands.svelte.js +0 -266
  62. package/src/lib/util.js +0 -269
@@ -1,6 +1,16 @@
1
1
  /**
2
- * @import { Annotation, AnnotatedText } from './types.d.ts';
2
+ * @import { Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
3
3
  */
4
+ /**
5
+ * Detect if the current browser is on a mobile device
6
+ * @returns {boolean} true if mobile browser, false otherwise
7
+ */
8
+ export function is_mobile_browser(): boolean;
9
+ /**
10
+ * Detect if the current browser is Chrome on desktop
11
+ * @returns {boolean} true if Chrome desktop browser, false otherwise
12
+ */
13
+ export function is_chrome_desktop_browser(): boolean;
4
14
  /**
5
15
  * Get the actual character length (accounting for multi-byte characters)
6
16
  *
@@ -123,5 +133,15 @@ export function join_annotated_text(first_text: AnnotatedText, second_text: Anno
123
133
  */
124
134
  export function snake_to_pascal(str: string): string;
125
135
  export function traverse(node_id: any, schema: any, nodes: any): any[];
136
+ /**
137
+ * Extracts the normalized range from a text or node selection.
138
+ * Returns start and end offsets in document order (start <= end), regardless of selection direction.
139
+ * @param {Selection} [selection] - The selection to extract the range from
140
+ * @returns {SelectionRange | null} The normalized selection range, or null if selection is null, undefined, or a property selection
141
+ */
142
+ export function get_selection_range(selection?: Selection): SelectionRange | null;
143
+ export function is_selection_collapsed(selection: any): boolean;
126
144
  import type { AnnotatedText } from './types.d.ts';
127
- //# sourceMappingURL=util.d.ts.map
145
+ import type { Selection } from './types.d.ts';
146
+ import type { SelectionRange } from './types.d.ts';
147
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/lib/utils.js"],"names":[],"mappings":"AAAA;;GAEG;AAEH;;;GAGG;AACH,qCAFa,OAAO,CAanB;AAED;;;GAGG;AACH,6CAFa,OAAO,CAUnB;AAED;;;;;;;;;;;;;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,CAalB;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,CA6C1C;AAED;;;;;;;;;;;;;GAaG;AACH,gDARW,aAAa,eACb,aAAa,GACX,aAAa,CA4CzB;AAED;;;;;;;;GAQG;AACH,qCANW,MAAM,GACJ,MAAM,CAUlB;AAED,uEAgCC;AAED;;;;;GAKG;AACH,gDAHW,SAAS,GACP,cAAc,GAAG,IAAI,CAWjC;AAED,gEAMC;mCAhVwE,cAAc;+BAAd,cAAc;oCAAd,cAAc"}
package/dist/utils.js ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * @import { Annotation, AnnotatedText, SelectionRange, Selection } from './types.d.ts';
3
+ */
4
+
5
+ /**
6
+ * Detect if the current browser is on a mobile device
7
+ * @returns {boolean} true if mobile browser, false otherwise
8
+ */
9
+ export function is_mobile_browser() {
10
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
11
+ return false;
12
+ }
13
+
14
+ const user_agent = navigator.userAgent;
15
+ return (
16
+ /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
17
+ 'ontouchstart' in window ||
18
+ navigator.maxTouchPoints > 0
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Detect if the current browser is Chrome on desktop
24
+ * @returns {boolean} true if Chrome desktop browser, false otherwise
25
+ */
26
+ export function is_chrome_desktop_browser() {
27
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
28
+ return false;
29
+ }
30
+ const user_agent = navigator.userAgent;
31
+ const is_chrome = user_agent.includes('Chrome') && !user_agent.includes('Edg');
32
+ const is_mobile = is_mobile_browser();
33
+ return is_chrome && !is_mobile;
34
+ }
35
+
36
+ /**
37
+ * Get the actual character length (accounting for multi-byte characters)
38
+ *
39
+ * Uses Intl.Segmenter to count grapheme clusters rather than UTF-16 code units,
40
+ * ensuring emojis and other complex Unicode sequences are counted as single characters.
41
+ *
42
+ * @param {string} str - The string to measure
43
+ * @returns {number} The number of visual characters (grapheme clusters)
44
+ *
45
+ * @example
46
+ * get_char_length('Hello') // Returns: 5
47
+ * get_char_length('a😀b') // Returns: 3 (not 4)
48
+ * get_char_length('👋🏽') // Returns: 1 (not 4 - skin tone modifier treated as single char)
49
+ */
50
+ export function get_char_length(str) {
51
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
52
+ return [...segmenter.segment(str)].length;
53
+ }
54
+
55
+ /**
56
+ * Get a single character at the specified position (accounting for multi-byte characters)
57
+ *
58
+ * Uses Intl.Segmenter to access grapheme clusters rather than UTF-16 code units,
59
+ * ensuring emojis and other complex Unicode sequences are treated as single characters.
60
+ *
61
+ * @param {string} str - The string to access
62
+ * @param {number} index - Character position (0-based)
63
+ * @returns {string} The character at the specified position, or empty string if index is out of bounds
64
+ *
65
+ * @example
66
+ * get_char_at('Hello', 1) // Returns: 'e'
67
+ * get_char_at('a😀b', 1) // Returns: '😀' (full emoji)
68
+ * get_char_at('👋🏽', 0) // Returns: '👋🏽' (skin tone modifier included)
69
+ */
70
+ export function get_char_at(str, index) {
71
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
72
+ const segments = [...segmenter.segment(str)];
73
+ return segments[index].segment;
74
+ }
75
+
76
+ /**
77
+ * Slice string by character positions (accounting for multi-byte characters)
78
+ *
79
+ * Uses Intl.Segmenter to slice by grapheme clusters rather than UTF-16 code units,
80
+ * ensuring emojis and other complex Unicode sequences remain intact.
81
+ *
82
+ * @param {string} str - The string to slice
83
+ * @param {number} start - Starting character position (inclusive)
84
+ * @param {number} [end] - Ending character position (exclusive). If undefined, slices to end
85
+ * @returns {string} The sliced string with complete characters
86
+ *
87
+ * @example
88
+ * char_slice('Hello 😀 World', 6, 8) // Returns: '😀 ' (emoji stays intact)
89
+ * char_slice('a👋🏽b', 1, 2) // Returns: '👋🏽' (skin tone modifier included)
90
+ */
91
+ export function char_slice(str, start, end = undefined) {
92
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
93
+ const segments = [...segmenter.segment(str)];
94
+ return segments
95
+ .slice(start, end)
96
+ .map((s) => s.segment)
97
+ .join('');
98
+ }
99
+
100
+ /**
101
+ * Convert UTF-16 code unit offset to grapheme cluster offset within a string
102
+ *
103
+ * Converts DOM selection offsets (which use UTF-16 code units) to character-based
104
+ * offsets (grapheme clusters) for consistent text manipulation.
105
+ *
106
+ * @param {string} str - The string to convert offsets within
107
+ * @param {number} utf16_offset - The UTF-16 code unit offset from DOM
108
+ * @returns {number} The corresponding grapheme cluster offset
109
+ *
110
+ * @example
111
+ * // For string "a😀b" where 😀 uses 2 UTF-16 code units
112
+ * utf16_to_char_offset("a😀b", 3) // Returns: 2 (position after emoji)
113
+ */
114
+ export function utf16_to_char_offset(str, utf16_offset) {
115
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
116
+ const segments = [...segmenter.segment(str)];
117
+ let char_offset = 0;
118
+ let utf16_count = 0;
119
+
120
+ for (const segment of segments) {
121
+ if (utf16_count >= utf16_offset) break;
122
+ utf16_count += segment.segment.length;
123
+ if (utf16_count > utf16_offset) break;
124
+ char_offset++;
125
+ }
126
+
127
+ return char_offset;
128
+ }
129
+
130
+ /**
131
+ * Convert grapheme cluster offset to UTF-16 code unit offset within a string
132
+ *
133
+ * Converts character-based offsets (grapheme clusters) to DOM selection offsets
134
+ * (UTF-16 code units) for proper DOM selection positioning.
135
+ *
136
+ * @param {string} str - The string to convert offsets within
137
+ * @param {number} char_offset - The grapheme cluster offset
138
+ * @returns {number} The corresponding UTF-16 code unit offset for DOM operations
139
+ *
140
+ * @example
141
+ * // For string "a😀b" where 😀 uses 2 UTF-16 code units
142
+ * char_to_utf16_offset("a😀b", 2) // Returns: 3 (UTF-16 position after emoji)
143
+ */
144
+ export function char_to_utf16_offset(str, char_offset) {
145
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
146
+ const segments = [...segmenter.segment(str)];
147
+ let utf16_offset = 0;
148
+
149
+ for (let i = 0; i < Math.min(char_offset, segments.length); i++) {
150
+ utf16_offset += segments[i].segment.length;
151
+ }
152
+
153
+ return utf16_offset;
154
+ }
155
+
156
+ /**
157
+ * Splits an annotated text at the specified character position.
158
+ *
159
+ * Annotations that span the split point will be divided appropriately,
160
+ * with offsets adjusted for each resulting part.
161
+ *
162
+ * @param {AnnotatedText} text_with_annotations - Annotated text object
163
+ * @param {number} at_position - Character position where to split (0-based)
164
+ * @returns {[AnnotatedText, AnnotatedText]} Tuple of [left_part, right_part]
165
+ *
166
+ * @example
167
+ * split_annotated_text({text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}, 8)
168
+ * // Returns:
169
+ * // [
170
+ * // {text: "Hello wo", annotations: [{start_offset: 6, end_offset: 8, node_id: "strong"}]},
171
+ * // {text: "rld", annotations: [{start_offset: 0, end_offset: 3, node_id: "strong"}]}
172
+ * // ]
173
+ */
174
+ export function split_annotated_text(text_with_annotations, at_position) {
175
+ const { text, annotations } = text_with_annotations;
176
+
177
+ // Split the text using character-aware slicing
178
+ const left_text = char_slice(text, 0, at_position);
179
+ const right_text = char_slice(text, at_position);
180
+
181
+ /** @type {Array<Annotation>} */
182
+ const left_annotations = [];
183
+ /** @type {Array<Annotation>} */
184
+ const right_annotations = [];
185
+
186
+ // Process each annotation
187
+ for (const { start_offset, end_offset, node_id } of annotations) {
188
+ if (end_offset <= at_position) {
189
+ // Annotation is entirely in the left part
190
+ left_annotations.push({ start_offset, end_offset, node_id });
191
+ } else if (start_offset >= at_position) {
192
+ // Annotation is entirely in the right part - shift offsets
193
+ right_annotations.push({
194
+ start_offset: start_offset - at_position,
195
+ end_offset: end_offset - at_position,
196
+ node_id
197
+ });
198
+ } else {
199
+ // Annotation spans the split point - split it
200
+ left_annotations.push({ start_offset, end_offset: at_position, node_id });
201
+ right_annotations.push({ start_offset: 0, end_offset: end_offset - at_position, node_id });
202
+ }
203
+ }
204
+
205
+ return [
206
+ { text: left_text, annotations: left_annotations },
207
+ { text: right_text, annotations: right_annotations }
208
+ ];
209
+ }
210
+
211
+ /**
212
+ * Joins two annotated texts into a single annotated text.
213
+ *
214
+ * Annotations from the second text will have their offsets shifted by the length
215
+ * of the first text. Adjacent annotations of the same type and data will be merged.
216
+ *
217
+ * @param {AnnotatedText} first_text - First annotated text object
218
+ * @param {AnnotatedText} second_text - Second annotated text object
219
+ * @returns {AnnotatedText} Combined annotated text object
220
+ *
221
+ * @example
222
+ * 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"}]})
223
+ * // Returns: {text: "Hello world", annotations: [{start_offset: 6, end_offset: 11, node_id: "strong"}]}
224
+ */
225
+ export function join_annotated_text(first_text, second_text) {
226
+ const { text: first_text_content, annotations: first_annotations } = first_text;
227
+ const { text: second_text_content, annotations: second_annotations } = second_text;
228
+
229
+ // Join the text content
230
+ const joined_text = first_text_content + second_text_content;
231
+
232
+ // Start with all annotations from the first text (unchanged)
233
+ /** @type {Array<Annotation>} */
234
+ const joined_annotations = [...first_annotations];
235
+
236
+ // Add annotations from the second text, shifting their offsets
237
+ const offset = get_char_length(first_text_content);
238
+ for (const { start_offset, end_offset, node_id } of second_annotations) {
239
+ /** @type {Annotation} */
240
+ const shifted_annotation = {
241
+ start_offset: start_offset + offset,
242
+ end_offset: end_offset + offset,
243
+ node_id
244
+ };
245
+
246
+ // Check if this annotation can be merged with the last annotation from first text
247
+ const last_annotation = joined_annotations[joined_annotations.length - 1];
248
+ if (
249
+ last_annotation &&
250
+ last_annotation.end_offset === shifted_annotation.start_offset && // annotations are adjacent
251
+ last_annotation.node_id === shifted_annotation.node_id
252
+ ) {
253
+ // same node_id
254
+ // Merge by extending the end position of the last annotation
255
+ last_annotation.end_offset = shifted_annotation.end_offset;
256
+ } else {
257
+ // Add as separate annotation
258
+ joined_annotations.push(shifted_annotation);
259
+ }
260
+ }
261
+
262
+ return { text: joined_text, annotations: joined_annotations };
263
+ }
264
+
265
+ /**
266
+ * Convert snake_case string to PascalCase
267
+ *
268
+ * @param {string} str - The snake_case string to convert
269
+ * @returns {string} The converted PascalCase string
270
+ *
271
+ * @example
272
+ * snake_to_pascal('list_item') // Returns: 'ListItem'
273
+ */
274
+ export function snake_to_pascal(str) {
275
+ return str
276
+ .split('_')
277
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
278
+ .join('');
279
+ }
280
+
281
+ export function traverse(node_id, schema, nodes) {
282
+ const json = [];
283
+ const visited = {};
284
+ const visit = (node) => {
285
+ if (!node || visited[node.id]) {
286
+ return;
287
+ }
288
+ visited[node.id] = true;
289
+ for (const [property_name, value] of Object.entries(node)) {
290
+ const property_definition = schema[node.type].properties[property_name];
291
+
292
+ if (property_definition?.type === 'node_array') {
293
+ for (const v of value) {
294
+ if (typeof v === 'string') {
295
+ visit(nodes[v]);
296
+ }
297
+ }
298
+ } else if (property_definition?.type === 'node') {
299
+ visit(nodes[value]);
300
+ } else if (property_definition?.type === 'annotated_text') {
301
+ for (const annotation of value.annotations) {
302
+ visit(nodes[annotation.node_id]);
303
+ }
304
+ }
305
+ }
306
+ // Finally add the node to the result.
307
+ // Deep clone, to make sure nothing of the original document is referenced.
308
+ json.push(structuredClone(node));
309
+ };
310
+ // Start with the root node (document_id)
311
+ visit(nodes[node_id]);
312
+ return json;
313
+ }
314
+
315
+ /**
316
+ * Extracts the normalized range from a text or node selection.
317
+ * Returns start and end offsets in document order (start <= end), regardless of selection direction.
318
+ * @param {Selection} [selection] - The selection to extract the range from
319
+ * @returns {SelectionRange | null} The normalized selection range, or null if selection is null, undefined, or a property selection
320
+ */
321
+ export function get_selection_range(selection) {
322
+ if (selection && selection.type !== 'property') {
323
+ return {
324
+ start_offset: Math.min(selection.anchor_offset, selection.focus_offset),
325
+ end_offset: Math.max(selection.anchor_offset, selection.focus_offset)
326
+ };
327
+ } else {
328
+ return null;
329
+ }
330
+ }
331
+
332
+ export function is_selection_collapsed(selection) {
333
+ if (selection && selection.type !== 'property') {
334
+ return selection.anchor_offset === selection.focus_offset;
335
+ } else {
336
+ return false;
337
+ }
338
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "dev": "vite dev",
@@ -39,27 +39,29 @@
39
39
  "svelte": "^5.0.0"
40
40
  },
41
41
  "devDependencies": {
42
- "@eslint/compat": "^1.4.0",
43
- "@eslint/js": "^9.36.0",
44
- "@sveltejs/adapter-auto": "^6.1.0",
45
- "@sveltejs/kit": "^2.43.7",
42
+ "@eslint/compat": "^1.4.1",
43
+ "@eslint/js": "^9.39.1",
44
+ "@sveltejs/adapter-auto": "^7.0.0",
45
+ "@sveltejs/kit": "^2.48.4",
46
46
  "@sveltejs/package": "^2.5.4",
47
47
  "@sveltejs/vite-plugin-svelte": "^6.2.1",
48
- "@typescript-eslint/eslint-plugin": "^8.45.0",
49
- "@typescript-eslint/parser": "^8.45.0",
50
- "@vitest/browser": "^3.2.4",
51
- "eslint": "^9.36.0",
48
+ "@typescript-eslint/eslint-plugin": "^8.46.3",
49
+ "@typescript-eslint/parser": "^8.46.3",
50
+ "@vitest/browser": "^4.0.8",
51
+ "@vitest/browser-preview": "^4.0.8",
52
+ "eslint": "^9.39.1",
52
53
  "eslint-config-prettier": "^10.1.8",
53
- "eslint-plugin-svelte": "^3.12.4",
54
- "globals": "^16.4.0",
54
+ "eslint-plugin-svelte": "^3.13.0",
55
+ "globals": "^16.5.0",
56
+ "nanoid": "^5.1.6",
55
57
  "prettier": "^3.6.2",
56
58
  "prettier-plugin-svelte": "^3.4.0",
57
- "publint": "^0.3.13",
58
- "svelte": "^5.39.8",
59
- "svelte-check": "^4.3.2",
60
- "vite": "^7.1.7",
61
- "vitest": "^3.2.4",
62
- "vitest-browser-svelte": "^1.1.0",
63
- "nanoid": "^5.1.6"
59
+ "publint": "^0.3.15",
60
+ "svelte": "^5.43.4",
61
+ "svelte-check": "^4.3.3",
62
+ "vite": "^7.2.2",
63
+ "typescript": "^5.9.3",
64
+ "vitest": "^4.0.8",
65
+ "vitest-browser-svelte": "^2.0.1"
64
66
  }
65
67
  }
@@ -1,58 +1,77 @@
1
1
  <script>
2
2
  import { getContext } from 'svelte';
3
- import { char_slice, get_char_length, snake_to_pascal } from './util.js';
3
+ import { char_slice, get_char_length, snake_to_pascal, get_selection_range } from './utils.js';
4
4
 
5
- /** @import { AnnotatedTextPropertyProps, Annotation, AnnotationFragment } from './types.d.ts'; */
5
+ /** @import { AnnotatedTextPropertyProps, Annotation, Fragment, SelectionRange } from './types.d.ts'; */
6
6
 
7
7
  const svedit = getContext('svedit');
8
8
 
9
- /** @type {AnnotatedTextPropertyProps} */
10
- let {
11
- path,
12
- class: css_class,
13
- placeholder = '',
14
- } = $props();
9
+ /** @type {AnnotatedTextPropertyProps} */
10
+ let { path, class: css_class, placeholder = '', tag = 'div' } = $props();
15
11
 
16
12
  let is_focused = $derived.by(() => {
17
- return svedit.doc.selection?.type === 'text' && path.join('.') === svedit.doc.selection?.path.join('.');
13
+ return (
14
+ svedit.session.selection?.type === 'text' &&
15
+ path.join('.') === svedit.session.selection?.path.join('.')
16
+ );
17
+ });
18
+
19
+ // Get selection highlight range if not inside an annotation
20
+ let selection_highlight_range = $derived.by(() => {
21
+ if (!is_focused) return null;
22
+ if (svedit.session.active_annotation()) return null;
23
+ const sel = svedit.session.selection;
24
+ if (!sel || sel.type !== 'text') return null;
25
+ return get_selection_range(sel);
18
26
  });
19
27
 
20
28
  /**
21
29
  * Converts text with annotations into renderable fragments for display.
22
30
  * @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
31
+ * @param {Array<Annotation>} annotations - Array of annotations
32
+ * @param {SelectionRange} [selection_highlight_range] - Optional selection highlight range
33
+ * @returns {Array<Fragment>} Array of fragments
25
34
  */
26
- function get_fragments(text, annotations) {
35
+ function get_fragments(text, annotations, selection_highlight_range) {
36
+ /** @type {Array<Fragment>} */
27
37
  let fragments = [];
28
38
  let last_index = 0;
29
39
 
30
- // Sort annotations by start_offset
31
- const sorted_annotations = [...annotations].sort((a, b) => a.start_offset - b.start_offset);
40
+ // Merge annotations with selection highlight and sort by start offset
41
+ const ranges = [
42
+ ...annotations,
43
+ ...(selection_highlight_range ? [selection_highlight_range] : [])
44
+ ].sort((a, b) => a.start_offset - b.start_offset);
32
45
 
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));
46
+ for (const range of ranges) {
47
+ // Add text before this range
48
+ if (range.start_offset > last_index) {
49
+ fragments.push(char_slice(text, last_index, range.start_offset));
37
50
  }
38
51
 
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
- });
52
+ const content = char_slice(text, range.start_offset, range.end_offset);
53
+
54
+ if ('node_id' in range) {
55
+ const node = svedit.session.get(range.node_id);
56
+ if (!node) throw new Error(`Node not found for annotation ${range.node_id}`);
57
+
58
+ fragments.push({
59
+ type: 'annotation',
60
+ node,
61
+ content,
62
+ annotation_index: annotations.indexOf(/** @type {Annotation} */ (range))
63
+ });
64
+ } else {
65
+ fragments.push({
66
+ type: 'selection_highlight',
67
+ content
68
+ });
69
+ }
51
70
 
52
- last_index = annotation.end_offset;
71
+ last_index = range.end_offset;
53
72
  }
54
73
 
55
- // Add any remaining text after the last annotation using character-aware slicing
74
+ // Add any remaining text
56
75
  if (last_index < get_char_length(text)) {
57
76
  fragments.push(char_slice(text, last_index));
58
77
  }
@@ -60,58 +79,86 @@
60
79
  return fragments;
61
80
  }
62
81
 
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
-
82
+ let fragments = $derived(
83
+ get_fragments(
84
+ svedit.session.get(path).text,
85
+ svedit.session.get(path).annotations,
86
+ selection_highlight_range
87
+ )
88
+ );
89
+ let plain_text = $derived(svedit.session.get(path).text);
90
+ let is_empty = $derived(
91
+ get_char_length(plain_text) === 0 && !(svedit.is_composing && is_focused)
92
+ );
66
93
  </script>
67
94
 
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}
95
+ <!-- ATTENTION: The comments are needed to prevent unwanted text nodes with whitespace. -->
96
+ <!-- We need to use <br> so the element is reachable by Arrow Up/Down navigation. -->
97
+ <!-- But as soon as the element is focused, we get rid of the <br> as it causes issues with caret positioning. -->
98
+ <!-- Before, we were just hiding it on focus, but that caused the cursor to disappear on Safari Desktop (tested with v26). -->
99
+ <!-- Edge Case: Shift Enter stops working if <br> is not present on a non-empty text property. -->
100
+ <svelte:element
101
+ this={tag}
102
+ data-type="text"
103
+ data-path={path.join('.')}
104
+ style="anchor-name: --{path.join('-')};"
105
+ class="text svedit-selectable {css_class}"
106
+ class:empty={is_empty}
107
+ class:focused={is_focused}
108
+ {placeholder}
109
+ >
110
+ {#each fragments as fragment, index (index)}
111
+ {#if typeof fragment === 'string'}{fragment}{:else if fragment.type === 'selection_highlight'}<span
112
+ class="selection-highlight"
113
+ style="anchor-name: --selection-highlight;">{fragment.content}</span
114
+ >{:else if fragment.type === 'annotation'}
115
+ {@const AnnotationComponent =
116
+ svedit.session.config.node_components[snake_to_pascal(fragment.node.type)]}
117
+ <AnnotationComponent
118
+ path={[...path, 'annotations', fragment.annotation_index, 'node_id']}
119
+ content={fragment.content}
120
+ />
121
+ {/if}
122
+ {/each}<!--
123
+ -->{#if !is_focused || !is_empty}<br />{/if}
124
+ </svelte:element>
88
125
 
89
126
  <style>
90
- .text {
91
- white-space: pre-wrap;
127
+ .text {
128
+ white-space: pre-wrap;
92
129
  overflow-wrap: anywhere;
93
130
  box-sizing: content-box;
94
131
 
95
132
  &.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 {
133
+ text-wrap: var(--text-wrap);
134
+ }
135
+ }
136
+ /* We switch from ::before to ::after when the element is focused. So the the caret is always before the placeholder. */
137
+ [placeholder].empty:not(.focused)::before,
138
+ [placeholder].empty.focused::after {
139
+ content: attr(placeholder);
140
+ pointer-events: none;
141
+ color: color-mix(in oklch, currentcolor 50%, transparent);
142
+ }
143
+
144
+ /* Hide flickering: in Chrome, the caret jumps from end of placeholder string to start of text property when we focus */
145
+ .text:not(.focused) {
146
+ caret-color: transparent;
147
+ }
148
+
149
+ /*.text.focused {
114
150
  background: none;
115
151
  outline: 1px solid var(--editing-stroke-color);
116
152
  }*/
153
+
154
+ /* Dim the selection highlight when canvas loses native focus */
155
+ :global(.svedit-canvas:not(:focus)) .selection-highlight {
156
+ background: oklch(from var(--editing-fill-color) l 0.02 h / 0.3);
157
+ }
158
+
159
+ /* Make a collapsed cursor visible */
160
+ :global(.svedit-canvas:not(:focus)) .selection-highlight:empty {
161
+ background: none;
162
+ outline: 0.5px solid oklch(from var(--editing-stroke-color) l 0.02 h / 0.3);
163
+ }
117
164
  </style>