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/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
|
|
|
@@ -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
|
}
|
|
@@ -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"}
|