svedit 0.9.0 → 0.11.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.
- package/README.md +476 -364
- package/dist/AnnotatedTextProperty.svelte +40 -10
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.d.ts +0 -1
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +48 -6
- package/dist/CustomProperty.svelte +20 -9
- package/dist/CustomProperty.svelte.d.ts +2 -1
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/Node.svelte +7 -5
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +59 -48
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeCaret.svelte +0 -7
- package/dist/NodeGap.svelte +208 -118
- package/dist/NodeGap.svelte.d.ts +0 -2
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/NodeGapMarkers.svelte +251 -84
- package/dist/NodeGapMarkers.svelte.d.ts +2 -0
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
- package/dist/NodeSelectionMarkers.svelte +4 -3
- package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.d.ts +12 -1
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +40 -3
- package/dist/Svedit.svelte +496 -221
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +24 -5
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +103 -61
- package/dist/doc_utils.d.ts +69 -3
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +276 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -1
- package/dist/node_visibility.svelte.d.ts +9 -0
- package/dist/node_visibility.svelte.d.ts.map +1 -0
- package/dist/node_visibility.svelte.js +718 -0
- package/dist/paste_utils.d.ts +46 -0
- package/dist/paste_utils.d.ts.map +1 -0
- package/dist/paste_utils.js +167 -0
- package/dist/styles/svedit-animations.css +8 -0
- package/dist/styles/svedit-colors.css +1 -0
- package/dist/types.d.ts +46 -3
- package/dist/utils.d.ts +52 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +109 -3
- package/package.json +3 -2
- package/src/lib/AnnotatedTextProperty.svelte +40 -10
- package/src/lib/Command.svelte.js +48 -6
- package/src/lib/CustomProperty.svelte +20 -9
- package/src/lib/Node.svelte +7 -5
- package/src/lib/NodeArrayProperty.svelte +59 -48
- package/src/lib/NodeCaret.svelte +0 -7
- package/src/lib/NodeGap.svelte +208 -118
- package/src/lib/NodeGapMarkers.svelte +251 -84
- package/src/lib/NodeSelectionMarkers.svelte +4 -3
- package/src/lib/Session.svelte.js +40 -3
- package/src/lib/Svedit.svelte +496 -221
- package/src/lib/Transaction.svelte.js +103 -61
- package/src/lib/doc_utils.js +276 -10
- package/src/lib/index.js +6 -1
- package/src/lib/node_visibility.svelte.js +718 -0
- package/src/lib/paste_utils.js +167 -0
- package/src/lib/styles/svedit-animations.css +8 -0
- package/src/lib/styles/svedit-colors.css +1 -0
- package/src/lib/types.d.ts +46 -3
- package/src/lib/utils.js +109 -3
- package/dist/node_gap_computation.svelte.d.ts +0 -5
- package/dist/node_gap_computation.svelte.d.ts.map +0 -1
- package/dist/node_gap_computation.svelte.js +0 -621
- package/src/lib/node_gap_computation.svelte.js +0 -621
package/src/lib/Svedit.svelte
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { flushSync, getContext, setContext } from 'svelte';
|
|
3
3
|
import {
|
|
4
|
-
snake_to_pascal,
|
|
5
4
|
get_char_length,
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
char_to_utf16_offset,
|
|
6
|
+
deserialize_path,
|
|
7
|
+
paths_equal,
|
|
8
|
+
serialize_path,
|
|
9
|
+
is_selection_collapsed
|
|
8
10
|
} from './utils.js';
|
|
9
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
normalize_line_endings,
|
|
13
|
+
dedent_plain_text,
|
|
14
|
+
split_plain_text_paragraphs,
|
|
15
|
+
normalize_plain_text_for_single_line_property,
|
|
16
|
+
get_text_property_name,
|
|
17
|
+
get_text_content,
|
|
18
|
+
is_text_like_node_payload,
|
|
19
|
+
get_default_text_node,
|
|
20
|
+
create_plain_text_nodes_payload
|
|
21
|
+
} from './paste_utils.js';
|
|
22
|
+
import { create_node_visibility } from './node_visibility.svelte.js';
|
|
10
23
|
import DefaultNodeSelectionMarkers from './NodeSelectionMarkers.svelte';
|
|
11
24
|
import './styles/svedit-colors.css';
|
|
25
|
+
import './styles/svedit-animations.css';
|
|
12
26
|
|
|
13
27
|
/** @import {
|
|
14
28
|
* SveditProps,
|
|
15
|
-
* DocumentPath,
|
|
16
29
|
* Selection,
|
|
17
30
|
* TextSelection,
|
|
18
31
|
* NodeSelection,
|
|
@@ -33,14 +46,19 @@
|
|
|
33
46
|
|
|
34
47
|
let canvas_el;
|
|
35
48
|
let root_node = $derived(session.get(path));
|
|
36
|
-
let Overlays = $derived(session.config.system_components?.
|
|
37
|
-
let NodeSelectionMarkers = $derived(
|
|
38
|
-
|
|
49
|
+
let Overlays = $derived(session.config.system_components?.overlays);
|
|
50
|
+
let NodeSelectionMarkers = $derived(
|
|
51
|
+
session.config.system_components?.node_selection_markers ?? DefaultNodeSelectionMarkers
|
|
52
|
+
);
|
|
53
|
+
let RootComponent = $derived(session.config.node_components[root_node.type]);
|
|
39
54
|
|
|
40
55
|
let is_composing = $state(false);
|
|
41
56
|
let canvas_focused = $state(false);
|
|
42
57
|
let before_composition_selection = null;
|
|
43
|
-
|
|
58
|
+
// Set by onselectionchange before it commits a DOM-derived selection
|
|
59
|
+
// to the model. render_selection consumes-and-clears it to skip
|
|
60
|
+
// rerender on DOM-driven changes (the DOM is already in place).
|
|
61
|
+
let selection_source_is_dom = false;
|
|
44
62
|
|
|
45
63
|
// let is_mobile = $derived(is_mobile_browser());
|
|
46
64
|
// let is_chrome_desktop = $derived(is_chrome_desktop_browser());
|
|
@@ -71,7 +89,51 @@
|
|
|
71
89
|
};
|
|
72
90
|
|
|
73
91
|
setContext('svedit', context);
|
|
74
|
-
|
|
92
|
+
create_node_visibility(context);
|
|
93
|
+
|
|
94
|
+
$effect(() => {
|
|
95
|
+
check_duplicate_paths();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function check_duplicate_paths() {
|
|
99
|
+
if (!canvas_el) return;
|
|
100
|
+
|
|
101
|
+
/** @type {Record<string, true>} */
|
|
102
|
+
const mounted_paths = Object.create(null);
|
|
103
|
+
|
|
104
|
+
for (const element of canvas_el.querySelectorAll('[data-path]')) {
|
|
105
|
+
if (element.closest('.svedit-canvas') !== canvas_el) continue;
|
|
106
|
+
|
|
107
|
+
const path_str = element.getAttribute('data-path');
|
|
108
|
+
if (!path_str) continue;
|
|
109
|
+
|
|
110
|
+
if (mounted_paths[path_str]) {
|
|
111
|
+
console.warn(
|
|
112
|
+
`[svedit] Path "${path_str}" is mounted more than once. Within a single Svedit document, each path may be mounted exactly once. To render shared content in multiple places (e.g. header + footer nav), use distinct node_arrays or separate Svedit instances.`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
mounted_paths[path_str] = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Test-only hook: expose the svedit context so test specs can read
|
|
121
|
+
// near_map / edge_map for diagnostics. Gated on `import.meta.env.MODE`
|
|
122
|
+
// — Vite replaces the literal so the comparison is constant-false in
|
|
123
|
+
// production and the branch is dead-code-eliminated. Wrapped in an
|
|
124
|
+
// effect so unmount clears the reference and tests don't leak state
|
|
125
|
+
// across remounts.
|
|
126
|
+
if (import.meta.env?.MODE === 'test') {
|
|
127
|
+
$effect(() => {
|
|
128
|
+
const g = /** @type {any} */ (globalThis);
|
|
129
|
+
g.__svedit_ctx_for_test = context;
|
|
130
|
+
return () => {
|
|
131
|
+
if (g.__svedit_ctx_for_test === context) {
|
|
132
|
+
delete g.__svedit_ctx_for_test;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
}
|
|
75
137
|
|
|
76
138
|
// Get KeyMapper from context (may be undefined if not provided)
|
|
77
139
|
const key_mapper = getContext('key_mapper');
|
|
@@ -145,14 +207,14 @@
|
|
|
145
207
|
if (
|
|
146
208
|
['deleteContentBackward', 'deleteWordBackward', 'deleteContent'].includes(event.inputType)
|
|
147
209
|
) {
|
|
148
|
-
|
|
210
|
+
session.apply(session.tr.delete_selection('backward'));
|
|
149
211
|
event.preventDefault();
|
|
150
212
|
event.stopPropagation();
|
|
151
213
|
return;
|
|
152
214
|
}
|
|
153
215
|
|
|
154
216
|
if (['deleteContentForward', 'deleteWordForward'].includes(event.inputType)) {
|
|
155
|
-
|
|
217
|
+
session.apply(session.tr.delete_selection('forward'));
|
|
156
218
|
event.preventDefault();
|
|
157
219
|
event.stopPropagation();
|
|
158
220
|
return;
|
|
@@ -190,10 +252,9 @@
|
|
|
190
252
|
/**
|
|
191
253
|
* Handles composition start events for input methods like dead keys
|
|
192
254
|
* This occurs when user starts typing a composed character (e.g., backtick for accents)
|
|
193
|
-
* @param {CompositionEvent} event
|
|
194
255
|
*/
|
|
195
|
-
function oncompositionstart(event) {
|
|
196
|
-
console.log('DEBUG: oncompositionstart', event.data);
|
|
256
|
+
function oncompositionstart(/*event*/) {
|
|
257
|
+
// console.log('DEBUG: oncompositionstart', event.data);
|
|
197
258
|
if (session.selection.type !== 'text') {
|
|
198
259
|
// Remove all ranges - completely clears the selection
|
|
199
260
|
window.getSelection()?.removeAllRanges();
|
|
@@ -217,7 +278,7 @@
|
|
|
217
278
|
* This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
|
|
218
279
|
*/
|
|
219
280
|
function oncompositionend(event) {
|
|
220
|
-
console.log('DEBUG: oncompositionend, insert:', event.data, event);
|
|
281
|
+
// console.log('DEBUG: oncompositionend, insert:', event.data, event);
|
|
221
282
|
if (!canvas_el?.contains(document.activeElement)) return;
|
|
222
283
|
if (session.selection?.type === 'text') {
|
|
223
284
|
// We need to remember the user's selection, as it might have changed in the process
|
|
@@ -229,12 +290,16 @@
|
|
|
229
290
|
// native undo command. Then the DOM will be in sync again with the editor's internal state.
|
|
230
291
|
document.execCommand('undo', false, null);
|
|
231
292
|
|
|
293
|
+
// Firefox may not undo native composition DOM. If the composed text is
|
|
294
|
+
// still present, remove it manually before applying the Svedit transaction.
|
|
295
|
+
__remove_native_composition_text(before_composition_selection, event.data);
|
|
296
|
+
|
|
232
297
|
// Set the selection to where the user initiated the composition, make changes, and apply.
|
|
233
298
|
// NOTE: We need to check for valid selection here, as there is a rare race condition
|
|
234
299
|
// where the user had no text selection at the start of composition.
|
|
235
300
|
if (before_composition_selection) {
|
|
236
301
|
session.selection = before_composition_selection;
|
|
237
|
-
console.log('event.data', event.data);
|
|
302
|
+
// console.log('event.data', event.data);
|
|
238
303
|
const tr = session.tr;
|
|
239
304
|
tr.insert_text(event.data);
|
|
240
305
|
session.apply(tr);
|
|
@@ -261,6 +326,7 @@
|
|
|
261
326
|
function onselectionchange() {
|
|
262
327
|
if (!editable) return;
|
|
263
328
|
if (!canvas_focused) return;
|
|
329
|
+
if (is_composing) return;
|
|
264
330
|
const dom_selection = window.getSelection();
|
|
265
331
|
if (!dom_selection.rangeCount) return;
|
|
266
332
|
|
|
@@ -273,6 +339,7 @@
|
|
|
273
339
|
// structurally identical — prevents a redundant $effect cycle
|
|
274
340
|
// (render_selection → scrollIntoView) on every DOM layout change.
|
|
275
341
|
if (JSON.stringify(selection) === JSON.stringify(session.selection)) return;
|
|
342
|
+
selection_source_is_dom = true;
|
|
276
343
|
session.selection = selection;
|
|
277
344
|
}
|
|
278
345
|
}
|
|
@@ -442,25 +509,24 @@ ${fallback_html}`;
|
|
|
442
509
|
annotated_text = session.get_selected_annotated_text();
|
|
443
510
|
const fallback_html = `<span>${annotated_text.text}</span>`;
|
|
444
511
|
|
|
445
|
-
console.log('Text copy:', {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
});
|
|
512
|
+
// console.log('Text copy:', {
|
|
513
|
+
// annotated_text,
|
|
514
|
+
// plain_text,
|
|
515
|
+
// html
|
|
516
|
+
// });
|
|
450
517
|
|
|
451
518
|
html = create_svedit_html_format(annotated_text, fallback_html);
|
|
452
519
|
} else if (session.selection?.type === 'node') {
|
|
453
520
|
const selected_nodes = session.get_selected_nodes();
|
|
454
521
|
const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
|
|
455
|
-
|
|
456
522
|
const json_data = { nodes, main_nodes };
|
|
457
523
|
|
|
458
|
-
console.log('Node copy:', {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
});
|
|
524
|
+
// console.log('Node copy:', {
|
|
525
|
+
// selected_nodes,
|
|
526
|
+
// nodes,
|
|
527
|
+
// total_nodes: Object.keys(nodes).length,
|
|
528
|
+
// operation: delete_selection ? 'cut' : 'copy'
|
|
529
|
+
// });
|
|
464
530
|
|
|
465
531
|
// Generate fallback HTML for cross-app compatibility
|
|
466
532
|
const selected_node_objects = main_nodes.map((id) => nodes[id]);
|
|
@@ -479,7 +545,6 @@ ${fallback_html}`;
|
|
|
479
545
|
type: property_definition.type,
|
|
480
546
|
value
|
|
481
547
|
};
|
|
482
|
-
console.log('Property copy:', json_data);
|
|
483
548
|
html = create_svedit_html_format(json_data, `<span>${value}</span>`);
|
|
484
549
|
plain_text = String(value);
|
|
485
550
|
}
|
|
@@ -488,36 +553,82 @@ ${fallback_html}`;
|
|
|
488
553
|
try {
|
|
489
554
|
event.clipboardData?.setData('text/plain', plain_text || '');
|
|
490
555
|
event.clipboardData?.setData('text/html', html || '');
|
|
491
|
-
console.log('Data copied to clipboard successfully');
|
|
492
556
|
} catch (err) {
|
|
493
557
|
console.error('Failed to copy data: ', err);
|
|
494
558
|
}
|
|
495
559
|
|
|
496
560
|
if (delete_selection) {
|
|
497
|
-
|
|
561
|
+
session.apply(session.tr.delete_selection());
|
|
498
562
|
}
|
|
499
563
|
}
|
|
500
564
|
|
|
565
|
+
function oncut(event) {
|
|
566
|
+
if (!editable) return;
|
|
567
|
+
oncopy(event, true);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
|
|
501
572
|
/**
|
|
502
|
-
*
|
|
503
|
-
* For property selections, delegates to the config's handle_property_deletion hook.
|
|
504
|
-
* For text/node selections, uses the standard transaction delete_selection.
|
|
505
|
-
*
|
|
506
|
-
* @param {'backward' | 'forward'} [direction]
|
|
573
|
+
* @returns {NodeSelection|null}
|
|
507
574
|
*/
|
|
508
|
-
function
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
575
|
+
function get_root_node_insert_caret() {
|
|
576
|
+
const root_node = session.get(path);
|
|
577
|
+
const root_schema = root_node ? session.schema[root_node.type] : null;
|
|
578
|
+
if (!root_schema?.properties) return null;
|
|
579
|
+
|
|
580
|
+
const preferred_property_name =
|
|
581
|
+
root_schema.properties.body?.type === 'node_array'
|
|
582
|
+
? 'body'
|
|
583
|
+
: Object.entries(root_schema.properties).find(([, property_definition]) => {
|
|
584
|
+
return property_definition.type === 'node_array';
|
|
585
|
+
})?.[0];
|
|
586
|
+
if (!preferred_property_name) return null;
|
|
587
|
+
|
|
588
|
+
const node_array_path = [...path, preferred_property_name];
|
|
589
|
+
const node_array = session.get(node_array_path);
|
|
590
|
+
if (!Array.isArray(node_array)) return null;
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
type: /** @type {const} */ ('node'),
|
|
594
|
+
path: node_array_path,
|
|
595
|
+
anchor_offset: node_array.length,
|
|
596
|
+
focus_offset: node_array.length
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* @param {Selection|null} [selection]
|
|
602
|
+
* @returns {NodeSelection|null}
|
|
603
|
+
*/
|
|
604
|
+
function get_target_node_insert_caret(selection = session.selection) {
|
|
605
|
+
if (selection?.type === 'node') {
|
|
606
|
+
return selection;
|
|
515
607
|
}
|
|
608
|
+
|
|
609
|
+
const next_node_insert_caret = session.get_next_node_insert_caret(selection);
|
|
610
|
+
if (next_node_insert_caret?.type === 'node') {
|
|
611
|
+
return next_node_insert_caret;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return get_root_node_insert_caret();
|
|
516
615
|
}
|
|
517
616
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
617
|
+
/**
|
|
618
|
+
* @param {Selection|null} [selection]
|
|
619
|
+
* @returns {NodeSelection|null}
|
|
620
|
+
*/
|
|
621
|
+
function get_node_insert_caret_after_text_selection(selection = session.selection) {
|
|
622
|
+
if (selection?.type !== 'text') return null;
|
|
623
|
+
const node_index = parseInt(String(selection.path.at(-2)), 10);
|
|
624
|
+
if (Number.isNaN(node_index)) return null;
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
type: /** @type {const} */ ('node'),
|
|
628
|
+
path: selection.path.slice(0, -2),
|
|
629
|
+
anchor_offset: node_index + 1,
|
|
630
|
+
focus_offset: node_index + 1
|
|
631
|
+
};
|
|
521
632
|
}
|
|
522
633
|
|
|
523
634
|
/**
|
|
@@ -528,40 +639,46 @@ ${fallback_html}`;
|
|
|
528
639
|
* @returns {boolean} True if the paste operation was successful, false otherwise
|
|
529
640
|
*/
|
|
530
641
|
function try_node_paste(pasted_json, selection) {
|
|
531
|
-
const { nodes, main_nodes } = pasted_json;
|
|
642
|
+
const { nodes, main_nodes } = pasted_json || {};
|
|
643
|
+
if (!nodes || !main_nodes?.length) return false;
|
|
532
644
|
|
|
533
|
-
// NOTE: At this point, nodes contains a subgraph from the copy
|
|
534
|
-
// with original ids.
|
|
535
645
|
let tr = session.tr;
|
|
536
646
|
if (selection) {
|
|
537
647
|
tr.set_selection(selection);
|
|
538
648
|
}
|
|
649
|
+
if (tr.selection?.type !== 'node') return false;
|
|
539
650
|
|
|
540
|
-
// We can safely assume we're dealing with a node_array property
|
|
541
651
|
const property_definition = session.inspect(tr.selection.path);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
);
|
|
652
|
+
if (property_definition?.type !== 'node_array') return false;
|
|
653
|
+
|
|
654
|
+
const default_text_node_type = get_default_text_node(property_definition, session.schema);
|
|
655
|
+
const target_text_property_name = get_text_property_name(default_text_node_type, session.schema);
|
|
545
656
|
|
|
546
657
|
const nodes_to_insert = [];
|
|
547
658
|
let rejected = false;
|
|
548
659
|
for (const node_id of main_nodes) {
|
|
549
660
|
const node = nodes[node_id];
|
|
661
|
+
if (!node) {
|
|
662
|
+
rejected = true;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
|
|
550
666
|
if (!property_definition.node_types.includes(node.type)) {
|
|
551
|
-
|
|
552
|
-
if (
|
|
667
|
+
const text_content = get_text_content(node, session.schema);
|
|
668
|
+
if (
|
|
669
|
+
is_text_like_node_payload(node, session.schema) &&
|
|
670
|
+
default_text_node_type &&
|
|
671
|
+
target_text_property_name
|
|
672
|
+
) {
|
|
553
673
|
const new_node_id = tr.build('the_node', {
|
|
554
674
|
the_node: {
|
|
555
675
|
id: 'the_node',
|
|
556
|
-
type:
|
|
557
|
-
|
|
676
|
+
type: default_text_node_type,
|
|
677
|
+
[target_text_property_name]: text_content || { text: '', annotations: [] }
|
|
558
678
|
}
|
|
559
679
|
});
|
|
560
680
|
nodes_to_insert.push(new_node_id);
|
|
561
681
|
} else {
|
|
562
|
-
console.log(
|
|
563
|
-
`rejected ${node.type}. Only ${property_definition.node_types.join(', ')} allowed.`
|
|
564
|
-
);
|
|
565
682
|
rejected = true;
|
|
566
683
|
break;
|
|
567
684
|
}
|
|
@@ -575,12 +692,12 @@ ${fallback_html}`;
|
|
|
575
692
|
tr.insert_nodes(nodes_to_insert);
|
|
576
693
|
session.apply(tr);
|
|
577
694
|
return true;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (tr.selection.path.length >= 2) {
|
|
698
|
+
const next_node_insert_caret = session.get_next_node_insert_caret(tr.selection);
|
|
699
|
+
if (next_node_insert_caret) {
|
|
700
|
+
return try_node_paste(pasted_json, next_node_insert_caret);
|
|
584
701
|
}
|
|
585
702
|
}
|
|
586
703
|
return false;
|
|
@@ -599,7 +716,11 @@ ${fallback_html}`;
|
|
|
599
716
|
// NOTE: For some reason, await navigator.clipboard.read()
|
|
600
717
|
const clipboard_items = event.clipboardData?.items || [];
|
|
601
718
|
for (const item of clipboard_items || []) {
|
|
602
|
-
if (
|
|
719
|
+
if (
|
|
720
|
+
item.type.startsWith('image/') ||
|
|
721
|
+
item.type.startsWith('video/') ||
|
|
722
|
+
item.type.startsWith('audio/')
|
|
723
|
+
) {
|
|
603
724
|
const blob = item.getAsFile();
|
|
604
725
|
const data_url = URL.createObjectURL(blob);
|
|
605
726
|
pasted_media.push({
|
|
@@ -612,9 +733,9 @@ ${fallback_html}`;
|
|
|
612
733
|
}
|
|
613
734
|
|
|
614
735
|
if (pasted_media.length > 0) {
|
|
615
|
-
const handle_media_paste =
|
|
736
|
+
const handle_media_paste =
|
|
737
|
+
session.config.handle_media_paste || session.config.handle_image_paste;
|
|
616
738
|
pasted_json = await handle_media_paste(session, pasted_media);
|
|
617
|
-
console.log('pasted_json_after_media_paste', pasted_json);
|
|
618
739
|
// NOTE: If no pasted_json is returned from the custom handler, we assume that content creation has been
|
|
619
740
|
// handled inside handle_media_paste already.
|
|
620
741
|
if (!pasted_json) return;
|
|
@@ -625,8 +746,8 @@ ${fallback_html}`;
|
|
|
625
746
|
if (html_content) {
|
|
626
747
|
pasted_json = extract_svedit_data_from_html(html_content);
|
|
627
748
|
}
|
|
628
|
-
} catch
|
|
629
|
-
|
|
749
|
+
} catch {
|
|
750
|
+
// No HTML format available or failed to extract svedit data
|
|
630
751
|
pasted_json = undefined;
|
|
631
752
|
}
|
|
632
753
|
|
|
@@ -636,28 +757,51 @@ ${fallback_html}`;
|
|
|
636
757
|
console.error('Failed to paste any content:', e);
|
|
637
758
|
}
|
|
638
759
|
|
|
639
|
-
// Try to
|
|
760
|
+
// Try to construct a node payload from plain text when applicable
|
|
640
761
|
if (!pasted_json && typeof plain_text === 'string') {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
762
|
+
plain_text = normalize_line_endings(plain_text);
|
|
763
|
+
plain_text = dedent_plain_text(plain_text);
|
|
764
|
+
const plain_text_fragments = split_plain_text_paragraphs(plain_text);
|
|
765
|
+
const has_multiple_paragraphs = plain_text_fragments.length > 1;
|
|
766
|
+
|
|
767
|
+
if (session.selection?.type === 'text') {
|
|
768
|
+
const property_definition = session.inspect(session.selection.path);
|
|
769
|
+
if (property_definition?.type === 'annotated_text' && !property_definition.allow_newlines) {
|
|
770
|
+
plain_text = normalize_plain_text_for_single_line_property(plain_text);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const owner_node = session.get(session.selection.path.slice(0, -1));
|
|
774
|
+
const owner_is_text_node = owner_node && session.kind(owner_node) === 'text';
|
|
775
|
+
|
|
776
|
+
if (owner_is_text_node && has_multiple_paragraphs) {
|
|
777
|
+
const node_array_property_definition = session.inspect(
|
|
778
|
+
session.selection.path.slice(0, -2)
|
|
779
|
+
);
|
|
780
|
+
const default_text_node_type = get_default_text_node(node_array_property_definition, session.schema);
|
|
781
|
+
const node_insert_caret = get_node_insert_caret_after_text_selection(session.selection);
|
|
782
|
+
const plain_text_nodes_payload = create_plain_text_nodes_payload(
|
|
783
|
+
plain_text_fragments,
|
|
784
|
+
default_text_node_type,
|
|
785
|
+
session.schema
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
if (node_insert_caret && plain_text_nodes_payload) {
|
|
789
|
+
const did_paste_nodes = try_node_paste(plain_text_nodes_payload, node_insert_caret);
|
|
790
|
+
if (did_paste_nodes) {
|
|
791
|
+
return;
|
|
658
792
|
}
|
|
659
|
-
}
|
|
660
|
-
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
} else {
|
|
796
|
+
const target_node_insert_caret = get_target_node_insert_caret(session.selection);
|
|
797
|
+
if (target_node_insert_caret) {
|
|
798
|
+
const node_array_property_definition = session.inspect(target_node_insert_caret.path);
|
|
799
|
+
const default_text_node_type = get_default_text_node(node_array_property_definition, session.schema);
|
|
800
|
+
pasted_json = create_plain_text_nodes_payload(
|
|
801
|
+
plain_text_fragments,
|
|
802
|
+
default_text_node_type,
|
|
803
|
+
session.schema
|
|
804
|
+
);
|
|
661
805
|
}
|
|
662
806
|
}
|
|
663
807
|
}
|
|
@@ -695,46 +839,57 @@ ${fallback_html}`;
|
|
|
695
839
|
} else if (
|
|
696
840
|
session.selection?.type === 'text' &&
|
|
697
841
|
pasted_json?.main_nodes?.length === 1 &&
|
|
698
|
-
|
|
842
|
+
is_text_like_node_payload(pasted_json?.nodes[pasted_json.main_nodes[0]], session.schema)
|
|
699
843
|
) {
|
|
700
844
|
// Paste a single text node, at a text caret
|
|
701
|
-
const text_property =
|
|
702
|
-
|
|
703
|
-
session.
|
|
845
|
+
const text_property = get_text_content(
|
|
846
|
+
pasted_json.nodes[pasted_json.main_nodes[0]],
|
|
847
|
+
session.schema
|
|
704
848
|
);
|
|
849
|
+
if (text_property) {
|
|
850
|
+
session.apply(
|
|
851
|
+
session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
|
|
852
|
+
);
|
|
853
|
+
}
|
|
705
854
|
} else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
|
|
706
855
|
// Paste nodes at a text or property selection by finding the next valid insert caret
|
|
707
|
-
const
|
|
708
|
-
|
|
856
|
+
const target_node_insert_caret = get_target_node_insert_caret(session.selection);
|
|
857
|
+
if (target_node_insert_caret) {
|
|
858
|
+
try_node_paste(pasted_json, target_node_insert_caret);
|
|
859
|
+
}
|
|
709
860
|
} else if (typeof plain_text === 'string') {
|
|
710
861
|
// External paste: Fallback to plain text when no svedit data is found
|
|
711
|
-
session.apply(session.tr.insert_text(plain_text
|
|
712
|
-
} else {
|
|
713
|
-
console.log('Could not paste.');
|
|
862
|
+
session.apply(session.tr.insert_text(plain_text));
|
|
714
863
|
}
|
|
715
864
|
}
|
|
716
865
|
|
|
717
|
-
function render_selection() {
|
|
866
|
+
function render_selection(dom_driven = false) {
|
|
718
867
|
const selection = /** @type {Selection} */ (session.selection);
|
|
719
868
|
|
|
720
869
|
if (!selection) {
|
|
721
870
|
// No model selection -> just leave things as they are
|
|
722
|
-
|
|
723
|
-
|
|
871
|
+
// NOTE: removeAllRanges() makes the document lose selection on
|
|
872
|
+
// refocus of the window, hence I disable it for now.
|
|
873
|
+
// let dom_selection = window.getSelection();
|
|
874
|
+
// dom_selection.removeAllRanges();
|
|
724
875
|
return;
|
|
725
876
|
}
|
|
726
877
|
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
878
|
+
// DOM-driven updates already reflect the new selection — skip
|
|
879
|
+
// the rerender when DOM matches model. Model-driven updates
|
|
880
|
+
// (insert/undo) can have DOM matching by coincidence (Svelte
|
|
881
|
+
// reuses gap elements with shifted data-gap-offset), so for those
|
|
882
|
+
// we always rerender to scroll the cursor into view.
|
|
883
|
+
const is_empty_text_selection =
|
|
884
|
+
selection.type === 'text' && session.get(selection.path).text.length === 0;
|
|
885
|
+
if (dom_driven && !is_empty_text_selection) {
|
|
886
|
+
const prev_selection = __get_selection_from_dom();
|
|
887
|
+
if (
|
|
888
|
+
JSON.stringify(selection) === JSON.stringify(prev_selection) &&
|
|
889
|
+
canvas_el?.contains(document.activeElement)
|
|
890
|
+
) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
738
893
|
}
|
|
739
894
|
|
|
740
895
|
if (selection?.type === 'text') {
|
|
@@ -744,7 +899,7 @@ ${fallback_html}`;
|
|
|
744
899
|
} else if (selection?.type === 'property') {
|
|
745
900
|
__render_property_selection();
|
|
746
901
|
} else {
|
|
747
|
-
console.
|
|
902
|
+
console.warn('unsupported selection', $state.snapshot(selection));
|
|
748
903
|
}
|
|
749
904
|
}
|
|
750
905
|
|
|
@@ -753,18 +908,12 @@ ${fallback_html}`;
|
|
|
753
908
|
// Use flushSync so highlight spans are removed from the DOM
|
|
754
909
|
// immediately, before the browser processes the click's selection.
|
|
755
910
|
flushSync(() => {
|
|
756
|
-
// Clear the model selection so render_selection() does not call
|
|
757
|
-
// setBaseAndExtent() with the old position, which would battle
|
|
758
|
-
// with the new selection the user is making (the click/drag that
|
|
759
|
-
// triggered this focus). The browser will place the caret and
|
|
760
|
-
// fire selectionchange, which sets the model selection correctly.
|
|
761
|
-
session.selection = null;
|
|
762
911
|
canvas_focused = true;
|
|
763
912
|
});
|
|
764
913
|
key_mapper?.push_scope(session.keymap);
|
|
765
914
|
}
|
|
766
915
|
|
|
767
|
-
// Handle blur - pop document's keymap from stack
|
|
916
|
+
// Handle blur - pop document's keymap from stack.
|
|
768
917
|
function handle_canvas_blur() {
|
|
769
918
|
// Use flushSync so the selection highlight span (with its CSS anchor)
|
|
770
919
|
// is in the DOM immediately, before any popover/dialog tries to
|
|
@@ -794,11 +943,13 @@ ${fallback_html}`;
|
|
|
794
943
|
function __resolve_node_from_gap(el) {
|
|
795
944
|
const gap = /** @type {HTMLElement | null} */ (el.closest('[data-gap-array-path]'));
|
|
796
945
|
if (!gap) return null;
|
|
797
|
-
const array_path = gap.dataset.gapArrayPath;
|
|
946
|
+
const array_path = deserialize_path(gap.dataset.gapArrayPath);
|
|
798
947
|
const offset = parseInt(gap.dataset.gapOffset, 10);
|
|
799
948
|
const node_idx = offset > 0 ? offset - 1 : 0;
|
|
800
949
|
return /** @type {HTMLElement | null} */ (
|
|
801
|
-
canvas_el.querySelector(
|
|
950
|
+
canvas_el.querySelector(
|
|
951
|
+
`[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`
|
|
952
|
+
)
|
|
802
953
|
);
|
|
803
954
|
}
|
|
804
955
|
|
|
@@ -821,13 +972,34 @@ ${fallback_html}`;
|
|
|
821
972
|
if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
|
|
822
973
|
if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
|
|
823
974
|
|
|
975
|
+
// EDGE CASE: Collapsed selection inside an empty node placeholder.
|
|
976
|
+
// Firefox can place the DOM selection on the placeholder node itself,
|
|
977
|
+
// which otherwise looks like selecting node index 0 in an empty array.
|
|
978
|
+
const focus_empty_placeholder = /** @type {HTMLElement | null} */ (
|
|
979
|
+
focus_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
|
|
980
|
+
);
|
|
981
|
+
const anchor_empty_placeholder = /** @type {HTMLElement | null} */ (
|
|
982
|
+
anchor_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
|
|
983
|
+
);
|
|
984
|
+
if (focus_empty_placeholder && focus_empty_placeholder === anchor_empty_placeholder) {
|
|
985
|
+
const empty_placeholder_path = deserialize_path(focus_empty_placeholder.dataset.path);
|
|
986
|
+
const array_path = empty_placeholder_path.slice(0, -1);
|
|
987
|
+
const node_array = session.get(array_path);
|
|
988
|
+
if (Array.isArray(node_array) && node_array.length === 0) {
|
|
989
|
+
return {
|
|
990
|
+
type: 'node',
|
|
991
|
+
path: array_path,
|
|
992
|
+
anchor_offset: 0,
|
|
993
|
+
focus_offset: 0
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
824
998
|
// EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
|
|
825
999
|
// Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
|
|
826
|
-
const gap_el = /** @type {HTMLElement | null} */ (
|
|
827
|
-
focus_node.closest('[data-gap-array-path]')
|
|
828
|
-
);
|
|
1000
|
+
const gap_el = /** @type {HTMLElement | null} */ (focus_node.closest('[data-gap-array-path]'));
|
|
829
1001
|
if (gap_el && focus_node === anchor_node) {
|
|
830
|
-
const array_path = gap_el.dataset.gapArrayPath
|
|
1002
|
+
const array_path = deserialize_path(gap_el.dataset.gapArrayPath);
|
|
831
1003
|
const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
|
|
832
1004
|
return {
|
|
833
1005
|
type: 'node',
|
|
@@ -837,16 +1009,18 @@ ${fallback_html}`;
|
|
|
837
1009
|
};
|
|
838
1010
|
}
|
|
839
1011
|
|
|
840
|
-
let focus_root =
|
|
841
|
-
|
|
1012
|
+
let focus_root =
|
|
1013
|
+
__resolve_node_from_gap(focus_node) ??
|
|
1014
|
+
/** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
|
|
842
1015
|
if (!focus_root) return null;
|
|
843
1016
|
|
|
844
|
-
let anchor_root =
|
|
845
|
-
|
|
1017
|
+
let anchor_root =
|
|
1018
|
+
__resolve_node_from_gap(anchor_node) ??
|
|
1019
|
+
/** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
|
|
846
1020
|
if (!anchor_root) return null;
|
|
847
1021
|
|
|
848
|
-
let focus_root_path = focus_root.dataset.path
|
|
849
|
-
let anchor_root_path = anchor_root.dataset.path
|
|
1022
|
+
let focus_root_path = deserialize_path(focus_root.dataset.path);
|
|
1023
|
+
let anchor_root_path = deserialize_path(anchor_root.dataset.path);
|
|
850
1024
|
let focus_node_depth = focus_root_path.length;
|
|
851
1025
|
let anchor_node_depth = anchor_root_path.length;
|
|
852
1026
|
|
|
@@ -856,30 +1030,28 @@ ${fallback_html}`;
|
|
|
856
1030
|
// onto its index within that array.
|
|
857
1031
|
let focus_walked_up = false;
|
|
858
1032
|
let anchor_walked_up = false;
|
|
859
|
-
while (
|
|
860
|
-
focus_root_path.slice(0, -1).join('.') !== anchor_root_path.slice(0, -1).join('.')
|
|
861
|
-
) {
|
|
1033
|
+
while (!paths_equal(focus_root_path.slice(0, -1), anchor_root_path.slice(0, -1))) {
|
|
862
1034
|
if (focus_root_path.length > anchor_root_path.length) {
|
|
863
1035
|
// Focus is deeper — walk it up
|
|
864
1036
|
focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
865
1037
|
if (!focus_root) return null;
|
|
866
|
-
focus_root_path = focus_root.dataset.path
|
|
1038
|
+
focus_root_path = deserialize_path(focus_root.dataset.path);
|
|
867
1039
|
focus_walked_up = true;
|
|
868
1040
|
} else if (anchor_root_path.length > focus_root_path.length) {
|
|
869
1041
|
// Anchor is deeper — walk it up
|
|
870
1042
|
anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
871
1043
|
if (!anchor_root) return null;
|
|
872
|
-
anchor_root_path = anchor_root.dataset.path
|
|
1044
|
+
anchor_root_path = deserialize_path(anchor_root.dataset.path);
|
|
873
1045
|
anchor_walked_up = true;
|
|
874
1046
|
} else {
|
|
875
1047
|
// Same depth but different node arrays — walk both up
|
|
876
1048
|
focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
877
1049
|
if (!focus_root) return null;
|
|
878
|
-
focus_root_path = focus_root.dataset.path
|
|
1050
|
+
focus_root_path = deserialize_path(focus_root.dataset.path);
|
|
879
1051
|
focus_walked_up = true;
|
|
880
1052
|
anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
881
1053
|
if (!anchor_root) return null;
|
|
882
|
-
anchor_root_path = anchor_root.dataset.path
|
|
1054
|
+
anchor_root_path = deserialize_path(anchor_root.dataset.path);
|
|
883
1055
|
anchor_walked_up = true;
|
|
884
1056
|
}
|
|
885
1057
|
}
|
|
@@ -893,16 +1065,13 @@ ${fallback_html}`;
|
|
|
893
1065
|
const parent_property = session.inspect(parent_array_path);
|
|
894
1066
|
if (!parent_property || parent_property.type !== 'node_array') return null;
|
|
895
1067
|
|
|
896
|
-
let anchor_offset =
|
|
897
|
-
let focus_offset =
|
|
1068
|
+
let anchor_offset = Number(anchor_root_path.at(-1));
|
|
1069
|
+
let focus_offset = Number(focus_root_path.at(-1));
|
|
898
1070
|
|
|
899
1071
|
// Check if it's a backwards selection
|
|
900
1072
|
const is_backwards = __is_dom_selection_backwards();
|
|
901
|
-
if (is_backwards)
|
|
902
|
-
|
|
903
|
-
} else {
|
|
904
|
-
focus_offset += 1;
|
|
905
|
-
}
|
|
1073
|
+
if (is_backwards) anchor_offset += 1;
|
|
1074
|
+
else focus_offset += 1;
|
|
906
1075
|
|
|
907
1076
|
// EDGE CASE: Exclude first node when anchor_node is a gap-after
|
|
908
1077
|
// in a non-collapsed forward selection.
|
|
@@ -961,7 +1130,7 @@ ${fallback_html}`;
|
|
|
961
1130
|
if (focus_root === anchor_root) {
|
|
962
1131
|
return {
|
|
963
1132
|
type: 'property',
|
|
964
|
-
path: focus_root.dataset.path
|
|
1133
|
+
path: deserialize_path(focus_root.dataset.path)
|
|
965
1134
|
};
|
|
966
1135
|
}
|
|
967
1136
|
return null;
|
|
@@ -1004,19 +1173,30 @@ ${fallback_html}`;
|
|
|
1004
1173
|
range = dom_selection.getRangeAt(0);
|
|
1005
1174
|
}
|
|
1006
1175
|
|
|
1176
|
+
function get_text_root(node) {
|
|
1177
|
+
if (!node) return null;
|
|
1178
|
+
|
|
1179
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1180
|
+
return /** @type {HTMLElement | null} */ (
|
|
1181
|
+
node.closest?.('[data-path][data-type="text"]') ?? null
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
return /** @type {HTMLElement | null} */ (
|
|
1186
|
+
node.parentElement?.closest('[data-path][data-type="text"]') ?? null
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1007
1190
|
let focus_root, anchor_root;
|
|
1008
1191
|
|
|
1009
1192
|
if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
|
|
1010
1193
|
// EDGE CASE 1: Either text node is empty (only a <br> is present), or caret is after a <br> at the very end of the text node
|
|
1011
1194
|
focus_root = anchor_root = focus_node;
|
|
1012
1195
|
} else {
|
|
1013
|
-
focus_root =
|
|
1014
|
-
focus_node.parentElement?.closest('[data-path][data-type="text"]')
|
|
1015
|
-
);
|
|
1196
|
+
focus_root = get_text_root(focus_node);
|
|
1016
1197
|
if (!focus_root) return null;
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
);
|
|
1198
|
+
|
|
1199
|
+
anchor_root = get_text_root(anchor_node);
|
|
1020
1200
|
if (!anchor_root) return null;
|
|
1021
1201
|
}
|
|
1022
1202
|
|
|
@@ -1024,7 +1204,7 @@ ${fallback_html}`;
|
|
|
1024
1204
|
return null;
|
|
1025
1205
|
}
|
|
1026
1206
|
|
|
1027
|
-
const path = focus_root.dataset.path
|
|
1207
|
+
const path = deserialize_path(focus_root.dataset.path);
|
|
1028
1208
|
|
|
1029
1209
|
if (!path) return null;
|
|
1030
1210
|
|
|
@@ -1033,12 +1213,14 @@ ${fallback_html}`;
|
|
|
1033
1213
|
// AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
|
|
1034
1214
|
// When the user places their caret after this <br>, focusNode is the container
|
|
1035
1215
|
// element (not a text node), and normal processing would return position 0.
|
|
1036
|
-
// We detect this and return
|
|
1037
|
-
|
|
1038
|
-
|
|
1216
|
+
// We detect this and return the current DOM text length instead.
|
|
1217
|
+
// During compositionend, the browser has already inserted the composed
|
|
1218
|
+
// character into the DOM, while the Svedit model still has the old text.
|
|
1219
|
+
const dom_text_length = get_char_length(focus_root.textContent ?? '');
|
|
1039
1220
|
const child_nodes = focus_root.childNodes;
|
|
1040
1221
|
|
|
1041
1222
|
if (
|
|
1223
|
+
range.collapsed &&
|
|
1042
1224
|
focus_node === anchor_node &&
|
|
1043
1225
|
focus_node === focus_root &&
|
|
1044
1226
|
focus_root.dataset?.type === 'text' &&
|
|
@@ -1046,7 +1228,10 @@ ${fallback_html}`;
|
|
|
1046
1228
|
) {
|
|
1047
1229
|
// Find the last non-comment child node (comments are inserted by Svelte)
|
|
1048
1230
|
let last_element_index = child_nodes.length - 1;
|
|
1049
|
-
while (
|
|
1231
|
+
while (
|
|
1232
|
+
last_element_index >= 0 &&
|
|
1233
|
+
child_nodes[last_element_index].nodeType === Node.COMMENT_NODE
|
|
1234
|
+
) {
|
|
1050
1235
|
last_element_index--;
|
|
1051
1236
|
}
|
|
1052
1237
|
|
|
@@ -1059,43 +1244,21 @@ ${fallback_html}`;
|
|
|
1059
1244
|
return {
|
|
1060
1245
|
type: 'text',
|
|
1061
1246
|
path,
|
|
1062
|
-
anchor_offset:
|
|
1063
|
-
focus_offset:
|
|
1247
|
+
anchor_offset: dom_text_length,
|
|
1248
|
+
focus_offset: dom_text_length
|
|
1064
1249
|
};
|
|
1065
1250
|
}
|
|
1066
1251
|
}
|
|
1067
1252
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
1074
|
-
const nodeText = node.textContent;
|
|
1075
|
-
const nodeCharLength = get_char_length(nodeText);
|
|
1076
|
-
if (node === range.startContainer) {
|
|
1077
|
-
// Convert UTF-16 offset to character offset
|
|
1078
|
-
const char_start_offset = utf16_to_char_offset(nodeText, range.startOffset);
|
|
1079
|
-
start_offset = current_offset + char_start_offset;
|
|
1080
|
-
}
|
|
1081
|
-
if (node === range.endContainer) {
|
|
1082
|
-
// Convert UTF-16 offset to character offset
|
|
1083
|
-
const char_end_offset = utf16_to_char_offset(nodeText, range.endOffset);
|
|
1084
|
-
end_offset = current_offset + char_end_offset;
|
|
1085
|
-
}
|
|
1086
|
-
current_offset += nodeCharLength;
|
|
1087
|
-
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1088
|
-
for (const childNode of node.childNodes) {
|
|
1089
|
-
processNode(childNode);
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
return end_offset !== 0;
|
|
1253
|
+
function get_text_offset(container, offset) {
|
|
1254
|
+
const offset_range = window.document.createRange();
|
|
1255
|
+
offset_range.setStart(focus_root, 0);
|
|
1256
|
+
offset_range.setEnd(container, offset);
|
|
1257
|
+
return get_char_length(offset_range.toString());
|
|
1093
1258
|
}
|
|
1094
1259
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
if (processNode(childNode)) break;
|
|
1098
|
-
}
|
|
1260
|
+
const start_offset = get_text_offset(range.startContainer, range.startOffset);
|
|
1261
|
+
const end_offset = get_text_offset(range.endContainer, range.endOffset);
|
|
1099
1262
|
|
|
1100
1263
|
// Check if it's a backward selection
|
|
1101
1264
|
// When range is provided, we can't detect backward selection from the range alone
|
|
@@ -1115,19 +1278,34 @@ ${fallback_html}`;
|
|
|
1115
1278
|
}
|
|
1116
1279
|
|
|
1117
1280
|
function __get_node_element(node_array_path, node_offset) {
|
|
1281
|
+
if (!canvas_el) return null;
|
|
1118
1282
|
return canvas_el.querySelector(
|
|
1119
|
-
`[data-path="${node_array_path
|
|
1283
|
+
`[data-path="${serialize_path([...node_array_path, node_offset])}"][data-type="node"]`
|
|
1120
1284
|
);
|
|
1121
1285
|
}
|
|
1122
1286
|
|
|
1287
|
+
/**
|
|
1288
|
+
* True when any part of the element's border box overlaps the window
|
|
1289
|
+
* viewport. Used to keep node-selection re-renders from scrolling a
|
|
1290
|
+
* cursor that is already on screen. Tests the window viewport, so a
|
|
1291
|
+
* node clipped only by an inner scroll container still counts visible.
|
|
1292
|
+
* @param {Element | null | undefined} el
|
|
1293
|
+
*/
|
|
1294
|
+
function __intersects_viewport(el) {
|
|
1295
|
+
if (!el) return false;
|
|
1296
|
+
const r = el.getBoundingClientRect();
|
|
1297
|
+
return r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1123
1300
|
function __render_node_selection() {
|
|
1124
1301
|
const selection = /** @type {NodeSelection} */ (session.selection);
|
|
1125
|
-
const node_array_path = selection.path
|
|
1126
|
-
const
|
|
1302
|
+
const node_array_path = selection.path;
|
|
1303
|
+
const node_array_path_str = serialize_path(node_array_path);
|
|
1304
|
+
const is_collapsed = is_selection_collapsed(selection);
|
|
1127
1305
|
const is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
|
|
1128
1306
|
|
|
1129
1307
|
const node_array_el = canvas_el.querySelector(
|
|
1130
|
-
`[data-path="${
|
|
1308
|
+
`[data-path="${node_array_path_str}"][data-type="node_array"]`
|
|
1131
1309
|
);
|
|
1132
1310
|
if (!node_array_el) return;
|
|
1133
1311
|
|
|
@@ -1135,7 +1313,7 @@ ${fallback_html}`;
|
|
|
1135
1313
|
const range = window.document.createRange();
|
|
1136
1314
|
|
|
1137
1315
|
const gap_selector = (offset) =>
|
|
1138
|
-
`[data-gap-array-path="${
|
|
1316
|
+
`[data-gap-array-path="${node_array_path_str}"][data-gap-offset="${offset}"]`;
|
|
1139
1317
|
|
|
1140
1318
|
if (is_collapsed) {
|
|
1141
1319
|
const gap_el = node_array_el.querySelector(gap_selector(selection.anchor_offset));
|
|
@@ -1168,24 +1346,59 @@ ${fallback_html}`;
|
|
|
1168
1346
|
}
|
|
1169
1347
|
}
|
|
1170
1348
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1349
|
+
// Scroll the cursor into view, but only when it is genuinely
|
|
1350
|
+
// off-screen. This runs on every node-selection re-render — a
|
|
1351
|
+
// transaction (type/layout change), select-parent, window refocus
|
|
1352
|
+
// — so an unconditional scroll would yank the viewport on each of
|
|
1353
|
+
// them. cursor_offset is a gap offset and the gap has no box of
|
|
1354
|
+
// its own, so visibility is judged from the nodes flanking it.
|
|
1355
|
+
setTimeout(() => {
|
|
1356
|
+
// Collapsed: anchor === focus, so focus is the gap offset.
|
|
1357
|
+
// Range: cursor sits at the focus end (anchor when backward).
|
|
1358
|
+
const cursor_offset = is_backward ? selection.anchor_offset : selection.focus_offset;
|
|
1359
|
+
const array_length = session.get(node_array_path).length;
|
|
1360
|
+
|
|
1361
|
+
// If either node flanking the cursor is already (even
|
|
1362
|
+
// partially) on screen, the cursor is visible — keep the
|
|
1363
|
+
// viewport stable and scroll nothing. node_before is null at
|
|
1364
|
+
// offset 0: cursor_offset - 1 would be -1, and serialize_path
|
|
1365
|
+
// rejects a negative index.
|
|
1366
|
+
const node_before =
|
|
1367
|
+
cursor_offset > 0 ? __get_node_element(node_array_path, cursor_offset - 1) : null;
|
|
1368
|
+
const node_after = __get_node_element(node_array_path, cursor_offset);
|
|
1369
|
+
if (__intersects_viewport(node_before) || __intersects_viewport(node_after)) return;
|
|
1370
|
+
|
|
1371
|
+
if (cursor_offset === 0) {
|
|
1372
|
+
node_array_el.scrollLeft = 0;
|
|
1373
|
+
node_array_el.scrollTop = 0;
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
if (cursor_offset >= array_length) {
|
|
1377
|
+
const max_left = Math.max(0, node_array_el.scrollWidth - node_array_el.clientWidth);
|
|
1378
|
+
const max_top = Math.max(0, node_array_el.scrollHeight - node_array_el.clientHeight);
|
|
1379
|
+
node_array_el.scrollLeft = max_left;
|
|
1380
|
+
node_array_el.scrollTop = max_top;
|
|
1381
|
+
if (max_left === 0 && max_top === 0) {
|
|
1382
|
+
node_before?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1383
|
+
}
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
// A range selection's selected node IS node_before; scrolling
|
|
1387
|
+
// node_after would reveal the following node and leave the
|
|
1388
|
+
// selection itself off-screen. For a collapsed caret node_after's
|
|
1389
|
+
// leading edge is the cursor, so that stays the right target.
|
|
1390
|
+
const target = is_collapsed ? node_after : node_before;
|
|
1391
|
+
target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1392
|
+
}, 0);
|
|
1181
1393
|
}
|
|
1182
1394
|
|
|
1183
1395
|
function __render_property_selection() {
|
|
1184
1396
|
const selection = session.selection;
|
|
1185
1397
|
// The element that holds the property
|
|
1186
1398
|
const el = canvas_el.querySelector(
|
|
1187
|
-
`[data-path="${selection.path
|
|
1399
|
+
`[data-path="${serialize_path(selection.path)}"][data-type="property"]`
|
|
1188
1400
|
);
|
|
1401
|
+
|
|
1189
1402
|
const gap_selectable = el.querySelector('.svedit-selectable');
|
|
1190
1403
|
const range = window.document.createRange();
|
|
1191
1404
|
const dom_selection = window.getSelection();
|
|
@@ -1198,10 +1411,7 @@ ${fallback_html}`;
|
|
|
1198
1411
|
|
|
1199
1412
|
// Scroll the selection into view
|
|
1200
1413
|
setTimeout(() => {
|
|
1201
|
-
|
|
1202
|
-
if (selectedElement) {
|
|
1203
|
-
selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1204
|
-
}
|
|
1414
|
+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1205
1415
|
}, 0);
|
|
1206
1416
|
}
|
|
1207
1417
|
|
|
@@ -1209,7 +1419,7 @@ ${fallback_html}`;
|
|
|
1209
1419
|
const selection = /** @type {any} */ (session.selection);
|
|
1210
1420
|
// The element that holds the annotated string
|
|
1211
1421
|
const el = canvas_el.querySelector(
|
|
1212
|
-
`[data-path="${selection.path
|
|
1422
|
+
`[data-path="${serialize_path(selection.path)}"][data-type="text"]`
|
|
1213
1423
|
);
|
|
1214
1424
|
const empty_text = session.get(selection.path).text.length === 0;
|
|
1215
1425
|
const dom_selection = window.getSelection();
|
|
@@ -1296,7 +1506,6 @@ ${fallback_html}`;
|
|
|
1296
1506
|
focus_node,
|
|
1297
1507
|
focus_node_offset
|
|
1298
1508
|
);
|
|
1299
|
-
el.focus(); // needed?
|
|
1300
1509
|
|
|
1301
1510
|
// Scroll the selection into view
|
|
1302
1511
|
setTimeout(() => {
|
|
@@ -1311,6 +1520,52 @@ ${fallback_html}`;
|
|
|
1311
1520
|
// Utils
|
|
1312
1521
|
// --------------------------
|
|
1313
1522
|
|
|
1523
|
+
function __remove_native_composition_text(selection, inserted_text) {
|
|
1524
|
+
if (!selection || selection.type !== 'text' || !inserted_text) return;
|
|
1525
|
+
|
|
1526
|
+
const text_el = canvas_el.querySelector(
|
|
1527
|
+
`[data-path="${serialize_path(selection.path)}"][data-type="text"]`
|
|
1528
|
+
);
|
|
1529
|
+
if (!text_el) return;
|
|
1530
|
+
|
|
1531
|
+
const model_text = session.get(selection.path).text;
|
|
1532
|
+
if ((text_el.textContent ?? '') === model_text) return;
|
|
1533
|
+
|
|
1534
|
+
let current_offset = 0;
|
|
1535
|
+
function get_dom_text_position(root, target_offset) {
|
|
1536
|
+
for (const node of root.childNodes) {
|
|
1537
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1538
|
+
const node_text = node.textContent ?? '';
|
|
1539
|
+
const node_length = get_char_length(node_text);
|
|
1540
|
+
if (current_offset + node_length >= target_offset) {
|
|
1541
|
+
return {
|
|
1542
|
+
node,
|
|
1543
|
+
offset: char_to_utf16_offset(node_text, target_offset - current_offset)
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
current_offset += node_length;
|
|
1547
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1548
|
+
const position = get_dom_text_position(node, target_offset);
|
|
1549
|
+
if (position) return position;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
return null;
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
|
|
1556
|
+
const end_offset = start_offset + get_char_length(inserted_text);
|
|
1557
|
+
current_offset = 0;
|
|
1558
|
+
const start_position = get_dom_text_position(text_el, start_offset);
|
|
1559
|
+
current_offset = 0;
|
|
1560
|
+
const end_position = get_dom_text_position(text_el, end_offset);
|
|
1561
|
+
if (!start_position || !end_position) return;
|
|
1562
|
+
|
|
1563
|
+
const range = window.document.createRange();
|
|
1564
|
+
range.setStart(start_position.node, start_position.offset);
|
|
1565
|
+
range.setEnd(end_position.node, end_position.offset);
|
|
1566
|
+
range.deleteContents();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1314
1569
|
function __is_dom_selection_backwards() {
|
|
1315
1570
|
const dom_selection = window.getSelection();
|
|
1316
1571
|
|
|
@@ -1333,12 +1588,17 @@ ${fallback_html}`;
|
|
|
1333
1588
|
return comparisonRange.collapsed;
|
|
1334
1589
|
}
|
|
1335
1590
|
|
|
1336
|
-
// Whenever the model selection changes, render the selection
|
|
1591
|
+
// Whenever the model selection changes, render the selection.
|
|
1337
1592
|
// Skip when canvas is not focused to avoid stealing focus back
|
|
1338
|
-
// (e.g., when a dialog is open and selection highlight fragments re-render)
|
|
1593
|
+
// (e.g., when a dialog is open and selection highlight fragments re-render).
|
|
1594
|
+
// Consume the dom-driven flag at the top so it never carries stale
|
|
1595
|
+
// state into the next change, even when we early-return.
|
|
1339
1596
|
$effect(() => {
|
|
1597
|
+
session.selection;
|
|
1598
|
+
const dom_driven = selection_source_is_dom;
|
|
1599
|
+
selection_source_is_dom = false;
|
|
1340
1600
|
if (!canvas_focused) return;
|
|
1341
|
-
render_selection();
|
|
1601
|
+
render_selection(dom_driven);
|
|
1342
1602
|
});
|
|
1343
1603
|
</script>
|
|
1344
1604
|
|
|
@@ -1349,10 +1609,20 @@ ${fallback_html}`;
|
|
|
1349
1609
|
<svelte:document {onselectionchange} {oncut} {oncopy} {onpaste} />
|
|
1350
1610
|
|
|
1351
1611
|
<!-- TODO: move oncut/copy/paste handlers inside .svedit -->
|
|
1352
|
-
<div class="svedit">
|
|
1612
|
+
<div class="svedit" class:editable>
|
|
1353
1613
|
<!-- Overlays must be before canvas so they initialize first. -->
|
|
1354
|
-
<NodeSelectionMarkers />
|
|
1614
|
+
{#if editable}<NodeSelectionMarkers />{/if}
|
|
1355
1615
|
{#if Overlays}<Overlays />{/if}
|
|
1616
|
+
<!--
|
|
1617
|
+
inputmode is derived from the model selection: when a custom property
|
|
1618
|
+
(image, embed, etc.) is selected, set inputmode="none" so iOS does
|
|
1619
|
+
not open the virtual keyboard. For any other selection (text, node,
|
|
1620
|
+
none), allow the keyboard via inputmode="text". The browser's native
|
|
1621
|
+
selection-change flow updates session.selection on tap, which flips
|
|
1622
|
+
this attribute before iOS decides whether to show the keyboard — no
|
|
1623
|
+
imperative state tracking, drag detection, or click/pointerdown
|
|
1624
|
+
handlers are required for keyboard suppression.
|
|
1625
|
+
-->
|
|
1356
1626
|
<div
|
|
1357
1627
|
class="svedit-canvas {css_class}"
|
|
1358
1628
|
class:hide-selection={session.selection?.type === 'node'}
|
|
@@ -1365,6 +1635,7 @@ ${fallback_html}`;
|
|
|
1365
1635
|
{oncompositionend}
|
|
1366
1636
|
onfocus={handle_canvas_focus}
|
|
1367
1637
|
onblur={handle_canvas_blur}
|
|
1638
|
+
inputmode={session.selection?.type === 'text' ? 'text' : 'none'}
|
|
1368
1639
|
contenteditable={editable ? 'true' : 'false'}
|
|
1369
1640
|
tabindex="-1"
|
|
1370
1641
|
{autocapitalize}
|
|
@@ -1385,7 +1656,7 @@ ${fallback_html}`;
|
|
|
1385
1656
|
|
|
1386
1657
|
<style>
|
|
1387
1658
|
.svedit-canvas {
|
|
1388
|
-
caret-color: var(--svedit-
|
|
1659
|
+
caret-color: var(--svedit-caret-color);
|
|
1389
1660
|
caret-shape: bar;
|
|
1390
1661
|
/* Default to vertical/ column flow with: --row: 0; (the most common case)
|
|
1391
1662
|
Prevents silent failures when developers forget to set the row property in their top level node component.
|
|
@@ -1396,7 +1667,11 @@ ${fallback_html}`;
|
|
|
1396
1667
|
}
|
|
1397
1668
|
}
|
|
1398
1669
|
|
|
1399
|
-
|
|
1670
|
+
/* Selection paint — wrapped in :where() so consumers can override with
|
|
1671
|
+
a plain `::selection` rule. Svelte still adds the scope hash inside
|
|
1672
|
+
the :where(), but the wrapper zeroes its specificity contribution.
|
|
1673
|
+
Final specificity is just (0,0,1), trivially beatable. */
|
|
1674
|
+
:where(.svedit-canvas) :global(::selection) {
|
|
1400
1675
|
background: var(--svedit-editing-fill);
|
|
1401
1676
|
}
|
|
1402
1677
|
|