svedit 0.1.1 → 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();
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
40
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)
@@ -354,14 +680,6 @@
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
  *
@@ -524,23 +913,23 @@
524
913
  focus_offset += 1;
525
914
  }
526
915
 
527
- // Exclude first node when anchor_node[data-type="after-node-cursor-trap"]
916
+ // EDGE CASE: Exclude first node when anchor_node is an afer-node-cursor-trap
528
917
  // in a non-collapsed forward selection
529
918
  if (
530
- anchor_node.dataset.type === 'after-node-cursor-trap' &&
919
+ anchor_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
531
920
  !is_backwards &&
532
921
  anchor_offset !== focus_offset
533
922
  ) {
534
923
  anchor_offset += 1;
535
924
  }
536
- // Exclude first node when focus_node[data-type="after-node-cursor-trap"]
925
+ // EDGE CASE: Exclude first node when focus_node is an afer-node-cursor-trap
537
926
  // in a non-collapsed backward selection
538
927
  else if (
539
- focus_node.dataset.type === 'after-node-cursor-trap' &&
928
+ focus_node.parentElement?.dataset.type === 'after-node-cursor-trap' &&
540
929
  is_backwards &&
541
930
  anchor_offset !== focus_offset &&
542
- // EDGE CASE: only apply this rule if focus node and anchor node are in the same container
543
- focus_node_depth === anchor_node_depth
931
+ // EDGE CASE: Only do correction when drag started from a deeper or equally deep anchor node
932
+ anchor_node_depth >= focus_node_depth
544
933
  ) {
545
934
  focus_offset += 1;
546
935
  }
@@ -553,7 +942,6 @@
553
942
  };
554
943
  }
555
944
 
556
-
557
945
  /**
558
946
  * Extracts a PropertySelection from the current DOM selection.
559
947
  *
@@ -593,8 +981,9 @@
593
981
  const focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
594
982
  let focus_root, anchor_root;
595
983
 
984
+
596
985
  if (dom_selection.focusNode === dom_selection.anchorNode && focus_node.dataset?.type === 'text') {
597
- // 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
598
987
  focus_root = anchor_root = focus_node;
599
988
  } else {
600
989
  focus_root = /** @type {HTMLElement} */ (dom_selection.focusNode.parentElement?.closest('[data-path][data-type="text"]'));
@@ -612,29 +1001,80 @@
612
1001
 
613
1002
  if (!path) return null;
614
1003
 
615
- let anchorOffset = 0;
616
- let focusOffset = 0;
617
- 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;
618
1056
 
619
1057
  function processNode(node) {
620
1058
  if (node.nodeType === Node.TEXT_NODE) {
621
- const nodeLength = node.length;
622
-
1059
+ const nodeText = node.textContent;
1060
+ const nodeCharLength = get_char_length(nodeText);
623
1061
  if (node === range.startContainer) {
624
- 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;
625
1065
  }
626
1066
  if (node === range.endContainer) {
627
- 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;
628
1070
  }
629
-
630
- currentOffset += nodeLength;
1071
+ current_offset += nodeCharLength;
631
1072
  } else if (node.nodeType === Node.ELEMENT_NODE) {
632
1073
  for (const childNode of node.childNodes) {
633
1074
  processNode(childNode);
634
1075
  }
635
1076
  }
636
-
637
- return (focusOffset !== 0);
1077
+ return (focus_offset !== 0);
638
1078
  }
639
1079
 
640
1080
  // Process nodes to find offsets
@@ -648,14 +1088,14 @@
648
1088
 
649
1089
  // Swap offsets if it's a backward selection
650
1090
  if (is_backward) {
651
- [anchorOffset, focusOffset] = [focusOffset, anchorOffset];
1091
+ [anchor_offset, focus_offset] = [focus_offset, anchor_offset];
652
1092
  }
653
1093
 
654
1094
  return {
655
1095
  type: 'text',
656
1096
  path,
657
- anchor_offset: anchorOffset,
658
- focus_offset: focusOffset
1097
+ anchor_offset: anchor_offset,
1098
+ focus_offset: focus_offset
659
1099
  };
660
1100
  }
661
1101
 
@@ -672,7 +1112,6 @@
672
1112
  function __render_node_selection() {
673
1113
  const selection = /** @type {NodeSelection} */ (doc.selection);
674
1114
  const node_array_path = selection.path.join('.');
675
-
676
1115
  let is_collapsed = selection.anchor_offset === selection.focus_offset;
677
1116
  let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
678
1117
 
@@ -704,7 +1143,6 @@
704
1143
  selection.anchor_offset === 0 ? ':scope > .position-zero-cursor-trap' : ':scope > .after-node-cursor-trap'
705
1144
  );
706
1145
 
707
-
708
1146
  range.setStart(cursor_trap_el, 1);
709
1147
  range.setEnd(cursor_trap_el, 1);
710
1148
  dom_selection.removeAllRanges();
@@ -754,13 +1192,13 @@
754
1192
  const selection = doc.selection;
755
1193
  // The element that holds the property
756
1194
  const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="property"]`);
757
- const cursor_trap = el.querySelector('.cursor-trap');
1195
+ const cursor_trap_selectable = el.querySelector('.svedit-selectable');
758
1196
 
759
1197
  const range = window.document.createRange();
760
1198
  const dom_selection = window.getSelection();
761
1199
 
762
1200
  // Select the entire cursor trap element contents and collapse to start
763
- range.selectNodeContents(cursor_trap);
1201
+ range.selectNodeContents(cursor_trap_selectable);
764
1202
  range.collapse(true); // Collapse to start position
765
1203
  dom_selection.removeAllRanges();
766
1204
  dom_selection.addRange(range);
@@ -787,40 +1225,44 @@
787
1225
  const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
788
1226
  const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
789
1227
 
790
-
791
1228
  // Helper function to process each node
792
1229
  function process_node(node) {
793
1230
  if (node.nodeType === Node.TEXT_NODE) {
794
- const node_length = node.length;
1231
+ const node_text = node.textContent;
1232
+ const node_char_length = get_char_length(node_text);
795
1233
 
796
1234
  if (is_backward) {
797
- if (!focus_node && current_offset + node_length >= start_offset) {
1235
+ if (!focus_node && current_offset + node_char_length >= start_offset) {
798
1236
  focus_node = node;
799
- 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);
800
1239
  }
801
1240
  } else {
802
- if (!anchor_node && current_offset + node_length >= start_offset) {
1241
+ if (!anchor_node && current_offset + node_char_length >= start_offset) {
803
1242
  anchor_node = node;
804
- 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);
805
1245
  }
806
1246
  }
807
1247
 
808
1248
  // Find end node
809
1249
  if (is_backward) {
810
- if (!anchor_node && current_offset + node_length >= end_offset) {
1250
+ if (!anchor_node && current_offset + node_char_length >= end_offset) {
811
1251
  anchor_node = node;
812
- 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);
813
1254
  return true; // Stop iteration
814
1255
  }
815
1256
  } else {
816
- if (!focus_node && current_offset + node_length >= end_offset) {
1257
+ if (!focus_node && current_offset + node_char_length >= end_offset) {
817
1258
  focus_node = node;
818
- 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);
819
1261
  return true; // Stop iteration
820
1262
  }
821
1263
  }
822
1264
 
823
- current_offset += node_length;
1265
+ current_offset += node_char_length;
824
1266
  } else if (node.nodeType === Node.ELEMENT_NODE) {
825
1267
  for (const child_node of node.childNodes) {
826
1268
  if (process_node(child_node)) return true; // Stop iteration if end found
@@ -936,9 +1378,24 @@
936
1378
  <div
937
1379
  class="svedit-canvas {css_class}"
938
1380
  class:hide-selection={doc.selection?.type === 'node'}
1381
+ class:node-cursor={doc.selection?.type === 'node' && doc.selection.anchor_offset === doc.selection.focus_offset}
939
1382
  bind:this={canvas_ref}
1383
+ {oninput}
940
1384
  {onbeforeinput}
1385
+ {oncompositionstart}
1386
+ {oncompositionend}
941
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
+ }}
942
1399
  >
943
1400
  <RootComponent {path} />
944
1401
  </div>
@@ -955,15 +1412,26 @@
955
1412
  }
956
1413
  }
957
1414
 
958
- .svedit-canvas.hide-selection {
959
- caret-color: transparent;
960
- }
961
-
962
1415
  .svedit-canvas :global(::selection) {
963
1416
  background: var(--editing-fill-color);
964
1417
  }
965
1418
 
966
- .svedit-canvas.hide-selection :global(::selection) {
967
- background: transparent;
1419
+ @media not (pointer: coarse) {
1420
+ .svedit-canvas.hide-selection {
1421
+ caret-color: transparent;
1422
+ }
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
+ }
968
1436
  }
969
1437
  </style>