svedit 0.2.0 → 0.4.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 +41 -21
- package/dist/AnnotatedTextProperty.svelte +117 -0
- package/dist/AnnotatedTextProperty.svelte.d.ts +7 -0
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -0
- package/dist/CustomProperty.svelte +20 -12
- package/dist/CustomProperty.svelte.d.ts +1 -2
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/Document.svelte.d.ts +40 -17
- package/dist/Document.svelte.d.ts.map +1 -1
- package/dist/Document.svelte.js +192 -77
- package/dist/Node.svelte +18 -14
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +1 -1
- package/dist/Svedit.svelte +628 -109
- package/dist/Svedit.svelte.d.ts +1 -1
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +4 -7
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +74 -81
- package/dist/commands.svelte.d.ts.map +1 -1
- package/dist/commands.svelte.js +20 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types.d.ts +66 -21
- package/dist/util.d.ts +125 -3
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +191 -34
- package/package.json +1 -1
- package/src/lib/AnnotatedTextProperty.svelte +117 -0
- package/src/lib/CustomProperty.svelte +20 -12
- package/src/lib/Document.svelte.js +192 -77
- package/src/lib/Node.svelte +18 -14
- package/src/lib/NodeArrayProperty.svelte +1 -1
- package/src/lib/Svedit.svelte +628 -109
- package/src/lib/Transaction.svelte.js +74 -81
- package/src/lib/commands.svelte.js +20 -10
- package/src/lib/index.js +1 -1
- package/src/lib/types.d.ts +66 -21
- package/src/lib/util.js +191 -34
- package/dist/AnnotatedStringProperty.svelte +0 -126
- package/dist/AnnotatedStringProperty.svelte.d.ts +0 -7
- package/dist/AnnotatedStringProperty.svelte.d.ts.map +0 -1
- package/src/lib/AnnotatedStringProperty.svelte +0 -126
package/src/lib/Svedit.svelte
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
<script>
|
|
2
2
|
import { setContext } from 'svelte';
|
|
3
|
-
import { snake_to_pascal } from './util.js';
|
|
3
|
+
import { snake_to_pascal, get_char_length, utf16_to_char_offset, char_to_utf16_offset, get_char_at, char_slice } from './util.js';
|
|
4
4
|
import { break_text_node, join_text_node, insert_default_node, select_all } from './commands.svelte.js';
|
|
5
5
|
|
|
6
6
|
/** @import { SveditProps, DocumentPath, Selection, TextSelection, NodeSelection, PropertySelection, NodeId } from './types.d.ts'; */
|
|
7
7
|
/** @type {SveditProps} */
|
|
8
8
|
let {
|
|
9
9
|
doc,
|
|
10
|
-
editable = false,
|
|
10
|
+
editable = $bindable(false),
|
|
11
11
|
path,
|
|
12
12
|
class: css_class,
|
|
13
|
+
autocapitalize = "on",
|
|
14
|
+
spellcheck = "true",
|
|
13
15
|
} = $props();
|
|
14
16
|
|
|
15
17
|
let canvas_ref;
|
|
@@ -17,31 +19,311 @@
|
|
|
17
19
|
let Overlays = $derived(doc.config.system_components.Overlays);
|
|
18
20
|
let RootComponent = $derived(doc.config.node_components[snake_to_pascal(root_node.type)]);
|
|
19
21
|
|
|
22
|
+
// Track temporary disabled onkeydown events (e.g. during character composition)
|
|
23
|
+
let skip_onkeydown = false;
|
|
24
|
+
|
|
25
|
+
let is_composing = $state(false);
|
|
26
|
+
|
|
27
|
+
let input_selection = undefined;
|
|
28
|
+
|
|
29
|
+
// Detect Chrome on desktop (not mobile) - only available in browser
|
|
30
|
+
let is_chrome_desktop = $state(false);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Detect if the current browser is on a mobile device
|
|
34
|
+
* @returns {boolean} true if mobile browser, false otherwise
|
|
35
|
+
*/
|
|
36
|
+
function is_mobile_browser() {
|
|
37
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const user_agent = navigator.userAgent;
|
|
42
|
+
return /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
|
|
43
|
+
('ontouchstart' in window) ||
|
|
44
|
+
(navigator.maxTouchPoints > 0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
$effect(() => {
|
|
48
|
+
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
|
|
49
|
+
const user_agent = navigator.userAgent;
|
|
50
|
+
const is_chrome = user_agent.includes('Chrome') && !user_agent.includes('Edg');
|
|
51
|
+
const is_mobile = is_mobile_browser();
|
|
52
|
+
is_chrome_desktop = is_chrome && !is_mobile;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
20
56
|
/** Expose function so parent can call it */
|
|
21
57
|
export { focus_canvas };
|
|
22
58
|
|
|
23
59
|
setContext("svedit", {
|
|
24
60
|
get doc() {
|
|
25
61
|
return doc;
|
|
26
|
-
}
|
|
62
|
+
},
|
|
63
|
+
get editable() {
|
|
64
|
+
return editable;
|
|
65
|
+
},
|
|
66
|
+
get is_composing() {
|
|
67
|
+
return is_composing
|
|
68
|
+
},
|
|
27
69
|
});
|
|
28
70
|
|
|
71
|
+
function diff_text(old_text, new_text) {
|
|
72
|
+
const old_length = get_char_length(old_text);
|
|
73
|
+
const new_length = get_char_length(new_text);
|
|
74
|
+
|
|
75
|
+
let start = 0;
|
|
76
|
+
while (
|
|
77
|
+
start < old_length &&
|
|
78
|
+
start < new_length &&
|
|
79
|
+
get_char_at(old_text, start) === get_char_at(new_text, start)
|
|
80
|
+
) {
|
|
81
|
+
start++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let old_end = old_length;
|
|
85
|
+
let new_end = new_length;
|
|
86
|
+
while (
|
|
87
|
+
old_end > start &&
|
|
88
|
+
new_end > start &&
|
|
89
|
+
get_char_at(old_text, old_end - 1) === get_char_at(new_text, new_end - 1)
|
|
90
|
+
) {
|
|
91
|
+
old_end--;
|
|
92
|
+
new_end--;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
start_offset: start,
|
|
97
|
+
end_offset: old_end,
|
|
98
|
+
replacement_text: char_slice(new_text, start, new_end)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function commit_input() {
|
|
103
|
+
if (input_selection?.type !== 'text') return;
|
|
104
|
+
const model_text = doc.get(input_selection.path).text;
|
|
105
|
+
const dom_text_element = document.querySelector(`[data-path="${input_selection.path.join('.')}"]`);
|
|
106
|
+
const dom_text = dom_text_element.textContent;
|
|
107
|
+
const op = diff_text(model_text, dom_text);
|
|
108
|
+
// console.log('======== COMMIT INPUT ========')
|
|
109
|
+
// console.log('mdl_text:', model_text);
|
|
110
|
+
// console.log('dom_text:', dom_text);
|
|
111
|
+
// console.log('op', op);
|
|
112
|
+
|
|
113
|
+
// NOTE: We are currently in an out-of-sync DOM state, right after
|
|
114
|
+
// contenteditable applied the input. But we can use this state
|
|
115
|
+
// to determine the ideal intended selection after the operation.
|
|
116
|
+
// Because our diffing may miss some context. E.g. when you replace "mchael"
|
|
117
|
+
// to "Michael" it will replace "m" with "Mi" and put the cursor after the "i",
|
|
118
|
+
// but we actually want it after "Michael".
|
|
119
|
+
const before_selection = __get_text_selection_from_dom();
|
|
120
|
+
|
|
121
|
+
// Now we are applying the change to the document
|
|
122
|
+
const tr = doc.tr;
|
|
123
|
+
tr.set_selection({
|
|
124
|
+
type: 'text',
|
|
125
|
+
path: [...input_selection.path],
|
|
126
|
+
anchor_offset: op.start_offset,
|
|
127
|
+
focus_offset: op.end_offset,
|
|
128
|
+
});
|
|
129
|
+
tr.insert_text(op.replacement_text);
|
|
130
|
+
tr.set_selection(before_selection);
|
|
131
|
+
doc.apply(tr);
|
|
132
|
+
// console.log('applied op', op);
|
|
133
|
+
|
|
134
|
+
// After each commited replacement, we start accepting custom key handlers again.
|
|
135
|
+
skip_onkeydown = false;
|
|
136
|
+
is_composing = false;
|
|
137
|
+
input_selection = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function oninput(event) {
|
|
141
|
+
// console.log(`oninput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
|
|
142
|
+
if (
|
|
143
|
+
// canvas_ref?.contains(document.activeElement) &&
|
|
144
|
+
// doc.selection?.type === 'text' &&
|
|
145
|
+
input_selection.type === 'text' &&
|
|
146
|
+
!event.isComposing
|
|
147
|
+
) {
|
|
148
|
+
commit_input();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
29
152
|
/**
|
|
30
153
|
* @param {InputEvent} event
|
|
31
154
|
*/
|
|
32
155
|
function onbeforeinput(event) {
|
|
33
|
-
|
|
156
|
+
// console.log(`onbeforeinput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
|
|
34
157
|
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
158
|
+
// Only take input when in a valid text selection inside the canvas
|
|
159
|
+
if (
|
|
160
|
+
!(canvas_ref?.contains(document.activeElement))
|
|
161
|
+
) {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (event.inputType === 'formatBold' && doc.selection?.type === 'text') {
|
|
167
|
+
doc.apply(doc.tr.annotate_text('strong'));
|
|
168
|
+
event.preventDefault();
|
|
169
|
+
event.stopPropagation();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (event.inputType === 'formatItalic' && doc.selection?.type === 'text') {
|
|
173
|
+
doc.apply(doc.tr.annotate_text('emphasis'));
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
event.stopPropagation();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// NOTE: underline doesn't make much sense as a semantic annotation,
|
|
179
|
+
// so we rewire `cmd + u` to toggle highlights
|
|
180
|
+
if (event.inputType === 'formatUnderline' && doc.selection?.type === 'text') {
|
|
181
|
+
doc.apply(doc.tr.annotate_text('highlight'));
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if (event.inputType === 'deleteContentBackward') {
|
|
188
|
+
if (doc.selection?.type === 'property') {
|
|
189
|
+
// For property selections, clear the property value only if it's not already falsy
|
|
190
|
+
if (doc.get(doc.selection.path)) {
|
|
191
|
+
const tr = doc.tr;
|
|
192
|
+
tr.set(doc.selection.path, '');
|
|
193
|
+
doc.apply(tr);
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === 0 && doc.selection?.focus_offset === 0) {
|
|
197
|
+
const tr = doc.tr;
|
|
198
|
+
join_text_node(tr);
|
|
199
|
+
doc.apply(tr);
|
|
200
|
+
} else {
|
|
201
|
+
doc.apply(doc.tr.delete_selection());
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
event.stopPropagation();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (event.inputType === 'deleteContentForward') {
|
|
210
|
+
// Forward delete: only handle collapsed text selections
|
|
211
|
+
if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === doc.selection?.focus_offset) {
|
|
212
|
+
const text_content = doc.get(doc.selection.path).text;
|
|
213
|
+
const text_length = get_char_length(text_content);
|
|
214
|
+
|
|
215
|
+
if (doc.selection.focus_offset < text_length) {
|
|
216
|
+
// There's a character after the cursor - select and delete it
|
|
217
|
+
const tr = doc.tr;
|
|
218
|
+
tr.set_selection({
|
|
219
|
+
type: 'text',
|
|
220
|
+
path: [...doc.selection.path],
|
|
221
|
+
anchor_offset: doc.selection.focus_offset,
|
|
222
|
+
focus_offset: doc.selection.focus_offset + 1
|
|
223
|
+
});
|
|
224
|
+
tr.delete_selection();
|
|
225
|
+
doc.apply(tr);
|
|
226
|
+
} else {
|
|
227
|
+
// At end of text - try to join with next text node
|
|
228
|
+
const tr = doc.tr;
|
|
229
|
+
const node_index = parseInt(String(doc.selection.path.at(-2)), 10);
|
|
230
|
+
const successor_node = doc.get([...doc.selection.path.slice(0, -2), node_index + 1]);
|
|
231
|
+
|
|
232
|
+
// Check if next node is a text node
|
|
233
|
+
if (successor_node && doc.kind(successor_node) === 'text') {
|
|
234
|
+
// Set selection to beginning of next text node
|
|
235
|
+
tr.set_selection({
|
|
236
|
+
type: 'text',
|
|
237
|
+
path: [...doc.selection.path.slice(0, -2), node_index + 1, 'content'],
|
|
238
|
+
anchor_offset: 0,
|
|
239
|
+
focus_offset: 0
|
|
240
|
+
});
|
|
241
|
+
// Use join_text_node to merge with previous
|
|
242
|
+
join_text_node(tr);
|
|
243
|
+
doc.apply(tr);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
event.stopPropagation();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
// We remember the "input selection", because in some mobile browsers (Samsung Android) the onbeforeinput event
|
|
254
|
+
// is fired on a different selection than the input event.
|
|
255
|
+
input_selection = { ...doc.selection };
|
|
256
|
+
|
|
257
|
+
// If replacement is detected, I let contenteditable do the thing and wait for
|
|
258
|
+
// the oninput event to do the diffing and update the model.
|
|
259
|
+
if (event.inputType === 'insertReplacementText') {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// For now I reject drag+drop text movements.
|
|
264
|
+
// TODO: If I want to support those, I need to handle them in such a way that
|
|
265
|
+
// you can drag from one text property to another too.
|
|
266
|
+
if (event.inputType === 'deleteByDrag' || event.inputType === 'insertFromDrop') {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// While composing, I do nothing and let the oncompositionend
|
|
272
|
+
// event handle the final replacement by recovering it from the DOM.
|
|
273
|
+
if (event.isComposing) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Insert the character, unless there is none.
|
|
278
|
+
let inserted_text = event.data;
|
|
279
|
+
|
|
280
|
+
// Sometimes (e.g. for replacements) the inserted_text is available via
|
|
281
|
+
// event.dataTransfer, not event.data
|
|
282
|
+
if (!inserted_text && event.dataTransfer) {
|
|
283
|
+
inserted_text = event.dataTransfer?.getData('text/plain');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// If there's no inserted_text, I'm not handling this.
|
|
287
|
+
if (!inserted_text) {
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// For now I don't accept structural replacements for savety reasons.
|
|
293
|
+
// E.g. this will catch Apple's writing tools that will attempt to mess with our DOM.
|
|
294
|
+
if (inserted_text.includes('\n') || inserted_text.includes('\t')) {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function get_base_char(char) {
|
|
300
|
+
if (!char) return undefined;
|
|
301
|
+
const normalized = char.normalize('NFD');
|
|
302
|
+
// If normalization doesn't change it OR it's longer than 2 chars, it's probably not a simple diacritic
|
|
303
|
+
if (normalized.length <= 2 && normalized !== char) {
|
|
304
|
+
return normalized[0];
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (
|
|
310
|
+
(get_char_length(inserted_text) > 1) ||
|
|
311
|
+
(get_char_length(inserted_text) === 1 && get_base_char(inserted_text))
|
|
312
|
+
) {
|
|
313
|
+
skip_onkeydown = true;
|
|
314
|
+
return; // we are dealing with a replacement
|
|
40
315
|
}
|
|
316
|
+
|
|
317
|
+
const tr = doc.tr;
|
|
318
|
+
tr.insert_text(inserted_text);
|
|
319
|
+
doc.apply(tr);
|
|
320
|
+
input_selection = undefined;
|
|
321
|
+
event.preventDefault();
|
|
41
322
|
}
|
|
42
323
|
|
|
43
324
|
// Map DOM selection to internal model
|
|
44
325
|
function onselectionchange() {
|
|
326
|
+
if (!editable) return;
|
|
45
327
|
const dom_selection = window.getSelection();
|
|
46
328
|
if (!dom_selection.rangeCount) return;
|
|
47
329
|
|
|
@@ -54,6 +336,100 @@
|
|
|
54
336
|
}
|
|
55
337
|
}
|
|
56
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Creates HTML clipboard format with embedded svedit data
|
|
341
|
+
* @param {Object} json_data - The svedit data to embed
|
|
342
|
+
* @param {string} fallback_html - HTML for cross-app compatibility
|
|
343
|
+
* @returns {string} HTML with embedded svedit data
|
|
344
|
+
*/
|
|
345
|
+
function create_svedit_html_format(json_data, fallback_html) {
|
|
346
|
+
// Use encodeURIComponent to handle Unicode, then base64 encode
|
|
347
|
+
const json_string = JSON.stringify(json_data);
|
|
348
|
+
const encoded_data = btoa(encodeURIComponent(json_string));
|
|
349
|
+
|
|
350
|
+
return `<meta charset="utf-8">
|
|
351
|
+
<div>
|
|
352
|
+
<span data-svedit="${encoded_data}"></span>
|
|
353
|
+
</div>
|
|
354
|
+
${fallback_html}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Extracts svedit data from HTML clipboard format
|
|
359
|
+
* @param {string} html - HTML content from clipboard
|
|
360
|
+
* @returns {Object|null} Parsed svedit data or null if not found
|
|
361
|
+
*/
|
|
362
|
+
function extract_svedit_data_from_html(html) {
|
|
363
|
+
const svedit_regex = /data-svedit="([^"]+)"/;
|
|
364
|
+
const match = html.match(svedit_regex);
|
|
365
|
+
|
|
366
|
+
if (match && match[1]) {
|
|
367
|
+
try {
|
|
368
|
+
// Decode base64, then decode URI component to handle Unicode
|
|
369
|
+
const base64_decoded = atob(match[1]);
|
|
370
|
+
const decoded_data = decodeURIComponent(base64_decoded);
|
|
371
|
+
return JSON.parse(decoded_data);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.warn('Failed to decode svedit data from HTML:', e);
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Default node exporter for nodes without specific exporters
|
|
383
|
+
* @param {Object} node - Node object
|
|
384
|
+
* @returns {string} HTML representation
|
|
385
|
+
*/
|
|
386
|
+
function default_node_exporter(node) {
|
|
387
|
+
let html = '';
|
|
388
|
+
let has_content = false;
|
|
389
|
+
|
|
390
|
+
for (const [prop_name, prop_value] of Object.entries(node)) {
|
|
391
|
+
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
392
|
+
|
|
393
|
+
// Check if this is an annotated_text property (object with text property)
|
|
394
|
+
if (typeof prop_value === 'object' && prop_value !== null && typeof prop_value.text === 'string') {
|
|
395
|
+
const text_content = prop_value.text;
|
|
396
|
+
if (text_content.trim()) {
|
|
397
|
+
html += `<p>${text_content}</p>\n`;
|
|
398
|
+
has_content = true;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!has_content) {
|
|
404
|
+
// Generic fallback for unknown node types with no annotated_text content
|
|
405
|
+
html += `<div data-node-type="${node.type}">Content from ${node.type}</div>\n`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return html;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Exports nodes to HTML using document config exporters
|
|
413
|
+
* @param {Object[]} nodes - Array of node objects
|
|
414
|
+
* @returns {string} HTML representation
|
|
415
|
+
*/
|
|
416
|
+
function export_html(nodes) {
|
|
417
|
+
let html = '';
|
|
418
|
+
|
|
419
|
+
for (const node of nodes) {
|
|
420
|
+
const html_exporters = doc.config.html_exporters || {};
|
|
421
|
+
|
|
422
|
+
if (html_exporters[node.type]) {
|
|
423
|
+
// Use custom exporter for this node type
|
|
424
|
+
html += html_exporters[node.type](node, doc);
|
|
425
|
+
} else {
|
|
426
|
+
// Use default exporter
|
|
427
|
+
html += default_node_exporter(node);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return html || '<p>Copied content</p>';
|
|
432
|
+
}
|
|
57
433
|
|
|
58
434
|
/**
|
|
59
435
|
* @param {NodeId[]} selected_node_ids
|
|
@@ -77,7 +453,6 @@
|
|
|
77
453
|
return { nodes, main_nodes: selected_node_ids };
|
|
78
454
|
}
|
|
79
455
|
|
|
80
|
-
|
|
81
456
|
/**
|
|
82
457
|
* @param {ClipboardEvent} event
|
|
83
458
|
* @param {boolean} delete_selection - used by oncut()
|
|
@@ -89,35 +464,46 @@
|
|
|
89
464
|
event.preventDefault();
|
|
90
465
|
event.stopPropagation();
|
|
91
466
|
|
|
92
|
-
let plain_text, html
|
|
467
|
+
let plain_text, html;
|
|
93
468
|
|
|
94
469
|
if (doc.selection?.type === 'text') {
|
|
95
470
|
plain_text = doc.get_selected_plain_text();
|
|
96
|
-
html = plain_text
|
|
471
|
+
html = `<span style="white-space:pre-wrap;">${plain_text}</span>`;
|
|
97
472
|
} else if (doc.selection?.type === 'node') {
|
|
98
473
|
const selected_nodes = doc.get_selected_nodes();
|
|
99
474
|
const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
|
|
100
475
|
|
|
101
|
-
json_data = { nodes, main_nodes };
|
|
476
|
+
const json_data = { nodes, main_nodes };
|
|
102
477
|
|
|
103
478
|
console.log('Copy operation:', {
|
|
104
479
|
selected_nodes,
|
|
105
|
-
|
|
480
|
+
nodes,
|
|
106
481
|
total_nodes: Object.keys(nodes).length,
|
|
107
482
|
operation: delete_selection ? 'cut' : 'copy'
|
|
108
483
|
});
|
|
484
|
+
|
|
485
|
+
// Generate fallback HTML for cross-app compatibility
|
|
486
|
+
const selected_node_objects = main_nodes.map(id => nodes[id]);
|
|
487
|
+
const fallback_html = export_html(selected_node_objects);
|
|
488
|
+
|
|
489
|
+
// Create HTML with embedded svedit data
|
|
490
|
+
html = create_svedit_html_format(json_data, fallback_html);
|
|
491
|
+
|
|
492
|
+
// Create plain text representation
|
|
493
|
+
plain_text = selected_node_objects.map(node => {
|
|
494
|
+
if (node.type === 'story') {
|
|
495
|
+
return (node.title || 'Untitled Story') + (node.content ? '\n' + node.content : '');
|
|
496
|
+
}
|
|
497
|
+
return `Content from ${node.type}`;
|
|
498
|
+
}).join('\n\n');
|
|
109
499
|
}
|
|
110
500
|
|
|
111
501
|
// Create a ClipboardItem with multiple formats
|
|
112
502
|
const data = {
|
|
113
|
-
'text/plain': new Blob([plain_text], {type: 'text/plain'}),
|
|
114
|
-
'text/html': new Blob([html], {type: 'text/html'}),
|
|
503
|
+
'text/plain': new Blob([plain_text || ''], {type: 'text/plain'}),
|
|
504
|
+
'text/html': new Blob([html || ''], {type: 'text/html'}),
|
|
115
505
|
};
|
|
116
506
|
|
|
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
507
|
const clipboard_item_raw = new ClipboardItem(data);
|
|
122
508
|
|
|
123
509
|
// Write to clipboard
|
|
@@ -145,12 +531,13 @@
|
|
|
145
531
|
|
|
146
532
|
let pasted_json;
|
|
147
533
|
|
|
148
|
-
//
|
|
149
|
-
// plaintext copy and pasting for all other situations.
|
|
534
|
+
// First try to extract svedit data from HTML format
|
|
150
535
|
try {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
536
|
+
const html_blob = await clipboardItems[0].getType('text/html');
|
|
537
|
+
const html_content = await html_blob.text();
|
|
538
|
+
pasted_json = extract_svedit_data_from_html(html_content);
|
|
539
|
+
} catch (e) {
|
|
540
|
+
console.log('No HTML format available or failed to extract svedit data:', e);
|
|
154
541
|
pasted_json = undefined;
|
|
155
542
|
}
|
|
156
543
|
|
|
@@ -177,12 +564,18 @@
|
|
|
177
564
|
for (const [property, value] of Object.entries(new_node)) {
|
|
178
565
|
if (property === 'id' || property === 'type') continue;
|
|
179
566
|
|
|
180
|
-
const prop_type = doc.schema[node.type]?.[property]?.type;
|
|
567
|
+
const prop_type = doc.schema[node.type]?.properties?.[property]?.type;
|
|
181
568
|
|
|
182
569
|
if (prop_type === 'node_array' && Array.isArray(value)) {
|
|
183
570
|
new_node[property] = value.map(ref_id => id_mapping[ref_id] || ref_id);
|
|
184
571
|
} else if (prop_type === 'node' && typeof value === 'string') {
|
|
185
572
|
new_node[property] = id_mapping[value] || value;
|
|
573
|
+
} else if (prop_type === 'annotated_text') {
|
|
574
|
+
const annotations = value.annotations.map(annotation => {
|
|
575
|
+
const {start_offset, end_offset, node_id} = annotation;
|
|
576
|
+
return {start_offset, end_offset, node_id: id_mapping[node_id] || node_id};
|
|
577
|
+
});
|
|
578
|
+
new_node[property] = {text: value.text, annotations};
|
|
186
579
|
}
|
|
187
580
|
}
|
|
188
581
|
|
|
@@ -202,10 +595,14 @@
|
|
|
202
595
|
|
|
203
596
|
doc.apply(tr);
|
|
204
597
|
} else {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
598
|
+
// Fallback to plain text when no svedit data is found
|
|
599
|
+
try {
|
|
600
|
+
const plain_text_blob = await clipboardItems[0].getType('text/plain');
|
|
601
|
+
const plain_text = await plain_text_blob.text();
|
|
602
|
+
doc.apply(doc.tr.insert_text(plain_text));
|
|
603
|
+
} catch (e) {
|
|
604
|
+
console.error('Failed to paste any content:', e);
|
|
605
|
+
}
|
|
209
606
|
}
|
|
210
607
|
}
|
|
211
608
|
|
|
@@ -233,7 +630,7 @@
|
|
|
233
630
|
} else if (selection?.type === 'property') {
|
|
234
631
|
__render_property_selection();
|
|
235
632
|
} else {
|
|
236
|
-
console.log('unsupported selection', selection);
|
|
633
|
+
console.log('unsupported selection', $state.snapshot(selection));
|
|
237
634
|
}
|
|
238
635
|
}
|
|
239
636
|
|
|
@@ -254,45 +651,68 @@
|
|
|
254
651
|
}
|
|
255
652
|
|
|
256
653
|
function onkeydown(e) {
|
|
654
|
+
// Turn editable on
|
|
655
|
+
if (e.key === 'e' && (e.ctrlKey || e.metaKey)) {
|
|
656
|
+
editable = true;
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Turn editable off (=save)
|
|
661
|
+
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
|
662
|
+
doc.selection = null;
|
|
663
|
+
editable = false;
|
|
664
|
+
e.preventDefault();
|
|
665
|
+
e.stopPropagation();
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
257
669
|
// Only handle keyboard events if focus is within the canvas
|
|
258
670
|
if (!canvas_ref?.contains(document.activeElement)) return;
|
|
259
671
|
|
|
672
|
+
// Key handling temporarily disabled (e.g. while character composition takes place)
|
|
673
|
+
if (skip_onkeydown) {
|
|
674
|
+
// console.log('onkeydown skipped');
|
|
675
|
+
// Currently we do nothing, but we could handle keydown during character composition here.
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
260
679
|
const selection = /** @type {any} */ (doc.selection);
|
|
261
680
|
const is_collapsed = selection?.anchor_offset === selection?.focus_offset;
|
|
262
681
|
|
|
263
|
-
// console.log('onkeydown', e.key);
|
|
264
682
|
if (
|
|
265
|
-
(e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.
|
|
266
|
-
(e.key === 'ArrowRight' && e.altKey && e.ctrlKey && e.shiftKey && doc.
|
|
683
|
+
(e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.layout_node) ||
|
|
684
|
+
(e.key === 'ArrowRight' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
|
|
267
685
|
) {
|
|
268
|
-
const node = doc.
|
|
686
|
+
const node = doc.layout_node;
|
|
269
687
|
const layout_count = doc.config.node_layouts[node.type];
|
|
270
688
|
|
|
271
689
|
if (layout_count > 1 && node?.layout) {
|
|
272
690
|
const next_layout = (node.layout % layout_count) + 1;
|
|
273
691
|
console.log('layout / count / next_layout', node.layout, layout_count, next_layout);
|
|
274
692
|
const tr = doc.tr;
|
|
275
|
-
tr.set([doc.
|
|
693
|
+
tr.set([doc.layout_node?.id, 'layout'], next_layout);
|
|
276
694
|
doc.apply(tr);
|
|
277
695
|
}
|
|
696
|
+
e.preventDefault();
|
|
278
697
|
} else if (
|
|
279
|
-
(e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.
|
|
280
|
-
(e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && e.shiftKey && doc.
|
|
698
|
+
(e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.layout_node) ||
|
|
699
|
+
(e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
|
|
281
700
|
) {
|
|
282
|
-
const node = doc.
|
|
701
|
+
const node = doc.layout_node;
|
|
283
702
|
const layout_count = doc.config.node_layouts[node.type];
|
|
284
703
|
if (layout_count > 1 && node?.layout) {
|
|
285
704
|
const prev_layout = ((node.layout - 2 + layout_count) % layout_count) + 1;
|
|
286
705
|
const tr = doc.tr;
|
|
287
|
-
tr.set([doc.
|
|
706
|
+
tr.set([doc.layout_node?.id, 'layout'], prev_layout);
|
|
288
707
|
doc.apply(tr);
|
|
289
708
|
console.log('layout / count / prev_layout', node.layout, layout_count, prev_layout);
|
|
290
709
|
}
|
|
710
|
+
e.preventDefault();
|
|
291
711
|
} else if (
|
|
292
|
-
(e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.
|
|
293
|
-
(e.key === 'ArrowDown' && e.altKey && e.ctrlKey && e.shiftKey && doc.
|
|
712
|
+
(e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.layout_node) ||
|
|
713
|
+
(e.key === 'ArrowDown' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node )
|
|
294
714
|
) {
|
|
295
|
-
const node = doc.
|
|
715
|
+
const node = doc.layout_node;
|
|
296
716
|
|
|
297
717
|
if (doc.selection.type !== 'node') {
|
|
298
718
|
doc.select_parent();
|
|
@@ -310,11 +730,12 @@
|
|
|
310
730
|
doc.config.inserters[next_type](tr);
|
|
311
731
|
tr.set_selection(old_selection);
|
|
312
732
|
doc.apply(tr);
|
|
733
|
+
e.preventDefault();
|
|
313
734
|
} else if (
|
|
314
|
-
(e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.
|
|
315
|
-
(e.key === 'ArrowUp' && e.altKey && e.ctrlKey && e.shiftKey && doc.
|
|
735
|
+
(e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.layout_node) ||
|
|
736
|
+
(e.key === 'ArrowUp' && e.altKey && e.ctrlKey && e.shiftKey && doc.layout_node)
|
|
316
737
|
) {
|
|
317
|
-
const node = doc.
|
|
738
|
+
const node = doc.layout_node;
|
|
318
739
|
|
|
319
740
|
if (doc.selection.type !== 'node') {
|
|
320
741
|
doc.select_parent();
|
|
@@ -334,6 +755,7 @@
|
|
|
334
755
|
doc.config.inserters[prev_type](tr);
|
|
335
756
|
tr.set_selection(old_selection);
|
|
336
757
|
doc.apply(tr);
|
|
758
|
+
e.preventDefault();
|
|
337
759
|
|
|
338
760
|
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
|
339
761
|
const tr = doc.tr;
|
|
@@ -350,44 +772,32 @@
|
|
|
350
772
|
doc.redo();
|
|
351
773
|
e.preventDefault();
|
|
352
774
|
e.stopPropagation();
|
|
353
|
-
} else if (e.key === 'Enter' && (e.
|
|
775
|
+
} else if (e.key === 'Enter' && (e.shiftKey) && !is_mobile_browser() && selection?.type === 'text' && doc.inspect(selection.path).allow_newlines) {
|
|
354
776
|
doc.apply(doc.tr.insert_text('\n'));
|
|
355
777
|
e.preventDefault();
|
|
356
778
|
e.stopPropagation();
|
|
357
|
-
} else if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
|
|
779
|
+
} else if (e.key === 'b' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
|
|
358
780
|
doc.apply(doc.tr.annotate_text('strong'));
|
|
359
781
|
e.preventDefault();
|
|
360
782
|
e.stopPropagation();
|
|
361
|
-
} else if (e.key === 'i' && (e.ctrlKey || e.metaKey)) {
|
|
783
|
+
} else if (e.key === 'i' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
|
|
362
784
|
doc.apply(doc.tr.annotate_text('emphasis'));
|
|
363
785
|
e.preventDefault();
|
|
364
786
|
e.stopPropagation();
|
|
365
|
-
} else if (e.key === '
|
|
366
|
-
doc.apply(doc.tr.annotate_text('
|
|
367
|
-
href: window.prompt('Enter the URL', 'https://example.com')
|
|
368
|
-
}));
|
|
787
|
+
} else if (e.key === 'u' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
|
|
788
|
+
doc.apply(doc.tr.annotate_text('highlight'));
|
|
369
789
|
e.preventDefault();
|
|
370
790
|
e.stopPropagation();
|
|
371
|
-
} else if (e.key === '
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
tr.set(selection.path, '');
|
|
377
|
-
doc.apply(tr);
|
|
378
|
-
}
|
|
791
|
+
} else if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
|
|
792
|
+
const has_link = doc.active_annotation('link');
|
|
793
|
+
if (has_link) {
|
|
794
|
+
// Delete link
|
|
795
|
+
doc.apply(doc.tr.annotate_text('link'));
|
|
379
796
|
} else {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
doc.apply(tr);
|
|
385
|
-
} else {
|
|
386
|
-
// TODO: Things are not yet properly composable, I think we need to
|
|
387
|
-
// realize delete_selection as a command as well.
|
|
388
|
-
// See https://prosemirror.net/docs/guide/#commands for inspiration.
|
|
389
|
-
// For other selections, use the normal delete behavior
|
|
390
|
-
doc.apply(doc.tr.delete_selection());
|
|
797
|
+
// Create link
|
|
798
|
+
const href = window.prompt('Enter the URL', 'https://example.com');
|
|
799
|
+
if (href) {
|
|
800
|
+
doc.apply(doc.tr.annotate_text('link', { href }));
|
|
391
801
|
}
|
|
392
802
|
}
|
|
393
803
|
|
|
@@ -420,8 +830,11 @@
|
|
|
420
830
|
e.stopPropagation();
|
|
421
831
|
} else if (e.key === 'Enter' && selection?.type === 'text') {
|
|
422
832
|
const tr = doc.tr;
|
|
423
|
-
break_text_node(tr)
|
|
424
|
-
|
|
833
|
+
if (break_text_node(tr)) {
|
|
834
|
+
doc.apply(tr);
|
|
835
|
+
}
|
|
836
|
+
e.preventDefault();
|
|
837
|
+
e.stopPropagation();
|
|
425
838
|
} else if (e.key === 'Escape' && selection) {
|
|
426
839
|
doc.select_parent();
|
|
427
840
|
e.preventDefault();
|
|
@@ -429,6 +842,34 @@
|
|
|
429
842
|
}
|
|
430
843
|
}
|
|
431
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Handles composition start events for input methods like dead keys
|
|
847
|
+
* This occurs when user starts typing a composed character (e.g., backtick for accents)
|
|
848
|
+
* @param {CompositionEvent} event
|
|
849
|
+
*/
|
|
850
|
+
function oncompositionstart(event) {
|
|
851
|
+
if (doc.selection.type !== 'text') return;
|
|
852
|
+
console.log('DEBUG: oncompositionstart', event.data);
|
|
853
|
+
// Disable keydown event handling during composition. This way, you can confirm
|
|
854
|
+
// a diacritic (a->ä) with ENTER without causing a line break.
|
|
855
|
+
skip_onkeydown = true;
|
|
856
|
+
is_composing = true;
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Handles composition end events for input methods like dead keys
|
|
862
|
+
* This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
|
|
863
|
+
*/
|
|
864
|
+
function oncompositionend(/*event*/) {
|
|
865
|
+
// console.log('DEBUG: oncompositionend, insert:', event.data);
|
|
866
|
+
if (!canvas_ref?.contains(document.activeElement)) return;
|
|
867
|
+
if (canvas_ref?.contains(document.activeElement) && doc.selection?.type === 'text') {
|
|
868
|
+
commit_input();
|
|
869
|
+
}
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
432
873
|
/**
|
|
433
874
|
* Extracts a NodeSelection from the current DOM selection.
|
|
434
875
|
*
|
|
@@ -592,8 +1033,9 @@
|
|
|
592
1033
|
const focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
|
|
593
1034
|
let focus_root, anchor_root;
|
|
594
1035
|
|
|
1036
|
+
|
|
595
1037
|
if (dom_selection.focusNode === dom_selection.anchorNode && focus_node.dataset?.type === 'text') {
|
|
596
|
-
//
|
|
1038
|
+
// EDGE CASE 1: Either text node is empty (only a <br> is present), or cursor is after a <br> at the very end of the text node
|
|
597
1039
|
focus_root = anchor_root = focus_node;
|
|
598
1040
|
} else {
|
|
599
1041
|
focus_root = /** @type {HTMLElement} */ (dom_selection.focusNode.parentElement?.closest('[data-path][data-type="text"]'));
|
|
@@ -611,29 +1053,80 @@
|
|
|
611
1053
|
|
|
612
1054
|
if (!path) return null;
|
|
613
1055
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
1056
|
+
// EDGE CASE 1B: Cursor after trailing <br> at end of text
|
|
1057
|
+
//
|
|
1058
|
+
// When text ends with a newline (\n), the browser renders it as a <br> element.
|
|
1059
|
+
// If the user places their cursor after this <br>, the selection detection
|
|
1060
|
+
// incorrectly reports position 0 instead of the actual text length.
|
|
1061
|
+
//
|
|
1062
|
+
// Example DOM structure when text is "Hello\n":
|
|
1063
|
+
// <div data-type="text">
|
|
1064
|
+
// #text "Hello" ← index 0
|
|
1065
|
+
// <br> ← index 1 (represents the \n)
|
|
1066
|
+
// </div>
|
|
1067
|
+
//
|
|
1068
|
+
// When cursor is after the <br>, we get:
|
|
1069
|
+
// - focusNode: the div element
|
|
1070
|
+
// - focusOffset: 2 (after the 2nd child)
|
|
1071
|
+
// - But normal processing returns position 0 instead of text.length
|
|
1072
|
+
//
|
|
1073
|
+
// This edge case detects when:
|
|
1074
|
+
// 1. Text content ends with \n
|
|
1075
|
+
// 2. Last child node is a <br> (the visual representation of that \n)
|
|
1076
|
+
// 3. Cursor is positioned at or near the end
|
|
1077
|
+
// And returns the correct text.length position instead.
|
|
1078
|
+
const text_content = doc.get(path).text;
|
|
1079
|
+
const text_length = get_char_length(text_content);
|
|
1080
|
+
if (text_length > 0) {
|
|
1081
|
+
const last_char = get_char_at(text_content, text_length - 1);
|
|
1082
|
+
if (dom_selection.focusNode === dom_selection.anchorNode &&
|
|
1083
|
+
dom_selection.focusNode === focus_node &&
|
|
1084
|
+
focus_node.dataset?.type === 'text' &&
|
|
1085
|
+
!focus_node.classList.contains('empty') &&
|
|
1086
|
+
last_char === '\n') {
|
|
1087
|
+
|
|
1088
|
+
const child_nodes = focus_node.childNodes;
|
|
1089
|
+
|
|
1090
|
+
// Check if the last child is a BR (indicating text ends with newline)
|
|
1091
|
+
// and cursor is positioned near the end (after any of the last few elements)
|
|
1092
|
+
if (child_nodes.length > 0 &&
|
|
1093
|
+
child_nodes[child_nodes.length - 1].nodeName === 'BR' &&
|
|
1094
|
+
dom_selection.focusOffset >= child_nodes.length - 2) {
|
|
1095
|
+
return {
|
|
1096
|
+
type: 'text',
|
|
1097
|
+
path,
|
|
1098
|
+
anchor_offset: text_length,
|
|
1099
|
+
focus_offset: text_length
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
let anchor_offset = 0;
|
|
1106
|
+
let focus_offset = 0;
|
|
1107
|
+
let current_offset = 0;
|
|
617
1108
|
|
|
618
1109
|
function processNode(node) {
|
|
619
1110
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
620
|
-
const
|
|
621
|
-
|
|
1111
|
+
const nodeText = node.textContent;
|
|
1112
|
+
const nodeCharLength = get_char_length(nodeText);
|
|
622
1113
|
if (node === range.startContainer) {
|
|
623
|
-
|
|
1114
|
+
// Convert UTF-16 offset to character offset
|
|
1115
|
+
const char_start_offset = utf16_to_char_offset(nodeText, range.startOffset);
|
|
1116
|
+
anchor_offset = current_offset + char_start_offset;
|
|
624
1117
|
}
|
|
625
1118
|
if (node === range.endContainer) {
|
|
626
|
-
|
|
1119
|
+
// Convert UTF-16 offset to character offset
|
|
1120
|
+
const char_end_offset = utf16_to_char_offset(nodeText, range.endOffset);
|
|
1121
|
+
focus_offset = current_offset + char_end_offset;
|
|
627
1122
|
}
|
|
628
|
-
|
|
629
|
-
currentOffset += nodeLength;
|
|
1123
|
+
current_offset += nodeCharLength;
|
|
630
1124
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
631
1125
|
for (const childNode of node.childNodes) {
|
|
632
1126
|
processNode(childNode);
|
|
633
1127
|
}
|
|
634
1128
|
}
|
|
635
|
-
|
|
636
|
-
return (focusOffset !== 0);
|
|
1129
|
+
return (focus_offset !== 0);
|
|
637
1130
|
}
|
|
638
1131
|
|
|
639
1132
|
// Process nodes to find offsets
|
|
@@ -647,14 +1140,14 @@
|
|
|
647
1140
|
|
|
648
1141
|
// Swap offsets if it's a backward selection
|
|
649
1142
|
if (is_backward) {
|
|
650
|
-
[
|
|
1143
|
+
[anchor_offset, focus_offset] = [focus_offset, anchor_offset];
|
|
651
1144
|
}
|
|
652
1145
|
|
|
653
1146
|
return {
|
|
654
1147
|
type: 'text',
|
|
655
1148
|
path,
|
|
656
|
-
anchor_offset:
|
|
657
|
-
focus_offset:
|
|
1149
|
+
anchor_offset: anchor_offset,
|
|
1150
|
+
focus_offset: focus_offset
|
|
658
1151
|
};
|
|
659
1152
|
}
|
|
660
1153
|
|
|
@@ -671,7 +1164,6 @@
|
|
|
671
1164
|
function __render_node_selection() {
|
|
672
1165
|
const selection = /** @type {NodeSelection} */ (doc.selection);
|
|
673
1166
|
const node_array_path = selection.path.join('.');
|
|
674
|
-
|
|
675
1167
|
let is_collapsed = selection.anchor_offset === selection.focus_offset;
|
|
676
1168
|
let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
|
|
677
1169
|
|
|
@@ -703,7 +1195,6 @@
|
|
|
703
1195
|
selection.anchor_offset === 0 ? ':scope > .position-zero-cursor-trap' : ':scope > .after-node-cursor-trap'
|
|
704
1196
|
);
|
|
705
1197
|
|
|
706
|
-
|
|
707
1198
|
range.setStart(cursor_trap_el, 1);
|
|
708
1199
|
range.setEnd(cursor_trap_el, 1);
|
|
709
1200
|
dom_selection.removeAllRanges();
|
|
@@ -769,7 +1260,7 @@
|
|
|
769
1260
|
const selection = /** @type {any} */ (doc.selection);
|
|
770
1261
|
// The element that holds the annotated string
|
|
771
1262
|
const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="text"]`);
|
|
772
|
-
const empty_text = doc.get(selection.path)
|
|
1263
|
+
const empty_text = doc.get(selection.path).text.length === 0;
|
|
773
1264
|
|
|
774
1265
|
const range = window.document.createRange();
|
|
775
1266
|
const dom_selection = window.getSelection();
|
|
@@ -786,40 +1277,44 @@
|
|
|
786
1277
|
const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
|
|
787
1278
|
const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
|
|
788
1279
|
|
|
789
|
-
|
|
790
1280
|
// Helper function to process each node
|
|
791
1281
|
function process_node(node) {
|
|
792
1282
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
793
|
-
const
|
|
1283
|
+
const node_text = node.textContent;
|
|
1284
|
+
const node_char_length = get_char_length(node_text);
|
|
794
1285
|
|
|
795
1286
|
if (is_backward) {
|
|
796
|
-
if (!focus_node && current_offset +
|
|
1287
|
+
if (!focus_node && current_offset + node_char_length >= start_offset) {
|
|
797
1288
|
focus_node = node;
|
|
798
|
-
|
|
1289
|
+
const char_offset = start_offset - current_offset;
|
|
1290
|
+
focus_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
799
1291
|
}
|
|
800
1292
|
} else {
|
|
801
|
-
if (!anchor_node && current_offset +
|
|
1293
|
+
if (!anchor_node && current_offset + node_char_length >= start_offset) {
|
|
802
1294
|
anchor_node = node;
|
|
803
|
-
|
|
1295
|
+
const char_offset = start_offset - current_offset;
|
|
1296
|
+
anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
804
1297
|
}
|
|
805
1298
|
}
|
|
806
1299
|
|
|
807
1300
|
// Find end node
|
|
808
1301
|
if (is_backward) {
|
|
809
|
-
if (!anchor_node && current_offset +
|
|
1302
|
+
if (!anchor_node && current_offset + node_char_length >= end_offset) {
|
|
810
1303
|
anchor_node = node;
|
|
811
|
-
|
|
1304
|
+
const char_offset = end_offset - current_offset;
|
|
1305
|
+
anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
812
1306
|
return true; // Stop iteration
|
|
813
1307
|
}
|
|
814
1308
|
} else {
|
|
815
|
-
if (!focus_node && current_offset +
|
|
1309
|
+
if (!focus_node && current_offset + node_char_length >= end_offset) {
|
|
816
1310
|
focus_node = node;
|
|
817
|
-
|
|
1311
|
+
const char_offset = end_offset - current_offset;
|
|
1312
|
+
focus_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
818
1313
|
return true; // Stop iteration
|
|
819
1314
|
}
|
|
820
1315
|
}
|
|
821
1316
|
|
|
822
|
-
current_offset +=
|
|
1317
|
+
current_offset += node_char_length;
|
|
823
1318
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
824
1319
|
for (const child_node of node.childNodes) {
|
|
825
1320
|
if (process_node(child_node)) return true; // Stop iteration if end found
|
|
@@ -935,9 +1430,24 @@
|
|
|
935
1430
|
<div
|
|
936
1431
|
class="svedit-canvas {css_class}"
|
|
937
1432
|
class:hide-selection={doc.selection?.type === 'node'}
|
|
1433
|
+
class:node-cursor={doc.selection?.type === 'node' && doc.selection.anchor_offset === doc.selection.focus_offset}
|
|
938
1434
|
bind:this={canvas_ref}
|
|
1435
|
+
{oninput}
|
|
939
1436
|
{onbeforeinput}
|
|
1437
|
+
{oncompositionstart}
|
|
1438
|
+
{oncompositionend}
|
|
940
1439
|
contenteditable={editable ? 'true' : 'false'}
|
|
1440
|
+
{autocapitalize}
|
|
1441
|
+
{spellcheck}
|
|
1442
|
+
{...{
|
|
1443
|
+
// NOTE: Autocomplete and autocorrect make immense troubles
|
|
1444
|
+
// on Desktop Chrome, so we disable them only for Chrome desktop.
|
|
1445
|
+
// Additionally, OSX-native auto-complete also breaks, because
|
|
1446
|
+
// I'm using a keyed block that always wipes the DOM of a text node
|
|
1447
|
+
// on every change.
|
|
1448
|
+
autocomplete: is_chrome_desktop ? "off" : "on",
|
|
1449
|
+
autocorrect: is_chrome_desktop ? "off" : "on"
|
|
1450
|
+
}}
|
|
941
1451
|
>
|
|
942
1452
|
<RootComponent {path} />
|
|
943
1453
|
</div>
|
|
@@ -954,17 +1464,26 @@
|
|
|
954
1464
|
}
|
|
955
1465
|
}
|
|
956
1466
|
|
|
957
|
-
.svedit-canvas.hide-selection {
|
|
958
|
-
caret-color: transparent;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
1467
|
.svedit-canvas :global(::selection) {
|
|
962
1468
|
background: var(--editing-fill-color);
|
|
963
1469
|
}
|
|
964
1470
|
|
|
965
1471
|
@media not (pointer: coarse) {
|
|
966
|
-
|
|
967
|
-
|
|
1472
|
+
.svedit-canvas.hide-selection {
|
|
1473
|
+
caret-color: transparent;
|
|
968
1474
|
}
|
|
969
1475
|
}
|
|
1476
|
+
|
|
1477
|
+
/* When the cursor is in a cursor-trap we never want to see the caret */
|
|
1478
|
+
.svedit-canvas.node-cursor {
|
|
1479
|
+
caret-color: transparent;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
@media not (pointer: coarse) {
|
|
1483
|
+
@supports (anchor-name: --test) {
|
|
1484
|
+
.svedit-canvas.hide-selection :global(::selection) {
|
|
1485
|
+
background: transparent;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
970
1489
|
</style>
|