svedit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +41 -21
  2. package/dist/AnnotatedTextProperty.svelte +117 -0
  3. package/dist/AnnotatedTextProperty.svelte.d.ts +7 -0
  4. package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -0
  5. package/dist/CustomProperty.svelte +20 -12
  6. package/dist/CustomProperty.svelte.d.ts +1 -2
  7. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  8. package/dist/Document.svelte.d.ts +40 -17
  9. package/dist/Document.svelte.d.ts.map +1 -1
  10. package/dist/Document.svelte.js +192 -77
  11. package/dist/Node.svelte +18 -14
  12. package/dist/Node.svelte.d.ts.map +1 -1
  13. package/dist/NodeArrayProperty.svelte +1 -1
  14. package/dist/Svedit.svelte +628 -109
  15. package/dist/Svedit.svelte.d.ts +1 -1
  16. package/dist/Svedit.svelte.d.ts.map +1 -1
  17. package/dist/Transaction.svelte.d.ts +4 -7
  18. package/dist/Transaction.svelte.d.ts.map +1 -1
  19. package/dist/Transaction.svelte.js +74 -81
  20. package/dist/commands.svelte.d.ts.map +1 -1
  21. package/dist/commands.svelte.js +20 -10
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/types.d.ts +66 -21
  25. package/dist/util.d.ts +125 -3
  26. package/dist/util.d.ts.map +1 -1
  27. package/dist/util.js +191 -34
  28. package/package.json +1 -1
  29. package/src/lib/AnnotatedTextProperty.svelte +117 -0
  30. package/src/lib/CustomProperty.svelte +20 -12
  31. package/src/lib/Document.svelte.js +192 -77
  32. package/src/lib/Node.svelte +18 -14
  33. package/src/lib/NodeArrayProperty.svelte +1 -1
  34. package/src/lib/Svedit.svelte +628 -109
  35. package/src/lib/Transaction.svelte.js +74 -81
  36. package/src/lib/commands.svelte.js +20 -10
  37. package/src/lib/index.js +1 -1
  38. package/src/lib/types.d.ts +66 -21
  39. package/src/lib/util.js +191 -34
  40. package/dist/AnnotatedStringProperty.svelte +0 -126
  41. package/dist/AnnotatedStringProperty.svelte.d.ts +0 -7
  42. package/dist/AnnotatedStringProperty.svelte.d.ts.map +0 -1
  43. package/src/lib/AnnotatedStringProperty.svelte +0 -126
@@ -1,15 +1,17 @@
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
+ autocapitalize = "on",
14
+ spellcheck = "true",
13
15
  } = $props();
14
16
 
15
17
  let canvas_ref;
@@ -17,31 +19,311 @@
17
19
  let Overlays = $derived(doc.config.system_components.Overlays);
18
20
  let RootComponent = $derived(doc.config.node_components[snake_to_pascal(root_node.type)]);
19
21
 
22
+ // Track temporary disabled onkeydown events (e.g. during character composition)
23
+ let skip_onkeydown = false;
24
+
25
+ let is_composing = $state(false);
26
+
27
+ let input_selection = undefined;
28
+
29
+ // Detect Chrome on desktop (not mobile) - only available in browser
30
+ let is_chrome_desktop = $state(false);
31
+
32
+ /**
33
+ * Detect if the current browser is on a mobile device
34
+ * @returns {boolean} true if mobile browser, false otherwise
35
+ */
36
+ function is_mobile_browser() {
37
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
38
+ return false;
39
+ }
40
+
41
+ const user_agent = navigator.userAgent;
42
+ return /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
43
+ ('ontouchstart' in window) ||
44
+ (navigator.maxTouchPoints > 0);
45
+ }
46
+
47
+ $effect(() => {
48
+ if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
49
+ const user_agent = navigator.userAgent;
50
+ const is_chrome = user_agent.includes('Chrome') && !user_agent.includes('Edg');
51
+ const is_mobile = is_mobile_browser();
52
+ is_chrome_desktop = is_chrome && !is_mobile;
53
+ }
54
+ });
55
+
20
56
  /** Expose function so parent can call it */
21
57
  export { focus_canvas };
22
58
 
23
59
  setContext("svedit", {
24
60
  get doc() {
25
61
  return doc;
26
- }
62
+ },
63
+ get editable() {
64
+ return editable;
65
+ },
66
+ get is_composing() {
67
+ return is_composing
68
+ },
27
69
  });
28
70
 
71
+ function diff_text(old_text, new_text) {
72
+ const old_length = get_char_length(old_text);
73
+ const new_length = get_char_length(new_text);
74
+
75
+ let start = 0;
76
+ while (
77
+ start < old_length &&
78
+ start < new_length &&
79
+ get_char_at(old_text, start) === get_char_at(new_text, start)
80
+ ) {
81
+ start++;
82
+ }
83
+
84
+ let old_end = old_length;
85
+ let new_end = new_length;
86
+ while (
87
+ old_end > start &&
88
+ new_end > start &&
89
+ get_char_at(old_text, old_end - 1) === get_char_at(new_text, new_end - 1)
90
+ ) {
91
+ old_end--;
92
+ new_end--;
93
+ }
94
+
95
+ return {
96
+ start_offset: start,
97
+ end_offset: old_end,
98
+ replacement_text: char_slice(new_text, start, new_end)
99
+ };
100
+ }
101
+
102
+ function commit_input() {
103
+ if (input_selection?.type !== 'text') return;
104
+ const model_text = doc.get(input_selection.path).text;
105
+ const dom_text_element = document.querySelector(`[data-path="${input_selection.path.join('.')}"]`);
106
+ const dom_text = dom_text_element.textContent;
107
+ const op = diff_text(model_text, dom_text);
108
+ // console.log('======== COMMIT INPUT ========')
109
+ // console.log('mdl_text:', model_text);
110
+ // console.log('dom_text:', dom_text);
111
+ // console.log('op', op);
112
+
113
+ // NOTE: We are currently in an out-of-sync DOM state, right after
114
+ // contenteditable applied the input. But we can use this state
115
+ // to determine the ideal intended selection after the operation.
116
+ // Because our diffing may miss some context. E.g. when you replace "mchael"
117
+ // to "Michael" it will replace "m" with "Mi" and put the cursor after the "i",
118
+ // but we actually want it after "Michael".
119
+ const before_selection = __get_text_selection_from_dom();
120
+
121
+ // Now we are applying the change to the document
122
+ const tr = doc.tr;
123
+ tr.set_selection({
124
+ type: 'text',
125
+ path: [...input_selection.path],
126
+ anchor_offset: op.start_offset,
127
+ focus_offset: op.end_offset,
128
+ });
129
+ tr.insert_text(op.replacement_text);
130
+ tr.set_selection(before_selection);
131
+ doc.apply(tr);
132
+ // console.log('applied op', op);
133
+
134
+ // After each commited replacement, we start accepting custom key handlers again.
135
+ skip_onkeydown = false;
136
+ is_composing = false;
137
+ input_selection = null;
138
+ }
139
+
140
+ function oninput(event) {
141
+ // console.log(`oninput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
142
+ if (
143
+ // canvas_ref?.contains(document.activeElement) &&
144
+ // doc.selection?.type === 'text' &&
145
+ input_selection.type === 'text' &&
146
+ !event.isComposing
147
+ ) {
148
+ commit_input();
149
+ }
150
+ }
151
+
29
152
  /**
30
153
  * @param {InputEvent} event
31
154
  */
32
155
  function onbeforeinput(event) {
33
- const inserted_char = event.data;
156
+ // console.log(`onbeforeinput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
34
157
 
35
- event.preventDefault();
36
- if (inserted_char) {
37
- const tr = doc.tr;
38
- tr.insert_text(inserted_char);
39
- doc.apply(tr);
158
+ // Only take input when in a valid text selection inside the canvas
159
+ if (
160
+ !(canvas_ref?.contains(document.activeElement))
161
+ ) {
162
+ event.preventDefault();
163
+ return;
164
+ }
165
+
166
+ if (event.inputType === 'formatBold' && doc.selection?.type === 'text') {
167
+ doc.apply(doc.tr.annotate_text('strong'));
168
+ event.preventDefault();
169
+ event.stopPropagation();
170
+ }
171
+
172
+ if (event.inputType === 'formatItalic' && doc.selection?.type === 'text') {
173
+ doc.apply(doc.tr.annotate_text('emphasis'));
174
+ event.preventDefault();
175
+ event.stopPropagation();
176
+ }
177
+
178
+ // NOTE: underline doesn't make much sense as a semantic annotation,
179
+ // so we rewire `cmd + u` to toggle highlights
180
+ if (event.inputType === 'formatUnderline' && doc.selection?.type === 'text') {
181
+ doc.apply(doc.tr.annotate_text('highlight'));
182
+ event.preventDefault();
183
+ event.stopPropagation();
184
+ }
185
+
186
+
187
+ if (event.inputType === 'deleteContentBackward') {
188
+ if (doc.selection?.type === 'property') {
189
+ // For property selections, clear the property value only if it's not already falsy
190
+ if (doc.get(doc.selection.path)) {
191
+ const tr = doc.tr;
192
+ tr.set(doc.selection.path, '');
193
+ doc.apply(tr);
194
+ }
195
+ } else {
196
+ if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === 0 && doc.selection?.focus_offset === 0) {
197
+ const tr = doc.tr;
198
+ join_text_node(tr);
199
+ doc.apply(tr);
200
+ } else {
201
+ doc.apply(doc.tr.delete_selection());
202
+ }
203
+ }
204
+
205
+ event.preventDefault();
206
+ event.stopPropagation();
207
+ }
208
+
209
+ if (event.inputType === 'deleteContentForward') {
210
+ // Forward delete: only handle collapsed text selections
211
+ if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === doc.selection?.focus_offset) {
212
+ const text_content = doc.get(doc.selection.path).text;
213
+ const text_length = get_char_length(text_content);
214
+
215
+ if (doc.selection.focus_offset < text_length) {
216
+ // There's a character after the cursor - select and delete it
217
+ const tr = doc.tr;
218
+ tr.set_selection({
219
+ type: 'text',
220
+ path: [...doc.selection.path],
221
+ anchor_offset: doc.selection.focus_offset,
222
+ focus_offset: doc.selection.focus_offset + 1
223
+ });
224
+ tr.delete_selection();
225
+ doc.apply(tr);
226
+ } else {
227
+ // At end of text - try to join with next text node
228
+ const tr = doc.tr;
229
+ const node_index = parseInt(String(doc.selection.path.at(-2)), 10);
230
+ const successor_node = doc.get([...doc.selection.path.slice(0, -2), node_index + 1]);
231
+
232
+ // Check if next node is a text node
233
+ if (successor_node && doc.kind(successor_node) === 'text') {
234
+ // Set selection to beginning of next text node
235
+ tr.set_selection({
236
+ type: 'text',
237
+ path: [...doc.selection.path.slice(0, -2), node_index + 1, 'content'],
238
+ anchor_offset: 0,
239
+ focus_offset: 0
240
+ });
241
+ // Use join_text_node to merge with previous
242
+ join_text_node(tr);
243
+ doc.apply(tr);
244
+ }
245
+ }
246
+ }
247
+
248
+ event.preventDefault();
249
+ event.stopPropagation();
250
+ }
251
+
252
+
253
+ // We remember the "input selection", because in some mobile browsers (Samsung Android) the onbeforeinput event
254
+ // is fired on a different selection than the input event.
255
+ input_selection = { ...doc.selection };
256
+
257
+ // If replacement is detected, I let contenteditable do the thing and wait for
258
+ // the oninput event to do the diffing and update the model.
259
+ if (event.inputType === 'insertReplacementText') {
260
+ return;
261
+ }
262
+
263
+ // For now I reject drag+drop text movements.
264
+ // TODO: If I want to support those, I need to handle them in such a way that
265
+ // you can drag from one text property to another too.
266
+ if (event.inputType === 'deleteByDrag' || event.inputType === 'insertFromDrop') {
267
+ event.preventDefault();
268
+ return;
269
+ }
270
+
271
+ // While composing, I do nothing and let the oncompositionend
272
+ // event handle the final replacement by recovering it from the DOM.
273
+ if (event.isComposing) {
274
+ return;
275
+ }
276
+
277
+ // Insert the character, unless there is none.
278
+ let inserted_text = event.data;
279
+
280
+ // Sometimes (e.g. for replacements) the inserted_text is available via
281
+ // event.dataTransfer, not event.data
282
+ if (!inserted_text && event.dataTransfer) {
283
+ inserted_text = event.dataTransfer?.getData('text/plain');
284
+ }
285
+
286
+ // If there's no inserted_text, I'm not handling this.
287
+ if (!inserted_text) {
288
+ event.preventDefault();
289
+ return;
290
+ }
291
+
292
+ // For now I don't accept structural replacements for savety reasons.
293
+ // E.g. this will catch Apple's writing tools that will attempt to mess with our DOM.
294
+ if (inserted_text.includes('\n') || inserted_text.includes('\t')) {
295
+ event.preventDefault();
296
+ return;
297
+ }
298
+
299
+ function get_base_char(char) {
300
+ if (!char) return undefined;
301
+ const normalized = char.normalize('NFD');
302
+ // If normalization doesn't change it OR it's longer than 2 chars, it's probably not a simple diacritic
303
+ if (normalized.length <= 2 && normalized !== char) {
304
+ return normalized[0];
305
+ }
306
+ return undefined;
307
+ }
308
+
309
+ if (
310
+ (get_char_length(inserted_text) > 1) ||
311
+ (get_char_length(inserted_text) === 1 && get_base_char(inserted_text))
312
+ ) {
313
+ skip_onkeydown = true;
314
+ return; // we are dealing with a replacement
40
315
  }
316
+
317
+ const tr = doc.tr;
318
+ tr.insert_text(inserted_text);
319
+ doc.apply(tr);
320
+ input_selection = undefined;
321
+ event.preventDefault();
41
322
  }
42
323
 
43
324
  // Map DOM selection to internal model
44
325
  function onselectionchange() {
326
+ if (!editable) return;
45
327
  const dom_selection = window.getSelection();
46
328
  if (!dom_selection.rangeCount) return;
47
329
 
@@ -54,6 +336,100 @@
54
336
  }
55
337
  }
56
338
 
339
+ /**
340
+ * Creates HTML clipboard format with embedded svedit data
341
+ * @param {Object} json_data - The svedit data to embed
342
+ * @param {string} fallback_html - HTML for cross-app compatibility
343
+ * @returns {string} HTML with embedded svedit data
344
+ */
345
+ function create_svedit_html_format(json_data, fallback_html) {
346
+ // Use encodeURIComponent to handle Unicode, then base64 encode
347
+ const json_string = JSON.stringify(json_data);
348
+ const encoded_data = btoa(encodeURIComponent(json_string));
349
+
350
+ return `<meta charset="utf-8">
351
+ <div>
352
+ <span data-svedit="${encoded_data}"></span>
353
+ </div>
354
+ ${fallback_html}`;
355
+ }
356
+
357
+ /**
358
+ * Extracts svedit data from HTML clipboard format
359
+ * @param {string} html - HTML content from clipboard
360
+ * @returns {Object|null} Parsed svedit data or null if not found
361
+ */
362
+ function extract_svedit_data_from_html(html) {
363
+ const svedit_regex = /data-svedit="([^"]+)"/;
364
+ const match = html.match(svedit_regex);
365
+
366
+ if (match && match[1]) {
367
+ try {
368
+ // Decode base64, then decode URI component to handle Unicode
369
+ const base64_decoded = atob(match[1]);
370
+ const decoded_data = decodeURIComponent(base64_decoded);
371
+ return JSON.parse(decoded_data);
372
+ } catch (e) {
373
+ console.warn('Failed to decode svedit data from HTML:', e);
374
+ return null;
375
+ }
376
+ }
377
+
378
+ return null;
379
+ }
380
+
381
+ /**
382
+ * Default node exporter for nodes without specific exporters
383
+ * @param {Object} node - Node object
384
+ * @returns {string} HTML representation
385
+ */
386
+ function default_node_exporter(node) {
387
+ let html = '';
388
+ let has_content = false;
389
+
390
+ for (const [prop_name, prop_value] of Object.entries(node)) {
391
+ if (prop_name === 'id' || prop_name === 'type') continue;
392
+
393
+ // Check if this is an annotated_text property (object with text property)
394
+ if (typeof prop_value === 'object' && prop_value !== null && typeof prop_value.text === 'string') {
395
+ const text_content = prop_value.text;
396
+ if (text_content.trim()) {
397
+ html += `<p>${text_content}</p>\n`;
398
+ has_content = true;
399
+ }
400
+ }
401
+ }
402
+
403
+ if (!has_content) {
404
+ // Generic fallback for unknown node types with no annotated_text content
405
+ html += `<div data-node-type="${node.type}">Content from ${node.type}</div>\n`;
406
+ }
407
+
408
+ return html;
409
+ }
410
+
411
+ /**
412
+ * Exports nodes to HTML using document config exporters
413
+ * @param {Object[]} nodes - Array of node objects
414
+ * @returns {string} HTML representation
415
+ */
416
+ function export_html(nodes) {
417
+ let html = '';
418
+
419
+ for (const node of nodes) {
420
+ const html_exporters = doc.config.html_exporters || {};
421
+
422
+ if (html_exporters[node.type]) {
423
+ // Use custom exporter for this node type
424
+ html += html_exporters[node.type](node, doc);
425
+ } else {
426
+ // Use default exporter
427
+ html += default_node_exporter(node);
428
+ }
429
+ }
430
+
431
+ return html || '<p>Copied content</p>';
432
+ }
57
433
 
58
434
  /**
59
435
  * @param {NodeId[]} selected_node_ids
@@ -77,7 +453,6 @@
77
453
  return { nodes, main_nodes: selected_node_ids };
78
454
  }
79
455
 
80
-
81
456
  /**
82
457
  * @param {ClipboardEvent} event
83
458
  * @param {boolean} delete_selection - used by oncut()
@@ -89,35 +464,46 @@
89
464
  event.preventDefault();
90
465
  event.stopPropagation();
91
466
 
92
- let plain_text, html, json_data;
467
+ let plain_text, html;
93
468
 
94
469
  if (doc.selection?.type === 'text') {
95
470
  plain_text = doc.get_selected_plain_text();
96
- html = plain_text;
471
+ html = `<span style="white-space:pre-wrap;">${plain_text}</span>`;
97
472
  } else if (doc.selection?.type === 'node') {
98
473
  const selected_nodes = doc.get_selected_nodes();
99
474
  const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
100
475
 
101
- json_data = { nodes, main_nodes };
476
+ const json_data = { nodes, main_nodes };
102
477
 
103
478
  console.log('Copy operation:', {
104
479
  selected_nodes,
105
- main_nodes,
480
+ nodes,
106
481
  total_nodes: Object.keys(nodes).length,
107
482
  operation: delete_selection ? 'cut' : 'copy'
108
483
  });
484
+
485
+ // Generate fallback HTML for cross-app compatibility
486
+ const selected_node_objects = main_nodes.map(id => nodes[id]);
487
+ const fallback_html = export_html(selected_node_objects);
488
+
489
+ // Create HTML with embedded svedit data
490
+ html = create_svedit_html_format(json_data, fallback_html);
491
+
492
+ // Create plain text representation
493
+ plain_text = selected_node_objects.map(node => {
494
+ if (node.type === 'story') {
495
+ return (node.title || 'Untitled Story') + (node.content ? '\n' + node.content : '');
496
+ }
497
+ return `Content from ${node.type}`;
498
+ }).join('\n\n');
109
499
  }
110
500
 
111
501
  // Create a ClipboardItem with multiple formats
112
502
  const data = {
113
- 'text/plain': new Blob([plain_text], {type: 'text/plain'}),
114
- 'text/html': new Blob([html], {type: 'text/html'}),
503
+ 'text/plain': new Blob([plain_text || ''], {type: 'text/plain'}),
504
+ 'text/html': new Blob([html || ''], {type: 'text/html'}),
115
505
  };
116
506
 
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
507
  const clipboard_item_raw = new ClipboardItem(data);
122
508
 
123
509
  // Write to clipboard
@@ -145,12 +531,13 @@
145
531
 
146
532
  let pasted_json;
147
533
 
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.
534
+ // First try to extract svedit data from HTML format
150
535
  try {
151
- const json_blob = await clipboardItems[0].getType('web application/json');
152
- pasted_json = JSON.parse(await json_blob.text());
153
- } catch {
536
+ const html_blob = await clipboardItems[0].getType('text/html');
537
+ const html_content = await html_blob.text();
538
+ pasted_json = extract_svedit_data_from_html(html_content);
539
+ } catch (e) {
540
+ console.log('No HTML format available or failed to extract svedit data:', e);
154
541
  pasted_json = undefined;
155
542
  }
156
543
 
@@ -177,12 +564,18 @@
177
564
  for (const [property, value] of Object.entries(new_node)) {
178
565
  if (property === 'id' || property === 'type') continue;
179
566
 
180
- const prop_type = doc.schema[node.type]?.[property]?.type;
567
+ const prop_type = doc.schema[node.type]?.properties?.[property]?.type;
181
568
 
182
569
  if (prop_type === 'node_array' && Array.isArray(value)) {
183
570
  new_node[property] = value.map(ref_id => id_mapping[ref_id] || ref_id);
184
571
  } else if (prop_type === 'node' && typeof value === 'string') {
185
572
  new_node[property] = id_mapping[value] || value;
573
+ } else if (prop_type === 'annotated_text') {
574
+ const annotations = value.annotations.map(annotation => {
575
+ const {start_offset, end_offset, node_id} = annotation;
576
+ return {start_offset, end_offset, node_id: id_mapping[node_id] || node_id};
577
+ });
578
+ new_node[property] = {text: value.text, annotations};
186
579
  }
187
580
  }
188
581
 
@@ -202,10 +595,14 @@
202
595
 
203
596
  doc.apply(tr);
204
597
  } 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));
598
+ // Fallback to plain text when no svedit data is found
599
+ try {
600
+ const plain_text_blob = await clipboardItems[0].getType('text/plain');
601
+ const plain_text = await plain_text_blob.text();
602
+ doc.apply(doc.tr.insert_text(plain_text));
603
+ } catch (e) {
604
+ console.error('Failed to paste any content:', e);
605
+ }
209
606
  }
210
607
  }
211
608
 
@@ -233,7 +630,7 @@
233
630
  } else if (selection?.type === 'property') {
234
631
  __render_property_selection();
235
632
  } else {
236
- console.log('unsupported selection', selection);
633
+ console.log('unsupported selection', $state.snapshot(selection));
237
634
  }
238
635
  }
239
636
 
@@ -254,45 +651,68 @@
254
651
  }
255
652
 
256
653
  function onkeydown(e) {
654
+ // Turn editable on
655
+ if (e.key === 'e' && (e.ctrlKey || e.metaKey)) {
656
+ editable = true;
657
+ return;
658
+ }
659
+
660
+ // Turn editable off (=save)
661
+ if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
662
+ doc.selection = null;
663
+ editable = false;
664
+ e.preventDefault();
665
+ e.stopPropagation();
666
+ return;
667
+ }
668
+
257
669
  // Only handle keyboard events if focus is within the canvas
258
670
  if (!canvas_ref?.contains(document.activeElement)) return;
259
671
 
672
+ // Key handling temporarily disabled (e.g. while character composition takes place)
673
+ if (skip_onkeydown) {
674
+ // console.log('onkeydown skipped');
675
+ // Currently we do nothing, but we could handle keydown during character composition here.
676
+ return;
677
+ }
678
+
260
679
  const selection = /** @type {any} */ (doc.selection);
261
680
  const is_collapsed = selection?.anchor_offset === selection?.focus_offset;
262
681
 
263
- // console.log('onkeydown', e.key);
264
682
  if (
265
- (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.selected_node) ||
266
- (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node)
683
+ (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.layout_node) ||
684
+ (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
267
685
  ) {
268
- const node = doc.selected_node;
686
+ const node = doc.layout_node;
269
687
  const layout_count = doc.config.node_layouts[node.type];
270
688
 
271
689
  if (layout_count > 1 && node?.layout) {
272
690
  const next_layout = (node.layout % layout_count) + 1;
273
691
  console.log('layout / count / next_layout', node.layout, layout_count, next_layout);
274
692
  const tr = doc.tr;
275
- tr.set([doc.selected_node?.id, 'layout'], next_layout);
693
+ tr.set([doc.layout_node?.id, 'layout'], next_layout);
276
694
  doc.apply(tr);
277
695
  }
696
+ e.preventDefault();
278
697
  } else if (
279
- (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.selected_node) ||
280
- (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node)
698
+ (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.layout_node) ||
699
+ (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
281
700
  ) {
282
- const node = doc.selected_node;
701
+ const node = doc.layout_node;
283
702
  const layout_count = doc.config.node_layouts[node.type];
284
703
  if (layout_count > 1 && node?.layout) {
285
704
  const prev_layout = ((node.layout - 2 + layout_count) % layout_count) + 1;
286
705
  const tr = doc.tr;
287
- tr.set([doc.selected_node?.id, 'layout'], prev_layout);
706
+ tr.set([doc.layout_node?.id, 'layout'], prev_layout);
288
707
  doc.apply(tr);
289
708
  console.log('layout / count / prev_layout', node.layout, layout_count, prev_layout);
290
709
  }
710
+ e.preventDefault();
291
711
  } else if (
292
- (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.selected_node) ||
293
- (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node )
712
+ (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.layout_node) ||
713
+ (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node )
294
714
  ) {
295
- const node = doc.selected_node;
715
+ const node = doc.layout_node;
296
716
 
297
717
  if (doc.selection.type !== 'node') {
298
718
  doc.select_parent();
@@ -310,11 +730,12 @@
310
730
  doc.config.inserters[next_type](tr);
311
731
  tr.set_selection(old_selection);
312
732
  doc.apply(tr);
733
+ e.preventDefault();
313
734
  } else if (
314
- (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.selected_node) ||
315
- (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node)
735
+ (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.layout_node) ||
736
+ (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
316
737
  ) {
317
- const node = doc.selected_node;
738
+ const node = doc.layout_node;
318
739
 
319
740
  if (doc.selection.type !== 'node') {
320
741
  doc.select_parent();
@@ -334,6 +755,7 @@
334
755
  doc.config.inserters[prev_type](tr);
335
756
  tr.set_selection(old_selection);
336
757
  doc.apply(tr);
758
+ e.preventDefault();
337
759
 
338
760
  } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
339
761
  const tr = doc.tr;
@@ -350,44 +772,32 @@
350
772
  doc.redo();
351
773
  e.preventDefault();
352
774
  e.stopPropagation();
353
- } else if (e.key === 'Enter' && (e.altKey)) {
775
+ } else if (e.key === 'Enter' && (e.shiftKey) && !is_mobile_browser() && selection?.type === 'text' && doc.inspect(selection.path).allow_newlines) {
354
776
  doc.apply(doc.tr.insert_text('\n'));
355
777
  e.preventDefault();
356
778
  e.stopPropagation();
357
- } else if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
779
+ } else if (e.key === 'b' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
358
780
  doc.apply(doc.tr.annotate_text('strong'));
359
781
  e.preventDefault();
360
782
  e.stopPropagation();
361
- } else if (e.key === 'i' && (e.ctrlKey || e.metaKey)) {
783
+ } else if (e.key === 'i' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
362
784
  doc.apply(doc.tr.annotate_text('emphasis'));
363
785
  e.preventDefault();
364
786
  e.stopPropagation();
365
- } else if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
366
- doc.apply(doc.tr.annotate_text('link', {
367
- href: window.prompt('Enter the URL', 'https://example.com')
368
- }));
787
+ } else if (e.key === 'u' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
788
+ doc.apply(doc.tr.annotate_text('highlight'));
369
789
  e.preventDefault();
370
790
  e.stopPropagation();
371
- } else if (e.key === 'Backspace') {
372
- if (selection?.type === 'property') {
373
- // For property selections, clear the property value only if it's not already falsy
374
- if (doc.get(selection.path)) {
375
- const tr = doc.tr;
376
- tr.set(selection.path, '');
377
- doc.apply(tr);
378
- }
791
+ } else if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
792
+ const has_link = doc.active_annotation('link');
793
+ if (has_link) {
794
+ // Delete link
795
+ doc.apply(doc.tr.annotate_text('link'));
379
796
  } else {
380
- 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
- const tr = doc.tr;
383
- join_text_node(tr);
384
- doc.apply(tr);
385
- } else {
386
- // TODO: Things are not yet properly composable, I think we need to
387
- // realize delete_selection as a command as well.
388
- // See https://prosemirror.net/docs/guide/#commands for inspiration.
389
- // For other selections, use the normal delete behavior
390
- doc.apply(doc.tr.delete_selection());
797
+ // Create link
798
+ const href = window.prompt('Enter the URL', 'https://example.com');
799
+ if (href) {
800
+ doc.apply(doc.tr.annotate_text('link', { href }));
391
801
  }
392
802
  }
393
803
 
@@ -420,8 +830,11 @@
420
830
  e.stopPropagation();
421
831
  } else if (e.key === 'Enter' && selection?.type === 'text') {
422
832
  const tr = doc.tr;
423
- break_text_node(tr);
424
- doc.apply(tr);
833
+ if (break_text_node(tr)) {
834
+ doc.apply(tr);
835
+ }
836
+ e.preventDefault();
837
+ e.stopPropagation();
425
838
  } else if (e.key === 'Escape' && selection) {
426
839
  doc.select_parent();
427
840
  e.preventDefault();
@@ -429,6 +842,34 @@
429
842
  }
430
843
  }
431
844
 
845
+ /**
846
+ * Handles composition start events for input methods like dead keys
847
+ * This occurs when user starts typing a composed character (e.g., backtick for accents)
848
+ * @param {CompositionEvent} event
849
+ */
850
+ function oncompositionstart(event) {
851
+ if (doc.selection.type !== 'text') return;
852
+ console.log('DEBUG: oncompositionstart', event.data);
853
+ // Disable keydown event handling during composition. This way, you can confirm
854
+ // a diacritic (a->ä) with ENTER without causing a line break.
855
+ skip_onkeydown = true;
856
+ is_composing = true;
857
+ return;
858
+ }
859
+
860
+ /**
861
+ * Handles composition end events for input methods like dead keys
862
+ * This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
863
+ */
864
+ function oncompositionend(/*event*/) {
865
+ // console.log('DEBUG: oncompositionend, insert:', event.data);
866
+ if (!canvas_ref?.contains(document.activeElement)) return;
867
+ if (canvas_ref?.contains(document.activeElement) && doc.selection?.type === 'text') {
868
+ commit_input();
869
+ }
870
+ return;
871
+ }
872
+
432
873
  /**
433
874
  * Extracts a NodeSelection from the current DOM selection.
434
875
  *
@@ -592,8 +1033,9 @@
592
1033
  const focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
593
1034
  let focus_root, anchor_root;
594
1035
 
1036
+
595
1037
  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)
1038
+ // 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
1039
  focus_root = anchor_root = focus_node;
598
1040
  } else {
599
1041
  focus_root = /** @type {HTMLElement} */ (dom_selection.focusNode.parentElement?.closest('[data-path][data-type="text"]'));
@@ -611,29 +1053,80 @@
611
1053
 
612
1054
  if (!path) return null;
613
1055
 
614
- let anchorOffset = 0;
615
- let focusOffset = 0;
616
- let currentOffset = 0;
1056
+ // EDGE CASE 1B: Cursor after trailing <br> at end of text
1057
+ //
1058
+ // When text ends with a newline (\n), the browser renders it as a <br> element.
1059
+ // If the user places their cursor after this <br>, the selection detection
1060
+ // incorrectly reports position 0 instead of the actual text length.
1061
+ //
1062
+ // Example DOM structure when text is "Hello\n":
1063
+ // <div data-type="text">
1064
+ // #text "Hello" ← index 0
1065
+ // <br> ← index 1 (represents the \n)
1066
+ // </div>
1067
+ //
1068
+ // When cursor is after the <br>, we get:
1069
+ // - focusNode: the div element
1070
+ // - focusOffset: 2 (after the 2nd child)
1071
+ // - But normal processing returns position 0 instead of text.length
1072
+ //
1073
+ // This edge case detects when:
1074
+ // 1. Text content ends with \n
1075
+ // 2. Last child node is a <br> (the visual representation of that \n)
1076
+ // 3. Cursor is positioned at or near the end
1077
+ // And returns the correct text.length position instead.
1078
+ const text_content = doc.get(path).text;
1079
+ const text_length = get_char_length(text_content);
1080
+ if (text_length > 0) {
1081
+ const last_char = get_char_at(text_content, text_length - 1);
1082
+ if (dom_selection.focusNode === dom_selection.anchorNode &&
1083
+ dom_selection.focusNode === focus_node &&
1084
+ focus_node.dataset?.type === 'text' &&
1085
+ !focus_node.classList.contains('empty') &&
1086
+ last_char === '\n') {
1087
+
1088
+ const child_nodes = focus_node.childNodes;
1089
+
1090
+ // Check if the last child is a BR (indicating text ends with newline)
1091
+ // and cursor is positioned near the end (after any of the last few elements)
1092
+ if (child_nodes.length > 0 &&
1093
+ child_nodes[child_nodes.length - 1].nodeName === 'BR' &&
1094
+ dom_selection.focusOffset >= child_nodes.length - 2) {
1095
+ return {
1096
+ type: 'text',
1097
+ path,
1098
+ anchor_offset: text_length,
1099
+ focus_offset: text_length
1100
+ };
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ let anchor_offset = 0;
1106
+ let focus_offset = 0;
1107
+ let current_offset = 0;
617
1108
 
618
1109
  function processNode(node) {
619
1110
  if (node.nodeType === Node.TEXT_NODE) {
620
- const nodeLength = node.length;
621
-
1111
+ const nodeText = node.textContent;
1112
+ const nodeCharLength = get_char_length(nodeText);
622
1113
  if (node === range.startContainer) {
623
- anchorOffset = currentOffset + range.startOffset;
1114
+ // Convert UTF-16 offset to character offset
1115
+ const char_start_offset = utf16_to_char_offset(nodeText, range.startOffset);
1116
+ anchor_offset = current_offset + char_start_offset;
624
1117
  }
625
1118
  if (node === range.endContainer) {
626
- focusOffset = currentOffset + range.endOffset;
1119
+ // Convert UTF-16 offset to character offset
1120
+ const char_end_offset = utf16_to_char_offset(nodeText, range.endOffset);
1121
+ focus_offset = current_offset + char_end_offset;
627
1122
  }
628
-
629
- currentOffset += nodeLength;
1123
+ current_offset += nodeCharLength;
630
1124
  } else if (node.nodeType === Node.ELEMENT_NODE) {
631
1125
  for (const childNode of node.childNodes) {
632
1126
  processNode(childNode);
633
1127
  }
634
1128
  }
635
-
636
- return (focusOffset !== 0);
1129
+ return (focus_offset !== 0);
637
1130
  }
638
1131
 
639
1132
  // Process nodes to find offsets
@@ -647,14 +1140,14 @@
647
1140
 
648
1141
  // Swap offsets if it's a backward selection
649
1142
  if (is_backward) {
650
- [anchorOffset, focusOffset] = [focusOffset, anchorOffset];
1143
+ [anchor_offset, focus_offset] = [focus_offset, anchor_offset];
651
1144
  }
652
1145
 
653
1146
  return {
654
1147
  type: 'text',
655
1148
  path,
656
- anchor_offset: anchorOffset,
657
- focus_offset: focusOffset
1149
+ anchor_offset: anchor_offset,
1150
+ focus_offset: focus_offset
658
1151
  };
659
1152
  }
660
1153
 
@@ -671,7 +1164,6 @@
671
1164
  function __render_node_selection() {
672
1165
  const selection = /** @type {NodeSelection} */ (doc.selection);
673
1166
  const node_array_path = selection.path.join('.');
674
-
675
1167
  let is_collapsed = selection.anchor_offset === selection.focus_offset;
676
1168
  let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
677
1169
 
@@ -703,7 +1195,6 @@
703
1195
  selection.anchor_offset === 0 ? ':scope > .position-zero-cursor-trap' : ':scope > .after-node-cursor-trap'
704
1196
  );
705
1197
 
706
-
707
1198
  range.setStart(cursor_trap_el, 1);
708
1199
  range.setEnd(cursor_trap_el, 1);
709
1200
  dom_selection.removeAllRanges();
@@ -769,7 +1260,7 @@
769
1260
  const selection = /** @type {any} */ (doc.selection);
770
1261
  // The element that holds the annotated string
771
1262
  const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="text"]`);
772
- const empty_text = doc.get(selection.path)[0].length === 0;
1263
+ const empty_text = doc.get(selection.path).text.length === 0;
773
1264
 
774
1265
  const range = window.document.createRange();
775
1266
  const dom_selection = window.getSelection();
@@ -786,40 +1277,44 @@
786
1277
  const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
787
1278
  const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
788
1279
 
789
-
790
1280
  // Helper function to process each node
791
1281
  function process_node(node) {
792
1282
  if (node.nodeType === Node.TEXT_NODE) {
793
- const node_length = node.length;
1283
+ const node_text = node.textContent;
1284
+ const node_char_length = get_char_length(node_text);
794
1285
 
795
1286
  if (is_backward) {
796
- if (!focus_node && current_offset + node_length >= start_offset) {
1287
+ if (!focus_node && current_offset + node_char_length >= start_offset) {
797
1288
  focus_node = node;
798
- focus_node_offset = start_offset - current_offset;
1289
+ const char_offset = start_offset - current_offset;
1290
+ focus_node_offset = char_to_utf16_offset(node_text, char_offset);
799
1291
  }
800
1292
  } else {
801
- if (!anchor_node && current_offset + node_length >= start_offset) {
1293
+ if (!anchor_node && current_offset + node_char_length >= start_offset) {
802
1294
  anchor_node = node;
803
- anchor_node_offset = start_offset - current_offset;
1295
+ const char_offset = start_offset - current_offset;
1296
+ anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
804
1297
  }
805
1298
  }
806
1299
 
807
1300
  // Find end node
808
1301
  if (is_backward) {
809
- if (!anchor_node && current_offset + node_length >= end_offset) {
1302
+ if (!anchor_node && current_offset + node_char_length >= end_offset) {
810
1303
  anchor_node = node;
811
- anchor_node_offset = end_offset - current_offset;
1304
+ const char_offset = end_offset - current_offset;
1305
+ anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
812
1306
  return true; // Stop iteration
813
1307
  }
814
1308
  } else {
815
- if (!focus_node && current_offset + node_length >= end_offset) {
1309
+ if (!focus_node && current_offset + node_char_length >= end_offset) {
816
1310
  focus_node = node;
817
- focus_node_offset = end_offset - current_offset;
1311
+ const char_offset = end_offset - current_offset;
1312
+ focus_node_offset = char_to_utf16_offset(node_text, char_offset);
818
1313
  return true; // Stop iteration
819
1314
  }
820
1315
  }
821
1316
 
822
- current_offset += node_length;
1317
+ current_offset += node_char_length;
823
1318
  } else if (node.nodeType === Node.ELEMENT_NODE) {
824
1319
  for (const child_node of node.childNodes) {
825
1320
  if (process_node(child_node)) return true; // Stop iteration if end found
@@ -935,9 +1430,24 @@
935
1430
  <div
936
1431
  class="svedit-canvas {css_class}"
937
1432
  class:hide-selection={doc.selection?.type === 'node'}
1433
+ class:node-cursor={doc.selection?.type === 'node' && doc.selection.anchor_offset === doc.selection.focus_offset}
938
1434
  bind:this={canvas_ref}
1435
+ {oninput}
939
1436
  {onbeforeinput}
1437
+ {oncompositionstart}
1438
+ {oncompositionend}
940
1439
  contenteditable={editable ? 'true' : 'false'}
1440
+ {autocapitalize}
1441
+ {spellcheck}
1442
+ {...{
1443
+ // NOTE: Autocomplete and autocorrect make immense troubles
1444
+ // on Desktop Chrome, so we disable them only for Chrome desktop.
1445
+ // Additionally, OSX-native auto-complete also breaks, because
1446
+ // I'm using a keyed block that always wipes the DOM of a text node
1447
+ // on every change.
1448
+ autocomplete: is_chrome_desktop ? "off" : "on",
1449
+ autocorrect: is_chrome_desktop ? "off" : "on"
1450
+ }}
941
1451
  >
942
1452
  <RootComponent {path} />
943
1453
  </div>
@@ -954,17 +1464,26 @@
954
1464
  }
955
1465
  }
956
1466
 
957
- .svedit-canvas.hide-selection {
958
- caret-color: transparent;
959
- }
960
-
961
1467
  .svedit-canvas :global(::selection) {
962
1468
  background: var(--editing-fill-color);
963
1469
  }
964
1470
 
965
1471
  @media not (pointer: coarse) {
966
- .svedit-canvas.hide-selection :global(::selection) {
967
- background: transparent;
1472
+ .svedit-canvas.hide-selection {
1473
+ caret-color: transparent;
968
1474
  }
969
1475
  }
1476
+
1477
+ /* When the cursor is in a cursor-trap we never want to see the caret */
1478
+ .svedit-canvas.node-cursor {
1479
+ caret-color: transparent;
1480
+ }
1481
+
1482
+ @media not (pointer: coarse) {
1483
+ @supports (anchor-name: --test) {
1484
+ .svedit-canvas.hide-selection :global(::selection) {
1485
+ background: transparent;
1486
+ }
1487
+ }
1488
+ }
970
1489
  </style>