svedit 0.6.4 → 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 +14 -17
- 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 +153 -88
- 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 +8 -68
- 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 +14 -17
- 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 +153 -88
- package/src/lib/Transaction.svelte.js +8 -68
- 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
|
|
|
@@ -502,7 +503,7 @@ ${fallback_html}`;
|
|
|
502
503
|
* Attempts to paste JSON data as a node at the current selection.
|
|
503
504
|
*
|
|
504
505
|
* @param {string|object} pasted_json - The JSON data to paste, either as a string or parsed object
|
|
505
|
-
* @param {Selection} [selection] - Optional selection (node
|
|
506
|
+
* @param {Selection} [selection] - Optional selection (node caret) where the payload should be pasted
|
|
506
507
|
* @returns {boolean} True if the paste operation was successful, false otherwise
|
|
507
508
|
*/
|
|
508
509
|
function try_node_paste(pasted_json, selection) {
|
|
@@ -555,9 +556,9 @@ ${fallback_html}`;
|
|
|
555
556
|
return true;
|
|
556
557
|
} else {
|
|
557
558
|
if (tr.selection.path.length >= 2) {
|
|
558
|
-
const
|
|
559
|
-
if (
|
|
560
|
-
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);
|
|
561
562
|
}
|
|
562
563
|
}
|
|
563
564
|
}
|
|
@@ -640,8 +641,8 @@ ${fallback_html}`;
|
|
|
640
641
|
}
|
|
641
642
|
}
|
|
642
643
|
|
|
643
|
-
console.log('plain_text', plain_text);
|
|
644
|
-
console.log('pasted_json', pasted_json);
|
|
644
|
+
// console.log('plain_text', plain_text);
|
|
645
|
+
// console.log('pasted_json', pasted_json);
|
|
645
646
|
|
|
646
647
|
if (pasted_json?.main_nodes && session.selection?.type === 'node') {
|
|
647
648
|
// Paste nodes at a node selection
|
|
@@ -674,15 +675,15 @@ ${fallback_html}`;
|
|
|
674
675
|
pasted_json?.main_nodes?.length === 1 &&
|
|
675
676
|
session.kind(pasted_json?.nodes[pasted_json.main_nodes[0]]) === 'text'
|
|
676
677
|
) {
|
|
677
|
-
// Paste a single text node, at a text
|
|
678
|
+
// Paste a single text node, at a text caret
|
|
678
679
|
const text_property = pasted_json.nodes[pasted_json.main_nodes[0]].content;
|
|
679
680
|
session.apply(
|
|
680
681
|
session.tr.insert_text(text_property.text, text_property.annotations, pasted_json.nodes)
|
|
681
682
|
);
|
|
682
683
|
} else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
|
|
683
|
-
// Paste nodes at a text or property selection by finding the next valid insert
|
|
684
|
-
const
|
|
685
|
-
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);
|
|
686
687
|
} else if (typeof plain_text === 'string') {
|
|
687
688
|
// External paste: Fallback to plain text when no svedit data is found
|
|
688
689
|
session.apply(session.tr.insert_text(plain_text.trim()));
|
|
@@ -693,19 +694,19 @@ ${fallback_html}`;
|
|
|
693
694
|
|
|
694
695
|
function render_selection() {
|
|
695
696
|
const selection = /** @type {Selection} */ (session.selection);
|
|
696
|
-
let prev_selection =
|
|
697
|
-
__get_property_selection_from_dom() ||
|
|
698
|
-
__get_text_selection_from_dom() ||
|
|
699
|
-
__get_node_selection_from_dom();
|
|
700
697
|
|
|
701
698
|
if (!selection) {
|
|
702
|
-
// No model selection -> just leave things as they are
|
|
699
|
+
// No model selection -> just leave things as they are
|
|
703
700
|
let dom_selection = window.getSelection();
|
|
704
701
|
dom_selection.removeAllRanges();
|
|
705
702
|
return;
|
|
706
703
|
}
|
|
707
704
|
|
|
708
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();
|
|
709
710
|
if (
|
|
710
711
|
JSON.stringify(selection) === JSON.stringify(prev_selection) &&
|
|
711
712
|
canvas_el?.contains(document.activeElement)
|
|
@@ -725,9 +726,41 @@ ${fallback_html}`;
|
|
|
725
726
|
}
|
|
726
727
|
}
|
|
727
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
|
+
|
|
728
756
|
function focus_canvas() {
|
|
729
|
-
//
|
|
730
|
-
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();
|
|
731
764
|
}
|
|
732
765
|
|
|
733
766
|
/**
|
|
@@ -749,14 +782,14 @@ ${fallback_html}`;
|
|
|
749
782
|
if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
|
|
750
783
|
if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
|
|
751
784
|
|
|
752
|
-
// EDGE CASE: Let's first check if we are in a
|
|
753
|
-
let
|
|
754
|
-
focus_node && focus_node?.closest('[data-type="after
|
|
755
|
-
if (
|
|
756
|
-
// Find the node that this
|
|
757
|
-
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"]'));
|
|
758
791
|
if (!node) {
|
|
759
|
-
console.log('No corresponding node found for after
|
|
792
|
+
console.log('No corresponding node found for gap-after');
|
|
760
793
|
return null;
|
|
761
794
|
}
|
|
762
795
|
const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
|
|
@@ -770,11 +803,11 @@ ${fallback_html}`;
|
|
|
770
803
|
};
|
|
771
804
|
}
|
|
772
805
|
|
|
773
|
-
// EDGE CASE: Let's check if we are in a
|
|
774
|
-
let
|
|
775
|
-
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) {
|
|
776
809
|
const node_array_el = /** @type {HTMLElement} */ (
|
|
777
|
-
|
|
810
|
+
gap_before_el.closest('[data-type="node_array"]')
|
|
778
811
|
);
|
|
779
812
|
const node_array_path = node_array_el.dataset.path.split('.');
|
|
780
813
|
return {
|
|
@@ -804,22 +837,38 @@ ${fallback_html}`;
|
|
|
804
837
|
let focus_node_depth = focus_root_path.length;
|
|
805
838
|
let anchor_node_depth = anchor_root_path.length;
|
|
806
839
|
|
|
807
|
-
//
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
+
}
|
|
823
872
|
}
|
|
824
873
|
|
|
825
874
|
let anchor_offset = parseInt(anchor_root_path.at(-1));
|
|
@@ -833,19 +882,24 @@ ${fallback_html}`;
|
|
|
833
882
|
focus_offset += 1;
|
|
834
883
|
}
|
|
835
884
|
|
|
836
|
-
// EDGE CASE: Exclude first node when anchor_node is
|
|
837
|
-
// 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.
|
|
838
889
|
if (
|
|
839
|
-
|
|
890
|
+
!anchor_walked_up &&
|
|
891
|
+
anchor_node.parentElement?.dataset.type === 'gap-after' &&
|
|
840
892
|
!is_backwards &&
|
|
841
893
|
anchor_offset !== focus_offset
|
|
842
894
|
) {
|
|
843
895
|
anchor_offset += 1;
|
|
844
896
|
}
|
|
845
|
-
// EDGE CASE: Exclude first node when focus_node is
|
|
846
|
-
// 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.
|
|
847
900
|
else if (
|
|
848
|
-
|
|
901
|
+
!focus_walked_up &&
|
|
902
|
+
focus_node.parentElement?.dataset.type === 'gap-after' &&
|
|
849
903
|
is_backwards &&
|
|
850
904
|
anchor_offset !== focus_offset &&
|
|
851
905
|
// EDGE CASE: Only do correction when drag started from a deeper or equally deep anchor node
|
|
@@ -931,7 +985,7 @@ ${fallback_html}`;
|
|
|
931
985
|
let focus_root, anchor_root;
|
|
932
986
|
|
|
933
987
|
if (focus_node === anchor_node && focus_node.dataset?.type === 'text') {
|
|
934
|
-
// 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
|
|
935
989
|
focus_root = anchor_root = focus_node;
|
|
936
990
|
} else {
|
|
937
991
|
focus_root = /** @type {HTMLElement} */ (
|
|
@@ -952,10 +1006,10 @@ ${fallback_html}`;
|
|
|
952
1006
|
|
|
953
1007
|
if (!path) return null;
|
|
954
1008
|
|
|
955
|
-
// EDGE CASE 1B:
|
|
1009
|
+
// EDGE CASE 1B: Caret after trailing <br> at end of text
|
|
956
1010
|
//
|
|
957
1011
|
// AnnotatedTextProperty renders a trailing <br> for non-empty or non-focused text.
|
|
958
|
-
// When the user places their
|
|
1012
|
+
// When the user places their caret after this <br>, focusNode is the container
|
|
959
1013
|
// element (not a text node), and normal processing would return position 0.
|
|
960
1014
|
// We detect this and return text_length instead.
|
|
961
1015
|
const text_content = session.get(path).text;
|
|
@@ -974,7 +1028,7 @@ ${fallback_html}`;
|
|
|
974
1028
|
last_element_index--;
|
|
975
1029
|
}
|
|
976
1030
|
|
|
977
|
-
// Check if
|
|
1031
|
+
// Check if caret is at or after the trailing <br>
|
|
978
1032
|
if (
|
|
979
1033
|
last_element_index >= 0 &&
|
|
980
1034
|
child_nodes[last_element_index].nodeName === 'BR' &&
|
|
@@ -1078,16 +1132,19 @@ ${fallback_html}`;
|
|
|
1078
1132
|
const range = window.document.createRange();
|
|
1079
1133
|
|
|
1080
1134
|
if (is_collapsed) {
|
|
1081
|
-
//
|
|
1082
|
-
// IMPORTANT: We need to look for direct children of anchor_node to find the right
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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);
|
|
1091
1148
|
dom_selection.removeAllRanges();
|
|
1092
1149
|
dom_selection.addRange(range);
|
|
1093
1150
|
} else {
|
|
@@ -1145,12 +1202,12 @@ ${fallback_html}`;
|
|
|
1145
1202
|
const el = canvas_el.querySelector(
|
|
1146
1203
|
`[data-path="${selection.path.join('.')}"][data-type="property"]`
|
|
1147
1204
|
);
|
|
1148
|
-
const
|
|
1205
|
+
const gap_selectable = el.querySelector('.svedit-selectable');
|
|
1149
1206
|
const range = window.document.createRange();
|
|
1150
1207
|
const dom_selection = window.getSelection();
|
|
1151
1208
|
|
|
1152
|
-
// Select the entire
|
|
1153
|
-
range.selectNodeContents(
|
|
1209
|
+
// Select the entire gap element contents and collapse to start
|
|
1210
|
+
range.selectNodeContents(gap_selectable);
|
|
1154
1211
|
range.collapse(true); // Collapse to start position
|
|
1155
1212
|
dom_selection.removeAllRanges();
|
|
1156
1213
|
dom_selection.addRange(range);
|
|
@@ -1233,7 +1290,7 @@ ${fallback_html}`;
|
|
|
1233
1290
|
// EDGE CASE: When text is empty, we need to set a different DOM selection
|
|
1234
1291
|
if (start_offset === end_offset && start_offset === 0 && empty_text) {
|
|
1235
1292
|
// Markup for empty text looks like this `<div data-type="text"><br></div>`.
|
|
1236
|
-
// And the correct
|
|
1293
|
+
// And the correct caret position is after the <br> element.
|
|
1237
1294
|
anchor_node = el;
|
|
1238
1295
|
anchor_node_offset = 1;
|
|
1239
1296
|
focus_node = el;
|
|
@@ -1293,7 +1350,10 @@ ${fallback_html}`;
|
|
|
1293
1350
|
}
|
|
1294
1351
|
|
|
1295
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)
|
|
1296
1355
|
$effect(() => {
|
|
1356
|
+
if (!canvas_focused) return;
|
|
1297
1357
|
render_selection();
|
|
1298
1358
|
});
|
|
1299
1359
|
</script>
|
|
@@ -1306,10 +1366,13 @@ ${fallback_html}`;
|
|
|
1306
1366
|
|
|
1307
1367
|
<!-- TODO: move oncut/copy/paste handlers inside .svedit -->
|
|
1308
1368
|
<div class="svedit">
|
|
1369
|
+
<!-- Overlays must be before canvas so they initialize first. -->
|
|
1370
|
+
<NodeSelectionMarkers />
|
|
1371
|
+
{#if Overlays}<Overlays />{/if}
|
|
1309
1372
|
<div
|
|
1310
1373
|
class="svedit-canvas {css_class}"
|
|
1311
1374
|
class:hide-selection={session.selection?.type === 'node'}
|
|
1312
|
-
class:node-
|
|
1375
|
+
class:node-caret={session.selection?.type === 'node' &&
|
|
1313
1376
|
session.selection.anchor_offset === session.selection.focus_offset}
|
|
1314
1377
|
class:property-selection={session.selection?.type === 'property'}
|
|
1315
1378
|
bind:this={canvas_el}
|
|
@@ -1334,21 +1397,23 @@ ${fallback_html}`;
|
|
|
1334
1397
|
>
|
|
1335
1398
|
<RootComponent {path} />
|
|
1336
1399
|
</div>
|
|
1337
|
-
<Overlays />
|
|
1338
1400
|
</div>
|
|
1339
1401
|
|
|
1340
1402
|
<style>
|
|
1341
1403
|
.svedit-canvas {
|
|
1342
|
-
|
|
1343
|
-
caret-color: var(--editing-stroke-color);
|
|
1404
|
+
caret-color: var(--svedit-editing-stroke);
|
|
1344
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;
|
|
1345
1410
|
&:focus {
|
|
1346
1411
|
outline: none;
|
|
1347
1412
|
}
|
|
1348
1413
|
}
|
|
1349
1414
|
|
|
1350
1415
|
.svedit-canvas :global(::selection) {
|
|
1351
|
-
background: var(--editing-fill
|
|
1416
|
+
background: var(--svedit-editing-fill);
|
|
1352
1417
|
}
|
|
1353
1418
|
|
|
1354
1419
|
@media not (pointer: coarse) {
|
|
@@ -1357,8 +1422,8 @@ ${fallback_html}`;
|
|
|
1357
1422
|
}
|
|
1358
1423
|
}
|
|
1359
1424
|
|
|
1360
|
-
/* When the
|
|
1361
|
-
.svedit-canvas.node-
|
|
1425
|
+
/* When the caret is in a node gap we never want to see the caret */
|
|
1426
|
+
.svedit-canvas.node-caret,
|
|
1362
1427
|
.svedit-canvas.property-selection {
|
|
1363
1428
|
caret-color: transparent;
|
|
1364
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);
|
|
@@ -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) => {
|