svedit 0.6.3 → 0.7.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 +117 -45
- package/dist/AnnotatedTextProperty.svelte +19 -16
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.d.ts +3 -3
- package/dist/Command.svelte.js +5 -5
- package/dist/CustomProperty.svelte +5 -4
- package/dist/Node.svelte +29 -14
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +29 -9
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeCaret.svelte +71 -0
- package/dist/NodeCaret.svelte.d.ts +27 -0
- package/dist/NodeCaret.svelte.d.ts.map +1 -0
- package/dist/NodeGap.svelte +249 -0
- package/dist/NodeGap.svelte.d.ts +20 -0
- package/dist/NodeGap.svelte.d.ts.map +1 -0
- package/dist/NodeGapMarkers.svelte +456 -0
- package/dist/NodeGapMarkers.svelte.d.ts +12 -0
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -0
- package/dist/NodeSelectionMarkers.svelte +49 -0
- package/dist/NodeSelectionMarkers.svelte.d.ts +7 -0
- package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -0
- package/dist/Session.svelte.d.ts +6 -6
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +13 -69
- package/dist/Svedit.svelte +158 -90
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +1 -1
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +10 -70
- package/dist/doc_utils.d.ts +12 -0
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +61 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/node_gap_computation.svelte.d.ts +5 -0
- package/dist/node_gap_computation.svelte.d.ts.map +1 -0
- package/dist/node_gap_computation.svelte.js +621 -0
- package/dist/styles/svedit-colors.css +8 -0
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +7 -10
- package/dist/types.d.ts +56 -68
- package/dist/utils.d.ts +0 -3
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +10 -10
- package/package.json +21 -21
- package/src/lib/AnnotatedTextProperty.svelte +19 -16
- package/src/lib/Command.svelte.js +5 -5
- package/src/lib/CustomProperty.svelte +5 -4
- package/src/lib/Node.svelte +29 -14
- package/src/lib/NodeArrayProperty.svelte +29 -9
- package/src/lib/NodeCaret.svelte +71 -0
- package/src/lib/NodeGap.svelte +249 -0
- package/src/lib/NodeGapMarkers.svelte +456 -0
- package/src/lib/NodeSelectionMarkers.svelte +49 -0
- package/src/lib/Session.svelte.js +13 -69
- package/src/lib/Svedit.svelte +158 -90
- package/src/lib/Transaction.svelte.js +10 -70
- package/src/lib/doc_utils.js +61 -2
- package/src/lib/index.js +4 -0
- package/src/lib/node_gap_computation.svelte.js +621 -0
- package/src/lib/styles/svedit-colors.css +8 -0
- package/src/lib/transforms.svelte.js +7 -10
- package/src/lib/types.d.ts +56 -68
- package/src/lib/utils.js +10 -10
package/src/lib/Svedit.svelte
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import { getContext, setContext } from 'svelte';
|
|
2
|
+
import { flushSync, getContext, setContext } from 'svelte';
|
|
3
3
|
import {
|
|
4
4
|
snake_to_pascal,
|
|
5
5
|
get_char_length,
|
|
6
6
|
utf16_to_char_offset,
|
|
7
7
|
char_to_utf16_offset
|
|
8
8
|
} from './utils.js';
|
|
9
|
+
import { create_gap_computation } from './node_gap_computation.svelte.js';
|
|
10
|
+
import DefaultNodeSelectionMarkers from './NodeSelectionMarkers.svelte';
|
|
11
|
+
import './styles/svedit-colors.css';
|
|
9
12
|
|
|
10
13
|
/** @import {
|
|
11
14
|
* SveditProps,
|
|
@@ -30,12 +33,15 @@
|
|
|
30
33
|
|
|
31
34
|
let canvas_el;
|
|
32
35
|
let root_node = $derived(session.get(path));
|
|
33
|
-
let Overlays = $derived(session.config.system_components
|
|
36
|
+
let Overlays = $derived(session.config.system_components?.Overlays);
|
|
37
|
+
let NodeSelectionMarkers = $derived(session.config.system_components?.NodeSelectionMarkers ?? DefaultNodeSelectionMarkers);
|
|
34
38
|
let RootComponent = $derived(session.config.node_components[snake_to_pascal(root_node.type)]);
|
|
35
39
|
|
|
36
40
|
let is_composing = $state(false);
|
|
41
|
+
let canvas_focused = $state(false);
|
|
37
42
|
let before_composition_selection = null;
|
|
38
43
|
|
|
44
|
+
|
|
39
45
|
// let is_mobile = $derived(is_mobile_browser());
|
|
40
46
|
// let is_chrome_desktop = $derived(is_chrome_desktop_browser());
|
|
41
47
|
|
|
@@ -58,10 +64,14 @@
|
|
|
58
64
|
get canvas_el() {
|
|
59
65
|
return canvas_el;
|
|
60
66
|
},
|
|
67
|
+
get canvas_focused() {
|
|
68
|
+
return canvas_focused;
|
|
69
|
+
},
|
|
61
70
|
focus_canvas
|
|
62
71
|
};
|
|
63
72
|
|
|
64
73
|
setContext('svedit', context);
|
|
74
|
+
create_gap_computation(context);
|
|
65
75
|
|
|
66
76
|
// Get KeyMapper from context (may be undefined if not provided)
|
|
67
77
|
const key_mapper = getContext('key_mapper');
|
|
@@ -71,16 +81,6 @@
|
|
|
71
81
|
session.initialize_commands(context);
|
|
72
82
|
});
|
|
73
83
|
|
|
74
|
-
// Handle focus - push session's keymap onto stack
|
|
75
|
-
function handle_canvas_focus() {
|
|
76
|
-
key_mapper?.push_scope(session.keymap);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Handle blur - pop document's keymap from stack
|
|
80
|
-
function handle_canvas_blur() {
|
|
81
|
-
key_mapper?.pop_scope();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
84
|
/**
|
|
85
85
|
* @param {InputEvent} event
|
|
86
86
|
*/
|
|
@@ -219,7 +219,7 @@
|
|
|
219
219
|
function oncompositionend(event) {
|
|
220
220
|
console.log('DEBUG: oncompositionend, insert:', event.data, event);
|
|
221
221
|
if (!canvas_el?.contains(document.activeElement)) return;
|
|
222
|
-
if (
|
|
222
|
+
if (session.selection?.type === 'text') {
|
|
223
223
|
// We need to remember the user's selection, as it might have changed in the process
|
|
224
224
|
// of finishing a composition. For instance, the user might have selected a different
|
|
225
225
|
// part of the text while composing.
|
|
@@ -260,6 +260,7 @@
|
|
|
260
260
|
// Map DOM selection to internal model
|
|
261
261
|
function onselectionchange() {
|
|
262
262
|
if (!editable) return;
|
|
263
|
+
if (!canvas_focused) return;
|
|
263
264
|
const dom_selection = window.getSelection();
|
|
264
265
|
if (!dom_selection.rangeCount) return;
|
|
265
266
|
|
|
@@ -423,7 +424,8 @@ ${fallback_html}`;
|
|
|
423
424
|
* @param {boolean} delete_selection - used by oncut()
|
|
424
425
|
*/
|
|
425
426
|
function oncopy(event, delete_selection = false) {
|
|
426
|
-
// Only handle copy events if focus is within the canvas
|
|
427
|
+
// Only handle copy events if editable and focus is within the canvas
|
|
428
|
+
if (!editable) return;
|
|
427
429
|
if (!canvas_el?.contains(document.activeElement)) return;
|
|
428
430
|
|
|
429
431
|
event.preventDefault();
|
|
@@ -493,6 +495,7 @@ ${fallback_html}`;
|
|
|
493
495
|
}
|
|
494
496
|
|
|
495
497
|
function oncut(event) {
|
|
498
|
+
if (!editable) return;
|
|
496
499
|
oncopy(event, true);
|
|
497
500
|
}
|
|
498
501
|
|
|
@@ -500,7 +503,7 @@ ${fallback_html}`;
|
|
|
500
503
|
* Attempts to paste JSON data as a node at the current selection.
|
|
501
504
|
*
|
|
502
505
|
* @param {string|object} pasted_json - The JSON data to paste, either as a string or parsed object
|
|
503
|
-
* @param {Selection} [selection] - Optional selection (node
|
|
506
|
+
* @param {Selection} [selection] - Optional selection (node caret) where the payload should be pasted
|
|
504
507
|
* @returns {boolean} True if the paste operation was successful, false otherwise
|
|
505
508
|
*/
|
|
506
509
|
function try_node_paste(pasted_json, selection) {
|
|
@@ -553,9 +556,9 @@ ${fallback_html}`;
|
|
|
553
556
|
return true;
|
|
554
557
|
} else {
|
|
555
558
|
if (tr.selection.path.length >= 2) {
|
|
556
|
-
const
|
|
557
|
-
if (
|
|
558
|
-
try_node_paste(pasted_json,
|
|
559
|
+
const next_node_insert_caret = session.get_next_node_insert_caret(tr.selection);
|
|
560
|
+
if (next_node_insert_caret) {
|
|
561
|
+
try_node_paste(pasted_json, next_node_insert_caret);
|
|
559
562
|
}
|
|
560
563
|
}
|
|
561
564
|
}
|
|
@@ -563,7 +566,8 @@ ${fallback_html}`;
|
|
|
563
566
|
}
|
|
564
567
|
|
|
565
568
|
async function onpaste(event) {
|
|
566
|
-
// Only handle paste events if focus is within the canvas
|
|
569
|
+
// Only handle paste events if editable and focus is within the canvas
|
|
570
|
+
if (!editable) return;
|
|
567
571
|
if (!canvas_el?.contains(document.activeElement)) return;
|
|
568
572
|
event.preventDefault();
|
|
569
573
|
|
|
@@ -637,8 +641,8 @@ ${fallback_html}`;
|
|
|
637
641
|
}
|
|
638
642
|
}
|
|
639
643
|
|
|
640
|
-
console.log('plain_text', plain_text);
|
|
641
|
-
console.log('pasted_json', pasted_json);
|
|
644
|
+
// console.log('plain_text', plain_text);
|
|
645
|
+
// console.log('pasted_json', pasted_json);
|
|
642
646
|
|
|
643
647
|
if (pasted_json?.main_nodes && session.selection?.type === 'node') {
|
|
644
648
|
// Paste nodes at a node selection
|
|
@@ -671,15 +675,15 @@ ${fallback_html}`;
|
|
|
671
675
|
pasted_json?.main_nodes?.length === 1 &&
|
|
672
676
|
session.kind(pasted_json?.nodes[pasted_json.main_nodes[0]]) === 'text'
|
|
673
677
|
) {
|
|
674
|
-
// Paste a single text node, at a text
|
|
678
|
+
// Paste a single text node, at a text caret
|
|
675
679
|
const text_property = pasted_json.nodes[pasted_json.main_nodes[0]].content;
|
|
676
680
|
session.apply(
|
|
677
681
|
session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
|
|
678
682
|
);
|
|
679
683
|
} else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
|
|
680
|
-
// Paste nodes at a text or property selection by finding the next valid insert
|
|
681
|
-
const
|
|
682
|
-
try_node_paste(pasted_json,
|
|
684
|
+
// Paste nodes at a text or property selection by finding the next valid insert caret
|
|
685
|
+
const next_node_insert_caret = session.get_next_node_insert_caret(session.selection);
|
|
686
|
+
try_node_paste(pasted_json, next_node_insert_caret);
|
|
683
687
|
} else if (typeof plain_text === 'string') {
|
|
684
688
|
// External paste: Fallback to plain text when no svedit data is found
|
|
685
689
|
session.apply(session.tr.insert_text(plain_text.trim()));
|
|
@@ -690,19 +694,19 @@ ${fallback_html}`;
|
|
|
690
694
|
|
|
691
695
|
function render_selection() {
|
|
692
696
|
const selection = /** @type {Selection} */ (session.selection);
|
|
693
|
-
let prev_selection =
|
|
694
|
-
__get_property_selection_from_dom() ||
|
|
695
|
-
__get_text_selection_from_dom() ||
|
|
696
|
-
__get_node_selection_from_dom();
|
|
697
697
|
|
|
698
698
|
if (!selection) {
|
|
699
|
-
// No model selection -> just leave things as they are
|
|
699
|
+
// No model selection -> just leave things as they are
|
|
700
700
|
let dom_selection = window.getSelection();
|
|
701
701
|
dom_selection.removeAllRanges();
|
|
702
702
|
return;
|
|
703
703
|
}
|
|
704
704
|
|
|
705
705
|
// NOTE: Skip rerender only when the selection is the same and the focus is already within the canvas
|
|
706
|
+
let prev_selection =
|
|
707
|
+
__get_property_selection_from_dom() ||
|
|
708
|
+
__get_text_selection_from_dom() ||
|
|
709
|
+
__get_node_selection_from_dom();
|
|
706
710
|
if (
|
|
707
711
|
JSON.stringify(selection) === JSON.stringify(prev_selection) &&
|
|
708
712
|
canvas_el?.contains(document.activeElement)
|
|
@@ -722,9 +726,41 @@ ${fallback_html}`;
|
|
|
722
726
|
}
|
|
723
727
|
}
|
|
724
728
|
|
|
729
|
+
// Handle focus - push session's keymap onto stack
|
|
730
|
+
function handle_canvas_focus() {
|
|
731
|
+
// Use flushSync so highlight spans are removed from the DOM
|
|
732
|
+
// immediately, before the browser processes the click's selection.
|
|
733
|
+
flushSync(() => {
|
|
734
|
+
// Clear the model selection so render_selection() does not call
|
|
735
|
+
// setBaseAndExtent() with the old position, which would battle
|
|
736
|
+
// with the new selection the user is making (the click/drag that
|
|
737
|
+
// triggered this focus). The browser will place the caret and
|
|
738
|
+
// fire selectionchange, which sets the model selection correctly.
|
|
739
|
+
session.selection = null;
|
|
740
|
+
canvas_focused = true;
|
|
741
|
+
});
|
|
742
|
+
key_mapper?.push_scope(session.keymap);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Handle blur - pop document's keymap from stack
|
|
746
|
+
function handle_canvas_blur() {
|
|
747
|
+
// Use flushSync so the selection highlight span (with its CSS anchor)
|
|
748
|
+
// is in the DOM immediately, before any popover/dialog tries to
|
|
749
|
+
// position itself.
|
|
750
|
+
flushSync(() => {
|
|
751
|
+
canvas_focused = false;
|
|
752
|
+
});
|
|
753
|
+
key_mapper?.pop_scope();
|
|
754
|
+
}
|
|
755
|
+
|
|
725
756
|
function focus_canvas() {
|
|
726
|
-
//
|
|
727
|
-
render_selection
|
|
757
|
+
// Use flushSync so highlight spans are removed from the DOM
|
|
758
|
+
// immediately, before we focus and render_selection walks the
|
|
759
|
+
// text nodes.
|
|
760
|
+
flushSync(() => {
|
|
761
|
+
canvas_focused = true;
|
|
762
|
+
});
|
|
763
|
+
canvas_el?.focus();
|
|
728
764
|
}
|
|
729
765
|
|
|
730
766
|
/**
|
|
@@ -746,14 +782,14 @@ ${fallback_html}`;
|
|
|
746
782
|
if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
|
|
747
783
|
if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
|
|
748
784
|
|
|
749
|
-
// EDGE CASE: Let's first check if we are in a
|
|
750
|
-
let
|
|
751
|
-
focus_node && focus_node?.closest('[data-type="after
|
|
752
|
-
if (
|
|
753
|
-
// Find the node that this
|
|
754
|
-
let node = /** @type {HTMLElement} */ (
|
|
785
|
+
// EDGE CASE: Let's first check if we are in a node gap for node carets
|
|
786
|
+
let gap_after_el =
|
|
787
|
+
focus_node && focus_node?.closest('[data-type="gap-after"]');
|
|
788
|
+
if (gap_after_el && focus_node === anchor_node) {
|
|
789
|
+
// Find the node that this gap belongs to
|
|
790
|
+
let node = /** @type {HTMLElement} */ (gap_after_el.closest('[data-type="node"]'));
|
|
755
791
|
if (!node) {
|
|
756
|
-
console.log('No corresponding node found for after
|
|
792
|
+
console.log('No corresponding node found for gap-after');
|
|
757
793
|
return null;
|
|
758
794
|
}
|
|
759
795
|
const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
|
|
@@ -767,11 +803,11 @@ ${fallback_html}`;
|
|
|
767
803
|
};
|
|
768
804
|
}
|
|
769
805
|
|
|
770
|
-
// EDGE CASE: Let's check if we are in a
|
|
771
|
-
let
|
|
772
|
-
if (
|
|
806
|
+
// EDGE CASE: Let's check if we are in a gap-before for node_arrays.
|
|
807
|
+
let gap_before_el = focus_node.closest('[data-type="gap-before"]');
|
|
808
|
+
if (gap_before_el && focus_node === anchor_node) {
|
|
773
809
|
const node_array_el = /** @type {HTMLElement} */ (
|
|
774
|
-
|
|
810
|
+
gap_before_el.closest('[data-type="node_array"]')
|
|
775
811
|
);
|
|
776
812
|
const node_array_path = node_array_el.dataset.path.split('.');
|
|
777
813
|
return {
|
|
@@ -801,22 +837,38 @@ ${fallback_html}`;
|
|
|
801
837
|
let focus_node_depth = focus_root_path.length;
|
|
802
838
|
let anchor_node_depth = anchor_root_path.length;
|
|
803
839
|
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
840
|
+
// Walk both endpoints up the DOM until they share the same parent node_array.
|
|
841
|
+
// This handles selections that span across arbitrarily nested node arrays by
|
|
842
|
+
// finding the lowest common ancestor node_array and projecting each endpoint
|
|
843
|
+
// onto its index within that array.
|
|
844
|
+
let focus_walked_up = false;
|
|
845
|
+
let anchor_walked_up = false;
|
|
846
|
+
while (
|
|
847
|
+
focus_root_path.slice(0, -1).join('.') !== anchor_root_path.slice(0, -1).join('.')
|
|
848
|
+
) {
|
|
849
|
+
if (focus_root_path.length > anchor_root_path.length) {
|
|
850
|
+
// Focus is deeper — walk it up
|
|
851
|
+
focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
852
|
+
if (!focus_root) return null;
|
|
853
|
+
focus_root_path = focus_root.dataset.path.split('.');
|
|
854
|
+
focus_walked_up = true;
|
|
855
|
+
} else if (anchor_root_path.length > focus_root_path.length) {
|
|
856
|
+
// Anchor is deeper — walk it up
|
|
857
|
+
anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
858
|
+
if (!anchor_root) return null;
|
|
859
|
+
anchor_root_path = anchor_root.dataset.path.split('.');
|
|
860
|
+
anchor_walked_up = true;
|
|
861
|
+
} else {
|
|
862
|
+
// Same depth but different node arrays — walk both up
|
|
863
|
+
focus_root = focus_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
864
|
+
if (!focus_root) return null;
|
|
865
|
+
focus_root_path = focus_root.dataset.path.split('.');
|
|
866
|
+
focus_walked_up = true;
|
|
867
|
+
anchor_root = anchor_root.parentElement?.closest('[data-path][data-type="node"]');
|
|
868
|
+
if (!anchor_root) return null;
|
|
869
|
+
anchor_root_path = anchor_root.dataset.path.split('.');
|
|
870
|
+
anchor_walked_up = true;
|
|
871
|
+
}
|
|
820
872
|
}
|
|
821
873
|
|
|
822
874
|
let anchor_offset = parseInt(anchor_root_path.at(-1));
|
|
@@ -830,19 +882,24 @@ ${fallback_html}`;
|
|
|
830
882
|
focus_offset += 1;
|
|
831
883
|
}
|
|
832
884
|
|
|
833
|
-
// EDGE CASE: Exclude first node when anchor_node is
|
|
834
|
-
// in a non-collapsed forward selection
|
|
885
|
+
// EDGE CASE: Exclude first node when anchor_node is a gap-after
|
|
886
|
+
// in a non-collapsed forward selection.
|
|
887
|
+
// Only apply when anchor wasn't walked up — if it was, the gap is at a
|
|
888
|
+
// deeper nesting level and no longer relevant to the resolved node array.
|
|
835
889
|
if (
|
|
836
|
-
|
|
890
|
+
!anchor_walked_up &&
|
|
891
|
+
anchor_node.parentElement?.dataset.type === 'gap-after' &&
|
|
837
892
|
!is_backwards &&
|
|
838
893
|
anchor_offset !== focus_offset
|
|
839
894
|
) {
|
|
840
895
|
anchor_offset += 1;
|
|
841
896
|
}
|
|
842
|
-
// EDGE CASE: Exclude first node when focus_node is
|
|
843
|
-
// in a non-collapsed backward selection
|
|
897
|
+
// EDGE CASE: Exclude first node when focus_node is a gap-after
|
|
898
|
+
// in a non-collapsed backward selection.
|
|
899
|
+
// Only apply when focus wasn't walked up — same reasoning as above.
|
|
844
900
|
else if (
|
|
845
|
-
|
|
901
|
+
!focus_walked_up &&
|
|
902
|
+
focus_node.parentElement?.dataset.type === 'gap-after' &&
|
|
846
903
|
is_backwards &&
|
|
847
904
|
anchor_offset !== focus_offset &&
|
|
848
905
|
// EDGE CASE: Only do correction when drag started from a deeper or equally deep anchor node
|
|
@@ -928,7 +985,7 @@ ${fallback_html}`;
|
|
|
928
985
|
let focus_root, anchor_root;
|
|
929
986
|
|
|
930
987
|
if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
|
|
931
|
-
// EDGE CASE 1: Either text node is empty (only a <br> is present), or
|
|
988
|
+
// 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
|
|
932
989
|
focus_root = anchor_root = focus_node;
|
|
933
990
|
} else {
|
|
934
991
|
focus_root = /** @type {HTMLElement} */ (
|
|
@@ -949,10 +1006,10 @@ ${fallback_html}`;
|
|
|
949
1006
|
|
|
950
1007
|
if (!path) return null;
|
|
951
1008
|
|
|
952
|
-
// EDGE CASE 1B:
|
|
1009
|
+
// EDGE CASE 1B: Caret after trailing <br> at end of text
|
|
953
1010
|
//
|
|
954
1011
|
// AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
|
|
955
|
-
// When the user places their
|
|
1012
|
+
// When the user places their caret after this <br>, focusNode is the container
|
|
956
1013
|
// element (not a text node), and normal processing would return position 0.
|
|
957
1014
|
// We detect this and return text_length instead.
|
|
958
1015
|
const text_content = session.get(path).text;
|
|
@@ -971,7 +1028,7 @@ ${fallback_html}`;
|
|
|
971
1028
|
last_element_index--;
|
|
972
1029
|
}
|
|
973
1030
|
|
|
974
|
-
// Check if
|
|
1031
|
+
// Check if caret is at or after the trailing <br>
|
|
975
1032
|
if (
|
|
976
1033
|
last_element_index >= 0 &&
|
|
977
1034
|
child_nodes[last_element_index].nodeName === 'BR' &&
|
|
@@ -1075,16 +1132,19 @@ ${fallback_html}`;
|
|
|
1075
1132
|
const range = window.document.createRange();
|
|
1076
1133
|
|
|
1077
1134
|
if (is_collapsed) {
|
|
1078
|
-
//
|
|
1079
|
-
// IMPORTANT: We need to look for direct children of anchor_node to find the right
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1135
|
+
// Caret position in between two nodes or at the very beginning/end of a node_array
|
|
1136
|
+
// IMPORTANT: We need to look for direct children of anchor_node to find the right node gap.
|
|
1137
|
+
const gap_selector = selection.anchor_offset === 0
|
|
1138
|
+
? '.gap-before'
|
|
1139
|
+
: '.gap-after';
|
|
1140
|
+
// In empty arrays the node gap is a sibling of the placeholder, not a child
|
|
1141
|
+
const gap_el =
|
|
1142
|
+
anchor_node.querySelector(`:scope > ${gap_selector}`) ??
|
|
1143
|
+
anchor_node.parentElement?.querySelector(`:scope > ${gap_selector}`);
|
|
1144
|
+
|
|
1145
|
+
if (!gap_el) return;
|
|
1146
|
+
range.setStart(gap_el, 1);
|
|
1147
|
+
range.setEnd(gap_el, 1);
|
|
1088
1148
|
dom_selection.removeAllRanges();
|
|
1089
1149
|
dom_selection.addRange(range);
|
|
1090
1150
|
} else {
|
|
@@ -1142,12 +1202,12 @@ ${fallback_html}`;
|
|
|
1142
1202
|
const el = canvas_el.querySelector(
|
|
1143
1203
|
`[data-path="${selection.path.join('.')}"][data-type="property"]`
|
|
1144
1204
|
);
|
|
1145
|
-
const
|
|
1205
|
+
const gap_selectable = el.querySelector('.svedit-selectable');
|
|
1146
1206
|
const range = window.document.createRange();
|
|
1147
1207
|
const dom_selection = window.getSelection();
|
|
1148
1208
|
|
|
1149
|
-
// Select the entire
|
|
1150
|
-
range.selectNodeContents(
|
|
1209
|
+
// Select the entire gap element contents and collapse to start
|
|
1210
|
+
range.selectNodeContents(gap_selectable);
|
|
1151
1211
|
range.collapse(true); // Collapse to start position
|
|
1152
1212
|
dom_selection.removeAllRanges();
|
|
1153
1213
|
dom_selection.addRange(range);
|
|
@@ -1230,7 +1290,7 @@ ${fallback_html}`;
|
|
|
1230
1290
|
// EDGE CASE: When text is empty, we need to set a different DOM selection
|
|
1231
1291
|
if (start_offset === end_offset && start_offset === 0 && empty_text) {
|
|
1232
1292
|
// Markup for empty text looks like this `<div data-type="text"><br></div>`.
|
|
1233
|
-
// And the correct
|
|
1293
|
+
// And the correct caret position is after the <br> element.
|
|
1234
1294
|
anchor_node = el;
|
|
1235
1295
|
anchor_node_offset = 1;
|
|
1236
1296
|
focus_node = el;
|
|
@@ -1290,7 +1350,10 @@ ${fallback_html}`;
|
|
|
1290
1350
|
}
|
|
1291
1351
|
|
|
1292
1352
|
// Whenever the model selection changes, render the selection
|
|
1353
|
+
// Skip when canvas is not focused to avoid stealing focus back
|
|
1354
|
+
// (e.g., when a dialog is open and selection highlight fragments re-render)
|
|
1293
1355
|
$effect(() => {
|
|
1356
|
+
if (!canvas_focused) return;
|
|
1294
1357
|
render_selection();
|
|
1295
1358
|
});
|
|
1296
1359
|
</script>
|
|
@@ -1303,10 +1366,13 @@ ${fallback_html}`;
|
|
|
1303
1366
|
|
|
1304
1367
|
<!-- TODO: move oncut/copy/paste handlers inside .svedit -->
|
|
1305
1368
|
<div class="svedit">
|
|
1369
|
+
<!-- Overlays must be before canvas so they initialize first. -->
|
|
1370
|
+
<NodeSelectionMarkers />
|
|
1371
|
+
{#if Overlays}<Overlays />{/if}
|
|
1306
1372
|
<div
|
|
1307
1373
|
class="svedit-canvas {css_class}"
|
|
1308
1374
|
class:hide-selection={session.selection?.type === 'node'}
|
|
1309
|
-
class:node-
|
|
1375
|
+
class:node-caret={session.selection?.type === 'node' &&
|
|
1310
1376
|
session.selection.anchor_offset === session.selection.focus_offset}
|
|
1311
1377
|
class:property-selection={session.selection?.type === 'property'}
|
|
1312
1378
|
bind:this={canvas_el}
|
|
@@ -1331,21 +1397,23 @@ ${fallback_html}`;
|
|
|
1331
1397
|
>
|
|
1332
1398
|
<RootComponent {path} />
|
|
1333
1399
|
</div>
|
|
1334
|
-
<Overlays />
|
|
1335
1400
|
</div>
|
|
1336
1401
|
|
|
1337
1402
|
<style>
|
|
1338
1403
|
.svedit-canvas {
|
|
1339
|
-
|
|
1340
|
-
caret-color: var(--editing-stroke-color);
|
|
1404
|
+
caret-color: var(--svedit-editing-stroke);
|
|
1341
1405
|
caret-shape: bar;
|
|
1406
|
+
/* Default to vertical/ column flow with: --row: 0; (the most common case)
|
|
1407
|
+
Prevents silent failures when developers forget to set the row property in their top level node component.
|
|
1408
|
+
TODO: Warn developers in dev mode via console if they forget to set the --row property and use a different flow.*/
|
|
1409
|
+
--row: 0;
|
|
1342
1410
|
&:focus {
|
|
1343
1411
|
outline: none;
|
|
1344
1412
|
}
|
|
1345
1413
|
}
|
|
1346
1414
|
|
|
1347
1415
|
.svedit-canvas :global(::selection) {
|
|
1348
|
-
background: var(--editing-fill
|
|
1416
|
+
background: var(--svedit-editing-fill);
|
|
1349
1417
|
}
|
|
1350
1418
|
|
|
1351
1419
|
@media not (pointer: coarse) {
|
|
@@ -1354,8 +1422,8 @@ ${fallback_html}`;
|
|
|
1354
1422
|
}
|
|
1355
1423
|
}
|
|
1356
1424
|
|
|
1357
|
-
/* When the
|
|
1358
|
-
.svedit-canvas.node-
|
|
1425
|
+
/* When the caret is in a node gap we never want to see the caret */
|
|
1426
|
+
.svedit-canvas.node-caret,
|
|
1359
1427
|
.svedit-canvas.property-selection {
|
|
1360
1428
|
caret-color: transparent;
|
|
1361
1429
|
}
|
|
@@ -18,7 +18,8 @@ import {
|
|
|
18
18
|
apply_op,
|
|
19
19
|
count_references_excluding_deleted,
|
|
20
20
|
validate_node,
|
|
21
|
-
get_active_annotation
|
|
21
|
+
get_active_annotation,
|
|
22
|
+
validate_selection
|
|
22
23
|
} from './doc_utils.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -145,7 +146,7 @@ export default class Transaction {
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
/**
|
|
148
|
-
* Returns the annotation object that is currently "under the
|
|
149
|
+
* Returns the annotation object that is currently "under the caret".
|
|
149
150
|
* NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
|
|
150
151
|
*
|
|
151
152
|
* @param {string} [annotation_type] Optional annotation type to filter by
|
|
@@ -336,6 +337,7 @@ export default class Transaction {
|
|
|
336
337
|
const previous_value = this.get(id);
|
|
337
338
|
if (!previous_value) {
|
|
338
339
|
console.warn(`Deletion of node ${id} skipped, as it does not exist.`);
|
|
340
|
+
return this;
|
|
339
341
|
}
|
|
340
342
|
// Get nodes referenced by this node BEFORE deleting it.
|
|
341
343
|
const referenced_nodes = this.get_referenced_nodes(id);
|
|
@@ -343,9 +345,7 @@ export default class Transaction {
|
|
|
343
345
|
this.ops.push(op);
|
|
344
346
|
this.inverse_ops.push(['create', previous_value]);
|
|
345
347
|
this._apply_op(op);
|
|
346
|
-
//
|
|
347
|
-
// NOTE: We don't do this yet, because we still have some manual child node removal code
|
|
348
|
-
// that causes errors. But we should soon implement this only here.
|
|
348
|
+
// Cascade delete any nodes that are now orphaned after removing this node
|
|
349
349
|
this._cascade_delete_unreferenced_nodes(referenced_nodes);
|
|
350
350
|
return this;
|
|
351
351
|
}
|
|
@@ -371,50 +371,7 @@ export default class Transaction {
|
|
|
371
371
|
* @private
|
|
372
372
|
*/
|
|
373
373
|
_validate_selection(selection) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const selection_type = selection.type;
|
|
377
|
-
if (!['node', 'text', 'property'].includes(selection_type)) {
|
|
378
|
-
throw new Error(`Invalid selection type: ${selection_type}`);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
if (selection_type === 'node') {
|
|
382
|
-
const node_array = this.get(selection.path);
|
|
383
|
-
|
|
384
|
-
if (!Array.isArray(node_array)) {
|
|
385
|
-
throw new Error('Node selection path must point to a node_array');
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const max_offset = node_array.length;
|
|
389
|
-
if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
|
|
390
|
-
throw new Error(
|
|
391
|
-
`Node selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${max_offset}.`
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
|
|
395
|
-
throw new Error(
|
|
396
|
-
`Node selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${max_offset}.`
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
} else if (selection_type === 'text') {
|
|
400
|
-
const annotated_text = this.get(selection.path);
|
|
401
|
-
|
|
402
|
-
if (!annotated_text || typeof annotated_text.text !== 'string') {
|
|
403
|
-
throw new Error('Text selection path must point to annotated_text');
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
const char_length = get_char_length(annotated_text.text);
|
|
407
|
-
if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
|
|
408
|
-
throw new Error(
|
|
409
|
-
`Text selection anchor_offset (${selection.anchor_offset}) is out of bounds. Max is ${char_length}.`
|
|
410
|
-
);
|
|
411
|
-
}
|
|
412
|
-
if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
|
|
413
|
-
throw new Error(
|
|
414
|
-
`Text selection focus_offset (${selection.focus_offset}) is out of bounds. Max is ${char_length}.`
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
|
-
}
|
|
374
|
+
validate_selection(selection, this);
|
|
418
375
|
}
|
|
419
376
|
|
|
420
377
|
/**
|
|
@@ -653,7 +610,7 @@ export default class Transaction {
|
|
|
653
610
|
insert_nodes(node_ids) {
|
|
654
611
|
if (this.selection.type !== 'node') return this;
|
|
655
612
|
|
|
656
|
-
// Unless
|
|
613
|
+
// Unless caret is collapsed, delete the selected nodes as a first step
|
|
657
614
|
if (this.selection.anchor_offset !== this.selection.focus_offset) {
|
|
658
615
|
this.delete_selection();
|
|
659
616
|
}
|
|
@@ -661,14 +618,7 @@ export default class Transaction {
|
|
|
661
618
|
const path = this.selection.path;
|
|
662
619
|
const node_array = [...this.get(path)];
|
|
663
620
|
|
|
664
|
-
// Get the start and end indices for the selection
|
|
665
621
|
let start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
|
|
666
|
-
let end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
|
|
667
|
-
|
|
668
|
-
if (start !== end) {
|
|
669
|
-
// Remove the selected nodes from the node_array
|
|
670
|
-
node_array.splice(start, end - start);
|
|
671
|
-
}
|
|
672
622
|
|
|
673
623
|
// Insert the new nodes
|
|
674
624
|
node_array.splice(start, 0, ...node_ids);
|
|
@@ -727,8 +677,8 @@ export default class Transaction {
|
|
|
727
677
|
return annotation;
|
|
728
678
|
}
|
|
729
679
|
|
|
730
|
-
//
|
|
731
|
-
if (annotation_start
|
|
680
|
+
// Insertion point is inside the annotation (not at the start) - extend it
|
|
681
|
+
if (annotation_start < range.start_offset && annotation_end >= range.start_offset) {
|
|
732
682
|
return {
|
|
733
683
|
start_offset: annotation_start,
|
|
734
684
|
end_offset: annotation_end + delta,
|
|
@@ -744,16 +694,6 @@ export default class Transaction {
|
|
|
744
694
|
node_id
|
|
745
695
|
};
|
|
746
696
|
}
|
|
747
|
-
|
|
748
|
-
// Insertion point is inside the annotation - extend the annotation
|
|
749
|
-
if (annotation_start < range.start_offset && annotation_end > range.start_offset) {
|
|
750
|
-
return {
|
|
751
|
-
start_offset: annotation_start,
|
|
752
|
-
end_offset: annotation_end + delta,
|
|
753
|
-
node_id
|
|
754
|
-
};
|
|
755
|
-
}
|
|
756
|
-
|
|
757
697
|
return annotation;
|
|
758
698
|
});
|
|
759
699
|
|
|
@@ -771,7 +711,7 @@ export default class Transaction {
|
|
|
771
711
|
this.selection = new_selection;
|
|
772
712
|
|
|
773
713
|
// Now we apply annotations if there are any, but only if there's no active annotation
|
|
774
|
-
// at the current collapsed
|
|
714
|
+
// at the current collapsed caret
|
|
775
715
|
if (!this.active_annotation() && annotations.length > 0) {
|
|
776
716
|
const new_annotations = annotations
|
|
777
717
|
.map((annotation) => {
|