svedit 0.8.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 +137 -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 +340 -147
- 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 +340 -147
- 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/src/lib/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');
|
|
@@ -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,7 +543,6 @@ ${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
|
}
|
|
@@ -498,6 +552,8 @@ ${fallback_html}`;
|
|
|
498
552
|
}
|
|
499
553
|
}
|
|
500
554
|
|
|
555
|
+
|
|
556
|
+
|
|
501
557
|
function oncut(event) {
|
|
502
558
|
if (!editable) return;
|
|
503
559
|
oncopy(event, true);
|
|
@@ -542,9 +598,9 @@ ${fallback_html}`;
|
|
|
542
598
|
});
|
|
543
599
|
nodes_to_insert.push(new_node_id);
|
|
544
600
|
} else {
|
|
545
|
-
console.log(
|
|
546
|
-
|
|
547
|
-
);
|
|
601
|
+
// console.log(
|
|
602
|
+
// `rejected ${node.type}. Only ${property_definition.node_types.join(', ')} allowed.`
|
|
603
|
+
// );
|
|
548
604
|
rejected = true;
|
|
549
605
|
break;
|
|
550
606
|
}
|
|
@@ -597,7 +653,6 @@ ${fallback_html}`;
|
|
|
597
653
|
if (pasted_media.length > 0) {
|
|
598
654
|
const handle_media_paste = session.config.handle_media_paste || session.config.handle_image_paste
|
|
599
655
|
pasted_json = await handle_media_paste(session, pasted_media);
|
|
600
|
-
console.log('pasted_json_after_media_paste', pasted_json);
|
|
601
656
|
// NOTE: If no pasted_json is returned from the custom handler, we assume that content creation has been
|
|
602
657
|
// handled inside handle_media_paste already.
|
|
603
658
|
if (!pasted_json) return;
|
|
@@ -608,8 +663,8 @@ ${fallback_html}`;
|
|
|
608
663
|
if (html_content) {
|
|
609
664
|
pasted_json = extract_svedit_data_from_html(html_content);
|
|
610
665
|
}
|
|
611
|
-
} catch
|
|
612
|
-
|
|
666
|
+
} catch {
|
|
667
|
+
// No HTML format available or failed to extract svedit data
|
|
613
668
|
pasted_json = undefined;
|
|
614
669
|
}
|
|
615
670
|
|
|
@@ -619,13 +674,18 @@ ${fallback_html}`;
|
|
|
619
674
|
console.error('Failed to paste any content:', e);
|
|
620
675
|
}
|
|
621
676
|
|
|
622
|
-
// Try to
|
|
677
|
+
// Try to construct a node payload from plain text when applicable
|
|
623
678
|
if (!pasted_json && typeof plain_text === 'string') {
|
|
624
679
|
const plain_text_fragments = plain_text
|
|
625
680
|
.split('\n\n')
|
|
626
681
|
.map((fragment) => fragment.trim())
|
|
627
682
|
.filter(Boolean);
|
|
628
|
-
|
|
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) {
|
|
629
689
|
pasted_json = {
|
|
630
690
|
main_nodes: [],
|
|
631
691
|
nodes: []
|
|
@@ -692,32 +752,36 @@ ${fallback_html}`;
|
|
|
692
752
|
} else if (typeof plain_text === 'string') {
|
|
693
753
|
// External paste: Fallback to plain text when no svedit data is found
|
|
694
754
|
session.apply(session.tr.insert_text(plain_text.trim()));
|
|
695
|
-
} else {
|
|
696
|
-
console.log('Could not paste.');
|
|
697
755
|
}
|
|
698
756
|
}
|
|
699
757
|
|
|
700
|
-
function render_selection() {
|
|
758
|
+
function render_selection(dom_driven = false) {
|
|
701
759
|
const selection = /** @type {Selection} */ (session.selection);
|
|
702
760
|
|
|
703
761
|
if (!selection) {
|
|
704
762
|
// No model selection -> just leave things as they are
|
|
705
|
-
|
|
706
|
-
|
|
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();
|
|
707
767
|
return;
|
|
708
768
|
}
|
|
709
769
|
|
|
710
|
-
//
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
+
}
|
|
721
785
|
}
|
|
722
786
|
|
|
723
787
|
if (selection?.type === 'text') {
|
|
@@ -727,7 +791,7 @@ ${fallback_html}`;
|
|
|
727
791
|
} else if (selection?.type === 'property') {
|
|
728
792
|
__render_property_selection();
|
|
729
793
|
} else {
|
|
730
|
-
console.
|
|
794
|
+
console.warn('unsupported selection', $state.snapshot(selection));
|
|
731
795
|
}
|
|
732
796
|
}
|
|
733
797
|
|
|
@@ -736,18 +800,12 @@ ${fallback_html}`;
|
|
|
736
800
|
// Use flushSync so highlight spans are removed from the DOM
|
|
737
801
|
// immediately, before the browser processes the click's selection.
|
|
738
802
|
flushSync(() => {
|
|
739
|
-
// Clear the model selection so render_selection() does not call
|
|
740
|
-
// setBaseAndExtent() with the old position, which would battle
|
|
741
|
-
// with the new selection the user is making (the click/drag that
|
|
742
|
-
// triggered this focus). The browser will place the caret and
|
|
743
|
-
// fire selectionchange, which sets the model selection correctly.
|
|
744
|
-
session.selection = null;
|
|
745
803
|
canvas_focused = true;
|
|
746
804
|
});
|
|
747
805
|
key_mapper?.push_scope(session.keymap);
|
|
748
806
|
}
|
|
749
807
|
|
|
750
|
-
// Handle blur - pop document's keymap from stack
|
|
808
|
+
// Handle blur - pop document's keymap from stack.
|
|
751
809
|
function handle_canvas_blur() {
|
|
752
810
|
// Use flushSync so the selection highlight span (with its CSS anchor)
|
|
753
811
|
// is in the DOM immediately, before any popover/dialog tries to
|
|
@@ -777,11 +835,11 @@ ${fallback_html}`;
|
|
|
777
835
|
function __resolve_node_from_gap(el) {
|
|
778
836
|
const gap = /** @type {HTMLElement | null} */ (el.closest('[data-gap-array-path]'));
|
|
779
837
|
if (!gap) return null;
|
|
780
|
-
const array_path = gap.dataset.gapArrayPath;
|
|
838
|
+
const array_path = deserialize_path(gap.dataset.gapArrayPath);
|
|
781
839
|
const offset = parseInt(gap.dataset.gapOffset, 10);
|
|
782
840
|
const node_idx = offset > 0 ? offset - 1 : 0;
|
|
783
841
|
return /** @type {HTMLElement | null} */ (
|
|
784
|
-
canvas_el.querySelector(`[data-path="${array_path
|
|
842
|
+
canvas_el.querySelector(`[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`)
|
|
785
843
|
);
|
|
786
844
|
}
|
|
787
845
|
|
|
@@ -804,13 +862,36 @@ ${fallback_html}`;
|
|
|
804
862
|
if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
|
|
805
863
|
if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
|
|
806
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
|
+
|
|
807
888
|
// EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
|
|
808
889
|
// Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
|
|
809
890
|
const gap_el = /** @type {HTMLElement | null} */ (
|
|
810
891
|
focus_node.closest('[data-gap-array-path]')
|
|
811
892
|
);
|
|
812
893
|
if (gap_el && focus_node === anchor_node) {
|
|
813
|
-
const array_path = gap_el.dataset.gapArrayPath
|
|
894
|
+
const array_path = deserialize_path(gap_el.dataset.gapArrayPath);
|
|
814
895
|
const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
|
|
815
896
|
return {
|
|
816
897
|
type: 'node',
|
|
@@ -828,8 +909,8 @@ ${fallback_html}`;
|
|
|
828
909
|
?? /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
|
|
829
910
|
if (!anchor_root) return null;
|
|
830
911
|
|
|
831
|
-
let focus_root_path = focus_root.dataset.path
|
|
832
|
-
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);
|
|
833
914
|
let focus_node_depth = focus_root_path.length;
|
|
834
915
|
let anchor_node_depth = anchor_root_path.length;
|
|
835
916
|
|
|
@@ -839,30 +920,28 @@ ${fallback_html}`;
|
|
|
839
920
|
// onto its index within that array.
|
|
840
921
|
let focus_walked_up = false;
|
|
841
922
|
let anchor_walked_up = false;
|
|
842
|
-
while (
|
|
843
|
-
focus_root_path.slice(0, -1).join('.') !== anchor_root_path.slice(0, -1).join('.')
|
|
844
|
-
) {
|
|
923
|
+
while (!paths_equal(focus_root_path.slice(0, -1), anchor_root_path.slice(0, -1))) {
|
|
845
924
|
if (focus_root_path.length > anchor_root_path.length) {
|
|
846
925
|
// Focus is deeper — walk it up
|
|
847
926
|
focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
848
927
|
if (!focus_root) return null;
|
|
849
|
-
focus_root_path = focus_root.dataset.path
|
|
928
|
+
focus_root_path = deserialize_path(focus_root.dataset.path);
|
|
850
929
|
focus_walked_up = true;
|
|
851
930
|
} else if (anchor_root_path.length > focus_root_path.length) {
|
|
852
931
|
// Anchor is deeper — walk it up
|
|
853
932
|
anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
854
933
|
if (!anchor_root) return null;
|
|
855
|
-
anchor_root_path = anchor_root.dataset.path
|
|
934
|
+
anchor_root_path = deserialize_path(anchor_root.dataset.path);
|
|
856
935
|
anchor_walked_up = true;
|
|
857
936
|
} else {
|
|
858
937
|
// Same depth but different node arrays — walk both up
|
|
859
938
|
focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
860
939
|
if (!focus_root) return null;
|
|
861
|
-
focus_root_path = focus_root.dataset.path
|
|
940
|
+
focus_root_path = deserialize_path(focus_root.dataset.path);
|
|
862
941
|
focus_walked_up = true;
|
|
863
942
|
anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
864
943
|
if (!anchor_root) return null;
|
|
865
|
-
anchor_root_path = anchor_root.dataset.path
|
|
944
|
+
anchor_root_path = deserialize_path(anchor_root.dataset.path);
|
|
866
945
|
anchor_walked_up = true;
|
|
867
946
|
}
|
|
868
947
|
}
|
|
@@ -871,16 +950,18 @@ ${fallback_html}`;
|
|
|
871
950
|
// (e.g. selecting between nav and page body). The resulting path would be empty.
|
|
872
951
|
if (anchor_root_path.length <= 1) return null;
|
|
873
952
|
|
|
874
|
-
|
|
875
|
-
|
|
953
|
+
const parent_array_path = anchor_root_path.slice(0, -1);
|
|
954
|
+
// A node selection is only valid inside a node_array property.
|
|
955
|
+
const parent_property = session.inspect(parent_array_path);
|
|
956
|
+
if (!parent_property || parent_property.type !== 'node_array') return null;
|
|
957
|
+
|
|
958
|
+
let anchor_offset = Number(anchor_root_path.at(-1));
|
|
959
|
+
let focus_offset = Number(focus_root_path.at(-1));
|
|
876
960
|
|
|
877
961
|
// Check if it's a backwards selection
|
|
878
962
|
const is_backwards = __is_dom_selection_backwards();
|
|
879
|
-
if (is_backwards)
|
|
880
|
-
|
|
881
|
-
} else {
|
|
882
|
-
focus_offset += 1;
|
|
883
|
-
}
|
|
963
|
+
if (is_backwards) anchor_offset += 1;
|
|
964
|
+
else focus_offset += 1;
|
|
884
965
|
|
|
885
966
|
// EDGE CASE: Exclude first node when anchor_node is a gap-after
|
|
886
967
|
// in a non-collapsed forward selection.
|
|
@@ -910,7 +991,7 @@ ${fallback_html}`;
|
|
|
910
991
|
|
|
911
992
|
return {
|
|
912
993
|
type: 'node',
|
|
913
|
-
path:
|
|
994
|
+
path: parent_array_path,
|
|
914
995
|
anchor_offset: anchor_offset,
|
|
915
996
|
focus_offset: focus_offset
|
|
916
997
|
};
|
|
@@ -939,7 +1020,7 @@ ${fallback_html}`;
|
|
|
939
1020
|
if (focus_root === anchor_root) {
|
|
940
1021
|
return {
|
|
941
1022
|
type: 'property',
|
|
942
|
-
path: focus_root.dataset.path
|
|
1023
|
+
path: deserialize_path(focus_root.dataset.path)
|
|
943
1024
|
};
|
|
944
1025
|
}
|
|
945
1026
|
return null;
|
|
@@ -982,19 +1063,30 @@ ${fallback_html}`;
|
|
|
982
1063
|
range = dom_selection.getRangeAt(0);
|
|
983
1064
|
}
|
|
984
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
|
+
|
|
985
1080
|
let focus_root, anchor_root;
|
|
986
1081
|
|
|
987
1082
|
if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
|
|
988
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
|
|
989
1084
|
focus_root = anchor_root = focus_node;
|
|
990
1085
|
} else {
|
|
991
|
-
focus_root =
|
|
992
|
-
focus_node.parentElement?.closest('[data-path][data-type="text"]')
|
|
993
|
-
);
|
|
1086
|
+
focus_root = get_text_root(focus_node);
|
|
994
1087
|
if (!focus_root) return null;
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
);
|
|
1088
|
+
|
|
1089
|
+
anchor_root = get_text_root(anchor_node);
|
|
998
1090
|
if (!anchor_root) return null;
|
|
999
1091
|
}
|
|
1000
1092
|
|
|
@@ -1002,7 +1094,7 @@ ${fallback_html}`;
|
|
|
1002
1094
|
return null;
|
|
1003
1095
|
}
|
|
1004
1096
|
|
|
1005
|
-
const path = focus_root.dataset.path
|
|
1097
|
+
const path = deserialize_path(focus_root.dataset.path);
|
|
1006
1098
|
|
|
1007
1099
|
if (!path) return null;
|
|
1008
1100
|
|
|
@@ -1011,12 +1103,14 @@ ${fallback_html}`;
|
|
|
1011
1103
|
// AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
|
|
1012
1104
|
// When the user places their caret after this <br>, focusNode is the container
|
|
1013
1105
|
// element (not a text node), and normal processing would return position 0.
|
|
1014
|
-
// We detect this and return
|
|
1015
|
-
|
|
1016
|
-
|
|
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 ?? '');
|
|
1017
1110
|
const child_nodes = focus_root.childNodes;
|
|
1018
1111
|
|
|
1019
1112
|
if (
|
|
1113
|
+
range.collapsed &&
|
|
1020
1114
|
focus_node === anchor_node &&
|
|
1021
1115
|
focus_node === focus_root &&
|
|
1022
1116
|
focus_root.dataset?.type === 'text' &&
|
|
@@ -1037,43 +1131,21 @@ ${fallback_html}`;
|
|
|
1037
1131
|
return {
|
|
1038
1132
|
type: 'text',
|
|
1039
1133
|
path,
|
|
1040
|
-
anchor_offset:
|
|
1041
|
-
focus_offset:
|
|
1134
|
+
anchor_offset: dom_text_length,
|
|
1135
|
+
focus_offset: dom_text_length
|
|
1042
1136
|
};
|
|
1043
1137
|
}
|
|
1044
1138
|
}
|
|
1045
1139
|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
1052
|
-
const nodeText = node.textContent;
|
|
1053
|
-
const nodeCharLength = get_char_length(nodeText);
|
|
1054
|
-
if (node === range.startContainer) {
|
|
1055
|
-
// Convert UTF-16 offset to character offset
|
|
1056
|
-
const char_start_offset = utf16_to_char_offset(nodeText, range.startOffset);
|
|
1057
|
-
start_offset = current_offset + char_start_offset;
|
|
1058
|
-
}
|
|
1059
|
-
if (node === range.endContainer) {
|
|
1060
|
-
// Convert UTF-16 offset to character offset
|
|
1061
|
-
const char_end_offset = utf16_to_char_offset(nodeText, range.endOffset);
|
|
1062
|
-
end_offset = current_offset + char_end_offset;
|
|
1063
|
-
}
|
|
1064
|
-
current_offset += nodeCharLength;
|
|
1065
|
-
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1066
|
-
for (const childNode of node.childNodes) {
|
|
1067
|
-
processNode(childNode);
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
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());
|
|
1071
1145
|
}
|
|
1072
1146
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
if (processNode(childNode)) break;
|
|
1076
|
-
}
|
|
1147
|
+
const start_offset = get_text_offset(range.startContainer, range.startOffset);
|
|
1148
|
+
const end_offset = get_text_offset(range.endContainer, range.endOffset);
|
|
1077
1149
|
|
|
1078
1150
|
// Check if it's a backward selection
|
|
1079
1151
|
// When range is provided, we can't detect backward selection from the range alone
|
|
@@ -1094,18 +1166,35 @@ ${fallback_html}`;
|
|
|
1094
1166
|
|
|
1095
1167
|
function __get_node_element(node_array_path, node_offset) {
|
|
1096
1168
|
return canvas_el.querySelector(
|
|
1097
|
-
`[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
|
|
1098
1186
|
);
|
|
1099
1187
|
}
|
|
1100
1188
|
|
|
1101
1189
|
function __render_node_selection() {
|
|
1102
1190
|
const selection = /** @type {NodeSelection} */ (session.selection);
|
|
1103
|
-
const node_array_path = selection.path
|
|
1104
|
-
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);
|
|
1105
1194
|
const is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
|
|
1106
1195
|
|
|
1107
1196
|
const node_array_el = canvas_el.querySelector(
|
|
1108
|
-
`[data-path="${
|
|
1197
|
+
`[data-path="${node_array_path_str}"][data-type="node_array"]`
|
|
1109
1198
|
);
|
|
1110
1199
|
if (!node_array_el) return;
|
|
1111
1200
|
|
|
@@ -1113,7 +1202,7 @@ ${fallback_html}`;
|
|
|
1113
1202
|
const range = window.document.createRange();
|
|
1114
1203
|
|
|
1115
1204
|
const gap_selector = (offset) =>
|
|
1116
|
-
`[data-gap-array-path="${
|
|
1205
|
+
`[data-gap-array-path="${node_array_path_str}"][data-gap-offset="${offset}"]`;
|
|
1117
1206
|
|
|
1118
1207
|
if (is_collapsed) {
|
|
1119
1208
|
const gap_el = node_array_el.querySelector(gap_selector(selection.anchor_offset));
|
|
@@ -1146,24 +1235,66 @@ ${fallback_html}`;
|
|
|
1146
1235
|
}
|
|
1147
1236
|
}
|
|
1148
1237
|
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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);
|
|
1159
1289
|
}
|
|
1160
1290
|
|
|
1161
1291
|
function __render_property_selection() {
|
|
1162
1292
|
const selection = session.selection;
|
|
1163
1293
|
// The element that holds the property
|
|
1164
1294
|
const el = canvas_el.querySelector(
|
|
1165
|
-
`[data-path="${selection.path
|
|
1295
|
+
`[data-path="${serialize_path(selection.path)}"][data-type="property"]`
|
|
1166
1296
|
);
|
|
1297
|
+
|
|
1167
1298
|
const gap_selectable = el.querySelector('.svedit-selectable');
|
|
1168
1299
|
const range = window.document.createRange();
|
|
1169
1300
|
const dom_selection = window.getSelection();
|
|
@@ -1176,10 +1307,7 @@ ${fallback_html}`;
|
|
|
1176
1307
|
|
|
1177
1308
|
// Scroll the selection into view
|
|
1178
1309
|
setTimeout(() => {
|
|
1179
|
-
|
|
1180
|
-
if (selectedElement) {
|
|
1181
|
-
selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1182
|
-
}
|
|
1310
|
+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1183
1311
|
}, 0);
|
|
1184
1312
|
}
|
|
1185
1313
|
|
|
@@ -1187,7 +1315,7 @@ ${fallback_html}`;
|
|
|
1187
1315
|
const selection = /** @type {any} */ (session.selection);
|
|
1188
1316
|
// The element that holds the annotated string
|
|
1189
1317
|
const el = canvas_el.querySelector(
|
|
1190
|
-
`[data-path="${selection.path
|
|
1318
|
+
`[data-path="${serialize_path(selection.path)}"][data-type="text"]`
|
|
1191
1319
|
);
|
|
1192
1320
|
const empty_text = session.get(selection.path).text.length === 0;
|
|
1193
1321
|
const dom_selection = window.getSelection();
|
|
@@ -1274,7 +1402,6 @@ ${fallback_html}`;
|
|
|
1274
1402
|
focus_node,
|
|
1275
1403
|
focus_node_offset
|
|
1276
1404
|
);
|
|
1277
|
-
el.focus(); // needed?
|
|
1278
1405
|
|
|
1279
1406
|
// Scroll the selection into view
|
|
1280
1407
|
setTimeout(() => {
|
|
@@ -1289,6 +1416,52 @@ ${fallback_html}`;
|
|
|
1289
1416
|
// Utils
|
|
1290
1417
|
// --------------------------
|
|
1291
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
|
+
|
|
1292
1465
|
function __is_dom_selection_backwards() {
|
|
1293
1466
|
const dom_selection = window.getSelection();
|
|
1294
1467
|
|
|
@@ -1311,12 +1484,17 @@ ${fallback_html}`;
|
|
|
1311
1484
|
return comparisonRange.collapsed;
|
|
1312
1485
|
}
|
|
1313
1486
|
|
|
1314
|
-
// Whenever the model selection changes, render the selection
|
|
1487
|
+
// Whenever the model selection changes, render the selection.
|
|
1315
1488
|
// Skip when canvas is not focused to avoid stealing focus back
|
|
1316
|
-
// (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.
|
|
1317
1492
|
$effect(() => {
|
|
1493
|
+
session.selection;
|
|
1494
|
+
const dom_driven = selection_source_is_dom;
|
|
1495
|
+
selection_source_is_dom = false;
|
|
1318
1496
|
if (!canvas_focused) return;
|
|
1319
|
-
render_selection();
|
|
1497
|
+
render_selection(dom_driven);
|
|
1320
1498
|
});
|
|
1321
1499
|
</script>
|
|
1322
1500
|
|
|
@@ -1327,10 +1505,20 @@ ${fallback_html}`;
|
|
|
1327
1505
|
<svelte:document {onselectionchange} {oncut} {oncopy} {onpaste} />
|
|
1328
1506
|
|
|
1329
1507
|
<!-- TODO: move oncut/copy/paste handlers inside .svedit -->
|
|
1330
|
-
<div class="svedit">
|
|
1508
|
+
<div class="svedit" class:editable>
|
|
1331
1509
|
<!-- Overlays must be before canvas so they initialize first. -->
|
|
1332
|
-
<NodeSelectionMarkers />
|
|
1510
|
+
{#if editable}<NodeSelectionMarkers />{/if}
|
|
1333
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
|
+
-->
|
|
1334
1522
|
<div
|
|
1335
1523
|
class="svedit-canvas {css_class}"
|
|
1336
1524
|
class:hide-selection={session.selection?.type === 'node'}
|
|
@@ -1343,6 +1531,7 @@ ${fallback_html}`;
|
|
|
1343
1531
|
{oncompositionend}
|
|
1344
1532
|
onfocus={handle_canvas_focus}
|
|
1345
1533
|
onblur={handle_canvas_blur}
|
|
1534
|
+
inputmode={session.selection?.type === 'text' ? 'text' : 'none'}
|
|
1346
1535
|
contenteditable={editable ? 'true' : 'false'}
|
|
1347
1536
|
tabindex="-1"
|
|
1348
1537
|
{autocapitalize}
|
|
@@ -1363,7 +1552,7 @@ ${fallback_html}`;
|
|
|
1363
1552
|
|
|
1364
1553
|
<style>
|
|
1365
1554
|
.svedit-canvas {
|
|
1366
|
-
caret-color: var(--svedit-
|
|
1555
|
+
caret-color: var(--svedit-caret-color);
|
|
1367
1556
|
caret-shape: bar;
|
|
1368
1557
|
/* Default to vertical/ column flow with: --row: 0; (the most common case)
|
|
1369
1558
|
Prevents silent failures when developers forget to set the row property in their top level node component.
|
|
@@ -1374,7 +1563,11 @@ ${fallback_html}`;
|
|
|
1374
1563
|
}
|
|
1375
1564
|
}
|
|
1376
1565
|
|
|
1377
|
-
|
|
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) {
|
|
1378
1571
|
background: var(--svedit-editing-fill);
|
|
1379
1572
|
}
|
|
1380
1573
|
|