svedit 0.2.0 → 0.3.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 +2 -0
- package/dist/AnnotatedStringProperty.svelte +42 -39
- package/dist/AnnotatedStringProperty.svelte.d.ts.map +1 -1
- 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 +1 -1
- package/dist/Document.svelte.d.ts.map +1 -1
- package/dist/Document.svelte.js +5 -2
- 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 +543 -76
- package/dist/Svedit.svelte.d.ts +1 -1
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +0 -3
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +19 -9
- package/dist/commands.svelte.d.ts.map +1 -1
- package/dist/commands.svelte.js +6 -4
- package/dist/util.d.ts +125 -3
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +170 -12
- package/package.json +1 -1
- package/src/lib/AnnotatedStringProperty.svelte +42 -39
- package/src/lib/CustomProperty.svelte +20 -12
- package/src/lib/Document.svelte.js +5 -2
- package/src/lib/Node.svelte +18 -14
- package/src/lib/NodeArrayProperty.svelte +1 -1
- package/src/lib/Svedit.svelte +543 -76
- package/src/lib/Transaction.svelte.js +19 -9
- package/src/lib/commands.svelte.js +6 -4
- package/src/lib/util.js +170 -12
package/dist/Svedit.svelte
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
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
13
|
} = $props();
|
|
@@ -17,31 +17,225 @@
|
|
|
17
17
|
let Overlays = $derived(doc.config.system_components.Overlays);
|
|
18
18
|
let RootComponent = $derived(doc.config.node_components[snake_to_pascal(root_node.type)]);
|
|
19
19
|
|
|
20
|
+
// Track temporary disabled onkeydown events (e.g. during character composition)
|
|
21
|
+
let skip_onkeydown = false;
|
|
22
|
+
|
|
23
|
+
let is_composing = $state(false);
|
|
24
|
+
|
|
25
|
+
let input_selection = undefined;
|
|
26
|
+
|
|
27
|
+
// Detect Chrome on desktop (not mobile) - only available in browser
|
|
28
|
+
let is_chrome_desktop = $state(false);
|
|
29
|
+
|
|
30
|
+
$effect(() => {
|
|
31
|
+
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
|
|
32
|
+
const user_agent = navigator.userAgent;
|
|
33
|
+
const is_chrome = user_agent.includes('Chrome') && !user_agent.includes('Edg');
|
|
34
|
+
const is_mobile = /iPhone|iPad|iPod|Android|Mobile/i.test(user_agent) ||
|
|
35
|
+
('ontouchstart' in window) ||
|
|
36
|
+
(navigator.maxTouchPoints > 0);
|
|
37
|
+
is_chrome_desktop = is_chrome && !is_mobile;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
20
41
|
/** Expose function so parent can call it */
|
|
21
42
|
export { focus_canvas };
|
|
22
43
|
|
|
23
44
|
setContext("svedit", {
|
|
24
45
|
get doc() {
|
|
25
46
|
return doc;
|
|
26
|
-
}
|
|
47
|
+
},
|
|
48
|
+
get editable() {
|
|
49
|
+
return editable;
|
|
50
|
+
},
|
|
51
|
+
get is_composing() {
|
|
52
|
+
return is_composing
|
|
53
|
+
},
|
|
27
54
|
});
|
|
28
55
|
|
|
56
|
+
function diff_text(old_text, new_text) {
|
|
57
|
+
const old_length = get_char_length(old_text);
|
|
58
|
+
const new_length = get_char_length(new_text);
|
|
59
|
+
|
|
60
|
+
let start = 0;
|
|
61
|
+
while (
|
|
62
|
+
start < old_length &&
|
|
63
|
+
start < new_length &&
|
|
64
|
+
get_char_at(old_text, start) === get_char_at(new_text, start)
|
|
65
|
+
) {
|
|
66
|
+
start++;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let old_end = old_length;
|
|
70
|
+
let new_end = new_length;
|
|
71
|
+
while (
|
|
72
|
+
old_end > start &&
|
|
73
|
+
new_end > start &&
|
|
74
|
+
get_char_at(old_text, old_end - 1) === get_char_at(new_text, new_end - 1)
|
|
75
|
+
) {
|
|
76
|
+
old_end--;
|
|
77
|
+
new_end--;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
start_offset: start,
|
|
82
|
+
end_offset: old_end,
|
|
83
|
+
replacement_text: char_slice(new_text, start, new_end)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function commit_input() {
|
|
88
|
+
if (input_selection?.type !== 'text') return;
|
|
89
|
+
const model_text = doc.get(input_selection.path)[0];
|
|
90
|
+
const dom_text_element = document.querySelector(`[data-path="${input_selection.path.join('.')}"]`);
|
|
91
|
+
const dom_text = dom_text_element.textContent;
|
|
92
|
+
const op = diff_text(model_text, dom_text);
|
|
93
|
+
// console.log('======== COMMIT INPUT ========')
|
|
94
|
+
// console.log('mdl_text:', model_text);
|
|
95
|
+
// console.log('dom_text:', dom_text);
|
|
96
|
+
// console.log('op', op);
|
|
97
|
+
|
|
98
|
+
// NOTE: We are currently in an out-of-sync DOM state, right after
|
|
99
|
+
// contenteditable applied the input. But we can use this state
|
|
100
|
+
// to determine the ideal intended selection after the operation.
|
|
101
|
+
// Because our diffing may miss some context. E.g. when you replace "mchael"
|
|
102
|
+
// to "Michael" it will replace "m" with "Mi" and put the cursor after the "i",
|
|
103
|
+
// but we actually want it after "Michael".
|
|
104
|
+
const before_selection = __get_text_selection_from_dom();
|
|
105
|
+
|
|
106
|
+
// Now we are applying the change to the document
|
|
107
|
+
const tr = doc.tr;
|
|
108
|
+
tr.set_selection({
|
|
109
|
+
type: 'text',
|
|
110
|
+
path: [...input_selection.path],
|
|
111
|
+
anchor_offset: op.start_offset,
|
|
112
|
+
focus_offset: op.end_offset,
|
|
113
|
+
});
|
|
114
|
+
tr.insert_text(op.replacement_text);
|
|
115
|
+
tr.set_selection(before_selection);
|
|
116
|
+
doc.apply(tr);
|
|
117
|
+
// console.log('applied op', op);
|
|
118
|
+
|
|
119
|
+
// After each commited replacement, we start accepting custom key handlers again.
|
|
120
|
+
skip_onkeydown = false;
|
|
121
|
+
is_composing = false;
|
|
122
|
+
input_selection = null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function oninput(event) {
|
|
126
|
+
// console.log(`oninput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
|
|
127
|
+
if (
|
|
128
|
+
// canvas_ref?.contains(document.activeElement) &&
|
|
129
|
+
// doc.selection?.type === 'text' &&
|
|
130
|
+
input_selection.type === 'text' &&
|
|
131
|
+
!event.isComposing
|
|
132
|
+
) {
|
|
133
|
+
commit_input();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
29
137
|
/**
|
|
30
138
|
* @param {InputEvent} event
|
|
31
139
|
*/
|
|
32
140
|
function onbeforeinput(event) {
|
|
33
|
-
|
|
141
|
+
// console.log(`onbeforeinput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
|
|
34
142
|
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
143
|
+
// Only take input when in a valid text selection inside the canvas
|
|
144
|
+
if (
|
|
145
|
+
!(canvas_ref?.contains(document.activeElement)) ||
|
|
146
|
+
(doc.selection?.type !== 'text')
|
|
147
|
+
) {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (event.inputType === 'formatBold') {
|
|
153
|
+
doc.apply(doc.tr.annotate_text('strong'));
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
event.stopPropagation();
|
|
40
156
|
}
|
|
157
|
+
|
|
158
|
+
if (event.inputType === 'formatItalic') {
|
|
159
|
+
doc.apply(doc.tr.annotate_text('emphasis'));
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
event.stopPropagation();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
// We remember the "input selection", because in some mobile browsers (Samsung Android) the onbeforeinput event
|
|
166
|
+
// is fired on a different selection than the input event.
|
|
167
|
+
input_selection = { ...doc.selection };
|
|
168
|
+
|
|
169
|
+
// If replacement is detected, I let contenteditable do the thing and wait for
|
|
170
|
+
// the oninput event to do the diffing and update the model.
|
|
171
|
+
if (event.inputType === 'insertReplacementText') {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// For now I reject drag+drop text movements.
|
|
176
|
+
// TODO: If I want to support those, I need to handle them in such a way that
|
|
177
|
+
// you can drag from one text property to another too.
|
|
178
|
+
if (event.inputType === 'deleteByDrag' || event.inputType === 'insertFromDrop') {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// While composing, I do nothing and let the oncompositionend
|
|
184
|
+
// event handle the final replacement by recovering it from the DOM.
|
|
185
|
+
if (event.isComposing) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Insert the character, unless there is none.
|
|
190
|
+
let inserted_text = event.data;
|
|
191
|
+
|
|
192
|
+
// Sometimes (e.g. for replacements) the inserted_text is available via
|
|
193
|
+
// event.dataTransfer, not event.data
|
|
194
|
+
if (!inserted_text && event.dataTransfer) {
|
|
195
|
+
inserted_text = event.dataTransfer?.getData('text/plain');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// If there's no inserted_text, I'm not handling this.
|
|
199
|
+
if (!inserted_text) {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// For now I don't accept structural replacements for savety reasons.
|
|
205
|
+
// E.g. this will catch Apple's writing tools that will attempt to mess with our DOM.
|
|
206
|
+
if (inserted_text.includes('\n') || inserted_text.includes('\t')) {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function get_base_char(char) {
|
|
212
|
+
if (!char) return undefined;
|
|
213
|
+
const normalized = char.normalize('NFD');
|
|
214
|
+
// If normalization doesn't change it OR it's longer than 2 chars, it's probably not a simple diacritic
|
|
215
|
+
if (normalized.length <= 2 && normalized !== char) {
|
|
216
|
+
return normalized[0];
|
|
217
|
+
}
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
(get_char_length(inserted_text) > 1) ||
|
|
223
|
+
(get_char_length(inserted_text) === 1 && get_base_char(inserted_text))
|
|
224
|
+
) {
|
|
225
|
+
skip_onkeydown = true;
|
|
226
|
+
return; // we are dealing with a replacement
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const tr = doc.tr;
|
|
230
|
+
tr.insert_text(inserted_text);
|
|
231
|
+
doc.apply(tr);
|
|
232
|
+
input_selection = undefined;
|
|
233
|
+
event.preventDefault();
|
|
41
234
|
}
|
|
42
235
|
|
|
43
236
|
// Map DOM selection to internal model
|
|
44
237
|
function onselectionchange() {
|
|
238
|
+
if (!editable) return;
|
|
45
239
|
const dom_selection = window.getSelection();
|
|
46
240
|
if (!dom_selection.rangeCount) return;
|
|
47
241
|
|
|
@@ -54,6 +248,100 @@
|
|
|
54
248
|
}
|
|
55
249
|
}
|
|
56
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Creates HTML clipboard format with embedded svedit data
|
|
253
|
+
* @param {Object} json_data - The svedit data to embed
|
|
254
|
+
* @param {string} fallback_html - HTML for cross-app compatibility
|
|
255
|
+
* @returns {string} HTML with embedded svedit data
|
|
256
|
+
*/
|
|
257
|
+
function create_svedit_html_format(json_data, fallback_html) {
|
|
258
|
+
// Use encodeURIComponent to handle Unicode, then base64 encode
|
|
259
|
+
const json_string = JSON.stringify(json_data);
|
|
260
|
+
const encoded_data = btoa(encodeURIComponent(json_string));
|
|
261
|
+
|
|
262
|
+
return `<meta charset="utf-8">
|
|
263
|
+
<div>
|
|
264
|
+
<span data-svedit="${encoded_data}"></span>
|
|
265
|
+
</div>
|
|
266
|
+
${fallback_html}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Extracts svedit data from HTML clipboard format
|
|
271
|
+
* @param {string} html - HTML content from clipboard
|
|
272
|
+
* @returns {Object|null} Parsed svedit data or null if not found
|
|
273
|
+
*/
|
|
274
|
+
function extract_svedit_data_from_html(html) {
|
|
275
|
+
const svedit_regex = /data-svedit="([^"]+)"/;
|
|
276
|
+
const match = html.match(svedit_regex);
|
|
277
|
+
|
|
278
|
+
if (match && match[1]) {
|
|
279
|
+
try {
|
|
280
|
+
// Decode base64, then decode URI component to handle Unicode
|
|
281
|
+
const base64_decoded = atob(match[1]);
|
|
282
|
+
const decoded_data = decodeURIComponent(base64_decoded);
|
|
283
|
+
return JSON.parse(decoded_data);
|
|
284
|
+
} catch (e) {
|
|
285
|
+
console.warn('Failed to decode svedit data from HTML:', e);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Default node exporter for nodes without specific exporters
|
|
295
|
+
* @param {Object} node - Node object
|
|
296
|
+
* @returns {string} HTML representation
|
|
297
|
+
*/
|
|
298
|
+
function default_node_exporter(node) {
|
|
299
|
+
let html = '';
|
|
300
|
+
let has_content = false;
|
|
301
|
+
|
|
302
|
+
for (const [prop_name, prop_value] of Object.entries(node)) {
|
|
303
|
+
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
304
|
+
|
|
305
|
+
// Check if this is an annotated_string property (array with string as first element)
|
|
306
|
+
if (Array.isArray(prop_value) && typeof prop_value[0] === 'string') {
|
|
307
|
+
const text_content = prop_value[0];
|
|
308
|
+
if (text_content.trim()) {
|
|
309
|
+
html += `<p>${text_content}</p>\n`;
|
|
310
|
+
has_content = true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!has_content) {
|
|
316
|
+
// Generic fallback for unknown node types with no annotated_string content
|
|
317
|
+
html += `<div data-node-type="${node.type}">Content from ${node.type}</div>\n`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return html;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Exports nodes to HTML using document config exporters
|
|
325
|
+
* @param {Object[]} nodes - Array of node objects
|
|
326
|
+
* @returns {string} HTML representation
|
|
327
|
+
*/
|
|
328
|
+
function export_html(nodes) {
|
|
329
|
+
let html = '';
|
|
330
|
+
|
|
331
|
+
for (const node of nodes) {
|
|
332
|
+
const html_exporters = doc.config.html_exporters || {};
|
|
333
|
+
|
|
334
|
+
if (html_exporters[node.type]) {
|
|
335
|
+
// Use custom exporter for this node type
|
|
336
|
+
html += html_exporters[node.type](node, doc);
|
|
337
|
+
} else {
|
|
338
|
+
// Use default exporter
|
|
339
|
+
html += default_node_exporter(node);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return html || '<p>Copied content</p>';
|
|
344
|
+
}
|
|
57
345
|
|
|
58
346
|
/**
|
|
59
347
|
* @param {NodeId[]} selected_node_ids
|
|
@@ -77,7 +365,6 @@
|
|
|
77
365
|
return { nodes, main_nodes: selected_node_ids };
|
|
78
366
|
}
|
|
79
367
|
|
|
80
|
-
|
|
81
368
|
/**
|
|
82
369
|
* @param {ClipboardEvent} event
|
|
83
370
|
* @param {boolean} delete_selection - used by oncut()
|
|
@@ -89,16 +376,16 @@
|
|
|
89
376
|
event.preventDefault();
|
|
90
377
|
event.stopPropagation();
|
|
91
378
|
|
|
92
|
-
let plain_text, html
|
|
379
|
+
let plain_text, html;
|
|
93
380
|
|
|
94
381
|
if (doc.selection?.type === 'text') {
|
|
95
382
|
plain_text = doc.get_selected_plain_text();
|
|
96
|
-
html = plain_text
|
|
383
|
+
html = `<span style="white-space:pre-wrap;">${plain_text}</span>`;
|
|
97
384
|
} else if (doc.selection?.type === 'node') {
|
|
98
385
|
const selected_nodes = doc.get_selected_nodes();
|
|
99
386
|
const { nodes, main_nodes } = prepare_copy_payload(selected_nodes);
|
|
100
387
|
|
|
101
|
-
json_data = { nodes, main_nodes };
|
|
388
|
+
const json_data = { nodes, main_nodes };
|
|
102
389
|
|
|
103
390
|
console.log('Copy operation:', {
|
|
104
391
|
selected_nodes,
|
|
@@ -106,18 +393,29 @@
|
|
|
106
393
|
total_nodes: Object.keys(nodes).length,
|
|
107
394
|
operation: delete_selection ? 'cut' : 'copy'
|
|
108
395
|
});
|
|
396
|
+
|
|
397
|
+
// Generate fallback HTML for cross-app compatibility
|
|
398
|
+
const selected_node_objects = main_nodes.map(id => nodes[id]);
|
|
399
|
+
const fallback_html = export_html(selected_node_objects);
|
|
400
|
+
|
|
401
|
+
// Create HTML with embedded svedit data
|
|
402
|
+
html = create_svedit_html_format(json_data, fallback_html);
|
|
403
|
+
|
|
404
|
+
// Create plain text representation
|
|
405
|
+
plain_text = selected_node_objects.map(node => {
|
|
406
|
+
if (node.type === 'story') {
|
|
407
|
+
return (node.title || 'Untitled Story') + (node.content ? '\n' + node.content : '');
|
|
408
|
+
}
|
|
409
|
+
return `Content from ${node.type}`;
|
|
410
|
+
}).join('\n\n');
|
|
109
411
|
}
|
|
110
412
|
|
|
111
413
|
// Create a ClipboardItem with multiple formats
|
|
112
414
|
const data = {
|
|
113
|
-
'text/plain': new Blob([plain_text], {type: 'text/plain'}),
|
|
114
|
-
'text/html': new Blob([html], {type: 'text/html'}),
|
|
415
|
+
'text/plain': new Blob([plain_text || ''], {type: 'text/plain'}),
|
|
416
|
+
'text/html': new Blob([html || ''], {type: 'text/html'}),
|
|
115
417
|
};
|
|
116
418
|
|
|
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
419
|
const clipboard_item_raw = new ClipboardItem(data);
|
|
122
420
|
|
|
123
421
|
// Write to clipboard
|
|
@@ -145,15 +443,18 @@
|
|
|
145
443
|
|
|
146
444
|
let pasted_json;
|
|
147
445
|
|
|
148
|
-
//
|
|
149
|
-
// plaintext copy and pasting for all other situations.
|
|
446
|
+
// First try to extract svedit data from HTML format
|
|
150
447
|
try {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
448
|
+
const html_blob = await clipboardItems[0].getType('text/html');
|
|
449
|
+
const html_content = await html_blob.text();
|
|
450
|
+
pasted_json = extract_svedit_data_from_html(html_content);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
console.log('No HTML format available or failed to extract svedit data:', e);
|
|
154
453
|
pasted_json = undefined;
|
|
155
454
|
}
|
|
156
455
|
|
|
456
|
+
|
|
457
|
+
|
|
157
458
|
if (pasted_json) {
|
|
158
459
|
const tr = doc.tr;
|
|
159
460
|
|
|
@@ -202,10 +503,14 @@
|
|
|
202
503
|
|
|
203
504
|
doc.apply(tr);
|
|
204
505
|
} else {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
506
|
+
// Fallback to plain text when no svedit data is found
|
|
507
|
+
try {
|
|
508
|
+
const plain_text_blob = await clipboardItems[0].getType('text/plain');
|
|
509
|
+
const plain_text = await plain_text_blob.text();
|
|
510
|
+
doc.apply(doc.tr.insert_text(plain_text));
|
|
511
|
+
} catch (e) {
|
|
512
|
+
console.error('Failed to paste any content:', e);
|
|
513
|
+
}
|
|
209
514
|
}
|
|
210
515
|
}
|
|
211
516
|
|
|
@@ -233,7 +538,7 @@
|
|
|
233
538
|
} else if (selection?.type === 'property') {
|
|
234
539
|
__render_property_selection();
|
|
235
540
|
} else {
|
|
236
|
-
console.log('unsupported selection', selection);
|
|
541
|
+
console.log('unsupported selection', $state.snapshot(selection));
|
|
237
542
|
}
|
|
238
543
|
}
|
|
239
544
|
|
|
@@ -254,13 +559,34 @@
|
|
|
254
559
|
}
|
|
255
560
|
|
|
256
561
|
function onkeydown(e) {
|
|
562
|
+
// Turn editable on
|
|
563
|
+
if (e.key === 'e' && (e.ctrlKey || e.metaKey)) {
|
|
564
|
+
editable = true;
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Turn editable off (=save)
|
|
569
|
+
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
|
|
570
|
+
doc.selection = null;
|
|
571
|
+
editable = false;
|
|
572
|
+
e.preventDefault();
|
|
573
|
+
e.stopPropagation();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
257
577
|
// Only handle keyboard events if focus is within the canvas
|
|
258
578
|
if (!canvas_ref?.contains(document.activeElement)) return;
|
|
259
579
|
|
|
580
|
+
// Key handling temporarily disabled (e.g. while character composition takes place)
|
|
581
|
+
if (skip_onkeydown) {
|
|
582
|
+
// console.log('onkeydown skipped');
|
|
583
|
+
// Currently we do nothing, but we could handle keydown during character composition here.
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
|
|
260
587
|
const selection = /** @type {any} */ (doc.selection);
|
|
261
588
|
const is_collapsed = selection?.anchor_offset === selection?.focus_offset;
|
|
262
589
|
|
|
263
|
-
// console.log('onkeydown', e.key);
|
|
264
590
|
if (
|
|
265
591
|
(e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.selected_node) ||
|
|
266
592
|
(e.key === 'ArrowRight' && e.altKey && e.ctrlKey && e.shiftKey && doc.selected_node)
|
|
@@ -350,18 +676,10 @@
|
|
|
350
676
|
doc.redo();
|
|
351
677
|
e.preventDefault();
|
|
352
678
|
e.stopPropagation();
|
|
353
|
-
} else if (e.key === 'Enter' && (e.
|
|
679
|
+
} else if (e.key === 'Enter' && (e.shiftKey)) {
|
|
354
680
|
doc.apply(doc.tr.insert_text('\n'));
|
|
355
681
|
e.preventDefault();
|
|
356
682
|
e.stopPropagation();
|
|
357
|
-
} else if (e.key === 'b' && (e.ctrlKey || e.metaKey)) {
|
|
358
|
-
doc.apply(doc.tr.annotate_text('strong'));
|
|
359
|
-
e.preventDefault();
|
|
360
|
-
e.stopPropagation();
|
|
361
|
-
} else if (e.key === 'i' && (e.ctrlKey || e.metaKey)) {
|
|
362
|
-
doc.apply(doc.tr.annotate_text('emphasis'));
|
|
363
|
-
e.preventDefault();
|
|
364
|
-
e.stopPropagation();
|
|
365
683
|
} else if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
|
|
366
684
|
doc.apply(doc.tr.annotate_text('link', {
|
|
367
685
|
href: window.prompt('Enter the URL', 'https://example.com')
|
|
@@ -378,7 +696,6 @@
|
|
|
378
696
|
}
|
|
379
697
|
} else {
|
|
380
698
|
if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === 0 && doc.selection?.focus_offset === 0) {
|
|
381
|
-
console.log('try to join with previous node');
|
|
382
699
|
const tr = doc.tr;
|
|
383
700
|
join_text_node(tr);
|
|
384
701
|
doc.apply(tr);
|
|
@@ -391,6 +708,47 @@
|
|
|
391
708
|
}
|
|
392
709
|
}
|
|
393
710
|
|
|
711
|
+
e.preventDefault();
|
|
712
|
+
e.stopPropagation();
|
|
713
|
+
} else if (e.key === 'Delete') {
|
|
714
|
+
// Forward delete: only handle collapsed text selections
|
|
715
|
+
if (selection?.type === 'text' && is_collapsed) {
|
|
716
|
+
const text_content = doc.get(selection.path)[0];
|
|
717
|
+
const text_length = get_char_length(text_content);
|
|
718
|
+
|
|
719
|
+
if (selection.focus_offset < text_length) {
|
|
720
|
+
// There's a character after the cursor - select and delete it
|
|
721
|
+
const tr = doc.tr;
|
|
722
|
+
tr.set_selection({
|
|
723
|
+
type: 'text',
|
|
724
|
+
path: [...selection.path],
|
|
725
|
+
anchor_offset: selection.focus_offset,
|
|
726
|
+
focus_offset: selection.focus_offset + 1
|
|
727
|
+
});
|
|
728
|
+
tr.delete_selection();
|
|
729
|
+
doc.apply(tr);
|
|
730
|
+
} else {
|
|
731
|
+
// At end of text - try to join with next text node
|
|
732
|
+
const tr = doc.tr;
|
|
733
|
+
const node_index = parseInt(selection.path.at(-2), 10);
|
|
734
|
+
const successor_node = doc.get([...selection.path.slice(0, -2), node_index + 1]);
|
|
735
|
+
|
|
736
|
+
// Check if next node is a text node
|
|
737
|
+
if (successor_node && doc.kind(successor_node) === 'text') {
|
|
738
|
+
// Set selection to beginning of next text node
|
|
739
|
+
tr.set_selection({
|
|
740
|
+
type: 'text',
|
|
741
|
+
path: [...selection.path.slice(0, -2), node_index + 1, 'content'],
|
|
742
|
+
anchor_offset: 0,
|
|
743
|
+
focus_offset: 0
|
|
744
|
+
});
|
|
745
|
+
// Use join_text_node to merge with previous
|
|
746
|
+
join_text_node(tr);
|
|
747
|
+
doc.apply(tr);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
394
752
|
e.preventDefault();
|
|
395
753
|
e.stopPropagation();
|
|
396
754
|
} else if (e.key === 'Enter' && selection?.type === 'property') {
|
|
@@ -420,8 +778,11 @@
|
|
|
420
778
|
e.stopPropagation();
|
|
421
779
|
} else if (e.key === 'Enter' && selection?.type === 'text') {
|
|
422
780
|
const tr = doc.tr;
|
|
423
|
-
break_text_node(tr)
|
|
424
|
-
|
|
781
|
+
if (break_text_node(tr)) {
|
|
782
|
+
doc.apply(tr);
|
|
783
|
+
}
|
|
784
|
+
e.preventDefault();
|
|
785
|
+
e.stopPropagation();
|
|
425
786
|
} else if (e.key === 'Escape' && selection) {
|
|
426
787
|
doc.select_parent();
|
|
427
788
|
e.preventDefault();
|
|
@@ -429,6 +790,34 @@
|
|
|
429
790
|
}
|
|
430
791
|
}
|
|
431
792
|
|
|
793
|
+
/**
|
|
794
|
+
* Handles composition start events for input methods like dead keys
|
|
795
|
+
* This occurs when user starts typing a composed character (e.g., backtick for accents)
|
|
796
|
+
* @param {CompositionEvent} event
|
|
797
|
+
*/
|
|
798
|
+
function oncompositionstart(event) {
|
|
799
|
+
if (doc.selection.type !== 'text') return;
|
|
800
|
+
console.log('DEBUG: oncompositionstart', event.data);
|
|
801
|
+
// Disable keydown event handling during composition. This way, you can confirm
|
|
802
|
+
// a diacritic (a->ä) with ENTER without causing a line break.
|
|
803
|
+
skip_onkeydown = true;
|
|
804
|
+
is_composing = true;
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Handles composition end events for input methods like dead keys
|
|
810
|
+
* This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
|
|
811
|
+
*/
|
|
812
|
+
function oncompositionend(/*event*/) {
|
|
813
|
+
// console.log('DEBUG: oncompositionend, insert:', event.data);
|
|
814
|
+
if (!canvas_ref?.contains(document.activeElement)) return;
|
|
815
|
+
if (canvas_ref?.contains(document.activeElement) && doc.selection?.type === 'text') {
|
|
816
|
+
commit_input();
|
|
817
|
+
}
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
432
821
|
/**
|
|
433
822
|
* Extracts a NodeSelection from the current DOM selection.
|
|
434
823
|
*
|
|
@@ -592,8 +981,9 @@
|
|
|
592
981
|
const focus_node = /** @type {HTMLElement} */ (dom_selection.focusNode);
|
|
593
982
|
let focus_root, anchor_root;
|
|
594
983
|
|
|
984
|
+
|
|
595
985
|
if (dom_selection.focusNode === dom_selection.anchorNode && focus_node.dataset?.type === 'text') {
|
|
596
|
-
//
|
|
986
|
+
// 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
987
|
focus_root = anchor_root = focus_node;
|
|
598
988
|
} else {
|
|
599
989
|
focus_root = /** @type {HTMLElement} */ (dom_selection.focusNode.parentElement?.closest('[data-path][data-type="text"]'));
|
|
@@ -611,29 +1001,80 @@
|
|
|
611
1001
|
|
|
612
1002
|
if (!path) return null;
|
|
613
1003
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
1004
|
+
// EDGE CASE 1B: Cursor after trailing <br> at end of text
|
|
1005
|
+
//
|
|
1006
|
+
// When text ends with a newline (\n), the browser renders it as a <br> element.
|
|
1007
|
+
// If the user places their cursor after this <br>, the selection detection
|
|
1008
|
+
// incorrectly reports position 0 instead of the actual text length.
|
|
1009
|
+
//
|
|
1010
|
+
// Example DOM structure when text is "Hello\n":
|
|
1011
|
+
// <div data-type="text">
|
|
1012
|
+
// #text "Hello" ← index 0
|
|
1013
|
+
// <br> ← index 1 (represents the \n)
|
|
1014
|
+
// </div>
|
|
1015
|
+
//
|
|
1016
|
+
// When cursor is after the <br>, we get:
|
|
1017
|
+
// - focusNode: the div element
|
|
1018
|
+
// - focusOffset: 2 (after the 2nd child)
|
|
1019
|
+
// - But normal processing returns position 0 instead of text.length
|
|
1020
|
+
//
|
|
1021
|
+
// This edge case detects when:
|
|
1022
|
+
// 1. Text content ends with \n
|
|
1023
|
+
// 2. Last child node is a <br> (the visual representation of that \n)
|
|
1024
|
+
// 3. Cursor is positioned at or near the end
|
|
1025
|
+
// And returns the correct text.length position instead.
|
|
1026
|
+
const text_content = doc.get(path)[0];
|
|
1027
|
+
const text_length = get_char_length(text_content);
|
|
1028
|
+
if (text_length > 0) {
|
|
1029
|
+
const last_char = get_char_at(text_content, text_length - 1);
|
|
1030
|
+
if (dom_selection.focusNode === dom_selection.anchorNode &&
|
|
1031
|
+
dom_selection.focusNode === focus_node &&
|
|
1032
|
+
focus_node.dataset?.type === 'text' &&
|
|
1033
|
+
!focus_node.classList.contains('empty') &&
|
|
1034
|
+
last_char === '\n') {
|
|
1035
|
+
|
|
1036
|
+
const child_nodes = focus_node.childNodes;
|
|
1037
|
+
|
|
1038
|
+
// Check if the last child is a BR (indicating text ends with newline)
|
|
1039
|
+
// and cursor is positioned near the end (after any of the last few elements)
|
|
1040
|
+
if (child_nodes.length > 0 &&
|
|
1041
|
+
child_nodes[child_nodes.length - 1].nodeName === 'BR' &&
|
|
1042
|
+
dom_selection.focusOffset >= child_nodes.length - 2) {
|
|
1043
|
+
return {
|
|
1044
|
+
type: 'text',
|
|
1045
|
+
path,
|
|
1046
|
+
anchor_offset: text_length,
|
|
1047
|
+
focus_offset: text_length
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
let anchor_offset = 0;
|
|
1054
|
+
let focus_offset = 0;
|
|
1055
|
+
let current_offset = 0;
|
|
617
1056
|
|
|
618
1057
|
function processNode(node) {
|
|
619
1058
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
620
|
-
const
|
|
621
|
-
|
|
1059
|
+
const nodeText = node.textContent;
|
|
1060
|
+
const nodeCharLength = get_char_length(nodeText);
|
|
622
1061
|
if (node === range.startContainer) {
|
|
623
|
-
|
|
1062
|
+
// Convert UTF-16 offset to character offset
|
|
1063
|
+
const char_start_offset = utf16_to_char_offset(nodeText, range.startOffset);
|
|
1064
|
+
anchor_offset = current_offset + char_start_offset;
|
|
624
1065
|
}
|
|
625
1066
|
if (node === range.endContainer) {
|
|
626
|
-
|
|
1067
|
+
// Convert UTF-16 offset to character offset
|
|
1068
|
+
const char_end_offset = utf16_to_char_offset(nodeText, range.endOffset);
|
|
1069
|
+
focus_offset = current_offset + char_end_offset;
|
|
627
1070
|
}
|
|
628
|
-
|
|
629
|
-
currentOffset += nodeLength;
|
|
1071
|
+
current_offset += nodeCharLength;
|
|
630
1072
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
631
1073
|
for (const childNode of node.childNodes) {
|
|
632
1074
|
processNode(childNode);
|
|
633
1075
|
}
|
|
634
1076
|
}
|
|
635
|
-
|
|
636
|
-
return (focusOffset !== 0);
|
|
1077
|
+
return (focus_offset !== 0);
|
|
637
1078
|
}
|
|
638
1079
|
|
|
639
1080
|
// Process nodes to find offsets
|
|
@@ -647,14 +1088,14 @@
|
|
|
647
1088
|
|
|
648
1089
|
// Swap offsets if it's a backward selection
|
|
649
1090
|
if (is_backward) {
|
|
650
|
-
[
|
|
1091
|
+
[anchor_offset, focus_offset] = [focus_offset, anchor_offset];
|
|
651
1092
|
}
|
|
652
1093
|
|
|
653
1094
|
return {
|
|
654
1095
|
type: 'text',
|
|
655
1096
|
path,
|
|
656
|
-
anchor_offset:
|
|
657
|
-
focus_offset:
|
|
1097
|
+
anchor_offset: anchor_offset,
|
|
1098
|
+
focus_offset: focus_offset
|
|
658
1099
|
};
|
|
659
1100
|
}
|
|
660
1101
|
|
|
@@ -671,7 +1112,6 @@
|
|
|
671
1112
|
function __render_node_selection() {
|
|
672
1113
|
const selection = /** @type {NodeSelection} */ (doc.selection);
|
|
673
1114
|
const node_array_path = selection.path.join('.');
|
|
674
|
-
|
|
675
1115
|
let is_collapsed = selection.anchor_offset === selection.focus_offset;
|
|
676
1116
|
let is_backward = !is_collapsed && selection.anchor_offset > selection.focus_offset;
|
|
677
1117
|
|
|
@@ -703,7 +1143,6 @@
|
|
|
703
1143
|
selection.anchor_offset === 0 ? ':scope > .position-zero-cursor-trap' : ':scope > .after-node-cursor-trap'
|
|
704
1144
|
);
|
|
705
1145
|
|
|
706
|
-
|
|
707
1146
|
range.setStart(cursor_trap_el, 1);
|
|
708
1147
|
range.setEnd(cursor_trap_el, 1);
|
|
709
1148
|
dom_selection.removeAllRanges();
|
|
@@ -786,40 +1225,44 @@
|
|
|
786
1225
|
const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
|
|
787
1226
|
const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
|
|
788
1227
|
|
|
789
|
-
|
|
790
1228
|
// Helper function to process each node
|
|
791
1229
|
function process_node(node) {
|
|
792
1230
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
793
|
-
const
|
|
1231
|
+
const node_text = node.textContent;
|
|
1232
|
+
const node_char_length = get_char_length(node_text);
|
|
794
1233
|
|
|
795
1234
|
if (is_backward) {
|
|
796
|
-
if (!focus_node && current_offset +
|
|
1235
|
+
if (!focus_node && current_offset + node_char_length >= start_offset) {
|
|
797
1236
|
focus_node = node;
|
|
798
|
-
|
|
1237
|
+
const char_offset = start_offset - current_offset;
|
|
1238
|
+
focus_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
799
1239
|
}
|
|
800
1240
|
} else {
|
|
801
|
-
if (!anchor_node && current_offset +
|
|
1241
|
+
if (!anchor_node && current_offset + node_char_length >= start_offset) {
|
|
802
1242
|
anchor_node = node;
|
|
803
|
-
|
|
1243
|
+
const char_offset = start_offset - current_offset;
|
|
1244
|
+
anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
804
1245
|
}
|
|
805
1246
|
}
|
|
806
1247
|
|
|
807
1248
|
// Find end node
|
|
808
1249
|
if (is_backward) {
|
|
809
|
-
if (!anchor_node && current_offset +
|
|
1250
|
+
if (!anchor_node && current_offset + node_char_length >= end_offset) {
|
|
810
1251
|
anchor_node = node;
|
|
811
|
-
|
|
1252
|
+
const char_offset = end_offset - current_offset;
|
|
1253
|
+
anchor_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
812
1254
|
return true; // Stop iteration
|
|
813
1255
|
}
|
|
814
1256
|
} else {
|
|
815
|
-
if (!focus_node && current_offset +
|
|
1257
|
+
if (!focus_node && current_offset + node_char_length >= end_offset) {
|
|
816
1258
|
focus_node = node;
|
|
817
|
-
|
|
1259
|
+
const char_offset = end_offset - current_offset;
|
|
1260
|
+
focus_node_offset = char_to_utf16_offset(node_text, char_offset);
|
|
818
1261
|
return true; // Stop iteration
|
|
819
1262
|
}
|
|
820
1263
|
}
|
|
821
1264
|
|
|
822
|
-
current_offset +=
|
|
1265
|
+
current_offset += node_char_length;
|
|
823
1266
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
824
1267
|
for (const child_node of node.childNodes) {
|
|
825
1268
|
if (process_node(child_node)) return true; // Stop iteration if end found
|
|
@@ -935,9 +1378,24 @@
|
|
|
935
1378
|
<div
|
|
936
1379
|
class="svedit-canvas {css_class}"
|
|
937
1380
|
class:hide-selection={doc.selection?.type === 'node'}
|
|
1381
|
+
class:node-cursor={doc.selection?.type === 'node' && doc.selection.anchor_offset === doc.selection.focus_offset}
|
|
938
1382
|
bind:this={canvas_ref}
|
|
1383
|
+
{oninput}
|
|
939
1384
|
{onbeforeinput}
|
|
1385
|
+
{oncompositionstart}
|
|
1386
|
+
{oncompositionend}
|
|
940
1387
|
contenteditable={editable ? 'true' : 'false'}
|
|
1388
|
+
autocapitalize="off"
|
|
1389
|
+
spellcheck="true"
|
|
1390
|
+
{...{
|
|
1391
|
+
// NOTE: Autocomplete and autocorrect make immense troubles
|
|
1392
|
+
// on Desktop Chrome, so we disable them only for Chrome desktop.
|
|
1393
|
+
// Additionally, OSX-native auto-complete also breaks, because
|
|
1394
|
+
// I'm using a keyed block that always wipes the DOM of a text node
|
|
1395
|
+
// on every change.
|
|
1396
|
+
autocomplete: is_chrome_desktop ? "off" : "on",
|
|
1397
|
+
autocorrect: is_chrome_desktop ? "off" : "on"
|
|
1398
|
+
}}
|
|
941
1399
|
>
|
|
942
1400
|
<RootComponent {path} />
|
|
943
1401
|
</div>
|
|
@@ -954,17 +1412,26 @@
|
|
|
954
1412
|
}
|
|
955
1413
|
}
|
|
956
1414
|
|
|
957
|
-
.svedit-canvas.hide-selection {
|
|
958
|
-
caret-color: transparent;
|
|
959
|
-
}
|
|
960
|
-
|
|
961
1415
|
.svedit-canvas :global(::selection) {
|
|
962
1416
|
background: var(--editing-fill-color);
|
|
963
1417
|
}
|
|
964
1418
|
|
|
965
1419
|
@media not (pointer: coarse) {
|
|
966
|
-
|
|
967
|
-
|
|
1420
|
+
.svedit-canvas.hide-selection {
|
|
1421
|
+
caret-color: transparent;
|
|
968
1422
|
}
|
|
969
1423
|
}
|
|
1424
|
+
|
|
1425
|
+
/* When the cursor is in a cursor-trap we never want to see the caret */
|
|
1426
|
+
.svedit-canvas.node-cursor {
|
|
1427
|
+
caret-color: transparent;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
@media not (pointer: coarse) {
|
|
1431
|
+
@supports (anchor-name: --test) {
|
|
1432
|
+
.svedit-canvas.hide-selection :global(::selection) {
|
|
1433
|
+
background: transparent;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
970
1437
|
</style>
|