svedit 0.1.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/LICENSE +21 -0
- package/README.md +323 -0
- package/dist/AnnotatedStringProperty.svelte +126 -0
- package/dist/AnnotatedStringProperty.svelte.d.ts +7 -0
- package/dist/AnnotatedStringProperty.svelte.d.ts.map +1 -0
- package/dist/CustomProperty.svelte +40 -0
- package/dist/CustomProperty.svelte.d.ts +8 -0
- package/dist/CustomProperty.svelte.d.ts.map +1 -0
- package/dist/Document.svelte.d.ts +191 -0
- package/dist/Document.svelte.d.ts.map +1 -0
- package/dist/Document.svelte.js +681 -0
- package/dist/Node.svelte +41 -0
- package/dist/Node.svelte.d.ts +7 -0
- package/dist/Node.svelte.d.ts.map +1 -0
- package/dist/NodeArrayProperty.svelte +45 -0
- package/dist/NodeArrayProperty.svelte.d.ts +7 -0
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -0
- package/dist/Svedit.svelte +936 -0
- package/dist/Svedit.svelte.d.ts +12 -0
- package/dist/Svedit.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.d.ts +198 -0
- package/dist/Transaction.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.js +577 -0
- package/dist/UnknownNode.svelte +12 -0
- package/dist/UnknownNode.svelte.d.ts +12 -0
- package/dist/UnknownNode.svelte.d.ts.map +1 -0
- package/dist/commands.svelte.d.ts +5 -0
- package/dist/commands.svelte.d.ts.map +1 -0
- package/dist/commands.svelte.js +261 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/types.d.ts +264 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +78 -0
- package/package.json +65 -0
- package/src/lib/AnnotatedStringProperty.svelte +126 -0
- package/src/lib/CustomProperty.svelte +40 -0
- package/src/lib/Document.svelte.js +681 -0
- package/src/lib/Node.svelte +41 -0
- package/src/lib/NodeArrayProperty.svelte +45 -0
- package/src/lib/Svedit.svelte +936 -0
- package/src/lib/Transaction.svelte.js +577 -0
- package/src/lib/UnknownNode.svelte +12 -0
- package/src/lib/commands.svelte.js +261 -0
- package/src/lib/index.js +14 -0
- package/src/lib/types.d.ts +264 -0
- package/src/lib/util.js +78 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { setContext } from 'svelte';
|
|
3
|
+
import { snake_to_pascal } from './util.js';
|
|
4
|
+
import { break_text_node, join_text_node, insert_default_node, select_all } from './commands.svelte.js';
|
|
5
|
+
|
|
6
|
+
/** @import { SveditProps, DocumentPath, Selection, TextSelection, NodeSelection, PropertySelection, NodeId } from './types.d.ts'; */
|
|
7
|
+
/** @type {SveditProps} */
|
|
8
|
+
let {
|
|
9
|
+
doc,
|
|
10
|
+
editable = false,
|
|
11
|
+
path,
|
|
12
|
+
class: css_class,
|
|
13
|
+
} = $props();
|
|
14
|
+
|
|
15
|
+
let canvas_ref;
|
|
16
|
+
let root_node = $derived(doc.get(path));
|
|
17
|
+
let Overlays = $derived(doc.config.system_components.Overlays);
|
|
18
|
+
let RootComponent = $derived(doc.config.node_components[snake_to_pascal(root_node.type)]);
|
|
19
|
+
|
|
20
|
+
/** Expose function so parent can call it */
|
|
21
|
+
export { focus_canvas };
|
|
22
|
+
|
|
23
|
+
setContext("svedit", {
|
|
24
|
+
get doc() {
|
|
25
|
+
return doc;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {InputEvent} event
|
|
31
|
+
*/
|
|
32
|
+
function onbeforeinput(event) {
|
|
33
|
+
const inserted_char = event.data;
|
|
34
|
+
|
|
35
|
+
event.preventDefault();
|
|
36
|
+
if (inserted_char) {
|
|
37
|
+
const tr = doc.tr;
|
|
38
|
+
tr.insert_text(inserted_char);
|
|
39
|
+
doc.apply(tr);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Map DOM selection to internal model
|
|
44
|
+
function onselectionchange() {
|
|
45
|
+
const dom_selection = window.getSelection();
|
|
46
|
+
if (!dom_selection.rangeCount) return;
|
|
47
|
+
|
|
48
|
+
// Only handle selection changes if selection is within the canvas
|
|
49
|
+
const range = dom_selection.getRangeAt(0);
|
|
50
|
+
if (!canvas_ref?.contains(range.commonAncestorContainer)) return;
|
|
51
|
+
let selection = __get_property_selection_from_dom() || __get_text_selection_from_dom() || __get_node_selection_from_dom();
|
|
52
|
+
if (selection) {
|
|
53
|
+
doc.selection = selection;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {NodeId[]} selected_node_ids
|
|
60
|
+
*/
|
|
61
|
+
function prepare_copy_payload(selected_node_ids) {
|
|
62
|
+
const nodes = {};
|
|
63
|
+
|
|
64
|
+
// Get subgraph for each selected node using doc.traverse()
|
|
65
|
+
for (const node_id of selected_node_ids) {
|
|
66
|
+
const subgraph = doc.traverse(node_id);
|
|
67
|
+
|
|
68
|
+
// Add all nodes from this subgraph to our nodes collection
|
|
69
|
+
for (const node of subgraph) {
|
|
70
|
+
if (!nodes[node.id]) {
|
|
71
|
+
nodes[node.id] = node;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Keep original IDs - we'll generate new ones during paste
|
|
77
|
+
return { nodes, main_nodes: selected_node_ids };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {ClipboardEvent} event
|
|
83
|
+
* @param {boolean} delete_selection - used by oncut()
|
|
84
|
+
*/
|
|
85
|
+
function oncopy(event, delete_selection = false) {
|
|
86
|
+
// Only handle copy events if focus is within the canvas
|
|
87
|
+
if (!canvas_ref?.contains(document.activeElement)) return;
|
|
88
|
+
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
event.stopPropagation();
|
|
91
|
+
|
|
92
|
+
let plain_text, html, json_data;
|
|
93
|
+
|
|
94
|
+
if (doc.selection?.type === 'text') {
|
|
95
|
+
plain_text = doc.get_selected_plain_text();
|
|
96
|
+
html = plain_text;
|
|
97
|
+
} else if (doc.selection?.type === 'node') {
|
|
98
|
+
const selected_nodes = doc.get_selected_nodes();
|
|
99
|
+
const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
|
|
100
|
+
|
|
101
|
+
json_data = { nodes, main_nodes };
|
|
102
|
+
|
|
103
|
+
console.log('Copy operation:', {
|
|
104
|
+
selected_nodes,
|
|
105
|
+
main_nodes,
|
|
106
|
+
total_nodes: Object.keys(nodes).length,
|
|
107
|
+
operation: delete_selection ? 'cut' : 'copy'
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Create a ClipboardItem with multiple formats
|
|
112
|
+
const data = {
|
|
113
|
+
'text/plain': new Blob([plain_text], {type: 'text/plain'}),
|
|
114
|
+
'text/html': new Blob([html], {type: 'text/html'}),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (json_data) {
|
|
118
|
+
const json_blob = new Blob([JSON.stringify(json_data)], {type: 'application/json'});
|
|
119
|
+
data['web application/json'] = json_blob;
|
|
120
|
+
}
|
|
121
|
+
const clipboard_item_raw = new ClipboardItem(data);
|
|
122
|
+
|
|
123
|
+
// Write to clipboard
|
|
124
|
+
navigator.clipboard.write([clipboard_item_raw]).then(() => {
|
|
125
|
+
console.log('Data copied to clipboard successfully');
|
|
126
|
+
}).catch(err => {
|
|
127
|
+
console.error('Failed to copy data: ', err);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
if (delete_selection) {
|
|
131
|
+
doc.apply(doc.tr.delete_selection());
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function oncut(event) {
|
|
136
|
+
oncopy(event, true);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function onpaste(event) {
|
|
140
|
+
// Only handle paste events if focus is within the canvas
|
|
141
|
+
if (!canvas_ref?.contains(document.activeElement)) return;
|
|
142
|
+
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
const clipboardItems = await navigator.clipboard.read();
|
|
145
|
+
|
|
146
|
+
let pasted_json;
|
|
147
|
+
|
|
148
|
+
// Wrapping this in a try-catch as this API only works in Chrome. We fallback to
|
|
149
|
+
// plaintext copy and pasting for all other situations.
|
|
150
|
+
try {
|
|
151
|
+
const json_blob = await clipboardItems[0].getType('web application/json');
|
|
152
|
+
pasted_json = JSON.parse(await json_blob.text());
|
|
153
|
+
} catch {
|
|
154
|
+
pasted_json = undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (pasted_json) {
|
|
158
|
+
const tr = doc.tr;
|
|
159
|
+
|
|
160
|
+
// Handle new format: { nodes: {id -> node}, main_nodes: [id1, id2, ...] }
|
|
161
|
+
if (pasted_json.nodes && pasted_json.main_nodes) {
|
|
162
|
+
const { nodes, main_nodes } = pasted_json;
|
|
163
|
+
|
|
164
|
+
// Generate new IDs for all nodes during paste
|
|
165
|
+
const id_mapping = {};
|
|
166
|
+
for (const node_id in nodes) {
|
|
167
|
+
id_mapping[node_id] = doc.generate_id();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Create nodes with new IDs and updated references
|
|
171
|
+
const updated_nodes = {};
|
|
172
|
+
for (const [old_id, node] of Object.entries(nodes)) {
|
|
173
|
+
const new_node = { ...node };
|
|
174
|
+
new_node.id = id_mapping[old_id];
|
|
175
|
+
|
|
176
|
+
// Update all property references to use new IDs
|
|
177
|
+
for (const [property, value] of Object.entries(new_node)) {
|
|
178
|
+
if (property === 'id' || property === 'type') continue;
|
|
179
|
+
|
|
180
|
+
const prop_type = doc.schema[node.type]?.[property]?.type;
|
|
181
|
+
|
|
182
|
+
if (prop_type === 'node_array' && Array.isArray(value)) {
|
|
183
|
+
new_node[property] = value.map(ref_id => id_mapping[ref_id] || ref_id);
|
|
184
|
+
} else if (prop_type === 'node' && typeof value === 'string') {
|
|
185
|
+
new_node[property] = id_mapping[value] || value;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
updated_nodes[new_node.id] = new_node;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Create all nodes first
|
|
193
|
+
for (const node of Object.values(updated_nodes)) {
|
|
194
|
+
tr.create(node);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Then insert the main nodes at the current selection
|
|
198
|
+
const new_main_nodes = main_nodes.map(id => id_mapping[id]);
|
|
199
|
+
const main_node_objects = new_main_nodes.map(id => updated_nodes[id]);
|
|
200
|
+
tr.insert_nodes(main_node_objects);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
doc.apply(tr);
|
|
204
|
+
} else {
|
|
205
|
+
const plain_text_blob = await clipboardItems[0].getType('text/plain');
|
|
206
|
+
// Convert the Blob to text
|
|
207
|
+
const plain_text = await plain_text_blob.text();
|
|
208
|
+
doc.apply(doc.tr.insert_text(plain_text));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function render_selection() {
|
|
213
|
+
const selection = /** @type {Selection} */ (doc.selection);
|
|
214
|
+
let prev_selection = __get_property_selection_from_dom() || __get_text_selection_from_dom() || __get_node_selection_from_dom();
|
|
215
|
+
|
|
216
|
+
if (!selection) {
|
|
217
|
+
// No model selection -> just leave things as they are'
|
|
218
|
+
let dom_selection = window.getSelection();
|
|
219
|
+
dom_selection.removeAllRanges();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// NOTE: Skip rerender only when the selection is the same and the focus is already within the canvas
|
|
224
|
+
if (JSON.stringify(selection) === JSON.stringify(prev_selection) && canvas_ref?.contains(document.activeElement)) {
|
|
225
|
+
// Skip. No need to rerender.
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (selection?.type === 'text') {
|
|
230
|
+
__render_text_selection();
|
|
231
|
+
} else if (selection?.type === 'node') {
|
|
232
|
+
__render_node_selection();
|
|
233
|
+
} else if (selection?.type === 'property') {
|
|
234
|
+
__render_property_selection();
|
|
235
|
+
} else {
|
|
236
|
+
console.log('unsupported selection', selection);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function focus_toolbar() {
|
|
241
|
+
// Find the first interactive element in the toolbar and focus it
|
|
242
|
+
const toolbar = document.querySelector('.editor-toolbar');
|
|
243
|
+
if (toolbar) {
|
|
244
|
+
const firstInteractive = /** @type {HTMLElement} */ (toolbar.querySelector('input, button, select, textarea'));
|
|
245
|
+
if (firstInteractive) {
|
|
246
|
+
firstInteractive.focus();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function focus_canvas() {
|
|
252
|
+
// We just render the selection (which will return focus to the canvas) implicitly
|
|
253
|
+
render_selection();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function onkeydown(e) {
|
|
257
|
+
// Only handle keyboard events if focus is within the canvas
|
|
258
|
+
if (!canvas_ref?.contains(document.activeElement)) return;
|
|
259
|
+
|
|
260
|
+
const selection = /** @type {any} */ (doc.selection);
|
|
261
|
+
const is_collapsed = selection?.anchor_offset === selection?.focus_offset;
|
|
262
|
+
|
|
263
|
+
// console.log('onkeydown', e.key);
|
|
264
|
+
if (e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.selected_node) {
|
|
265
|
+
const node = doc.selected_node;
|
|
266
|
+
const layout_count = doc.config.node_layouts[node.type];
|
|
267
|
+
|
|
268
|
+
if (layout_count > 1 && node?.layout) {
|
|
269
|
+
const next_layout = (node.layout % layout_count) + 1;
|
|
270
|
+
console.log('layout / count / next_layout', node.layout, layout_count, next_layout);
|
|
271
|
+
const tr = doc.tr;
|
|
272
|
+
tr.set([doc.selected_node?.id, 'layout'], next_layout);
|
|
273
|
+
doc.apply(tr);
|
|
274
|
+
}
|
|
275
|
+
} else if (e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.selected_node) {
|
|
276
|
+
const node = doc.selected_node;
|
|
277
|
+
const layout_count = doc.config.node_layouts[node.type];
|
|
278
|
+
if (layout_count > 1 && node?.layout) {
|
|
279
|
+
const prev_layout = ((node.layout - 2 + layout_count) % layout_count) + 1;
|
|
280
|
+
const tr = doc.tr;
|
|
281
|
+
tr.set([doc.selected_node?.id, 'layout'], prev_layout);
|
|
282
|
+
doc.apply(tr);
|
|
283
|
+
console.log('layout / count / prev_layout', node.layout, layout_count, prev_layout);
|
|
284
|
+
}
|
|
285
|
+
} else if (e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.selected_node) {
|
|
286
|
+
const node = doc.selected_node;
|
|
287
|
+
|
|
288
|
+
if (doc.selection.type !== 'node') {
|
|
289
|
+
doc.select_parent();
|
|
290
|
+
}
|
|
291
|
+
const old_selection = { ...doc.selection };
|
|
292
|
+
|
|
293
|
+
const node_array_schema = doc.inspect(doc.selection.path);
|
|
294
|
+
// If we are not dealing with a node selection in a container, return
|
|
295
|
+
if (node_array_schema.type !== 'node_array') return;
|
|
296
|
+
|
|
297
|
+
const current_type_index = node_array_schema.node_types.indexOf(node.type);
|
|
298
|
+
const next_type_index = (current_type_index + 1) % node_array_schema.node_types.length;
|
|
299
|
+
const next_type = node_array_schema.node_types[next_type_index];
|
|
300
|
+
const tr = doc.tr;
|
|
301
|
+
doc.config.inserters[next_type](tr);
|
|
302
|
+
tr.set_selection(old_selection);
|
|
303
|
+
doc.apply(tr);
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
} else if (e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.selected_node) {
|
|
307
|
+
const node = doc.selected_node;
|
|
308
|
+
|
|
309
|
+
if (doc.selection.type !== 'node') {
|
|
310
|
+
doc.select_parent();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// if (selection.type !== 'node') return;
|
|
314
|
+
const old_selection = { ...doc.selection };
|
|
315
|
+
const node_array_schema = doc.inspect(doc.selection.path);
|
|
316
|
+
// If we are not dealing with a node selection in a container, return
|
|
317
|
+
if (node_array_schema.type !== 'node_array') return;
|
|
318
|
+
|
|
319
|
+
const current_type_index = node_array_schema.node_types.indexOf(node.type);
|
|
320
|
+
// return (current_layout_number - 1 + layout_count) % layout_count;
|
|
321
|
+
const prev_type_index = (current_type_index - 1 + node_array_schema.node_types.length) % node_array_schema.node_types.length;
|
|
322
|
+
const prev_type = node_array_schema.node_types[prev_type_index];
|
|
323
|
+
const tr = doc.tr;
|
|
324
|
+
doc.config.inserters[prev_type](tr);
|
|
325
|
+
tr.set_selection(old_selection);
|
|
326
|
+
doc.apply(tr);
|
|
327
|
+
|
|
328
|
+
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
|
329
|
+
const tr = doc.tr;
|
|
330
|
+
if (select_all(tr)) {
|
|
331
|
+
doc.apply(tr);
|
|
332
|
+
}
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
e.stopPropagation();
|
|
335
|
+
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
|
336
|
+
doc.undo();
|
|
337
|
+
e.preventDefault();
|
|
338
|
+
e.stopPropagation();
|
|
339
|
+
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey) && e.shiftKey) {
|
|
340
|
+
doc.redo();
|
|
341
|
+
e.preventDefault();
|
|
342
|
+
e.stopPropagation();
|
|
343
|
+
} else if (e.key === 'Enter' && (e.shiftKey)) {
|
|
344
|
+
doc.apply(doc.tr.insert_text('\n'));
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
e.stopPropagation();
|
|
347
|
+
} else if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
|
|
348
|
+
doc.apply(doc.tr.annotate_text('strong'));
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
e.stopPropagation();
|
|
351
|
+
} else if (e.key === 'i' && (e.ctrlKey || e.metaKey)) {
|
|
352
|
+
doc.apply(doc.tr.annotate_text('emphasis'));
|
|
353
|
+
e.preventDefault();
|
|
354
|
+
e.stopPropagation();
|
|
355
|
+
} else if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
|
|
356
|
+
doc.apply(doc.tr.annotate_text('link', {
|
|
357
|
+
href: window.prompt('Enter the URL', 'https://example.com')
|
|
358
|
+
}));
|
|
359
|
+
e.preventDefault();
|
|
360
|
+
e.stopPropagation();
|
|
361
|
+
} else if (e.key === 'Backspace') {
|
|
362
|
+
if (selection?.type === 'property') {
|
|
363
|
+
// For property selections, clear the property value only if it's not already falsy
|
|
364
|
+
if (doc.get(selection.path)) {
|
|
365
|
+
const tr = doc.tr;
|
|
366
|
+
tr.set(selection.path, '');
|
|
367
|
+
doc.apply(tr);
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === 0 && doc.selection?.focus_offset === 0) {
|
|
371
|
+
console.log('try to join with previous node');
|
|
372
|
+
const tr = doc.tr;
|
|
373
|
+
join_text_node(tr);
|
|
374
|
+
doc.apply(tr);
|
|
375
|
+
} else {
|
|
376
|
+
// TODO: Things are not yet properly composable, I think we need to
|
|
377
|
+
// realize delete_selection as a command as well.
|
|
378
|
+
// See https://prosemirror.net/docs/guide/#commands for inspiration.
|
|
379
|
+
// For other selections, use the normal delete behavior
|
|
380
|
+
doc.apply(doc.tr.delete_selection());
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
e.preventDefault();
|
|
385
|
+
e.stopPropagation();
|
|
386
|
+
} else if (e.key === 'Enter' && selection?.type === 'property') {
|
|
387
|
+
// Focus toolbar for property selections
|
|
388
|
+
focus_toolbar();
|
|
389
|
+
e.preventDefault();
|
|
390
|
+
e.stopPropagation();
|
|
391
|
+
} else if (e.key === 'Enter' && selection?.type === 'node') {
|
|
392
|
+
const span_length = Math.abs(selection.focus_offset - selection.anchor_offset);
|
|
393
|
+
|
|
394
|
+
if (is_collapsed) {
|
|
395
|
+
// Try to insert default node if there's only one allowed ref_type
|
|
396
|
+
const tr = doc.tr;
|
|
397
|
+
insert_default_node(tr);
|
|
398
|
+
doc.apply(tr);
|
|
399
|
+
// if (insert_default_node(tr)) {
|
|
400
|
+
// doc.apply(tr);
|
|
401
|
+
// } else {
|
|
402
|
+
// // Fall back to focusing toolbar when multiple types are available
|
|
403
|
+
// focus_toolbar();
|
|
404
|
+
// }
|
|
405
|
+
} else if (span_length === 1) {
|
|
406
|
+
focus_toolbar();
|
|
407
|
+
}
|
|
408
|
+
// Node selections with multiple nodes do nothing on Enter
|
|
409
|
+
e.preventDefault();
|
|
410
|
+
e.stopPropagation();
|
|
411
|
+
} else if (e.key === 'Enter' && selection?.type === 'text') {
|
|
412
|
+
const tr = doc.tr;
|
|
413
|
+
break_text_node(tr);
|
|
414
|
+
doc.apply(tr);
|
|
415
|
+
} else if (e.key === 'Escape' && selection) {
|
|
416
|
+
doc.select_parent();
|
|
417
|
+
e.preventDefault();
|
|
418
|
+
e.stopPropagation();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Extracts a NodeSelection from the current DOM selection.
|
|
424
|
+
*
|
|
425
|
+
*
|
|
426
|
+
* @returns {NodeSelection | null} A NodeSelection object if the DOM selection
|
|
427
|
+
* represents a valid node selection, null otherwise
|
|
428
|
+
*/
|
|
429
|
+
function __get_node_selection_from_dom() {
|
|
430
|
+
const dom_selection = window.getSelection();
|
|
431
|
+
if (dom_selection.rangeCount === 0) return null;
|
|
432
|
+
|
|
433
|
+
let focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
|
|
434
|
+
let anchor_node = /** @type {HTMLElement} */ (dom_selection.anchorNode);
|
|
435
|
+
|
|
436
|
+
// If focus_node or anchor_node not an element node (e.g. a text node), we need
|
|
437
|
+
// to use the parent element, so we can perform the closest() query on it.
|
|
438
|
+
if (focus_node.nodeType !== Node.ELEMENT_NODE) focus_node = focus_node.parentElement;
|
|
439
|
+
if (anchor_node.nodeType !== Node.ELEMENT_NODE) anchor_node = anchor_node.parentElement;
|
|
440
|
+
|
|
441
|
+
// EDGE CASE: Let's first check if we are in a cursor trap for node cursors
|
|
442
|
+
let after_node_cursor_trap = focus_node && focus_node?.closest('[data-type="after-node-cursor-trap"]');
|
|
443
|
+
if (after_node_cursor_trap && focus_node === anchor_node) {
|
|
444
|
+
// Find the node that this cursor trap belongs to
|
|
445
|
+
let node = /** @type {HTMLElement} */ (after_node_cursor_trap.closest('[data-type="node"]'));
|
|
446
|
+
if (!node) {
|
|
447
|
+
console.log('No corresponding node found for after-node-cursor-trap');
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
const node_path = /** @type {DocumentPath} */ (node.dataset.path.split('.'));
|
|
451
|
+
const node_index = parseInt(String(node_path.at(-1)), 10);
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
type: "node",
|
|
455
|
+
path: node_path.slice(0, -1),
|
|
456
|
+
anchor_offset: node_index + 1,
|
|
457
|
+
focus_offset: node_index + 1,
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// EDGE CASE: Let's check if we are in a position-zero-cursor-trap for node_arrays.
|
|
462
|
+
let position_zero_cursor_trap = focus_node.closest('[data-type="position-zero-cursor-trap"]');
|
|
463
|
+
if (position_zero_cursor_trap && focus_node === anchor_node) {
|
|
464
|
+
const node_array_el = /** @type {HTMLElement} */ (position_zero_cursor_trap.closest('[data-type="node_array"]'));
|
|
465
|
+
const node_array_path = node_array_el.dataset.path.split('.');
|
|
466
|
+
return {
|
|
467
|
+
type: 'node',
|
|
468
|
+
path: node_array_path,
|
|
469
|
+
anchor_offset: 0,
|
|
470
|
+
focus_offset: 0,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let focus_root = /** @type {HTMLElement} */ (focus_node.closest('[data-path][data-type="node"]'));
|
|
475
|
+
if (!focus_root) return null;
|
|
476
|
+
|
|
477
|
+
let anchor_root = /** @type {HTMLElement} */ (anchor_node.closest('[data-path][data-type="node"]'));
|
|
478
|
+
if (!anchor_root) return null;
|
|
479
|
+
|
|
480
|
+
if (!(focus_root && anchor_root)) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let focus_root_path = focus_root.dataset.path.split('.');
|
|
485
|
+
let anchor_root_path = anchor_root.dataset.path.split('.');
|
|
486
|
+
|
|
487
|
+
// HACK: this works only for one level nesting - should be done recursively to work generally
|
|
488
|
+
if (focus_root_path.length > anchor_root_path.length) {
|
|
489
|
+
focus_root = focus_root.parentElement.closest('[data-path][data-type="node"]');
|
|
490
|
+
if (!focus_root) return null;
|
|
491
|
+
focus_root_path = focus_root.dataset.path.split('.');
|
|
492
|
+
} else if (anchor_root_path.length > focus_root_path.length) {
|
|
493
|
+
anchor_root = anchor_root.parentElement.closest('[data-path][data-type="node"]');
|
|
494
|
+
if (!anchor_root) return null;
|
|
495
|
+
anchor_root_path = anchor_root.dataset.path.split('.');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const is_same_node_array = focus_root_path.slice(0, -1).join('.') === anchor_root_path.slice(0, -1).join('.');
|
|
499
|
+
if (!is_same_node_array) {
|
|
500
|
+
console.log('invalid selection, not same node_array');
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let anchor_offset = parseInt(anchor_root_path.at(-1));
|
|
505
|
+
let focus_offset = parseInt(focus_root_path.at(-1));
|
|
506
|
+
|
|
507
|
+
// Check if it's a backwards selection
|
|
508
|
+
const is_backwards = __is_dom_selection_backwards();
|
|
509
|
+
if (is_backwards) {
|
|
510
|
+
anchor_offset += 1;
|
|
511
|
+
} else {
|
|
512
|
+
focus_offset += 1;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
type: 'node',
|
|
517
|
+
path: anchor_root_path.slice(0, -1),
|
|
518
|
+
anchor_offset: anchor_offset,
|
|
519
|
+
focus_offset: focus_offset,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Extracts a PropertySelection from the current DOM selection.
|
|
526
|
+
*
|
|
527
|
+
*
|
|
528
|
+
* @returns {PropertySelection | null} A PropertySelection object if the DOM selection
|
|
529
|
+
* represents a valid property selection, null otherwise
|
|
530
|
+
*/
|
|
531
|
+
function __get_property_selection_from_dom() {
|
|
532
|
+
const dom_selection = window.getSelection();
|
|
533
|
+
if (dom_selection.rangeCount === 0) return null;
|
|
534
|
+
|
|
535
|
+
let focus_root = /** @type {HTMLElement} */ (dom_selection.focusNode.parentElement?.closest('[data-path][data-type="property"]'));
|
|
536
|
+
if (!focus_root) return null;
|
|
537
|
+
let anchor_root = /** @type {HTMLElement} */ (dom_selection.anchorNode.parentElement?.closest('[data-path][data-type="property"]'));
|
|
538
|
+
if (!anchor_root) return null;
|
|
539
|
+
|
|
540
|
+
if (focus_root === anchor_root) {
|
|
541
|
+
return {
|
|
542
|
+
type: 'property',
|
|
543
|
+
path: focus_root.dataset.path.split('.')
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Extracts a TextSelection from the current DOM selection.
|
|
551
|
+
*
|
|
552
|
+
*
|
|
553
|
+
* @returns {TextSelection | null} A TextSelection object if the DOM selection
|
|
554
|
+
* represents a valid text selection, null otherwise
|
|
555
|
+
*/
|
|
556
|
+
function __get_text_selection_from_dom() {
|
|
557
|
+
const dom_selection = window.getSelection();
|
|
558
|
+
if (dom_selection.rangeCount === 0) return null;
|
|
559
|
+
|
|
560
|
+
const focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
|
|
561
|
+
let focus_root, anchor_root;
|
|
562
|
+
|
|
563
|
+
if (dom_selection.focusNode === dom_selection.anchorNode && focus_node.dataset?.type === 'text') {
|
|
564
|
+
// This is the case when the text node is empty (only a <br> is present)
|
|
565
|
+
focus_root = anchor_root = focus_node;
|
|
566
|
+
} else {
|
|
567
|
+
focus_root = /** @type {HTMLElement} */ (dom_selection.focusNode.parentElement?.closest('[data-path][data-type="text"]'));
|
|
568
|
+
if (!focus_root) return null;
|
|
569
|
+
anchor_root = /** @type {HTMLElement} */ (dom_selection.anchorNode.parentElement?.closest('[data-path][data-type="text"]'));
|
|
570
|
+
if (!anchor_root) return null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (focus_root !== anchor_root) {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const range = dom_selection.getRangeAt(0);
|
|
578
|
+
const path = focus_root.dataset.path.split('.');
|
|
579
|
+
|
|
580
|
+
if (!path) return null;
|
|
581
|
+
|
|
582
|
+
let anchorOffset = 0;
|
|
583
|
+
let focusOffset = 0;
|
|
584
|
+
let currentOffset = 0;
|
|
585
|
+
|
|
586
|
+
function processNode(node) {
|
|
587
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
588
|
+
const nodeLength = node.length;
|
|
589
|
+
|
|
590
|
+
if (node === range.startContainer) {
|
|
591
|
+
anchorOffset = currentOffset + range.startOffset;
|
|
592
|
+
}
|
|
593
|
+
if (node === range.endContainer) {
|
|
594
|
+
focusOffset = currentOffset + range.endOffset;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
currentOffset += nodeLength;
|
|
598
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
599
|
+
for (const childNode of node.childNodes) {
|
|
600
|
+
processNode(childNode);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return (focusOffset !== 0);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Process nodes to find offsets
|
|
608
|
+
for (const childNode of focus_root.childNodes) {
|
|
609
|
+
if (processNode(childNode)) break;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Check if it's a backward selection
|
|
613
|
+
const is_backward = dom_selection.anchorNode === range.endContainer &&
|
|
614
|
+
dom_selection.anchorOffset === range.endOffset;
|
|
615
|
+
|
|
616
|
+
// Swap offsets if it's a backward selection
|
|
617
|
+
if (is_backward) {
|
|
618
|
+
[anchorOffset, focusOffset] = [focusOffset, anchorOffset];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
type: 'text',
|
|
623
|
+
path,
|
|
624
|
+
anchor_offset: anchorOffset,
|
|
625
|
+
focus_offset: focusOffset
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function __get_node_element(node_array_path, node_offset) {
|
|
630
|
+
const node_array_el = canvas_ref.querySelector(`[data-path="${node_array_path}"][data-type="node_array"]`);
|
|
631
|
+
if (!node_array_el) return null;
|
|
632
|
+
|
|
633
|
+
const node_elements = node_array_el.children;
|
|
634
|
+
if (node_elements.length === 0) return null;
|
|
635
|
+
|
|
636
|
+
return node_elements[node_offset];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function __render_node_selection() {
|
|
640
|
+
const selection = /** @type {NodeSelection} */ (doc.selection);
|
|
641
|
+
const node_array_path = selection.path.join('.');
|
|
642
|
+
|
|
643
|
+
let is_collapsed = selection.anchor_offset === selection.focus_offset;
|
|
644
|
+
let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
|
|
645
|
+
|
|
646
|
+
// We need to translate the cusor offset to node offsets now
|
|
647
|
+
let anchor_node_offset, focus_node_offset;
|
|
648
|
+
|
|
649
|
+
if (is_collapsed) {
|
|
650
|
+
anchor_node_offset = Math.max(0, selection.anchor_offset - 1);
|
|
651
|
+
focus_node_offset = Math.max(0, selection.focus_offset - 1);
|
|
652
|
+
} else if (is_backward) {
|
|
653
|
+
anchor_node_offset = selection.anchor_offset - 1;
|
|
654
|
+
focus_node_offset = selection.focus_offset;
|
|
655
|
+
} else {
|
|
656
|
+
anchor_node_offset = selection.anchor_offset;
|
|
657
|
+
focus_node_offset = selection.focus_offset - 1;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const anchor_node = __get_node_element(node_array_path, anchor_node_offset);
|
|
661
|
+
const focus_node = __get_node_element(node_array_path, focus_node_offset);
|
|
662
|
+
|
|
663
|
+
if (!anchor_node || !focus_node) return;
|
|
664
|
+
const dom_selection = window.getSelection();
|
|
665
|
+
const range = window.document.createRange();
|
|
666
|
+
|
|
667
|
+
if (is_collapsed) {
|
|
668
|
+
// Cursor position in between two nodes or at the very beginning/end of a node_array
|
|
669
|
+
// IMPORTANT: We need to look for direct children of anchor_node to find the right cursor trap.
|
|
670
|
+
const cursor_trap_el = anchor_node.querySelector(
|
|
671
|
+
selection.anchor_offset === 0 ? ':scope > .position-zero-cursor-trap' : ':scope > .after-node-cursor-trap'
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
range.setStart(cursor_trap_el, 1);
|
|
676
|
+
range.setEnd(cursor_trap_el, 1);
|
|
677
|
+
dom_selection.removeAllRanges();
|
|
678
|
+
dom_selection.addRange(range);
|
|
679
|
+
} else {
|
|
680
|
+
// Expanded selection (one or more nodes are fully selectd)
|
|
681
|
+
if (is_backward) {
|
|
682
|
+
// Use the last selectable you find
|
|
683
|
+
const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(-1);
|
|
684
|
+
// Use the first selectable you find
|
|
685
|
+
const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(0);
|
|
686
|
+
|
|
687
|
+
range.setStart(focus_node_selectable, 1);
|
|
688
|
+
range.setEnd(anchor_node_selectable, 1);
|
|
689
|
+
dom_selection.removeAllRanges();
|
|
690
|
+
dom_selection.addRange(range);
|
|
691
|
+
// Phew, this was a hard nut to crack. But with that code the direction can be reversed.
|
|
692
|
+
dom_selection.setBaseAndExtent(anchor_node_selectable, 1, focus_node_selectable, 1);
|
|
693
|
+
|
|
694
|
+
} else {
|
|
695
|
+
// Use the first selectable you find
|
|
696
|
+
const anchor_node_selectable = [...anchor_node.querySelectorAll('.svedit-selectable')].at(0);
|
|
697
|
+
// Use the last selectable you find
|
|
698
|
+
const focus_node_selectable = [...focus_node.querySelectorAll('.svedit-selectable')].at(-1);
|
|
699
|
+
|
|
700
|
+
range.setStart(anchor_node_selectable, 1);
|
|
701
|
+
range.setEnd(focus_node_selectable, 1);
|
|
702
|
+
dom_selection.removeAllRanges();
|
|
703
|
+
dom_selection.addRange(range);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Ensure the node_array is focused
|
|
708
|
+
const node_array_el = canvas_ref.querySelector(`[data-path="${node_array_path}"][data-type="node_array"]`);
|
|
709
|
+
if (node_array_el) {
|
|
710
|
+
node_array_el.focus();
|
|
711
|
+
// Scroll the selection into view
|
|
712
|
+
setTimeout(() => {
|
|
713
|
+
(is_backward ? focus_node : anchor_node).scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
714
|
+
}, 0);
|
|
715
|
+
} else {
|
|
716
|
+
console.log('no container element found!!');
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function __render_property_selection() {
|
|
721
|
+
const selection = doc.selection;
|
|
722
|
+
// The element that holds the property
|
|
723
|
+
const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="property"]`);
|
|
724
|
+
const cursor_trap = el.querySelector('.cursor-trap');
|
|
725
|
+
|
|
726
|
+
const range = window.document.createRange();
|
|
727
|
+
const dom_selection = window.getSelection();
|
|
728
|
+
|
|
729
|
+
// Select the entire cursor trap element contents and collapse to start
|
|
730
|
+
range.selectNodeContents(cursor_trap);
|
|
731
|
+
range.collapse(true); // Collapse to start position
|
|
732
|
+
dom_selection.removeAllRanges();
|
|
733
|
+
dom_selection.addRange(range);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function __render_text_selection() {
|
|
737
|
+
const selection = /** @type {any} */ (doc.selection);
|
|
738
|
+
// The element that holds the annotated string
|
|
739
|
+
const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="text"]`);
|
|
740
|
+
const empty_text = doc.get(selection.path)[0].length === 0;
|
|
741
|
+
|
|
742
|
+
const range = window.document.createRange();
|
|
743
|
+
const dom_selection = window.getSelection();
|
|
744
|
+
let current_offset = 0;
|
|
745
|
+
/** @type {HTMLElement | Text} */
|
|
746
|
+
let anchor_node;
|
|
747
|
+
/** @type {HTMLElement | Text} */
|
|
748
|
+
let focus_node;
|
|
749
|
+
/** @type {number} */
|
|
750
|
+
let anchor_node_offset
|
|
751
|
+
/** @type {number} */
|
|
752
|
+
let focus_node_offset;
|
|
753
|
+
const is_backward = selection.anchor_offset > selection.focus_offset;
|
|
754
|
+
const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
|
|
755
|
+
const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
// Helper function to process each node
|
|
759
|
+
function process_node(node) {
|
|
760
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
761
|
+
const node_length = node.length;
|
|
762
|
+
|
|
763
|
+
if (is_backward) {
|
|
764
|
+
if (!focus_node && current_offset + node_length >= start_offset) {
|
|
765
|
+
focus_node = node;
|
|
766
|
+
focus_node_offset = start_offset - current_offset;
|
|
767
|
+
}
|
|
768
|
+
} else {
|
|
769
|
+
if (!anchor_node && current_offset + node_length >= start_offset) {
|
|
770
|
+
anchor_node = node;
|
|
771
|
+
anchor_node_offset = start_offset - current_offset;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Find end node
|
|
776
|
+
if (is_backward) {
|
|
777
|
+
if (!anchor_node && current_offset + node_length >= end_offset) {
|
|
778
|
+
anchor_node = node;
|
|
779
|
+
anchor_node_offset = end_offset - current_offset;
|
|
780
|
+
return true; // Stop iteration
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
if (!focus_node && current_offset + node_length >= end_offset) {
|
|
784
|
+
focus_node = node;
|
|
785
|
+
focus_node_offset = end_offset - current_offset;
|
|
786
|
+
return true; // Stop iteration
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
current_offset += node_length;
|
|
791
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
792
|
+
for (const child_node of node.childNodes) {
|
|
793
|
+
if (process_node(child_node)) return true; // Stop iteration if end found
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return false; // Continue iteration
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// EDGE CASE: When text is empty, we need to set a different DOM selection
|
|
800
|
+
if (start_offset === end_offset && start_offset === 0 && empty_text) {
|
|
801
|
+
// Markup for empty text looks like this `<div data-type="text"><br></div>`.
|
|
802
|
+
// And the correct cursor position is after the <br> element.
|
|
803
|
+
anchor_node = el;
|
|
804
|
+
anchor_node_offset = 1;
|
|
805
|
+
focus_node = el;
|
|
806
|
+
focus_node_offset = 1;
|
|
807
|
+
} else {
|
|
808
|
+
// DEFAULT CASE
|
|
809
|
+
for (const child_node of el.childNodes) {
|
|
810
|
+
if (process_node(child_node)) break;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// EDGE CASE: cursor at the end of an annotation
|
|
814
|
+
// TODO: It seems this edge case is handled elsewhere. We keep around the checker for a
|
|
815
|
+
// while and delete this branch, if we don't see any problems.
|
|
816
|
+
if (anchor_node && !focus_node && current_offset === end_offset) {
|
|
817
|
+
alert('EDGE CASE: cursor at the end of an annotation.');
|
|
818
|
+
// console.log('EDGE CASE: cursor at the end of an annotation');
|
|
819
|
+
// focus_node = /** @type {HTMLElement | Text} */ (anchor_node.nextSibling || anchor_node);
|
|
820
|
+
// focus_node_offset = focus_node === anchor_node ? anchor_node.length : 0;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Set the range if both start and end were found
|
|
825
|
+
if (anchor_node && focus_node) {
|
|
826
|
+
// Always set range in document order (start to end)
|
|
827
|
+
if (is_backward) {
|
|
828
|
+
range.setStart(focus_node, focus_node_offset);
|
|
829
|
+
range.setEnd(anchor_node, anchor_node_offset);
|
|
830
|
+
} else {
|
|
831
|
+
range.setStart(anchor_node, anchor_node_offset);
|
|
832
|
+
range.setEnd(focus_node, focus_node_offset);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
dom_selection.removeAllRanges();
|
|
836
|
+
if (is_backward) {
|
|
837
|
+
// For backward selections, collapse to end and extend to start
|
|
838
|
+
range.collapse(false); // collapse to end
|
|
839
|
+
dom_selection.addRange(range);
|
|
840
|
+
dom_selection.extend(focus_node, focus_node_offset);
|
|
841
|
+
} else {
|
|
842
|
+
dom_selection.addRange(range);
|
|
843
|
+
}
|
|
844
|
+
el.focus(); // needed?
|
|
845
|
+
|
|
846
|
+
// Scroll the selection into view
|
|
847
|
+
setTimeout(() => {
|
|
848
|
+
const selectedElement = dom_selection.focusNode.parentElement;
|
|
849
|
+
if (selectedElement) {
|
|
850
|
+
selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
851
|
+
}
|
|
852
|
+
}, 0);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Utils
|
|
857
|
+
// --------------------------
|
|
858
|
+
|
|
859
|
+
function __is_dom_selection_backwards() {
|
|
860
|
+
const dom_selection = window.getSelection();
|
|
861
|
+
|
|
862
|
+
// If there's no dom_selection, return false
|
|
863
|
+
if (dom_selection.rangeCount === 0) return false;
|
|
864
|
+
|
|
865
|
+
// Get the range of the dom_selection
|
|
866
|
+
const range = dom_selection.getRangeAt(0);
|
|
867
|
+
|
|
868
|
+
if (range.collapsed) return false;
|
|
869
|
+
|
|
870
|
+
// Create a new range for comparison
|
|
871
|
+
const comparisonRange = range.cloneRange();
|
|
872
|
+
|
|
873
|
+
// Set the comparison range to start at the dom_selection's anchor and end at its focus
|
|
874
|
+
comparisonRange.setStart(dom_selection.anchorNode, dom_selection.anchorOffset);
|
|
875
|
+
comparisonRange.setEnd(dom_selection.focusNode, dom_selection.focusOffset);
|
|
876
|
+
|
|
877
|
+
// If the comparison range is collapsed, the selection is backwards
|
|
878
|
+
return comparisonRange.collapsed;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Whenever the model selection changes, render the selection
|
|
882
|
+
$effect(() => {
|
|
883
|
+
render_selection();
|
|
884
|
+
});
|
|
885
|
+
</script>
|
|
886
|
+
|
|
887
|
+
<!--
|
|
888
|
+
TODO: We must get rid of the global handlers here, so Svedit doesn't conflict
|
|
889
|
+
with any app-specific event handling.
|
|
890
|
+
-->
|
|
891
|
+
<svelte:document
|
|
892
|
+
{onselectionchange}
|
|
893
|
+
{oncut}
|
|
894
|
+
{oncopy}
|
|
895
|
+
{onpaste}
|
|
896
|
+
/>
|
|
897
|
+
<svelte:window {onkeydown} />
|
|
898
|
+
|
|
899
|
+
<!-- TODO: move oncut/copy/paste handlers inside .svedit -->
|
|
900
|
+
<div
|
|
901
|
+
class="svedit"
|
|
902
|
+
>
|
|
903
|
+
<div
|
|
904
|
+
class="svedit-canvas {css_class}"
|
|
905
|
+
class:hide-selection={doc.selection?.type === 'node'}
|
|
906
|
+
bind:this={canvas_ref}
|
|
907
|
+
{onbeforeinput}
|
|
908
|
+
contenteditable={editable ? 'true' : 'false'}
|
|
909
|
+
>
|
|
910
|
+
<RootComponent {path} />
|
|
911
|
+
</div>
|
|
912
|
+
<Overlays />
|
|
913
|
+
</div>
|
|
914
|
+
|
|
915
|
+
<style>
|
|
916
|
+
.svedit-canvas {
|
|
917
|
+
--layout-orientation: vertical;
|
|
918
|
+
caret-color: var(--editing-stroke-color);
|
|
919
|
+
caret-shape: block;
|
|
920
|
+
&:focus {
|
|
921
|
+
outline: none;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
.svedit-canvas.hide-selection {
|
|
926
|
+
caret-color: transparent;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
.svedit-canvas :global(::selection) {
|
|
930
|
+
background: var(--editing-fill-color);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
.svedit-canvas.hide-selection :global(::selection) {
|
|
934
|
+
background: transparent;
|
|
935
|
+
}
|
|
936
|
+
</style>
|