svedit 0.9.0 → 0.10.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 +113 -4
- package/dist/AnnotatedTextProperty.svelte +41 -8
- 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 +18 -3
- 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 +65 -42
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeCaret.svelte +0 -7
- package/dist/NodeGap.svelte +179 -81
- 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 +336 -165
- 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 +64 -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 +5 -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/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 +41 -8
- package/src/lib/Command.svelte.js +18 -3
- package/src/lib/CustomProperty.svelte +20 -9
- package/src/lib/Node.svelte +7 -5
- package/src/lib/NodeArrayProperty.svelte +65 -42
- package/src/lib/NodeCaret.svelte +0 -7
- package/src/lib/NodeGap.svelte +179 -81
- 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 +336 -165
- package/src/lib/Transaction.svelte.js +103 -61
- package/src/lib/doc_utils.js +276 -10
- package/src/lib/index.js +5 -1
- package/src/lib/node_visibility.svelte.js +718 -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/dist/Svedit.svelte
CHANGED
|
@@ -3,16 +3,19 @@
|
|
|
3
3
|
import {
|
|
4
4
|
snake_to_pascal,
|
|
5
5
|
get_char_length,
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
char_to_utf16_offset,
|
|
7
|
+
deserialize_path,
|
|
8
|
+
paths_equal,
|
|
9
|
+
serialize_path,
|
|
10
|
+
is_selection_collapsed
|
|
8
11
|
} from './utils.js';
|
|
9
|
-
import {
|
|
12
|
+
import { create_node_visibility } from './node_visibility.svelte.js';
|
|
10
13
|
import DefaultNodeSelectionMarkers from './NodeSelectionMarkers.svelte';
|
|
11
14
|
import './styles/svedit-colors.css';
|
|
15
|
+
import './styles/svedit-animations.css';
|
|
12
16
|
|
|
13
17
|
/** @import {
|
|
14
18
|
* SveditProps,
|
|
15
|
-
* DocumentPath,
|
|
16
19
|
* Selection,
|
|
17
20
|
* TextSelection,
|
|
18
21
|
* NodeSelection,
|
|
@@ -37,9 +40,14 @@
|
|
|
37
40
|
let NodeSelectionMarkers = $derived(session.config.system_components?.NodeSelectionMarkers ?? DefaultNodeSelectionMarkers);
|
|
38
41
|
let RootComponent = $derived(session.config.node_components[snake_to_pascal(root_node.type)]);
|
|
39
42
|
|
|
43
|
+
|
|
40
44
|
let is_composing = $state(false);
|
|
41
45
|
let canvas_focused = $state(false);
|
|
42
46
|
let before_composition_selection = null;
|
|
47
|
+
// Set by onselectionchange before it commits a DOM-derived selection
|
|
48
|
+
// to the model. render_selection consumes-and-clears it to skip
|
|
49
|
+
// rerender on DOM-driven changes (the DOM is already in place).
|
|
50
|
+
let selection_source_is_dom = false;
|
|
43
51
|
|
|
44
52
|
|
|
45
53
|
// let is_mobile = $derived(is_mobile_browser());
|
|
@@ -71,7 +79,51 @@
|
|
|
71
79
|
};
|
|
72
80
|
|
|
73
81
|
setContext('svedit', context);
|
|
74
|
-
|
|
82
|
+
create_node_visibility(context);
|
|
83
|
+
|
|
84
|
+
$effect(() => {
|
|
85
|
+
check_duplicate_paths();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function check_duplicate_paths() {
|
|
89
|
+
if (!canvas_el) return;
|
|
90
|
+
|
|
91
|
+
/** @type {Record<string, true>} */
|
|
92
|
+
const mounted_paths = Object.create(null);
|
|
93
|
+
|
|
94
|
+
for (const element of canvas_el.querySelectorAll('[data-path]')) {
|
|
95
|
+
if (element.closest('.svedit-canvas') !== canvas_el) continue;
|
|
96
|
+
|
|
97
|
+
const path_str = element.getAttribute('data-path');
|
|
98
|
+
if (!path_str) continue;
|
|
99
|
+
|
|
100
|
+
if (mounted_paths[path_str]) {
|
|
101
|
+
console.warn(
|
|
102
|
+
`[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.`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
mounted_paths[path_str] = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Test-only hook: expose the svedit context so test specs can read
|
|
111
|
+
// near_map / edge_map for diagnostics. Gated on `import.meta.env.MODE`
|
|
112
|
+
// — Vite replaces the literal so the comparison is constant-false in
|
|
113
|
+
// production and the branch is dead-code-eliminated. Wrapped in an
|
|
114
|
+
// effect so unmount clears the reference and tests don't leak state
|
|
115
|
+
// across remounts.
|
|
116
|
+
if (import.meta.env?.MODE === 'test') {
|
|
117
|
+
$effect(() => {
|
|
118
|
+
const g = /** @type {any} */ (globalThis);
|
|
119
|
+
g.__svedit_ctx_for_test = context;
|
|
120
|
+
return () => {
|
|
121
|
+
if (g.__svedit_ctx_for_test === context) {
|
|
122
|
+
delete g.__svedit_ctx_for_test;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
}
|
|
75
127
|
|
|
76
128
|
// Get KeyMapper from context (may be undefined if not provided)
|
|
77
129
|
const key_mapper = getContext('key_mapper');
|
|
@@ -145,14 +197,14 @@
|
|
|
145
197
|
if (
|
|
146
198
|
['deleteContentBackward', 'deleteWordBackward', 'deleteContent'].includes(event.inputType)
|
|
147
199
|
) {
|
|
148
|
-
|
|
200
|
+
session.apply(session.tr.delete_selection('backward'));
|
|
149
201
|
event.preventDefault();
|
|
150
202
|
event.stopPropagation();
|
|
151
203
|
return;
|
|
152
204
|
}
|
|
153
205
|
|
|
154
206
|
if (['deleteContentForward', 'deleteWordForward'].includes(event.inputType)) {
|
|
155
|
-
|
|
207
|
+
session.apply(session.tr.delete_selection('forward'));
|
|
156
208
|
event.preventDefault();
|
|
157
209
|
event.stopPropagation();
|
|
158
210
|
return;
|
|
@@ -190,10 +242,9 @@
|
|
|
190
242
|
/**
|
|
191
243
|
* Handles composition start events for input methods like dead keys
|
|
192
244
|
* This occurs when user starts typing a composed character (e.g., backtick for accents)
|
|
193
|
-
* @param {CompositionEvent} event
|
|
194
245
|
*/
|
|
195
|
-
function oncompositionstart(event) {
|
|
196
|
-
console.log('DEBUG: oncompositionstart', event.data);
|
|
246
|
+
function oncompositionstart(/*event*/) {
|
|
247
|
+
// console.log('DEBUG: oncompositionstart', event.data);
|
|
197
248
|
if (session.selection.type !== 'text') {
|
|
198
249
|
// Remove all ranges - completely clears the selection
|
|
199
250
|
window.getSelection()?.removeAllRanges();
|
|
@@ -217,7 +268,7 @@
|
|
|
217
268
|
* This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
|
|
218
269
|
*/
|
|
219
270
|
function oncompositionend(event) {
|
|
220
|
-
console.log('DEBUG: oncompositionend, insert:', event.data, event);
|
|
271
|
+
// console.log('DEBUG: oncompositionend, insert:', event.data, event);
|
|
221
272
|
if (!canvas_el?.contains(document.activeElement)) return;
|
|
222
273
|
if (session.selection?.type === 'text') {
|
|
223
274
|
// We need to remember the user's selection, as it might have changed in the process
|
|
@@ -229,12 +280,16 @@
|
|
|
229
280
|
// native undo command. Then the DOM will be in sync again with the editor's internal state.
|
|
230
281
|
document.execCommand('undo', false, null);
|
|
231
282
|
|
|
283
|
+
// Firefox may not undo native composition DOM. If the composed text is
|
|
284
|
+
// still present, remove it manually before applying the Svedit transaction.
|
|
285
|
+
__remove_native_composition_text(before_composition_selection, event.data);
|
|
286
|
+
|
|
232
287
|
// Set the selection to where the user initiated the composition, make changes, and apply.
|
|
233
288
|
// NOTE: We need to check for valid selection here, as there is a rare race condition
|
|
234
289
|
// where the user had no text selection at the start of composition.
|
|
235
290
|
if (before_composition_selection) {
|
|
236
291
|
session.selection = before_composition_selection;
|
|
237
|
-
console.log('event.data', event.data);
|
|
292
|
+
// console.log('event.data', event.data);
|
|
238
293
|
const tr = session.tr;
|
|
239
294
|
tr.insert_text(event.data);
|
|
240
295
|
session.apply(tr);
|
|
@@ -261,6 +316,7 @@
|
|
|
261
316
|
function onselectionchange() {
|
|
262
317
|
if (!editable) return;
|
|
263
318
|
if (!canvas_focused) return;
|
|
319
|
+
if (is_composing) return;
|
|
264
320
|
const dom_selection = window.getSelection();
|
|
265
321
|
if (!dom_selection.rangeCount) return;
|
|
266
322
|
|
|
@@ -273,6 +329,7 @@
|
|
|
273
329
|
// structurally identical — prevents a redundant $effect cycle
|
|
274
330
|
// (render_selection → scrollIntoView) on every DOM layout change.
|
|
275
331
|
if (JSON.stringify(selection) === JSON.stringify(session.selection)) return;
|
|
332
|
+
selection_source_is_dom = true;
|
|
276
333
|
session.selection = selection;
|
|
277
334
|
}
|
|
278
335
|
}
|
|
@@ -442,25 +499,24 @@ ${fallback_html}`;
|
|
|
442
499
|
annotated_text = session.get_selected_annotated_text();
|
|
443
500
|
const fallback_html = `<span>${annotated_text.text}</span>`;
|
|
444
501
|
|
|
445
|
-
console.log('Text copy:', {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
});
|
|
502
|
+
// console.log('Text copy:', {
|
|
503
|
+
// annotated_text,
|
|
504
|
+
// plain_text,
|
|
505
|
+
// html
|
|
506
|
+
// });
|
|
450
507
|
|
|
451
508
|
html = create_svedit_html_format(annotated_text, fallback_html);
|
|
452
509
|
} else if (session.selection?.type === 'node') {
|
|
453
510
|
const selected_nodes = session.get_selected_nodes();
|
|
454
511
|
const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
|
|
455
|
-
|
|
456
512
|
const json_data = { nodes, main_nodes };
|
|
457
513
|
|
|
458
|
-
console.log('Node copy:', {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
});
|
|
514
|
+
// console.log('Node copy:', {
|
|
515
|
+
// selected_nodes,
|
|
516
|
+
// nodes,
|
|
517
|
+
// total_nodes: Object.keys(nodes).length,
|
|
518
|
+
// operation: delete_selection ? 'cut' : 'copy'
|
|
519
|
+
// });
|
|
464
520
|
|
|
465
521
|
// Generate fallback HTML for cross-app compatibility
|
|
466
522
|
const selected_node_objects = main_nodes.map((id) => nodes[id]);
|
|
@@ -479,7 +535,6 @@ ${fallback_html}`;
|
|
|
479
535
|
type: property_definition.type,
|
|
480
536
|
value
|
|
481
537
|
};
|
|
482
|
-
console.log('Property copy:', json_data);
|
|
483
538
|
html = create_svedit_html_format(json_data, `<span>${value}</span>`);
|
|
484
539
|
plain_text = String(value);
|
|
485
540
|
}
|
|
@@ -488,32 +543,16 @@ ${fallback_html}`;
|
|
|
488
543
|
try {
|
|
489
544
|
event.clipboardData?.setData('text/plain', plain_text || '');
|
|
490
545
|
event.clipboardData?.setData('text/html', html || '');
|
|
491
|
-
console.log('Data copied to clipboard successfully');
|
|
492
546
|
} catch (err) {
|
|
493
547
|
console.error('Failed to copy data: ', err);
|
|
494
548
|
}
|
|
495
549
|
|
|
496
550
|
if (delete_selection) {
|
|
497
|
-
|
|
551
|
+
session.apply(session.tr.delete_selection());
|
|
498
552
|
}
|
|
499
553
|
}
|
|
500
554
|
|
|
501
|
-
|
|
502
|
-
* Shared delete logic for cut, backspace, and forward-delete.
|
|
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]
|
|
507
|
-
*/
|
|
508
|
-
function delete_at_selection(direction = 'backward') {
|
|
509
|
-
if (session.selection?.type === 'property') {
|
|
510
|
-
if (session.config.handle_property_deletion) {
|
|
511
|
-
session.config.handle_property_deletion(session, session.selection.path);
|
|
512
|
-
}
|
|
513
|
-
} else {
|
|
514
|
-
session.apply(session.tr.delete_selection(direction));
|
|
515
|
-
}
|
|
516
|
-
}
|
|
555
|
+
|
|
517
556
|
|
|
518
557
|
function oncut(event) {
|
|
519
558
|
if (!editable) return;
|
|
@@ -559,9 +598,9 @@ ${fallback_html}`;
|
|
|
559
598
|
});
|
|
560
599
|
nodes_to_insert.push(new_node_id);
|
|
561
600
|
} else {
|
|
562
|
-
console.log(
|
|
563
|
-
|
|
564
|
-
);
|
|
601
|
+
// console.log(
|
|
602
|
+
// `rejected ${node.type}. Only ${property_definition.node_types.join(', ')} allowed.`
|
|
603
|
+
// );
|
|
565
604
|
rejected = true;
|
|
566
605
|
break;
|
|
567
606
|
}
|
|
@@ -614,7 +653,6 @@ ${fallback_html}`;
|
|
|
614
653
|
if (pasted_media.length > 0) {
|
|
615
654
|
const handle_media_paste = session.config.handle_media_paste || session.config.handle_image_paste
|
|
616
655
|
pasted_json = await handle_media_paste(session, pasted_media);
|
|
617
|
-
console.log('pasted_json_after_media_paste', pasted_json);
|
|
618
656
|
// NOTE: If no pasted_json is returned from the custom handler, we assume that content creation has been
|
|
619
657
|
// handled inside handle_media_paste already.
|
|
620
658
|
if (!pasted_json) return;
|
|
@@ -625,8 +663,8 @@ ${fallback_html}`;
|
|
|
625
663
|
if (html_content) {
|
|
626
664
|
pasted_json = extract_svedit_data_from_html(html_content);
|
|
627
665
|
}
|
|
628
|
-
} catch
|
|
629
|
-
|
|
666
|
+
} catch {
|
|
667
|
+
// No HTML format available or failed to extract svedit data
|
|
630
668
|
pasted_json = undefined;
|
|
631
669
|
}
|
|
632
670
|
|
|
@@ -636,13 +674,18 @@ ${fallback_html}`;
|
|
|
636
674
|
console.error('Failed to paste any content:', e);
|
|
637
675
|
}
|
|
638
676
|
|
|
639
|
-
// Try to
|
|
677
|
+
// Try to construct a node payload from plain text when applicable
|
|
640
678
|
if (!pasted_json && typeof plain_text === 'string') {
|
|
641
679
|
const plain_text_fragments = plain_text
|
|
642
680
|
.split('\n\n')
|
|
643
681
|
.map((fragment) => fragment.trim())
|
|
644
682
|
.filter(Boolean);
|
|
645
|
-
|
|
683
|
+
// Also create a node payload for single paragraphs at a node gap —
|
|
684
|
+
// without this, insert_text bails out (requires text selection) and
|
|
685
|
+
// the paste is silently dropped.
|
|
686
|
+
const needs_node_payload = plain_text_fragments.length > 1
|
|
687
|
+
|| (plain_text_fragments.length === 1 && session.selection?.type === 'node');
|
|
688
|
+
if (needs_node_payload) {
|
|
646
689
|
pasted_json = {
|
|
647
690
|
main_nodes: [],
|
|
648
691
|
nodes: []
|
|
@@ -709,32 +752,36 @@ ${fallback_html}`;
|
|
|
709
752
|
} else if (typeof plain_text === 'string') {
|
|
710
753
|
// External paste: Fallback to plain text when no svedit data is found
|
|
711
754
|
session.apply(session.tr.insert_text(plain_text.trim()));
|
|
712
|
-
} else {
|
|
713
|
-
console.log('Could not paste.');
|
|
714
755
|
}
|
|
715
756
|
}
|
|
716
757
|
|
|
717
|
-
function render_selection() {
|
|
758
|
+
function render_selection(dom_driven = false) {
|
|
718
759
|
const selection = /** @type {Selection} */ (session.selection);
|
|
719
760
|
|
|
720
761
|
if (!selection) {
|
|
721
762
|
// No model selection -> just leave things as they are
|
|
722
|
-
|
|
723
|
-
|
|
763
|
+
// NOTE: removeAllRanges() makes the document lose selection on
|
|
764
|
+
// refocus of the window, hence I disable it for now.
|
|
765
|
+
// let dom_selection = window.getSelection();
|
|
766
|
+
// dom_selection.removeAllRanges();
|
|
724
767
|
return;
|
|
725
768
|
}
|
|
726
769
|
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
770
|
+
// DOM-driven updates already reflect the new selection — skip
|
|
771
|
+
// the rerender when DOM matches model. Model-driven updates
|
|
772
|
+
// (insert/undo) can have DOM matching by coincidence (Svelte
|
|
773
|
+
// reuses gap elements with shifted data-gap-offset), so for those
|
|
774
|
+
// we always rerender to scroll the cursor into view.
|
|
775
|
+
const is_empty_text_selection =
|
|
776
|
+
selection.type === 'text' && session.get(selection.path).text.length === 0;
|
|
777
|
+
if (dom_driven && !is_empty_text_selection) {
|
|
778
|
+
const prev_selection = __get_selection_from_dom();
|
|
779
|
+
if (
|
|
780
|
+
JSON.stringify(selection) === JSON.stringify(prev_selection) &&
|
|
781
|
+
canvas_el?.contains(document.activeElement)
|
|
782
|
+
) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
738
785
|
}
|
|
739
786
|
|
|
740
787
|
if (selection?.type === 'text') {
|
|
@@ -744,7 +791,7 @@ ${fallback_html}`;
|
|
|
744
791
|
} else if (selection?.type === 'property') {
|
|
745
792
|
__render_property_selection();
|
|
746
793
|
} else {
|
|
747
|
-
console.
|
|
794
|
+
console.warn('unsupported selection', $state.snapshot(selection));
|
|
748
795
|
}
|
|
749
796
|
}
|
|
750
797
|
|
|
@@ -753,18 +800,12 @@ ${fallback_html}`;
|
|
|
753
800
|
// Use flushSync so highlight spans are removed from the DOM
|
|
754
801
|
// immediately, before the browser processes the click's selection.
|
|
755
802
|
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
803
|
canvas_focused = true;
|
|
763
804
|
});
|
|
764
805
|
key_mapper?.push_scope(session.keymap);
|
|
765
806
|
}
|
|
766
807
|
|
|
767
|
-
// Handle blur - pop document's keymap from stack
|
|
808
|
+
// Handle blur - pop document's keymap from stack.
|
|
768
809
|
function handle_canvas_blur() {
|
|
769
810
|
// Use flushSync so the selection highlight span (with its CSS anchor)
|
|
770
811
|
// is in the DOM immediately, before any popover/dialog tries to
|
|
@@ -794,11 +835,11 @@ ${fallback_html}`;
|
|
|
794
835
|
function __resolve_node_from_gap(el) {
|
|
795
836
|
const gap = /** @type {HTMLElement | null} */ (el.closest('[data-gap-array-path]'));
|
|
796
837
|
if (!gap) return null;
|
|
797
|
-
const array_path = gap.dataset.gapArrayPath;
|
|
838
|
+
const array_path = deserialize_path(gap.dataset.gapArrayPath);
|
|
798
839
|
const offset = parseInt(gap.dataset.gapOffset, 10);
|
|
799
840
|
const node_idx = offset > 0 ? offset - 1 : 0;
|
|
800
841
|
return /** @type {HTMLElement | null} */ (
|
|
801
|
-
canvas_el.querySelector(`[data-path="${array_path
|
|
842
|
+
canvas_el.querySelector(`[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`)
|
|
802
843
|
);
|
|
803
844
|
}
|
|
804
845
|
|
|
@@ -821,13 +862,36 @@ ${fallback_html}`;
|
|
|
821
862
|
if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
|
|
822
863
|
if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
|
|
823
864
|
|
|
865
|
+
// EDGE CASE: Collapsed selection inside an empty node placeholder.
|
|
866
|
+
// Firefox can place the DOM selection on the placeholder node itself,
|
|
867
|
+
// which otherwise looks like selecting node index 0 in an empty array.
|
|
868
|
+
const focus_empty_placeholder = /** @type {HTMLElement | null} */ (
|
|
869
|
+
focus_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
|
|
870
|
+
);
|
|
871
|
+
const anchor_empty_placeholder = /** @type {HTMLElement | null} */ (
|
|
872
|
+
anchor_node.closest('.empty-node-placeholder[data-path][data-type="node"]')
|
|
873
|
+
);
|
|
874
|
+
if (focus_empty_placeholder && focus_empty_placeholder === anchor_empty_placeholder) {
|
|
875
|
+
const empty_placeholder_path = deserialize_path(focus_empty_placeholder.dataset.path);
|
|
876
|
+
const array_path = empty_placeholder_path.slice(0, -1);
|
|
877
|
+
const node_array = session.get(array_path);
|
|
878
|
+
if (Array.isArray(node_array) && node_array.length === 0) {
|
|
879
|
+
return {
|
|
880
|
+
type: 'node',
|
|
881
|
+
path: array_path,
|
|
882
|
+
anchor_offset: 0,
|
|
883
|
+
focus_offset: 0
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
824
888
|
// EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
|
|
825
889
|
// Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
|
|
826
890
|
const gap_el = /** @type {HTMLElement | null} */ (
|
|
827
891
|
focus_node.closest('[data-gap-array-path]')
|
|
828
892
|
);
|
|
829
893
|
if (gap_el && focus_node === anchor_node) {
|
|
830
|
-
const array_path = gap_el.dataset.gapArrayPath
|
|
894
|
+
const array_path = deserialize_path(gap_el.dataset.gapArrayPath);
|
|
831
895
|
const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
|
|
832
896
|
return {
|
|
833
897
|
type: 'node',
|
|
@@ -845,8 +909,8 @@ ${fallback_html}`;
|
|
|
845
909
|
?? /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
|
|
846
910
|
if (!anchor_root) return null;
|
|
847
911
|
|
|
848
|
-
let focus_root_path = focus_root.dataset.path
|
|
849
|
-
let anchor_root_path = anchor_root.dataset.path
|
|
912
|
+
let focus_root_path = deserialize_path(focus_root.dataset.path);
|
|
913
|
+
let anchor_root_path = deserialize_path(anchor_root.dataset.path);
|
|
850
914
|
let focus_node_depth = focus_root_path.length;
|
|
851
915
|
let anchor_node_depth = anchor_root_path.length;
|
|
852
916
|
|
|
@@ -856,30 +920,28 @@ ${fallback_html}`;
|
|
|
856
920
|
// onto its index within that array.
|
|
857
921
|
let focus_walked_up = false;
|
|
858
922
|
let anchor_walked_up = false;
|
|
859
|
-
while (
|
|
860
|
-
focus_root_path.slice(0, -1).join('.') !== anchor_root_path.slice(0, -1).join('.')
|
|
861
|
-
) {
|
|
923
|
+
while (!paths_equal(focus_root_path.slice(0, -1), anchor_root_path.slice(0, -1))) {
|
|
862
924
|
if (focus_root_path.length > anchor_root_path.length) {
|
|
863
925
|
// Focus is deeper — walk it up
|
|
864
926
|
focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
865
927
|
if (!focus_root) return null;
|
|
866
|
-
focus_root_path = focus_root.dataset.path
|
|
928
|
+
focus_root_path = deserialize_path(focus_root.dataset.path);
|
|
867
929
|
focus_walked_up = true;
|
|
868
930
|
} else if (anchor_root_path.length > focus_root_path.length) {
|
|
869
931
|
// Anchor is deeper — walk it up
|
|
870
932
|
anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
871
933
|
if (!anchor_root) return null;
|
|
872
|
-
anchor_root_path = anchor_root.dataset.path
|
|
934
|
+
anchor_root_path = deserialize_path(anchor_root.dataset.path);
|
|
873
935
|
anchor_walked_up = true;
|
|
874
936
|
} else {
|
|
875
937
|
// Same depth but different node arrays — walk both up
|
|
876
938
|
focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
877
939
|
if (!focus_root) return null;
|
|
878
|
-
focus_root_path = focus_root.dataset.path
|
|
940
|
+
focus_root_path = deserialize_path(focus_root.dataset.path);
|
|
879
941
|
focus_walked_up = true;
|
|
880
942
|
anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
881
943
|
if (!anchor_root) return null;
|
|
882
|
-
anchor_root_path = anchor_root.dataset.path
|
|
944
|
+
anchor_root_path = deserialize_path(anchor_root.dataset.path);
|
|
883
945
|
anchor_walked_up = true;
|
|
884
946
|
}
|
|
885
947
|
}
|
|
@@ -893,16 +955,13 @@ ${fallback_html}`;
|
|
|
893
955
|
const parent_property = session.inspect(parent_array_path);
|
|
894
956
|
if (!parent_property || parent_property.type !== 'node_array') return null;
|
|
895
957
|
|
|
896
|
-
let anchor_offset =
|
|
897
|
-
let focus_offset =
|
|
958
|
+
let anchor_offset = Number(anchor_root_path.at(-1));
|
|
959
|
+
let focus_offset = Number(focus_root_path.at(-1));
|
|
898
960
|
|
|
899
961
|
// Check if it's a backwards selection
|
|
900
962
|
const is_backwards = __is_dom_selection_backwards();
|
|
901
|
-
if (is_backwards)
|
|
902
|
-
|
|
903
|
-
} else {
|
|
904
|
-
focus_offset += 1;
|
|
905
|
-
}
|
|
963
|
+
if (is_backwards) anchor_offset += 1;
|
|
964
|
+
else focus_offset += 1;
|
|
906
965
|
|
|
907
966
|
// EDGE CASE: Exclude first node when anchor_node is a gap-after
|
|
908
967
|
// in a non-collapsed forward selection.
|
|
@@ -961,7 +1020,7 @@ ${fallback_html}`;
|
|
|
961
1020
|
if (focus_root === anchor_root) {
|
|
962
1021
|
return {
|
|
963
1022
|
type: 'property',
|
|
964
|
-
path: focus_root.dataset.path
|
|
1023
|
+
path: deserialize_path(focus_root.dataset.path)
|
|
965
1024
|
};
|
|
966
1025
|
}
|
|
967
1026
|
return null;
|
|
@@ -1004,19 +1063,30 @@ ${fallback_html}`;
|
|
|
1004
1063
|
range = dom_selection.getRangeAt(0);
|
|
1005
1064
|
}
|
|
1006
1065
|
|
|
1066
|
+
function get_text_root(node) {
|
|
1067
|
+
if (!node) return null;
|
|
1068
|
+
|
|
1069
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1070
|
+
return /** @type {HTMLElement | null} */ (
|
|
1071
|
+
node.closest?.('[data-path][data-type="text"]') ?? null
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
return /** @type {HTMLElement | null} */ (
|
|
1076
|
+
node.parentElement?.closest('[data-path][data-type="text"]') ?? null
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1007
1080
|
let focus_root, anchor_root;
|
|
1008
1081
|
|
|
1009
1082
|
if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
|
|
1010
1083
|
// 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
1084
|
focus_root = anchor_root = focus_node;
|
|
1012
1085
|
} else {
|
|
1013
|
-
focus_root =
|
|
1014
|
-
focus_node.parentElement?.closest('[data-path][data-type="text"]')
|
|
1015
|
-
);
|
|
1086
|
+
focus_root = get_text_root(focus_node);
|
|
1016
1087
|
if (!focus_root) return null;
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
);
|
|
1088
|
+
|
|
1089
|
+
anchor_root = get_text_root(anchor_node);
|
|
1020
1090
|
if (!anchor_root) return null;
|
|
1021
1091
|
}
|
|
1022
1092
|
|
|
@@ -1024,7 +1094,7 @@ ${fallback_html}`;
|
|
|
1024
1094
|
return null;
|
|
1025
1095
|
}
|
|
1026
1096
|
|
|
1027
|
-
const path = focus_root.dataset.path
|
|
1097
|
+
const path = deserialize_path(focus_root.dataset.path);
|
|
1028
1098
|
|
|
1029
1099
|
if (!path) return null;
|
|
1030
1100
|
|
|
@@ -1033,12 +1103,14 @@ ${fallback_html}`;
|
|
|
1033
1103
|
// AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
|
|
1034
1104
|
// When the user places their caret after this <br>, focusNode is the container
|
|
1035
1105
|
// element (not a text node), and normal processing would return position 0.
|
|
1036
|
-
// We detect this and return
|
|
1037
|
-
|
|
1038
|
-
|
|
1106
|
+
// We detect this and return the current DOM text length instead.
|
|
1107
|
+
// During compositionend, the browser has already inserted the composed
|
|
1108
|
+
// character into the DOM, while the Svedit model still has the old text.
|
|
1109
|
+
const dom_text_length = get_char_length(focus_root.textContent ?? '');
|
|
1039
1110
|
const child_nodes = focus_root.childNodes;
|
|
1040
1111
|
|
|
1041
1112
|
if (
|
|
1113
|
+
range.collapsed &&
|
|
1042
1114
|
focus_node === anchor_node &&
|
|
1043
1115
|
focus_node === focus_root &&
|
|
1044
1116
|
focus_root.dataset?.type === 'text' &&
|
|
@@ -1059,43 +1131,21 @@ ${fallback_html}`;
|
|
|
1059
1131
|
return {
|
|
1060
1132
|
type: 'text',
|
|
1061
1133
|
path,
|
|
1062
|
-
anchor_offset:
|
|
1063
|
-
focus_offset:
|
|
1134
|
+
anchor_offset: dom_text_length,
|
|
1135
|
+
focus_offset: dom_text_length
|
|
1064
1136
|
};
|
|
1065
1137
|
}
|
|
1066
1138
|
}
|
|
1067
1139
|
|
|
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;
|
|
1140
|
+
function get_text_offset(container, offset) {
|
|
1141
|
+
const offset_range = window.document.createRange();
|
|
1142
|
+
offset_range.setStart(focus_root, 0);
|
|
1143
|
+
offset_range.setEnd(container, offset);
|
|
1144
|
+
return get_char_length(offset_range.toString());
|
|
1093
1145
|
}
|
|
1094
1146
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
if (processNode(childNode)) break;
|
|
1098
|
-
}
|
|
1147
|
+
const start_offset = get_text_offset(range.startContainer, range.startOffset);
|
|
1148
|
+
const end_offset = get_text_offset(range.endContainer, range.endOffset);
|
|
1099
1149
|
|
|
1100
1150
|
// Check if it's a backward selection
|
|
1101
1151
|
// When range is provided, we can't detect backward selection from the range alone
|
|
@@ -1116,18 +1166,35 @@ ${fallback_html}`;
|
|
|
1116
1166
|
|
|
1117
1167
|
function __get_node_element(node_array_path, node_offset) {
|
|
1118
1168
|
return canvas_el.querySelector(
|
|
1119
|
-
`[data-path="${node_array_path
|
|
1169
|
+
`[data-path="${serialize_path([...node_array_path, node_offset])}"][data-type="node"]`
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* True when any part of the element's border box overlaps the window
|
|
1175
|
+
* viewport. Used to keep node-selection re-renders from scrolling a
|
|
1176
|
+
* cursor that is already on screen. Tests the window viewport, so a
|
|
1177
|
+
* node clipped only by an inner scroll container still counts visible.
|
|
1178
|
+
* @param {Element | null | undefined} el
|
|
1179
|
+
*/
|
|
1180
|
+
function __intersects_viewport(el) {
|
|
1181
|
+
if (!el) return false;
|
|
1182
|
+
const r = el.getBoundingClientRect();
|
|
1183
|
+
return (
|
|
1184
|
+
r.bottom > 0 && r.top < window.innerHeight &&
|
|
1185
|
+
r.right > 0 && r.left < window.innerWidth
|
|
1120
1186
|
);
|
|
1121
1187
|
}
|
|
1122
1188
|
|
|
1123
1189
|
function __render_node_selection() {
|
|
1124
1190
|
const selection = /** @type {NodeSelection} */ (session.selection);
|
|
1125
|
-
const node_array_path = selection.path
|
|
1126
|
-
const
|
|
1191
|
+
const node_array_path = selection.path;
|
|
1192
|
+
const node_array_path_str = serialize_path(node_array_path);
|
|
1193
|
+
const is_collapsed = is_selection_collapsed(selection);
|
|
1127
1194
|
const is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
|
|
1128
1195
|
|
|
1129
1196
|
const node_array_el = canvas_el.querySelector(
|
|
1130
|
-
`[data-path="${
|
|
1197
|
+
`[data-path="${node_array_path_str}"][data-type="node_array"]`
|
|
1131
1198
|
);
|
|
1132
1199
|
if (!node_array_el) return;
|
|
1133
1200
|
|
|
@@ -1135,7 +1202,7 @@ ${fallback_html}`;
|
|
|
1135
1202
|
const range = window.document.createRange();
|
|
1136
1203
|
|
|
1137
1204
|
const gap_selector = (offset) =>
|
|
1138
|
-
`[data-gap-array-path="${
|
|
1205
|
+
`[data-gap-array-path="${node_array_path_str}"][data-gap-offset="${offset}"]`;
|
|
1139
1206
|
|
|
1140
1207
|
if (is_collapsed) {
|
|
1141
1208
|
const gap_el = node_array_el.querySelector(gap_selector(selection.anchor_offset));
|
|
@@ -1168,24 +1235,66 @@ ${fallback_html}`;
|
|
|
1168
1235
|
}
|
|
1169
1236
|
}
|
|
1170
1237
|
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1238
|
+
// Scroll the cursor into view, but only when it is genuinely
|
|
1239
|
+
// off-screen. This runs on every node-selection re-render — a
|
|
1240
|
+
// transaction (type/layout change), select-parent, window refocus
|
|
1241
|
+
// — so an unconditional scroll would yank the viewport on each of
|
|
1242
|
+
// them. cursor_offset is a gap offset and the gap has no box of
|
|
1243
|
+
// its own, so visibility is judged from the nodes flanking it.
|
|
1244
|
+
setTimeout(() => {
|
|
1245
|
+
// Collapsed: anchor === focus, so focus is the gap offset.
|
|
1246
|
+
// Range: cursor sits at the focus end (anchor when backward).
|
|
1247
|
+
const cursor_offset = is_backward ? selection.anchor_offset : selection.focus_offset;
|
|
1248
|
+
const array_length = session.get(node_array_path).length;
|
|
1249
|
+
|
|
1250
|
+
// If either node flanking the cursor is already (even
|
|
1251
|
+
// partially) on screen, the cursor is visible — keep the
|
|
1252
|
+
// viewport stable and scroll nothing. node_before is null at
|
|
1253
|
+
// offset 0: cursor_offset - 1 would be -1, and serialize_path
|
|
1254
|
+
// rejects a negative index.
|
|
1255
|
+
const node_before = cursor_offset > 0
|
|
1256
|
+
? __get_node_element(node_array_path, cursor_offset - 1)
|
|
1257
|
+
: null;
|
|
1258
|
+
const node_after = __get_node_element(node_array_path, cursor_offset);
|
|
1259
|
+
if (__intersects_viewport(node_before) || __intersects_viewport(node_after)) return;
|
|
1260
|
+
|
|
1261
|
+
if (cursor_offset === 0) {
|
|
1262
|
+
node_array_el.scrollLeft = 0;
|
|
1263
|
+
node_array_el.scrollTop = 0;
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
if (cursor_offset >= array_length) {
|
|
1267
|
+
const max_left = Math.max(
|
|
1268
|
+
0,
|
|
1269
|
+
node_array_el.scrollWidth - node_array_el.clientWidth
|
|
1270
|
+
);
|
|
1271
|
+
const max_top = Math.max(
|
|
1272
|
+
0,
|
|
1273
|
+
node_array_el.scrollHeight - node_array_el.clientHeight
|
|
1274
|
+
);
|
|
1275
|
+
node_array_el.scrollLeft = max_left;
|
|
1276
|
+
node_array_el.scrollTop = max_top;
|
|
1277
|
+
if (max_left === 0 && max_top === 0) {
|
|
1278
|
+
node_before?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1279
|
+
}
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
// A range selection's selected node IS node_before; scrolling
|
|
1283
|
+
// node_after would reveal the following node and leave the
|
|
1284
|
+
// selection itself off-screen. For a collapsed caret node_after's
|
|
1285
|
+
// leading edge is the cursor, so that stays the right target.
|
|
1286
|
+
const target = is_collapsed ? node_after : node_before;
|
|
1287
|
+
target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1288
|
+
}, 0);
|
|
1181
1289
|
}
|
|
1182
1290
|
|
|
1183
1291
|
function __render_property_selection() {
|
|
1184
1292
|
const selection = session.selection;
|
|
1185
1293
|
// The element that holds the property
|
|
1186
1294
|
const el = canvas_el.querySelector(
|
|
1187
|
-
`[data-path="${selection.path
|
|
1295
|
+
`[data-path="${serialize_path(selection.path)}"][data-type="property"]`
|
|
1188
1296
|
);
|
|
1297
|
+
|
|
1189
1298
|
const gap_selectable = el.querySelector('.svedit-selectable');
|
|
1190
1299
|
const range = window.document.createRange();
|
|
1191
1300
|
const dom_selection = window.getSelection();
|
|
@@ -1198,10 +1307,7 @@ ${fallback_html}`;
|
|
|
1198
1307
|
|
|
1199
1308
|
// Scroll the selection into view
|
|
1200
1309
|
setTimeout(() => {
|
|
1201
|
-
|
|
1202
|
-
if (selectedElement) {
|
|
1203
|
-
selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1204
|
-
}
|
|
1310
|
+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1205
1311
|
}, 0);
|
|
1206
1312
|
}
|
|
1207
1313
|
|
|
@@ -1209,7 +1315,7 @@ ${fallback_html}`;
|
|
|
1209
1315
|
const selection = /** @type {any} */ (session.selection);
|
|
1210
1316
|
// The element that holds the annotated string
|
|
1211
1317
|
const el = canvas_el.querySelector(
|
|
1212
|
-
`[data-path="${selection.path
|
|
1318
|
+
`[data-path="${serialize_path(selection.path)}"][data-type="text"]`
|
|
1213
1319
|
);
|
|
1214
1320
|
const empty_text = session.get(selection.path).text.length === 0;
|
|
1215
1321
|
const dom_selection = window.getSelection();
|
|
@@ -1296,7 +1402,6 @@ ${fallback_html}`;
|
|
|
1296
1402
|
focus_node,
|
|
1297
1403
|
focus_node_offset
|
|
1298
1404
|
);
|
|
1299
|
-
el.focus(); // needed?
|
|
1300
1405
|
|
|
1301
1406
|
// Scroll the selection into view
|
|
1302
1407
|
setTimeout(() => {
|
|
@@ -1311,6 +1416,52 @@ ${fallback_html}`;
|
|
|
1311
1416
|
// Utils
|
|
1312
1417
|
// --------------------------
|
|
1313
1418
|
|
|
1419
|
+
function __remove_native_composition_text(selection, inserted_text) {
|
|
1420
|
+
if (!selection || selection.type !== 'text' || !inserted_text) return;
|
|
1421
|
+
|
|
1422
|
+
const text_el = canvas_el.querySelector(
|
|
1423
|
+
`[data-path="${serialize_path(selection.path)}"][data-type="text"]`
|
|
1424
|
+
);
|
|
1425
|
+
if (!text_el) return;
|
|
1426
|
+
|
|
1427
|
+
const model_text = session.get(selection.path).text;
|
|
1428
|
+
if ((text_el.textContent ?? '') === model_text) return;
|
|
1429
|
+
|
|
1430
|
+
let current_offset = 0;
|
|
1431
|
+
function get_dom_text_position(root, target_offset) {
|
|
1432
|
+
for (const node of root.childNodes) {
|
|
1433
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1434
|
+
const node_text = node.textContent ?? '';
|
|
1435
|
+
const node_length = get_char_length(node_text);
|
|
1436
|
+
if (current_offset + node_length >= target_offset) {
|
|
1437
|
+
return {
|
|
1438
|
+
node,
|
|
1439
|
+
offset: char_to_utf16_offset(node_text, target_offset - current_offset)
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
current_offset += node_length;
|
|
1443
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1444
|
+
const position = get_dom_text_position(node, target_offset);
|
|
1445
|
+
if (position) return position;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return null;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
|
|
1452
|
+
const end_offset = start_offset + get_char_length(inserted_text);
|
|
1453
|
+
current_offset = 0;
|
|
1454
|
+
const start_position = get_dom_text_position(text_el, start_offset);
|
|
1455
|
+
current_offset = 0;
|
|
1456
|
+
const end_position = get_dom_text_position(text_el, end_offset);
|
|
1457
|
+
if (!start_position || !end_position) return;
|
|
1458
|
+
|
|
1459
|
+
const range = window.document.createRange();
|
|
1460
|
+
range.setStart(start_position.node, start_position.offset);
|
|
1461
|
+
range.setEnd(end_position.node, end_position.offset);
|
|
1462
|
+
range.deleteContents();
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1314
1465
|
function __is_dom_selection_backwards() {
|
|
1315
1466
|
const dom_selection = window.getSelection();
|
|
1316
1467
|
|
|
@@ -1333,12 +1484,17 @@ ${fallback_html}`;
|
|
|
1333
1484
|
return comparisonRange.collapsed;
|
|
1334
1485
|
}
|
|
1335
1486
|
|
|
1336
|
-
// Whenever the model selection changes, render the selection
|
|
1487
|
+
// Whenever the model selection changes, render the selection.
|
|
1337
1488
|
// 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)
|
|
1489
|
+
// (e.g., when a dialog is open and selection highlight fragments re-render).
|
|
1490
|
+
// Consume the dom-driven flag at the top so it never carries stale
|
|
1491
|
+
// state into the next change, even when we early-return.
|
|
1339
1492
|
$effect(() => {
|
|
1493
|
+
session.selection;
|
|
1494
|
+
const dom_driven = selection_source_is_dom;
|
|
1495
|
+
selection_source_is_dom = false;
|
|
1340
1496
|
if (!canvas_focused) return;
|
|
1341
|
-
render_selection();
|
|
1497
|
+
render_selection(dom_driven);
|
|
1342
1498
|
});
|
|
1343
1499
|
</script>
|
|
1344
1500
|
|
|
@@ -1349,10 +1505,20 @@ ${fallback_html}`;
|
|
|
1349
1505
|
<svelte:document {onselectionchange} {oncut} {oncopy} {onpaste} />
|
|
1350
1506
|
|
|
1351
1507
|
<!-- TODO: move oncut/copy/paste handlers inside .svedit -->
|
|
1352
|
-
<div class="svedit">
|
|
1508
|
+
<div class="svedit" class:editable>
|
|
1353
1509
|
<!-- Overlays must be before canvas so they initialize first. -->
|
|
1354
|
-
<NodeSelectionMarkers />
|
|
1510
|
+
{#if editable}<NodeSelectionMarkers />{/if}
|
|
1355
1511
|
{#if Overlays}<Overlays />{/if}
|
|
1512
|
+
<!--
|
|
1513
|
+
inputmode is derived from the model selection: when a custom property
|
|
1514
|
+
(image, embed, etc.) is selected, set inputmode="none" so iOS does
|
|
1515
|
+
not open the virtual keyboard. For any other selection (text, node,
|
|
1516
|
+
none), allow the keyboard via inputmode="text". The browser's native
|
|
1517
|
+
selection-change flow updates session.selection on tap, which flips
|
|
1518
|
+
this attribute before iOS decides whether to show the keyboard — no
|
|
1519
|
+
imperative state tracking, drag detection, or click/pointerdown
|
|
1520
|
+
handlers are required for keyboard suppression.
|
|
1521
|
+
-->
|
|
1356
1522
|
<div
|
|
1357
1523
|
class="svedit-canvas {css_class}"
|
|
1358
1524
|
class:hide-selection={session.selection?.type === 'node'}
|
|
@@ -1365,6 +1531,7 @@ ${fallback_html}`;
|
|
|
1365
1531
|
{oncompositionend}
|
|
1366
1532
|
onfocus={handle_canvas_focus}
|
|
1367
1533
|
onblur={handle_canvas_blur}
|
|
1534
|
+
inputmode={session.selection?.type === 'text' ? 'text' : 'none'}
|
|
1368
1535
|
contenteditable={editable ? 'true' : 'false'}
|
|
1369
1536
|
tabindex="-1"
|
|
1370
1537
|
{autocapitalize}
|
|
@@ -1385,7 +1552,7 @@ ${fallback_html}`;
|
|
|
1385
1552
|
|
|
1386
1553
|
<style>
|
|
1387
1554
|
.svedit-canvas {
|
|
1388
|
-
caret-color: var(--svedit-
|
|
1555
|
+
caret-color: var(--svedit-caret-color);
|
|
1389
1556
|
caret-shape: bar;
|
|
1390
1557
|
/* Default to vertical/ column flow with: --row: 0; (the most common case)
|
|
1391
1558
|
Prevents silent failures when developers forget to set the row property in their top level node component.
|
|
@@ -1396,7 +1563,11 @@ ${fallback_html}`;
|
|
|
1396
1563
|
}
|
|
1397
1564
|
}
|
|
1398
1565
|
|
|
1399
|
-
|
|
1566
|
+
/* Selection paint — wrapped in :where() so consumers can override with
|
|
1567
|
+
a plain `::selection` rule. Svelte still adds the scope hash inside
|
|
1568
|
+
the :where(), but the wrapper zeroes its specificity contribution.
|
|
1569
|
+
Final specificity is just (0,0,1), trivially beatable. */
|
|
1570
|
+
:where(.svedit-canvas) :global(::selection) {
|
|
1400
1571
|
background: var(--svedit-editing-fill);
|
|
1401
1572
|
}
|
|
1402
1573
|
|