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/dist/Session.svelte.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Transaction from './Transaction.svelte.js';
|
|
2
|
-
import { char_slice,
|
|
2
|
+
import { char_slice, traverse } from './utils.js';
|
|
3
3
|
import {
|
|
4
4
|
get as doc_get,
|
|
5
5
|
property_type as doc_property_type,
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
count_references as doc_count_references,
|
|
10
10
|
validate_document_schema,
|
|
11
11
|
validate_node,
|
|
12
|
-
get_active_annotation
|
|
12
|
+
get_active_annotation,
|
|
13
|
+
validate_selection
|
|
13
14
|
} from './doc_utils.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -33,7 +34,7 @@ const BATCH_WINDOW_MS = 1000; // 1 second
|
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
36
|
export default class Session {
|
|
36
|
-
/** @type {Selection |
|
|
37
|
+
/** @type {Selection | null} */
|
|
37
38
|
#selection = $state.raw(null);
|
|
38
39
|
|
|
39
40
|
/** @type {DocumentSchema} */
|
|
@@ -83,7 +84,7 @@ export default class Session {
|
|
|
83
84
|
|
|
84
85
|
/**
|
|
85
86
|
* Gets the current selection
|
|
86
|
-
* @returns {Selection |
|
|
87
|
+
* @returns {Selection | null}
|
|
87
88
|
*/
|
|
88
89
|
get selection() {
|
|
89
90
|
return this.#selection;
|
|
@@ -91,13 +92,11 @@ export default class Session {
|
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* Sets the selection with validation
|
|
94
|
-
* @param {Selection |
|
|
95
|
+
* @param {Selection | null} value - The new selection
|
|
95
96
|
* @throws {Error} Throws if the selection is invalid
|
|
96
97
|
*/
|
|
97
98
|
set selection(value) {
|
|
98
|
-
|
|
99
|
-
// this._validate_selection(value);
|
|
100
|
-
// }
|
|
99
|
+
this._validate_selection(value);
|
|
101
100
|
this.#selection = value;
|
|
102
101
|
}
|
|
103
102
|
|
|
@@ -109,62 +108,7 @@ export default class Session {
|
|
|
109
108
|
* @private
|
|
110
109
|
*/
|
|
111
110
|
_validate_selection(selection) {
|
|
112
|
-
|
|
113
|
-
if (selection && !Array.isArray(selection.path)) {
|
|
114
|
-
throw new Error('Selection must have a valid path');
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const selection_type = selection.type;
|
|
118
|
-
if (selection_type === 'node') {
|
|
119
|
-
// For node selections, path should point to a node_array
|
|
120
|
-
if (this.inspect(selection.path).type !== 'node_array') {
|
|
121
|
-
throw new Error(
|
|
122
|
-
`Node selection path does not point to a node array: ${selection.path.join('.')}`
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const node_array = this.get(selection.path);
|
|
127
|
-
|
|
128
|
-
// Validate anchor_offset and focus_offset are within bounds
|
|
129
|
-
const max_offset = node_array.length;
|
|
130
|
-
|
|
131
|
-
if (selection.anchor_offset < 0 || selection.anchor_offset > max_offset) {
|
|
132
|
-
throw new Error(
|
|
133
|
-
`Node selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${max_offset})`
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (selection.focus_offset < 0 || selection.focus_offset > max_offset) {
|
|
138
|
-
throw new Error(
|
|
139
|
-
`Node selection focus_offset ${selection.focus_offset} is out of bounds (0-${max_offset})`
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
} else if (selection_type === 'text') {
|
|
143
|
-
// For text selections, path should point to an annotated_text property
|
|
144
|
-
const annotated_text = this.get(selection.path);
|
|
145
|
-
|
|
146
|
-
// Validate anchor_offset and focus_offset are within text bounds
|
|
147
|
-
const char_length = get_char_length(annotated_text.text);
|
|
148
|
-
|
|
149
|
-
if (selection.anchor_offset < 0 || selection.anchor_offset > char_length) {
|
|
150
|
-
throw new Error(
|
|
151
|
-
`Text selection anchor_offset ${selection.anchor_offset} is out of bounds (0-${char_length})`
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (selection.focus_offset < 0 || selection.focus_offset > char_length) {
|
|
156
|
-
throw new Error(
|
|
157
|
-
`Text selection focus_offset ${selection.focus_offset} is out of bounds (0-${char_length})`
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
} else if (selection_type === 'property') {
|
|
161
|
-
// For property selections, just validate the path exists
|
|
162
|
-
if (!this.inspect(selection.path)) {
|
|
163
|
-
throw new Error(`Property selection path not found: ${selection.path.join('.')}`);
|
|
164
|
-
}
|
|
165
|
-
} else {
|
|
166
|
-
throw new Error(`Unknown selection type: ${selection_type}`);
|
|
167
|
-
}
|
|
111
|
+
validate_selection(selection, this);
|
|
168
112
|
}
|
|
169
113
|
|
|
170
114
|
/**
|
|
@@ -410,18 +354,18 @@ export default class Session {
|
|
|
410
354
|
}
|
|
411
355
|
|
|
412
356
|
// No insert position found yet, and root not reached, try one level up if possible
|
|
413
|
-
let
|
|
414
|
-
if (!
|
|
415
|
-
return this.can_insert(node_type,
|
|
357
|
+
let next_node_insert_caret = this.get_next_node_insert_caret(selection);
|
|
358
|
+
if (!next_node_insert_caret) return false;
|
|
359
|
+
return this.can_insert(node_type, next_node_insert_caret);
|
|
416
360
|
}
|
|
417
361
|
|
|
418
362
|
/**
|
|
419
363
|
* Compute next possible insert position from a given selection
|
|
420
364
|
*
|
|
421
365
|
* @param {Selection} [selection] - Reference selection
|
|
422
|
-
* @returns {Selection}
|
|
366
|
+
* @returns {Selection|null} The next node insert caret selection, or null if none is available
|
|
423
367
|
*/
|
|
424
|
-
|
|
368
|
+
get_next_node_insert_caret(selection = this.selection) {
|
|
425
369
|
// There's no parent path to insert into
|
|
426
370
|
if (!selection || selection.path.length <= 2) {
|
|
427
371
|
return null;
|
package/dist/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
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AAm3CA;;eAAqD;iCAt1C1C,cAAc"}
|
|
@@ -93,7 +93,7 @@ export default class Transaction {
|
|
|
93
93
|
*/
|
|
94
94
|
get available_annotation_types(): string[];
|
|
95
95
|
/**
|
|
96
|
-
* Returns the annotation object that is currently "under the
|
|
96
|
+
* Returns the annotation object that is currently "under the caret".
|
|
97
97
|
* NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
|
|
98
98
|
*
|
|
99
99
|
* @param {string} [annotation_type] Optional annotation type to filter by
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Transaction.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Transaction.svelte.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Transaction.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Transaction.svelte.js"],"names":[],"mappings":"AAwBA;;;;;;;;;;;;;;GAcG;AACH;IACC;;;;;;;OAOG;IACH,oBALW,cAAc,OACd,QAAQ,aACR,SAAS,GAAG,IAAI,UAChB,MAAM,EAgBhB;IAbA,uBAAoB;IACpB,qBAAqB;IACrB,KADW,MAAM,CACH;IACd,wBAAwB;IACxB,WADW,SAAS,CACM;IAC1B,YAAoB;IAEpB,oCAAoC;IACpC,KADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACnB;IACb,oCAAoC;IACpC,aADW,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,CACX;IAErB,4BAAiC;IAGlC;;;;;OAKG;IACH,UAHW,YAAY,GAAC,MAAM,GACjB,GAAG,CAIf;IAED;;;;;;OAMG;IACH,oBAJW,MAAM,YACN,MAAM,GACJ,MAAM,CAIlB;IAED;;;;;OAKG;IACH,WAHW,GAAG,GACD,OAAO,GAAC,MAAM,GAAC,YAAY,CAIvC;IAED;;;;;OAKG;IACH,cAHW,YAAY,GACV;QAAC,IAAI,EAAE,UAAU,GAAC,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAC,CAIzD;IAED;;;;OAIG;IACH,eAFa,MAAM,CAIlB;IAED;;;;;OAKG;IACH,oBAHW,GAAG,QAKb;IAED;;;;;OAKG;IACH,8BAHW,MAAM,GACJ,MAAM,EAAE,CAKpB;IAED;;;;OAIG;IACH,kCAFa,MAAM,EAAE,CAOpB;IAED;;;;;;OAMG;IACH,oCAHW,MAAM,GACJ,UAAU,GAAC,IAAI,CAI3B;IAED;;;;;OAKG;IACH,kBAEC;IAED;;;;;;;;;;;;;;;OAeG;IACH,UAVW,YAAY,SACZ,GAAG,GACD,WAAW,CAmDvB;IAKD,qCAsDC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAbW,GAAG,GACD,WAAW,CAyBvB;IAED;;;;;;;;;;;;OAYG;IACH,WARW,GAAG,GACD,WAAW,CAsBvB;IAED;;;;;;OAMG;IACH,yBAJW,SAAS,GACP,WAAW,CAOvB;IAED;;;;;;OAMG;IACH,4BAEC;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,+BAbW,GAAG,yBACH,GAAG,GACD,WAAW,CA+DvB;IAED;;;;;;;;;;;OAWG;IACH,6BAHW,UAAU,GAAG,SAAS,GACpB,WAAW,CA0IvB;IAED;;;;;;;;OAQG;IACH,uBAHW,MAAM,EAAE,GACN,WAAW,CA2BvB;IAED;;;;;;;;;;;;OAYG;IACH,2BALW,MAAM,+BAEN,MAAM,GACJ,WAAW,CA2FvB;IAED;;;;;;;;;OASG;IACH,2CAgCC;IAED;;;;;;;;;;OAUG;IACH,4CAOC;CACD;oCAlyByF,cAAc;+BAAd,cAAc;kCAAd,cAAc;4BAAd,cAAc;gCAAd,cAAc;8BAAd,cAAc"}
|