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.
@@ -1,13 +1,13 @@
1
1
  <script>
2
2
  import { setContext } from 'svelte';
3
- import { snake_to_pascal } from './util.js';
3
+ import { snake_to_pascal, get_char_length, utf16_to_char_offset, char_to_utf16_offset, get_char_at, char_slice } from './util.js';
4
4
  import { break_text_node, join_text_node, insert_default_node, select_all } from './commands.svelte.js';
5
5
 
6
6
  /** @import { SveditProps, DocumentPath, Selection, TextSelection, NodeSelection, PropertySelection, NodeId } from './types.d.ts'; */
7
7
  /** @type {SveditProps} */
8
8
  let {
9
9
  doc,
10
- editable = false,
10
+ editable = $bindable(false),
11
11
  path,
12
12
  class: css_class,
13
13
  } = $props();
@@ -17,31 +17,225 @@
17
17
  let Overlays = $derived(doc.config.system_components.Overlays);
18
18
  let RootComponent = $derived(doc.config.node_components[snake_to_pascal(root_node.type)]);
19
19
 
20
+ // Track temporary disabled onkeydown events (e.g. during character composition)
21
+ let skip_onkeydown = false;
22
+
23
+ let is_composing = $state(false);
24
+
25
+ let input_selection = undefined;
26
+
27
+ // Detect Chrome on desktop (not mobile) - only available in browser
28
+ let is_chrome_desktop = $state(false);
29
+
30
+ $effect(() => {
31
+ if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
32
+ const user_agent = navigator.userAgent;
33
+ const is_chrome = user_agent.includes('Chrome') && !user_agent.includes('Edg');
34
+ const is_mobile = /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
35
+ ('ontouchstart' in window) ||
36
+ (navigator.maxTouchPoints > 0);
37
+ is_chrome_desktop = is_chrome && !is_mobile;
38
+ }
39
+ });
40
+
20
41
  /** Expose function so parent can call it */
21
42
  export { focus_canvas };
22
43
 
23
44
  setContext("svedit", {
24
45
  get doc() {
25
46
  return doc;
26
- }
47
+ },
48
+ get editable() {
49
+ return editable;
50
+ },
51
+ get is_composing() {
52
+ return is_composing
53
+ },
27
54
  });
28
55
 
56
+ function diff_text(old_text, new_text) {
57
+ const old_length = get_char_length(old_text);
58
+ const new_length = get_char_length(new_text);
59
+
60
+ let start = 0;
61
+ while (
62
+ start < old_length &&
63
+ start < new_length &&
64
+ get_char_at(old_text, start) === get_char_at(new_text, start)
65
+ ) {
66
+ start++;
67
+ }
68
+
69
+ let old_end = old_length;
70
+ let new_end = new_length;
71
+ while (
72
+ old_end > start &&
73
+ new_end > start &&
74
+ get_char_at(old_text, old_end - 1) === get_char_at(new_text, new_end - 1)
75
+ ) {
76
+ old_end--;
77
+ new_end--;
78
+ }
79
+
80
+ return {
81
+ start_offset: start,
82
+ end_offset: old_end,
83
+ replacement_text: char_slice(new_text, start, new_end)
84
+ };
85
+ }
86
+
87
+ function commit_input() {
88
+ if (input_selection?.type !== 'text') return;
89
+ const model_text = doc.get(input_selection.path)[0];
90
+ const dom_text_element = document.querySelector(`[data-path="${input_selection.path.join('.')}"]`);
91
+ const dom_text = dom_text_element.textContent;
92
+ const op = diff_text(model_text, dom_text);
93
+ // console.log('======== COMMIT INPUT ========')
94
+ // console.log('mdl_text:', model_text);
95
+ // console.log('dom_text:', dom_text);
96
+ // console.log('op', op);
97
+
98
+ // NOTE: We are currently in an out-of-sync DOM state, right after
99
+ // contenteditable applied the input. But we can use this state
100
+ // to determine the ideal intended selection after the operation.
101
+ // Because our diffing may miss some context. E.g. when you replace "mchael"
102
+ // to "Michael" it will replace "m" with "Mi" and put the cursor after the "i",
103
+ // but we actually want it after "Michael".
104
+ const before_selection = __get_text_selection_from_dom();
105
+
106
+ // Now we are applying the change to the document
107
+ const tr = doc.tr;
108
+ tr.set_selection({
109
+ type: 'text',
110
+ path: [...input_selection.path],
111
+ anchor_offset: op.start_offset,
112
+ focus_offset: op.end_offset,
113
+ });
114
+ tr.insert_text(op.replacement_text);
115
+ tr.set_selection(before_selection);
116
+ doc.apply(tr);
117
+ // console.log('applied op', op);
118
+
119
+ // After each commited replacement, we start accepting custom key handlers again.
120
+ skip_onkeydown = false;
121
+ is_composing = false;
122
+ input_selection = null;
123
+ }
124
+
125
+ function oninput(event) {
126
+ // console.log(`oninput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
127
+ if (
128
+ // canvas_ref?.contains(document.activeElement) &&
129
+ // doc.selection?.type === 'text' &&
130
+ input_selection.type === 'text' &&
131
+ !event.isComposing
132
+ ) {
133
+ commit_input();
134
+ }
135
+ }
136
+
29
137
  /**
30
138
  * @param {InputEvent} event
31
139
  */
32
140
  function onbeforeinput(event) {
33
- const inserted_char = event.data;
141
+ // console.log(`onbeforeinput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
34
142
 
35
- event.preventDefault();
36
- if (inserted_char) {
37
- const tr = doc.tr;
38
- tr.insert_text(inserted_char);
39
- doc.apply(tr);
143
+ // Only take input when in a valid text selection inside the canvas
144
+ if (
145
+ !(canvas_ref?.contains(document.activeElement)) ||
146
+ (doc.selection?.type !== 'text')
147
+ ) {
148
+ event.preventDefault();
149
+ return;
150
+ }
151
+
152
+ if (event.inputType === 'formatBold') {
153
+ doc.apply(doc.tr.annotate_text('strong'));
154
+ event.preventDefault();
155
+ event.stopPropagation();
40
156
  }
157
+
158
+ if (event.inputType === 'formatItalic') {
159
+ doc.apply(doc.tr.annotate_text('emphasis'));
160
+ event.preventDefault();
161
+ event.stopPropagation();
162
+ }
163
+
164
+
165
+ // We remember the "input selection", because in some mobile browsers (Samsung Android) the onbeforeinput event
166
+ // is fired on a different selection than the input event.
167
+ input_selection = { ...doc.selection };
168
+
169
+ // If replacement is detected, I let contenteditable do the thing and wait for
170
+ // the oninput event to do the diffing and update the model.
171
+ if (event.inputType === 'insertReplacementText') {
172
+ return;
173
+ }
174
+
175
+ // For now I reject drag+drop text movements.
176
+ // TODO: If I want to support those, I need to handle them in such a way that
177
+ // you can drag from one text property to another too.
178
+ if (event.inputType === 'deleteByDrag' || event.inputType === 'insertFromDrop') {
179
+ event.preventDefault();
180
+ return;
181
+ }
182
+
183
+ // While composing, I do nothing and let the oncompositionend
184
+ // event handle the final replacement by recovering it from the DOM.
185
+ if (event.isComposing) {
186
+ return;
187
+ }
188
+
189
+ // Insert the character, unless there is none.
190
+ let inserted_text = event.data;
191
+
192
+ // Sometimes (e.g. for replacements) the inserted_text is available via
193
+ // event.dataTransfer, not event.data
194
+ if (!inserted_text && event.dataTransfer) {
195
+ inserted_text = event.dataTransfer?.getData('text/plain');
196
+ }
197
+
198
+ // If there's no inserted_text, I'm not handling this.
199
+ if (!inserted_text) {
200
+ event.preventDefault();
201
+ return;
202
+ }
203
+
204
+ // For now I don't accept structural replacements for savety reasons.
205
+ // E.g. this will catch Apple's writing tools that will attempt to mess with our DOM.
206
+ if (inserted_text.includes('\n') || inserted_text.includes('\t')) {
207
+ event.preventDefault();
208
+ return;
209
+ }
210
+
211
+ function get_base_char(char) {
212
+ if (!char) return undefined;
213
+ const normalized = char.normalize('NFD');
214
+ // If normalization doesn't change it OR it's longer than 2 chars, it's probably not a simple diacritic
215
+ if (normalized.length <= 2 && normalized !== char) {
216
+ return normalized[0];
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ if (
222
+ (get_char_length(inserted_text) > 1) ||
223
+ (get_char_length(inserted_text) === 1 && get_base_char(inserted_text))
224
+ ) {
225
+ skip_onkeydown = true;
226
+ return; // we are dealing with a replacement
227
+ }
228
+
229
+ const tr = doc.tr;
230
+ tr.insert_text(inserted_text);
231
+ doc.apply(tr);
232
+ input_selection = undefined;
233
+ event.preventDefault();
41
234
  }
42
235
 
43
236
  // Map DOM selection to internal model
44
237
  function onselectionchange() {
238
+ if (!editable) return;
45
239
  const dom_selection = window.getSelection();
46
240
  if (!dom_selection.rangeCount) return;
47
241
 
@@ -54,6 +248,100 @@
54
248
  }
55
249
  }
56
250
 
251
+ /**
252
+ * Creates HTML clipboard format with embedded svedit data
253
+ * @param {Object} json_data - The svedit data to embed
254
+ * @param {string} fallback_html - HTML for cross-app compatibility
255
+ * @returns {string} HTML with embedded svedit data
256
+ */
257
+ function create_svedit_html_format(json_data, fallback_html) {
258
+ // Use encodeURIComponent to handle Unicode, then base64 encode
259
+ const json_string = JSON.stringify(json_data);
260
+ const encoded_data = btoa(encodeURIComponent(json_string));
261
+
262
+ return `<meta charset="utf-8">
263
+ <div>
264
+ <span data-svedit="${encoded_data}"></span>
265
+ </div>
266
+ ${fallback_html}`;
267
+ }
268
+
269
+ /**
270
+ * Extracts svedit data from HTML clipboard format
271
+ * @param {string} html - HTML content from clipboard
272
+ * @returns {Object|null} Parsed svedit data or null if not found
273
+ */
274
+ function extract_svedit_data_from_html(html) {
275
+ const svedit_regex = /data-svedit="([^"]+)"/;
276
+ const match = html.match(svedit_regex);
277
+
278
+ if (match && match[1]) {
279
+ try {
280
+ // Decode base64, then decode URI component to handle Unicode
281
+ const base64_decoded = atob(match[1]);
282
+ const decoded_data = decodeURIComponent(base64_decoded);
283
+ return JSON.parse(decoded_data);
284
+ } catch (e) {
285
+ console.warn('Failed to decode svedit data from HTML:', e);
286
+ return null;
287
+ }
288
+ }
289
+
290
+ return null;
291
+ }
292
+
293
+ /**
294
+ * Default node exporter for nodes without specific exporters
295
+ * @param {Object} node - Node object
296
+ * @returns {string} HTML representation
297
+ */
298
+ function default_node_exporter(node) {
299
+ let html = '';
300
+ let has_content = false;
301
+
302
+ for (const [prop_name, prop_value] of Object.entries(node)) {
303
+ if (prop_name === 'id' || prop_name === 'type') continue;
304
+
305
+ // Check if this is an annotated_string property (array with string as first element)
306
+ if (Array.isArray(prop_value) && typeof prop_value[0] === 'string') {
307
+ const text_content = prop_value[0];
308
+ if (text_content.trim()) {
309
+ html += `<p>${text_content}</p>\n`;
310
+ has_content = true;
311
+ }
312
+ }
313
+ }
314
+
315
+ if (!has_content) {
316
+ // Generic fallback for unknown node types with no annotated_string content
317
+ html += `<div data-node-type="${node.type}">Content from ${node.type}</div>\n`;
318
+ }
319
+
320
+ return html;
321
+ }
322
+
323
+ /**
324
+ * Exports nodes to HTML using document config exporters
325
+ * @param {Object[]} nodes - Array of node objects
326
+ * @returns {string} HTML representation
327
+ */
328
+ function export_html(nodes) {
329
+ let html = '';
330
+
331
+ for (const node of nodes) {
332
+ const html_exporters = doc.config.html_exporters || {};
333
+
334
+ if (html_exporters[node.type]) {
335
+ // Use custom exporter for this node type
336
+ html += html_exporters[node.type](node, doc);
337
+ } else {
338
+ // Use default exporter
339
+ html += default_node_exporter(node);
340
+ }
341
+ }
342
+
343
+ return html || '<p>Copied content</p>';
344
+ }
57
345
 
58
346
  /**
59
347
  * @param {NodeId[]} selected_node_ids
@@ -77,7 +365,6 @@
77
365
  return { nodes, main_nodes: selected_node_ids };
78
366
  }
79
367
 
80
-
81
368
  /**
82
369
  * @param {ClipboardEvent} event
83
370
  * @param {boolean} delete_selection - used by oncut()
@@ -89,16 +376,16 @@
89
376
  event.preventDefault();
90
377
  event.stopPropagation();
91
378
 
92
- let plain_text, html, json_data;
379
+ let plain_text, html;
93
380
 
94
381
  if (doc.selection?.type === 'text') {
95
382
  plain_text = doc.get_selected_plain_text();
96
- html = plain_text;
383
+ html = `<span style="white-space:pre-wrap;">${plain_text}</span>`;
97
384
  } else if (doc.selection?.type === 'node') {
98
385
  const selected_nodes = doc.get_selected_nodes();
99
386
  const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
100
387
 
101
- json_data = { nodes, main_nodes };
388
+ const json_data = { nodes, main_nodes };
102
389
 
103
390
  console.log('Copy operation:', {
104
391
  selected_nodes,
@@ -106,18 +393,29 @@
106
393
  total_nodes: Object.keys(nodes).length,
107
394
  operation: delete_selection ? 'cut' : 'copy'
108
395
  });
396
+
397
+ // Generate fallback HTML for cross-app compatibility
398
+ const selected_node_objects = main_nodes.map(id => nodes[id]);
399
+ const fallback_html = export_html(selected_node_objects);
400
+
401
+ // Create HTML with embedded svedit data
402
+ html = create_svedit_html_format(json_data, fallback_html);
403
+
404
+ // Create plain text representation
405
+ plain_text = selected_node_objects.map(node => {
406
+ if (node.type === 'story') {
407
+ return (node.title || 'Untitled Story') + (node.content ? '\n' + node.content : '');
408
+ }
409
+ return `Content from ${node.type}`;
410
+ }).join('\n\n');
109
411
  }
110
412
 
111
413
  // Create a ClipboardItem with multiple formats
112
414
  const data = {
113
- 'text/plain': new Blob([plain_text], {type: 'text/plain'}),
114
- 'text/html': new Blob([html], {type: 'text/html'}),
415
+ 'text/plain': new Blob([plain_text || ''], {type: 'text/plain'}),
416
+ 'text/html': new Blob([html || ''], {type: 'text/html'}),
115
417
  };
116
418
 
117
- if (json_data) {
118
- const json_blob = new Blob([JSON.stringify(json_data)], {type: 'application/json'});
119
- data['web application/json'] = json_blob;
120
- }
121
419
  const clipboard_item_raw = new ClipboardItem(data);
122
420
 
123
421
  // Write to clipboard
@@ -145,15 +443,18 @@
145
443
 
146
444
  let pasted_json;
147
445
 
148
- // Wrapping this in a try-catch as this API only works in Chrome. We fallback to
149
- // plaintext copy and pasting for all other situations.
446
+ // First try to extract svedit data from HTML format
150
447
  try {
151
- const json_blob = await clipboardItems[0].getType('web application/json');
152
- pasted_json = JSON.parse(await json_blob.text());
153
- } catch {
448
+ const html_blob = await clipboardItems[0].getType('text/html');
449
+ const html_content = await html_blob.text();
450
+ pasted_json = extract_svedit_data_from_html(html_content);
451
+ } catch (e) {
452
+ console.log('No HTML format available or failed to extract svedit data:', e);
154
453
  pasted_json = undefined;
155
454
  }
156
455
 
456
+
457
+
157
458
  if (pasted_json) {
158
459
  const tr = doc.tr;
159
460
 
@@ -202,10 +503,14 @@
202
503
 
203
504
  doc.apply(tr);
204
505
  } else {
205
- const plain_text_blob = await clipboardItems[0].getType('text/plain');
206
- // Convert the Blob to text
207
- const plain_text = await plain_text_blob.text();
208
- doc.apply(doc.tr.insert_text(plain_text));
506
+ // Fallback to plain text when no svedit data is found
507
+ try {
508
+ const plain_text_blob = await clipboardItems[0].getType('text/plain');
509
+ const plain_text = await plain_text_blob.text();
510
+ doc.apply(doc.tr.insert_text(plain_text));
511
+ } catch (e) {
512
+ console.error('Failed to paste any content:', e);
513
+ }
209
514
  }
210
515
  }
211
516
 
@@ -233,7 +538,7 @@
233
538
  } else if (selection?.type === 'property') {
234
539
  __render_property_selection();
235
540
  } else {
236
- console.log('unsupported selection', selection);
541
+ console.log('unsupported selection', $state.snapshot(selection));
237
542
  }
238
543
  }
239
544
 
@@ -254,13 +559,34 @@
254
559
  }
255
560
 
256
561
  function onkeydown(e) {
562
+ // Turn editable on
563
+ if (e.key === 'e' && (e.ctrlKey || e.metaKey)) {
564
+ editable = true;
565
+ return;
566
+ }
567
+
568
+ // Turn editable off (=save)
569
+ if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
570
+ doc.selection = null;
571
+ editable = false;
572
+ e.preventDefault();
573
+ e.stopPropagation();
574
+ return;
575
+ }
576
+
257
577
  // Only handle keyboard events if focus is within the canvas
258
578
  if (!canvas_ref?.contains(document.activeElement)) return;
259
579
 
580
+ // Key handling temporarily disabled (e.g. while character composition takes place)
581
+ if (skip_onkeydown) {
582
+ // console.log('onkeydown skipped');
583
+ // Currently we do nothing, but we could handle keydown during character composition here.
584
+ return;
585
+ }
586
+
260
587
  const selection = /** @type {any} */ (doc.selection);
261
588
  const is_collapsed = selection?.anchor_offset === selection?.focus_offset;
262
589
 
263
- // console.log('onkeydown', e.key);
264
590
  if (
265
591
  (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.selected_node) ||
266
592
  (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node)
@@ -350,18 +676,10 @@
350
676
  doc.redo();
351
677
  e.preventDefault();
352
678
  e.stopPropagation();
353
- } else if (e.key === 'Enter' && (e.altKey)) {
679
+ } else if (e.key === 'Enter' && (e.shiftKey)) {
354
680
  doc.apply(doc.tr.insert_text('\n'));
355
681
  e.preventDefault();
356
682
  e.stopPropagation();
357
- } else if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
358
- doc.apply(doc.tr.annotate_text('strong'));
359
- e.preventDefault();
360
- e.stopPropagation();
361
- } else if (e.key === 'i' && (e.ctrlKey || e.metaKey)) {
362
- doc.apply(doc.tr.annotate_text('emphasis'));
363
- e.preventDefault();
364
- e.stopPropagation();
365
683
  } else if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
366
684
  doc.apply(doc.tr.annotate_text('link', {
367
685
  href: window.prompt('Enter the URL', 'https://example.com')
@@ -378,7 +696,6 @@
378
696
  }
379
697
  } else {
380
698
  if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === 0 && doc.selection?.focus_offset === 0) {
381
- console.log('try to join with previous node');
382
699
  const tr = doc.tr;
383
700
  join_text_node(tr);
384
701
  doc.apply(tr);
@@ -391,6 +708,47 @@
391
708
  }
392
709
  }
393
710
 
711
+ e.preventDefault();
712
+ e.stopPropagation();
713
+ } else if (e.key === 'Delete') {
714
+ // Forward delete: only handle collapsed text selections
715
+ if (selection?.type === 'text' && is_collapsed) {
716
+ const text_content = doc.get(selection.path)[0];
717
+ const text_length = get_char_length(text_content);
718
+
719
+ if (selection.focus_offset < text_length) {
720
+ // There's a character after the cursor - select and delete it
721
+ const tr = doc.tr;
722
+ tr.set_selection({
723
+ type: 'text',
724
+ path: [...selection.path],
725
+ anchor_offset: selection.focus_offset,
726
+ focus_offset: selection.focus_offset + 1
727
+ });
728
+ tr.delete_selection();
729
+ doc.apply(tr);
730
+ } else {
731
+ // At end of text - try to join with next text node
732
+ const tr = doc.tr;
733
+ const node_index = parseInt(selection.path.at(-2), 10);
734
+ const successor_node = doc.get([...selection.path.slice(0, -2), node_index + 1]);
735
+
736
+ // Check if next node is a text node
737
+ if (successor_node && doc.kind(successor_node) === 'text') {
738
+ // Set selection to beginning of next text node
739
+ tr.set_selection({
740
+ type: 'text',
741
+ path: [...selection.path.slice(0, -2), node_index + 1, 'content'],
742
+ anchor_offset: 0,
743
+ focus_offset: 0
744
+ });
745
+ // Use join_text_node to merge with previous
746
+ join_text_node(tr);
747
+ doc.apply(tr);
748
+ }
749
+ }
750
+ }
751
+
394
752
  e.preventDefault();
395
753
  e.stopPropagation();
396
754
  } else if (e.key === 'Enter' && selection?.type === 'property') {
@@ -420,8 +778,11 @@
420
778
  e.stopPropagation();
421
779
  } else if (e.key === 'Enter' && selection?.type === 'text') {
422
780
  const tr = doc.tr;
423
- break_text_node(tr);
424
- doc.apply(tr);
781
+ if (break_text_node(tr)) {
782
+ doc.apply(tr);
783
+ }
784
+ e.preventDefault();
785
+ e.stopPropagation();
425
786
  } else if (e.key === 'Escape' && selection) {
426
787
  doc.select_parent();
427
788
  e.preventDefault();
@@ -429,6 +790,34 @@
429
790
  }
430
791
  }
431
792
 
793
+ /**
794
+ * Handles composition start events for input methods like dead keys
795
+ * This occurs when user starts typing a composed character (e.g., backtick for accents)
796
+ * @param {CompositionEvent} event
797
+ */
798
+ function oncompositionstart(event) {
799
+ if (doc.selection.type !== 'text') return;
800
+ console.log('DEBUG: oncompositionstart', event.data);
801
+ // Disable keydown event handling during composition. This way, you can confirm
802
+ // a diacritic (a->ä) with ENTER without causing a line break.
803
+ skip_onkeydown = true;
804
+ is_composing = true;
805
+ return;
806
+ }
807
+
808
+ /**
809
+ * Handles composition end events for input methods like dead keys
810
+ * This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
811
+ */
812
+ function oncompositionend(/*event*/) {
813
+ // console.log('DEBUG: oncompositionend, insert:', event.data);
814
+ if (!canvas_ref?.contains(document.activeElement)) return;
815
+ if (canvas_ref?.contains(document.activeElement) && doc.selection?.type === 'text') {
816
+ commit_input();
817
+ }
818
+ return;
819
+ }
820
+
432
821
  /**
433
822
  * Extracts a NodeSelection from the current DOM selection.
434
823
  *
@@ -592,8 +981,9 @@
592
981
  const focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
593
982
  let focus_root, anchor_root;
594
983
 
984
+
595
985
  if (dom_selection.focusNode === dom_selection.anchorNode && focus_node.dataset?.type === 'text') {
596
- // This is the case when the text node is empty (only a <br> is present)
986
+ // EDGE CASE 1: Either text node is empty (only a <br> is present), or cursor is after a <br> at the very end of the text node
597
987
  focus_root = anchor_root = focus_node;
598
988
  } else {
599
989
  focus_root = /** @type {HTMLElement} */ (dom_selection.focusNode.parentElement?.closest('[data-path][data-type="text"]'));
@@ -611,29 +1001,80 @@
611
1001
 
612
1002
  if (!path) return null;
613
1003
 
614
- let anchorOffset = 0;
615
- let focusOffset = 0;
616
- let currentOffset = 0;
1004
+ // EDGE CASE 1B: Cursor after trailing <br> at end of text
1005
+ //
1006
+ // When text ends with a newline (\n), the browser renders it as a <br> element.
1007
+ // If the user places their cursor after this <br>, the selection detection
1008
+ // incorrectly reports position 0 instead of the actual text length.
1009
+ //
1010
+ // Example DOM structure when text is "Hello\n":
1011
+ // <div data-type="text">
1012
+ // #text "Hello" ← index 0
1013
+ // <br> ← index 1 (represents the \n)
1014
+ // </div>
1015
+ //
1016
+ // When cursor is after the <br>, we get:
1017
+ // - focusNode: the div element
1018
+ // - focusOffset: 2 (after the 2nd child)
1019
+ // - But normal processing returns position 0 instead of text.length
1020
+ //
1021
+ // This edge case detects when:
1022
+ // 1. Text content ends with \n
1023
+ // 2. Last child node is a <br> (the visual representation of that \n)
1024
+ // 3. Cursor is positioned at or near the end
1025
+ // And returns the correct text.length position instead.
1026
+ const text_content = doc.get(path)[0];
1027
+ const text_length = get_char_length(text_content);
1028
+ if (text_length > 0) {
1029
+ const last_char = get_char_at(text_content, text_length - 1);
1030
+ if (dom_selection.focusNode === dom_selection.anchorNode &&
1031
+ dom_selection.focusNode === focus_node &&
1032
+ focus_node.dataset?.type === 'text' &&
1033
+ !focus_node.classList.contains('empty') &&
1034
+ last_char === '\n') {
1035
+
1036
+ const child_nodes = focus_node.childNodes;
1037
+
1038
+ // Check if the last child is a BR (indicating text ends with newline)
1039
+ // and cursor is positioned near the end (after any of the last few elements)
1040
+ if (child_nodes.length > 0 &&
1041
+ child_nodes[child_nodes.length - 1].nodeName === 'BR' &&
1042
+ dom_selection.focusOffset >= child_nodes.length - 2) {
1043
+ return {
1044
+ type: 'text',
1045
+ path,
1046
+ anchor_offset: text_length,
1047
+ focus_offset: text_length
1048
+ };
1049
+ }
1050
+ }
1051
+ }
1052
+
1053
+ let anchor_offset = 0;
1054
+ let focus_offset = 0;
1055
+ let current_offset = 0;
617
1056
 
618
1057
  function processNode(node) {
619
1058
  if (node.nodeType === Node.TEXT_NODE) {
620
- const nodeLength = node.length;
621
-
1059
+ const nodeText = node.textContent;
1060
+ const nodeCharLength = get_char_length(nodeText);
622
1061
  if (node === range.startContainer) {
623
- anchorOffset = currentOffset + range.startOffset;
1062
+ // Convert UTF-16 offset to character offset
1063
+ const char_start_offset = utf16_to_char_offset(nodeText, range.startOffset);
1064
+ anchor_offset = current_offset + char_start_offset;
624
1065
  }
625
1066
  if (node === range.endContainer) {
626
- focusOffset = currentOffset + range.endOffset;
1067
+ // Convert UTF-16 offset to character offset
1068
+ const char_end_offset = utf16_to_char_offset(nodeText, range.endOffset);
1069
+ focus_offset = current_offset + char_end_offset;
627
1070
  }
628
-
629
- currentOffset += nodeLength;
1071
+ current_offset += nodeCharLength;
630
1072
  } else if (node.nodeType === Node.ELEMENT_NODE) {
631
1073
  for (const childNode of node.childNodes) {
632
1074
  processNode(childNode);
633
1075
  }
634
1076
  }
635
-
636
- return (focusOffset !== 0);
1077
+ return (focus_offset !== 0);
637
1078
  }
638
1079
 
639
1080
  // Process nodes to find offsets
@@ -647,14 +1088,14 @@
647
1088
 
648
1089
  // Swap offsets if it's a backward selection
649
1090
  if (is_backward) {
650
- [anchorOffset, focusOffset] = [focusOffset, anchorOffset];
1091
+ [anchor_offset, focus_offset] = [focus_offset, anchor_offset];
651
1092
  }
652
1093
 
653
1094
  return {
654
1095
  type: 'text',
655
1096
  path,
656
- anchor_offset: anchorOffset,
657
- focus_offset: focusOffset
1097
+ anchor_offset: anchor_offset,
1098
+ focus_offset: focus_offset
658
1099
  };
659
1100
  }
660
1101
 
@@ -671,7 +1112,6 @@
671
1112
  function __render_node_selection() {
672
1113
  const selection = /** @type {NodeSelection} */ (doc.selection);
673
1114
  const node_array_path = selection.path.join('.');
674
-
675
1115
  let is_collapsed = selection.anchor_offset === selection.focus_offset;
676
1116
  let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
677
1117
 
@@ -703,7 +1143,6 @@
703
1143
  selection.anchor_offset === 0 ? ':scope > .position-zero-cursor-trap' : ':scope > .after-node-cursor-trap'
704
1144
  );
705
1145
 
706
-
707
1146
  range.setStart(cursor_trap_el, 1);
708
1147
  range.setEnd(cursor_trap_el, 1);
709
1148
  dom_selection.removeAllRanges();
@@ -786,40 +1225,44 @@
786
1225
  const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
787
1226
  const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
788
1227
 
789
-
790
1228
  // Helper function to process each node
791
1229
  function process_node(node) {
792
1230
  if (node.nodeType === Node.TEXT_NODE) {
793
- const node_length = node.length;
1231
+ const node_text = node.textContent;
1232
+ const node_char_length = get_char_length(node_text);
794
1233
 
795
1234
  if (is_backward) {
796
- if (!focus_node && current_offset + node_length >= start_offset) {
1235
+ if (!focus_node && current_offset + node_char_length >= start_offset) {
797
1236
  focus_node = node;
798
- focus_node_offset = start_offset - current_offset;
1237
+ const char_offset = start_offset - current_offset;
1238
+ focus_node_offset = char_to_utf16_offset(node_text, char_offset);
799
1239
  }
800
1240
  } else {
801
- if (!anchor_node && current_offset + node_length >= start_offset) {
1241
+ if (!anchor_node && current_offset + node_char_length >= start_offset) {
802
1242
  anchor_node = node;
803
- anchor_node_offset = start_offset - current_offset;
1243
+ const char_offset = start_offset - current_offset;
1244
+ anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
804
1245
  }
805
1246
  }
806
1247
 
807
1248
  // Find end node
808
1249
  if (is_backward) {
809
- if (!anchor_node && current_offset + node_length >= end_offset) {
1250
+ if (!anchor_node && current_offset + node_char_length >= end_offset) {
810
1251
  anchor_node = node;
811
- anchor_node_offset = end_offset - current_offset;
1252
+ const char_offset = end_offset - current_offset;
1253
+ anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
812
1254
  return true; // Stop iteration
813
1255
  }
814
1256
  } else {
815
- if (!focus_node && current_offset + node_length >= end_offset) {
1257
+ if (!focus_node && current_offset + node_char_length >= end_offset) {
816
1258
  focus_node = node;
817
- focus_node_offset = end_offset - current_offset;
1259
+ const char_offset = end_offset - current_offset;
1260
+ focus_node_offset = char_to_utf16_offset(node_text, char_offset);
818
1261
  return true; // Stop iteration
819
1262
  }
820
1263
  }
821
1264
 
822
- current_offset += node_length;
1265
+ current_offset += node_char_length;
823
1266
  } else if (node.nodeType === Node.ELEMENT_NODE) {
824
1267
  for (const child_node of node.childNodes) {
825
1268
  if (process_node(child_node)) return true; // Stop iteration if end found
@@ -935,9 +1378,24 @@
935
1378
  <div
936
1379
  class="svedit-canvas {css_class}"
937
1380
  class:hide-selection={doc.selection?.type === 'node'}
1381
+ class:node-cursor={doc.selection?.type === 'node' && doc.selection.anchor_offset === doc.selection.focus_offset}
938
1382
  bind:this={canvas_ref}
1383
+ {oninput}
939
1384
  {onbeforeinput}
1385
+ {oncompositionstart}
1386
+ {oncompositionend}
940
1387
  contenteditable={editable ? 'true' : 'false'}
1388
+ autocapitalize="off"
1389
+ spellcheck="true"
1390
+ {...{
1391
+ // NOTE: Autocomplete and autocorrect make immense troubles
1392
+ // on Desktop Chrome, so we disable them only for Chrome desktop.
1393
+ // Additionally, OSX-native auto-complete also breaks, because
1394
+ // I'm using a keyed block that always wipes the DOM of a text node
1395
+ // on every change.
1396
+ autocomplete: is_chrome_desktop ? "off" : "on",
1397
+ autocorrect: is_chrome_desktop ? "off" : "on"
1398
+ }}
941
1399
  >
942
1400
  <RootComponent {path} />
943
1401
  </div>
@@ -954,17 +1412,26 @@
954
1412
  }
955
1413
  }
956
1414
 
957
- .svedit-canvas.hide-selection {
958
- caret-color: transparent;
959
- }
960
-
961
1415
  .svedit-canvas :global(::selection) {
962
1416
  background: var(--editing-fill-color);
963
1417
  }
964
1418
 
965
1419
  @media not (pointer: coarse) {
966
- .svedit-canvas.hide-selection :global(::selection) {
967
- background: transparent;
1420
+ .svedit-canvas.hide-selection {
1421
+ caret-color: transparent;
968
1422
  }
969
1423
  }
1424
+
1425
+ /* When the cursor is in a cursor-trap we never want to see the caret */
1426
+ .svedit-canvas.node-cursor {
1427
+ caret-color: transparent;
1428
+ }
1429
+
1430
+ @media not (pointer: coarse) {
1431
+ @supports (anchor-name: --test) {
1432
+ .svedit-canvas.hide-selection :global(::selection) {
1433
+ background: transparent;
1434
+ }
1435
+ }
1436
+ }
970
1437
  </style>