svedit 0.11.1 → 0.13.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 +260 -82
- package/dist/Command.svelte.d.ts +54 -29
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +286 -283
- package/dist/CustomProperty.svelte +3 -5
- package/dist/CustomProperty.svelte.d.ts +3 -6
- package/dist/CustomProperty.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.d.ts +19 -6
- package/dist/KeyMapper.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.js +95 -112
- package/dist/Node.svelte +40 -9
- package/dist/Node.svelte.d.ts +3 -5
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +142 -18
- package/dist/NodeArrayProperty.svelte.d.ts +3 -5
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeCaret.svelte +1 -1
- package/dist/NodeCaret.svelte.d.ts +6 -14
- package/dist/NodeCaret.svelte.d.ts.map +1 -1
- package/dist/NodeGap.svelte +60 -12
- package/dist/NodeGap.svelte.d.ts +7 -14
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/NodeGapMarkers.svelte +19 -10
- package/dist/NodeGapMarkers.svelte.d.ts +6 -11
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
- package/dist/NodeSelectionMarkers.svelte +20 -6
- package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
- package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.d.ts +85 -116
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +543 -571
- package/dist/Svedit.svelte +243 -216
- package/dist/Svedit.svelte.d.ts +30 -10
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
- package/dist/TextProperty.svelte.d.ts +5 -0
- package/dist/TextProperty.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.d.ts +101 -126
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +843 -827
- package/dist/UnknownNode.svelte +4 -3
- package/dist/UnknownNode.svelte.d.ts +5 -9
- package/dist/UnknownNode.svelte.d.ts.map +1 -1
- package/dist/doc_utils.d.ts +109 -122
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +726 -698
- package/dist/index.d.ts +20 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -18
- package/dist/node_visibility.svelte.d.ts +132 -5
- package/dist/node_visibility.svelte.d.ts.map +1 -1
- package/dist/node_visibility.svelte.js +535 -642
- package/dist/paste_utils.d.ts +11 -43
- package/dist/paste_utils.d.ts.map +1 -1
- package/dist/paste_utils.js +111 -149
- package/dist/transforms.svelte.d.ts +9 -7
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +126 -152
- package/dist/types.d.ts +329 -291
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +79 -95
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +392 -305
- package/package.json +69 -67
- package/src/lib/{Command.svelte.js → Command.svelte.ts} +111 -41
- package/src/lib/CustomProperty.svelte +3 -5
- package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
- package/src/lib/Node.svelte +40 -9
- package/src/lib/NodeArrayProperty.svelte +142 -18
- package/src/lib/NodeCaret.svelte +1 -1
- package/src/lib/NodeGap.svelte +60 -12
- package/src/lib/NodeGapMarkers.svelte +19 -10
- package/src/lib/NodeSelectionMarkers.svelte +20 -6
- package/src/lib/Session.svelte.ts +656 -0
- package/src/lib/Svedit.svelte +243 -216
- package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
- package/src/lib/Transaction.svelte.ts +1020 -0
- package/src/lib/UnknownNode.svelte +4 -3
- package/src/lib/doc_utils.ts +1120 -0
- package/src/lib/{index.js → index.ts} +48 -1
- package/src/lib/node_visibility.svelte.ts +658 -0
- package/src/lib/{paste_utils.js → paste_utils.ts} +51 -59
- package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +44 -27
- package/src/lib/{types.d.ts → types.ts} +250 -103
- package/src/lib/utils.ts +660 -0
- package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
- package/src/lib/Session.svelte.js +0 -575
- package/src/lib/Transaction.svelte.js +0 -846
- package/src/lib/doc_utils.js +0 -807
- package/src/lib/node_visibility.svelte.js +0 -718
- package/src/lib/utils.js +0 -465
package/dist/Svedit.svelte
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<script>
|
|
1
|
+
<script lang="ts" generics="S extends DocumentSchema">
|
|
2
2
|
import { flushSync, getContext, setContext } from 'svelte';
|
|
3
3
|
import {
|
|
4
4
|
get_char_length,
|
|
@@ -24,17 +24,21 @@
|
|
|
24
24
|
import './styles/svedit-colors.css';
|
|
25
25
|
import './styles/svedit-animations.css';
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
import type {
|
|
28
|
+
DocumentSchema,
|
|
29
|
+
SveditProps,
|
|
30
|
+
Selection,
|
|
31
|
+
NodeSelection,
|
|
32
|
+
TextSelection,
|
|
33
|
+
PropertySelection,
|
|
34
|
+
DocumentNode,
|
|
35
|
+
DocumentPath,
|
|
36
|
+
DynamicRecord,
|
|
37
|
+
NodeHtmlExporter
|
|
38
|
+
} from './types.js';
|
|
39
|
+
import type { KeyMapper } from './KeyMapper.svelte.js';
|
|
40
|
+
import type Session from './Session.svelte.js';
|
|
36
41
|
|
|
37
|
-
/** @type {SveditProps} */
|
|
38
42
|
let {
|
|
39
43
|
session,
|
|
40
44
|
editable = $bindable(false),
|
|
@@ -42,9 +46,9 @@
|
|
|
42
46
|
class: css_class,
|
|
43
47
|
autocapitalize = 'on',
|
|
44
48
|
spellcheck = 'true'
|
|
45
|
-
} = $props();
|
|
49
|
+
}: SveditProps<S> = $props();
|
|
46
50
|
|
|
47
|
-
let canvas_el;
|
|
51
|
+
let canvas_el: HTMLElement | undefined;
|
|
48
52
|
let root_node = $derived(session.get(path));
|
|
49
53
|
let Overlays = $derived(session.config.system_components?.overlays);
|
|
50
54
|
let NodeSelectionMarkers = $derived(
|
|
@@ -54,7 +58,13 @@
|
|
|
54
58
|
|
|
55
59
|
let is_composing = $state(false);
|
|
56
60
|
let canvas_focused = $state(false);
|
|
57
|
-
let before_composition_selection = null;
|
|
61
|
+
let before_composition_selection: TextSelection | null = null;
|
|
62
|
+
// Selection restoration and selection revealing are separate concerns.
|
|
63
|
+
// DOM changes (for example, toggling an annotation) can require rebuilding
|
|
64
|
+
// the native selection even when the logical selection did not move. Keep
|
|
65
|
+
// the last selection rendered while focused so those rebuilds do not also
|
|
66
|
+
// move the viewport.
|
|
67
|
+
let last_rendered_selection_snapshot: string | null = null;
|
|
58
68
|
// Set by onselectionchange before it commits a DOM-derived selection
|
|
59
69
|
// to the model. render_selection consumes-and-clears it to skip
|
|
60
70
|
// rerender on DOM-driven changes (the DOM is already in place).
|
|
@@ -98,8 +108,7 @@
|
|
|
98
108
|
function check_duplicate_paths() {
|
|
99
109
|
if (!canvas_el) return;
|
|
100
110
|
|
|
101
|
-
|
|
102
|
-
const mounted_paths = Object.create(null);
|
|
111
|
+
const mounted_paths: Record<string, true> = Object.create(null);
|
|
103
112
|
|
|
104
113
|
for (const element of canvas_el.querySelectorAll('[data-path]')) {
|
|
105
114
|
if (element.closest('.svedit-canvas') !== canvas_el) continue;
|
|
@@ -117,26 +126,8 @@
|
|
|
117
126
|
}
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
// Test-only hook: expose the svedit context so test specs can read
|
|
121
|
-
// near_map / edge_map for diagnostics. Gated on `import.meta.env.MODE`
|
|
122
|
-
// — Vite replaces the literal so the comparison is constant-false in
|
|
123
|
-
// production and the branch is dead-code-eliminated. Wrapped in an
|
|
124
|
-
// effect so unmount clears the reference and tests don't leak state
|
|
125
|
-
// across remounts.
|
|
126
|
-
if (import.meta.env?.MODE === 'test') {
|
|
127
|
-
$effect(() => {
|
|
128
|
-
const g = /** @type {any} */ (globalThis);
|
|
129
|
-
g.__svedit_ctx_for_test = context;
|
|
130
|
-
return () => {
|
|
131
|
-
if (g.__svedit_ctx_for_test === context) {
|
|
132
|
-
delete g.__svedit_ctx_for_test;
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
129
|
// Get KeyMapper from context (may be undefined if not provided)
|
|
139
|
-
const key_mapper = getContext('key_mapper');
|
|
130
|
+
const key_mapper = getContext<KeyMapper>('key_mapper');
|
|
140
131
|
|
|
141
132
|
// Initialize commands and keymap on the session
|
|
142
133
|
$effect(() => {
|
|
@@ -146,7 +137,7 @@
|
|
|
146
137
|
/**
|
|
147
138
|
* @param {InputEvent} event
|
|
148
139
|
*/
|
|
149
|
-
async function onbeforeinput(event) {
|
|
140
|
+
async function onbeforeinput(event: InputEvent) {
|
|
150
141
|
// console.log(`onbeforeinput: ${event.inputType}, data: "${event.data}", isComposing: ${event.isComposing}`, event);
|
|
151
142
|
|
|
152
143
|
if (event.inputType === 'historyUndo' && is_composing) {
|
|
@@ -185,21 +176,21 @@
|
|
|
185
176
|
}
|
|
186
177
|
|
|
187
178
|
if (event.inputType === 'formatBold' && session.selection?.type === 'text') {
|
|
188
|
-
session.apply(session.tr.
|
|
179
|
+
session.apply(session.tr.toggle_mark('strong'));
|
|
189
180
|
event.preventDefault();
|
|
190
181
|
event.stopPropagation();
|
|
191
182
|
}
|
|
192
183
|
|
|
193
184
|
if (event.inputType === 'formatItalic' && session.selection?.type === 'text') {
|
|
194
|
-
session.apply(session.tr.
|
|
185
|
+
session.apply(session.tr.toggle_mark('emphasis'));
|
|
195
186
|
event.preventDefault();
|
|
196
187
|
event.stopPropagation();
|
|
197
188
|
}
|
|
198
189
|
|
|
199
|
-
// NOTE: underline doesn't make much sense as a semantic
|
|
190
|
+
// NOTE: underline doesn't make much sense as a semantic mark,
|
|
200
191
|
// so we rewire `cmd + u` to toggle highlights
|
|
201
192
|
if (event.inputType === 'formatUnderline' && session.selection?.type === 'text') {
|
|
202
|
-
session.apply(session.tr.
|
|
193
|
+
session.apply(session.tr.toggle_mark('highlight'));
|
|
203
194
|
event.preventDefault();
|
|
204
195
|
event.stopPropagation();
|
|
205
196
|
}
|
|
@@ -277,7 +268,7 @@
|
|
|
277
268
|
* Handles composition end events for input methods like dead keys
|
|
278
269
|
* This occurs when composition is complete (e.g., after typing 'a' following backtick to get 'à')
|
|
279
270
|
*/
|
|
280
|
-
function oncompositionend(event) {
|
|
271
|
+
function oncompositionend(event: CompositionEvent) {
|
|
281
272
|
// console.log('DEBUG: oncompositionend, insert:', event.data, event);
|
|
282
273
|
if (!canvas_el?.contains(document.activeElement)) return;
|
|
283
274
|
if (session.selection?.type === 'text') {
|
|
@@ -350,7 +341,7 @@
|
|
|
350
341
|
* @param {string} fallback_html - HTML for cross-app compatibility
|
|
351
342
|
* @returns {string} HTML with embedded svedit data
|
|
352
343
|
*/
|
|
353
|
-
function create_svedit_html_format(json_data, fallback_html) {
|
|
344
|
+
function create_svedit_html_format(json_data: unknown, fallback_html: string): string {
|
|
354
345
|
// Use encodeURIComponent to handle Unicode, then base64 encode
|
|
355
346
|
const json_string = JSON.stringify(json_data);
|
|
356
347
|
const encoded_data = btoa(encodeURIComponent(json_string));
|
|
@@ -367,7 +358,7 @@ ${fallback_html}`;
|
|
|
367
358
|
* @param {string} html - HTML content from clipboard
|
|
368
359
|
* @returns {Object|null} Parsed svedit data or null if not found
|
|
369
360
|
*/
|
|
370
|
-
function extract_svedit_data_from_html(html) {
|
|
361
|
+
function extract_svedit_data_from_html(html: string): DynamicRecord | null {
|
|
371
362
|
const svedit_regex = /data-svedit="([^"]+)"/;
|
|
372
363
|
const match = html.match(svedit_regex);
|
|
373
364
|
|
|
@@ -391,21 +382,25 @@ ${fallback_html}`;
|
|
|
391
382
|
* @param {Object} node - Node object
|
|
392
383
|
* @returns {string} HTML representation
|
|
393
384
|
*/
|
|
394
|
-
function default_node_html_exporter(
|
|
385
|
+
function default_node_html_exporter(
|
|
386
|
+
node: DocumentNode,
|
|
387
|
+
session: Session<S>,
|
|
388
|
+
html_exporters: Record<string, NodeHtmlExporter<S>>
|
|
389
|
+
): string {
|
|
395
390
|
let html = '';
|
|
396
391
|
const node_schema = session.schema[node.type];
|
|
397
392
|
|
|
398
393
|
for (const [prop_name, prop_value] of Object.entries(node)) {
|
|
399
394
|
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
400
395
|
const property_definition = node_schema.properties[prop_name];
|
|
401
|
-
// Check if this is
|
|
402
|
-
if (property_definition.type === '
|
|
403
|
-
const text_content = prop_value.
|
|
396
|
+
// Check if this is a text property.
|
|
397
|
+
if (property_definition.type === 'text') {
|
|
398
|
+
const text_content = prop_value.content;
|
|
404
399
|
if (text_content.trim()) {
|
|
405
400
|
html += `<p>${text_content}</p>`;
|
|
406
401
|
}
|
|
407
402
|
} else if (property_definition.type === 'node_array') {
|
|
408
|
-
for (const child_id of prop_value) {
|
|
403
|
+
for (const child_id of prop_value.nodes) {
|
|
409
404
|
const child = session.get(child_id);
|
|
410
405
|
const child_exporter = html_exporters[child.type] || default_node_html_exporter;
|
|
411
406
|
html += child_exporter(child, session, html_exporters);
|
|
@@ -415,19 +410,19 @@ ${fallback_html}`;
|
|
|
415
410
|
return html;
|
|
416
411
|
}
|
|
417
412
|
|
|
418
|
-
function default_node_plain_text_exporter(node) {
|
|
413
|
+
function default_node_plain_text_exporter(node: DocumentNode): string {
|
|
419
414
|
let plain_text = '';
|
|
420
415
|
|
|
421
416
|
for (const [prop_name, prop_value] of Object.entries(node)) {
|
|
422
417
|
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
423
418
|
|
|
424
|
-
// Check if this is
|
|
419
|
+
// Check if this is a text property value.
|
|
425
420
|
if (
|
|
426
421
|
typeof prop_value === 'object' &&
|
|
427
422
|
prop_value !== null &&
|
|
428
|
-
typeof prop_value.
|
|
423
|
+
typeof prop_value.content === 'string'
|
|
429
424
|
) {
|
|
430
|
-
const text_content = prop_value.
|
|
425
|
+
const text_content = prop_value.content;
|
|
431
426
|
if (text_content.trim()) {
|
|
432
427
|
plain_text += `${text_content.trim()}\n\n`;
|
|
433
428
|
}
|
|
@@ -442,7 +437,7 @@ ${fallback_html}`;
|
|
|
442
437
|
* @param {Object[]} nodes - Array of node objects
|
|
443
438
|
* @returns {string} HTML representation
|
|
444
439
|
*/
|
|
445
|
-
function export_html(nodes) {
|
|
440
|
+
function export_html(nodes: DocumentNode[]): string {
|
|
446
441
|
let html = '';
|
|
447
442
|
|
|
448
443
|
for (const node of nodes) {
|
|
@@ -459,7 +454,7 @@ ${fallback_html}`;
|
|
|
459
454
|
return html;
|
|
460
455
|
}
|
|
461
456
|
|
|
462
|
-
function export_plain_text(nodes) {
|
|
457
|
+
function export_plain_text(nodes: DocumentNode[]): string {
|
|
463
458
|
let plain_text = '';
|
|
464
459
|
|
|
465
460
|
for (const node of nodes) {
|
|
@@ -468,33 +463,11 @@ ${fallback_html}`;
|
|
|
468
463
|
return plain_text.trim();
|
|
469
464
|
}
|
|
470
465
|
|
|
471
|
-
/**
|
|
472
|
-
* @param {NodeId[]} selected_node_ids
|
|
473
|
-
*/
|
|
474
|
-
function prepare_copy_payload(selected_node_ids) {
|
|
475
|
-
const nodes = {};
|
|
476
|
-
|
|
477
|
-
// Get subgraph for each selected node using session.traverse()
|
|
478
|
-
for (const node_id of selected_node_ids) {
|
|
479
|
-
const subgraph = session.traverse(node_id);
|
|
480
|
-
|
|
481
|
-
// Add all nodes from this subgraph to our nodes collection
|
|
482
|
-
for (const node of subgraph) {
|
|
483
|
-
if (!nodes[node.id]) {
|
|
484
|
-
nodes[node.id] = node;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Keep original IDs - we'll generate new ones during paste
|
|
490
|
-
return { nodes, main_nodes: selected_node_ids };
|
|
491
|
-
}
|
|
492
|
-
|
|
493
466
|
/**
|
|
494
467
|
* @param {ClipboardEvent} event
|
|
495
468
|
* @param {boolean} delete_selection - used by oncut()
|
|
496
469
|
*/
|
|
497
|
-
function oncopy(event, delete_selection = false) {
|
|
470
|
+
function oncopy(event: ClipboardEvent, delete_selection = false) {
|
|
498
471
|
// Only handle copy events if editable and focus is within the canvas
|
|
499
472
|
if (!editable) return;
|
|
500
473
|
if (!canvas_el?.contains(document.activeElement)) return;
|
|
@@ -502,24 +475,23 @@ ${fallback_html}`;
|
|
|
502
475
|
event.preventDefault();
|
|
503
476
|
event.stopPropagation();
|
|
504
477
|
|
|
505
|
-
let plain_text,
|
|
478
|
+
let plain_text, text, html;
|
|
506
479
|
|
|
507
480
|
if (session.selection?.type === 'text') {
|
|
508
481
|
plain_text = session.get_selected_plain_text();
|
|
509
|
-
|
|
510
|
-
const fallback_html = `<span>${
|
|
482
|
+
text = session.get_selected_text();
|
|
483
|
+
const fallback_html = `<span>${text.content}</span>`;
|
|
511
484
|
|
|
512
485
|
// console.log('Text copy:', {
|
|
513
|
-
//
|
|
486
|
+
// text,
|
|
514
487
|
// plain_text,
|
|
515
488
|
// html
|
|
516
489
|
// });
|
|
517
490
|
|
|
518
|
-
html = create_svedit_html_format(
|
|
491
|
+
html = create_svedit_html_format(text, fallback_html);
|
|
519
492
|
} else if (session.selection?.type === 'node') {
|
|
520
|
-
const
|
|
521
|
-
const { nodes, main_nodes } =
|
|
522
|
-
const json_data = { nodes, main_nodes };
|
|
493
|
+
const json_data = session.get_selected_annotated_nodes();
|
|
494
|
+
const { nodes, main_nodes } = json_data;
|
|
523
495
|
|
|
524
496
|
// console.log('Node copy:', {
|
|
525
497
|
// selected_nodes,
|
|
@@ -562,13 +534,11 @@ ${fallback_html}`;
|
|
|
562
534
|
}
|
|
563
535
|
}
|
|
564
536
|
|
|
565
|
-
function oncut(event) {
|
|
537
|
+
function oncut(event: ClipboardEvent) {
|
|
566
538
|
if (!editable) return;
|
|
567
539
|
oncopy(event, true);
|
|
568
540
|
}
|
|
569
541
|
|
|
570
|
-
|
|
571
|
-
|
|
572
542
|
/**
|
|
573
543
|
* @returns {NodeSelection|null}
|
|
574
544
|
*/
|
|
@@ -587,13 +557,13 @@ ${fallback_html}`;
|
|
|
587
557
|
|
|
588
558
|
const node_array_path = [...path, preferred_property_name];
|
|
589
559
|
const node_array = session.get(node_array_path);
|
|
590
|
-
if (!Array.isArray(node_array)) return null;
|
|
560
|
+
if (!node_array || !Array.isArray(node_array.nodes)) return null;
|
|
591
561
|
|
|
592
562
|
return {
|
|
593
|
-
type:
|
|
563
|
+
type: 'node' as const,
|
|
594
564
|
path: node_array_path,
|
|
595
|
-
anchor_offset: node_array.length,
|
|
596
|
-
focus_offset: node_array.length
|
|
565
|
+
anchor_offset: node_array.nodes.length,
|
|
566
|
+
focus_offset: node_array.nodes.length
|
|
597
567
|
};
|
|
598
568
|
}
|
|
599
569
|
|
|
@@ -620,11 +590,10 @@ ${fallback_html}`;
|
|
|
620
590
|
*/
|
|
621
591
|
function get_node_insert_caret_after_text_selection(selection = session.selection) {
|
|
622
592
|
if (selection?.type !== 'text') return null;
|
|
623
|
-
const node_index =
|
|
624
|
-
if (Number.isNaN(node_index)) return null;
|
|
593
|
+
const node_index = selection.path.at(-2) as number;
|
|
625
594
|
|
|
626
595
|
return {
|
|
627
|
-
type:
|
|
596
|
+
type: 'node' as const,
|
|
628
597
|
path: selection.path.slice(0, -2),
|
|
629
598
|
anchor_offset: node_index + 1,
|
|
630
599
|
focus_offset: node_index + 1
|
|
@@ -638,8 +607,8 @@ ${fallback_html}`;
|
|
|
638
607
|
* @param {Selection} [selection] - Optional selection (node caret) where the payload should be pasted
|
|
639
608
|
* @returns {boolean} True if the paste operation was successful, false otherwise
|
|
640
609
|
*/
|
|
641
|
-
function try_node_paste(pasted_json, selection) {
|
|
642
|
-
const { nodes, main_nodes } = pasted_json || {};
|
|
610
|
+
function try_node_paste(pasted_json: DynamicRecord | null, selection?: Selection): boolean {
|
|
611
|
+
const { nodes, main_nodes, marks = [], annotations = [] } = pasted_json || {};
|
|
643
612
|
if (!nodes || !main_nodes?.length) return false;
|
|
644
613
|
|
|
645
614
|
let tr = session.tr;
|
|
@@ -652,7 +621,10 @@ ${fallback_html}`;
|
|
|
652
621
|
if (property_definition?.type !== 'node_array') return false;
|
|
653
622
|
|
|
654
623
|
const default_text_node_type = get_default_text_node(property_definition, session.schema);
|
|
655
|
-
const target_text_property_name = get_text_property_name(
|
|
624
|
+
const target_text_property_name = get_text_property_name(
|
|
625
|
+
default_text_node_type,
|
|
626
|
+
session.schema
|
|
627
|
+
);
|
|
656
628
|
|
|
657
629
|
const nodes_to_insert = [];
|
|
658
630
|
let rejected = false;
|
|
@@ -674,7 +646,11 @@ ${fallback_html}`;
|
|
|
674
646
|
the_node: {
|
|
675
647
|
id: 'the_node',
|
|
676
648
|
type: default_text_node_type,
|
|
677
|
-
[target_text_property_name]: text_content || {
|
|
649
|
+
[target_text_property_name]: text_content || {
|
|
650
|
+
content: '',
|
|
651
|
+
marks: [],
|
|
652
|
+
annotations: []
|
|
653
|
+
}
|
|
678
654
|
}
|
|
679
655
|
});
|
|
680
656
|
nodes_to_insert.push(new_node_id);
|
|
@@ -689,7 +665,7 @@ ${fallback_html}`;
|
|
|
689
665
|
}
|
|
690
666
|
|
|
691
667
|
if (!rejected) {
|
|
692
|
-
tr.insert_nodes(nodes_to_insert);
|
|
668
|
+
tr.insert_nodes(nodes_to_insert, marks, annotations, nodes);
|
|
693
669
|
session.apply(tr);
|
|
694
670
|
return true;
|
|
695
671
|
}
|
|
@@ -703,7 +679,7 @@ ${fallback_html}`;
|
|
|
703
679
|
return false;
|
|
704
680
|
}
|
|
705
681
|
|
|
706
|
-
async function onpaste(event) {
|
|
682
|
+
async function onpaste(event: ClipboardEvent) {
|
|
707
683
|
// Only handle paste events if editable and focus is within the canvas
|
|
708
684
|
if (!editable) return;
|
|
709
685
|
if (!canvas_el?.contains(document.activeElement)) return;
|
|
@@ -766,7 +742,7 @@ ${fallback_html}`;
|
|
|
766
742
|
|
|
767
743
|
if (session.selection?.type === 'text') {
|
|
768
744
|
const property_definition = session.inspect(session.selection.path);
|
|
769
|
-
if (property_definition?.type === '
|
|
745
|
+
if (property_definition?.type === 'text' && !property_definition.allow_newlines) {
|
|
770
746
|
plain_text = normalize_plain_text_for_single_line_property(plain_text);
|
|
771
747
|
}
|
|
772
748
|
|
|
@@ -777,7 +753,10 @@ ${fallback_html}`;
|
|
|
777
753
|
const node_array_property_definition = session.inspect(
|
|
778
754
|
session.selection.path.slice(0, -2)
|
|
779
755
|
);
|
|
780
|
-
const default_text_node_type = get_default_text_node(
|
|
756
|
+
const default_text_node_type = get_default_text_node(
|
|
757
|
+
node_array_property_definition,
|
|
758
|
+
session.schema
|
|
759
|
+
);
|
|
781
760
|
const node_insert_caret = get_node_insert_caret_after_text_selection(session.selection);
|
|
782
761
|
const plain_text_nodes_payload = create_plain_text_nodes_payload(
|
|
783
762
|
plain_text_fragments,
|
|
@@ -796,7 +775,10 @@ ${fallback_html}`;
|
|
|
796
775
|
const target_node_insert_caret = get_target_node_insert_caret(session.selection);
|
|
797
776
|
if (target_node_insert_caret) {
|
|
798
777
|
const node_array_property_definition = session.inspect(target_node_insert_caret.path);
|
|
799
|
-
const default_text_node_type = get_default_text_node(
|
|
778
|
+
const default_text_node_type = get_default_text_node(
|
|
779
|
+
node_array_property_definition,
|
|
780
|
+
session.schema
|
|
781
|
+
);
|
|
800
782
|
pasted_json = create_plain_text_nodes_payload(
|
|
801
783
|
plain_text_fragments,
|
|
802
784
|
default_text_node_type,
|
|
@@ -831,10 +813,15 @@ ${fallback_html}`;
|
|
|
831
813
|
session.apply(session.tr.set(session.selection.path, pasted_json.value));
|
|
832
814
|
}
|
|
833
815
|
}
|
|
834
|
-
} else if (session.selection?.type === 'text' && pasted_json?.
|
|
816
|
+
} else if (session.selection?.type === 'text' && pasted_json?.content) {
|
|
835
817
|
// Paste text at a text selection
|
|
836
818
|
session.apply(
|
|
837
|
-
session.tr.insert_text(
|
|
819
|
+
session.tr.insert_text(
|
|
820
|
+
pasted_json.content,
|
|
821
|
+
pasted_json.marks,
|
|
822
|
+
pasted_json.annotations,
|
|
823
|
+
pasted_json.nodes
|
|
824
|
+
)
|
|
838
825
|
);
|
|
839
826
|
} else if (
|
|
840
827
|
session.selection?.type === 'text' &&
|
|
@@ -848,7 +835,12 @@ ${fallback_html}`;
|
|
|
848
835
|
);
|
|
849
836
|
if (text_property) {
|
|
850
837
|
session.apply(
|
|
851
|
-
session.tr.insert_text(
|
|
838
|
+
session.tr.insert_text(
|
|
839
|
+
text_property.content,
|
|
840
|
+
text_property.marks,
|
|
841
|
+
text_property.annotations,
|
|
842
|
+
pasted_json.nodes
|
|
843
|
+
)
|
|
852
844
|
);
|
|
853
845
|
}
|
|
854
846
|
} else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
|
|
@@ -863,8 +855,8 @@ ${fallback_html}`;
|
|
|
863
855
|
}
|
|
864
856
|
}
|
|
865
857
|
|
|
866
|
-
function render_selection(dom_driven = false) {
|
|
867
|
-
const selection =
|
|
858
|
+
function render_selection(dom_driven = false, should_scroll_selection_into_view = true) {
|
|
859
|
+
const selection = session.selection as Selection;
|
|
868
860
|
|
|
869
861
|
if (!selection) {
|
|
870
862
|
// No model selection -> just leave things as they are
|
|
@@ -881,7 +873,7 @@ ${fallback_html}`;
|
|
|
881
873
|
// reuses gap elements with shifted data-gap-offset), so for those
|
|
882
874
|
// we always rerender to scroll the cursor into view.
|
|
883
875
|
const is_empty_text_selection =
|
|
884
|
-
selection.type === 'text' && session.get(selection.path).
|
|
876
|
+
selection.type === 'text' && session.get(selection.path).content.length === 0;
|
|
885
877
|
if (dom_driven && !is_empty_text_selection) {
|
|
886
878
|
const prev_selection = __get_selection_from_dom();
|
|
887
879
|
if (
|
|
@@ -893,11 +885,11 @@ ${fallback_html}`;
|
|
|
893
885
|
}
|
|
894
886
|
|
|
895
887
|
if (selection?.type === 'text') {
|
|
896
|
-
__render_text_selection();
|
|
888
|
+
__render_text_selection(should_scroll_selection_into_view);
|
|
897
889
|
} else if (selection?.type === 'node') {
|
|
898
|
-
__render_node_selection();
|
|
890
|
+
__render_node_selection(should_scroll_selection_into_view);
|
|
899
891
|
} else if (selection?.type === 'property') {
|
|
900
|
-
__render_property_selection();
|
|
892
|
+
__render_property_selection(should_scroll_selection_into_view);
|
|
901
893
|
} else {
|
|
902
894
|
console.warn('unsupported selection', $state.snapshot(selection));
|
|
903
895
|
}
|
|
@@ -940,17 +932,15 @@ ${fallback_html}`;
|
|
|
940
932
|
* @param {HTMLElement} el
|
|
941
933
|
* @returns {HTMLElement | null}
|
|
942
934
|
*/
|
|
943
|
-
function __resolve_node_from_gap(el) {
|
|
944
|
-
const gap =
|
|
935
|
+
function __resolve_node_from_gap(el: Element): HTMLElement | null {
|
|
936
|
+
const gap = el.closest('[data-gap-array-path]') as HTMLElement | null;
|
|
945
937
|
if (!gap) return null;
|
|
946
938
|
const array_path = deserialize_path(gap.dataset.gapArrayPath);
|
|
947
939
|
const offset = parseInt(gap.dataset.gapOffset, 10);
|
|
948
940
|
const node_idx = offset > 0 ? offset - 1 : 0;
|
|
949
|
-
return
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
)
|
|
953
|
-
);
|
|
941
|
+
return canvas_el.querySelector<HTMLElement>(
|
|
942
|
+
`[data-path="${serialize_path([...array_path, node_idx])}"][data-type="node"]`
|
|
943
|
+
) as HTMLElement | null;
|
|
954
944
|
}
|
|
955
945
|
|
|
956
946
|
/**
|
|
@@ -960,12 +950,12 @@ ${fallback_html}`;
|
|
|
960
950
|
* @returns {NodeSelection | null} A NodeSelection object if the DOM selection
|
|
961
951
|
* represents a valid node selection, null otherwise
|
|
962
952
|
*/
|
|
963
|
-
function __get_node_selection_from_dom() {
|
|
953
|
+
function __get_node_selection_from_dom(): NodeSelection | null {
|
|
964
954
|
const dom_selection = window.getSelection();
|
|
965
955
|
if (dom_selection.rangeCount === 0) return null;
|
|
966
956
|
|
|
967
|
-
let focus_node =
|
|
968
|
-
let anchor_node =
|
|
957
|
+
let focus_node = dom_selection.focusNode as HTMLElement;
|
|
958
|
+
let anchor_node = dom_selection.anchorNode as HTMLElement;
|
|
969
959
|
|
|
970
960
|
// If focus_node or anchor_node not an element node (e.g. a text node), we need
|
|
971
961
|
// to use the parent element, so we can perform the closest() query on it.
|
|
@@ -975,17 +965,17 @@ ${fallback_html}`;
|
|
|
975
965
|
// EDGE CASE: Collapsed selection inside an empty node placeholder.
|
|
976
966
|
// Firefox can place the DOM selection on the placeholder node itself,
|
|
977
967
|
// which otherwise looks like selecting node index 0 in an empty array.
|
|
978
|
-
const focus_empty_placeholder =
|
|
979
|
-
|
|
980
|
-
);
|
|
981
|
-
const anchor_empty_placeholder =
|
|
982
|
-
|
|
983
|
-
);
|
|
968
|
+
const focus_empty_placeholder = focus_node.closest(
|
|
969
|
+
'.empty-node-placeholder[data-path][data-type="node"]'
|
|
970
|
+
) as HTMLElement | null;
|
|
971
|
+
const anchor_empty_placeholder = anchor_node.closest(
|
|
972
|
+
'.empty-node-placeholder[data-path][data-type="node"]'
|
|
973
|
+
) as HTMLElement | null;
|
|
984
974
|
if (focus_empty_placeholder && focus_empty_placeholder === anchor_empty_placeholder) {
|
|
985
975
|
const empty_placeholder_path = deserialize_path(focus_empty_placeholder.dataset.path);
|
|
986
976
|
const array_path = empty_placeholder_path.slice(0, -1);
|
|
987
977
|
const node_array = session.get(array_path);
|
|
988
|
-
if (
|
|
978
|
+
if (node_array?.nodes?.length === 0) {
|
|
989
979
|
return {
|
|
990
980
|
type: 'node',
|
|
991
981
|
path: array_path,
|
|
@@ -997,7 +987,7 @@ ${fallback_html}`;
|
|
|
997
987
|
|
|
998
988
|
// EDGE CASE: Collapsed selection inside a node gap (gap-after or gap-before).
|
|
999
989
|
// Gaps are siblings of nodes with data-gap-array-path and data-gap-offset.
|
|
1000
|
-
const gap_el =
|
|
990
|
+
const gap_el = focus_node.closest('[data-gap-array-path]') as HTMLElement | null;
|
|
1001
991
|
if (gap_el && focus_node === anchor_node) {
|
|
1002
992
|
const array_path = deserialize_path(gap_el.dataset.gapArrayPath);
|
|
1003
993
|
const gap_offset = parseInt(gap_el.dataset.gapOffset, 10);
|
|
@@ -1011,12 +1001,12 @@ ${fallback_html}`;
|
|
|
1011
1001
|
|
|
1012
1002
|
let focus_root =
|
|
1013
1003
|
__resolve_node_from_gap(focus_node) ??
|
|
1014
|
-
|
|
1004
|
+
(focus_node.closest('[data-path][data-type="node"]') as HTMLElement);
|
|
1015
1005
|
if (!focus_root) return null;
|
|
1016
1006
|
|
|
1017
1007
|
let anchor_root =
|
|
1018
1008
|
__resolve_node_from_gap(anchor_node) ??
|
|
1019
|
-
|
|
1009
|
+
(anchor_node.closest('[data-path][data-type="node"]') as HTMLElement);
|
|
1020
1010
|
if (!anchor_root) return null;
|
|
1021
1011
|
|
|
1022
1012
|
let focus_root_path = deserialize_path(focus_root.dataset.path);
|
|
@@ -1114,17 +1104,17 @@ ${fallback_html}`;
|
|
|
1114
1104
|
* @returns {PropertySelection | null} A PropertySelection object if the DOM selection
|
|
1115
1105
|
* represents a valid property selection, null otherwise
|
|
1116
1106
|
*/
|
|
1117
|
-
function __get_property_selection_from_dom() {
|
|
1107
|
+
function __get_property_selection_from_dom(): PropertySelection | null {
|
|
1118
1108
|
const dom_selection = window.getSelection();
|
|
1119
1109
|
if (dom_selection.rangeCount === 0) return null;
|
|
1120
1110
|
|
|
1121
|
-
let focus_root =
|
|
1122
|
-
|
|
1123
|
-
);
|
|
1111
|
+
let focus_root = dom_selection.focusNode.parentElement?.closest(
|
|
1112
|
+
'[data-path][data-type="property"]'
|
|
1113
|
+
) as HTMLElement;
|
|
1124
1114
|
if (!focus_root) return null;
|
|
1125
|
-
let anchor_root =
|
|
1126
|
-
|
|
1127
|
-
);
|
|
1115
|
+
let anchor_root = dom_selection.anchorNode.parentElement?.closest(
|
|
1116
|
+
'[data-path][data-type="property"]'
|
|
1117
|
+
) as HTMLElement;
|
|
1128
1118
|
if (!anchor_root) return null;
|
|
1129
1119
|
|
|
1130
1120
|
if (focus_root === anchor_root) {
|
|
@@ -1151,10 +1141,13 @@ ${fallback_html}`;
|
|
|
1151
1141
|
* @returns {TextSelection | null} A TextSelection object if the DOM selection
|
|
1152
1142
|
* represents a valid text selection, null otherwise
|
|
1153
1143
|
*/
|
|
1154
|
-
function __get_text_selection_from_dom(
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
let
|
|
1144
|
+
function __get_text_selection_from_dom(
|
|
1145
|
+
range: Range | StaticRange | null = null
|
|
1146
|
+
): TextSelection | null {
|
|
1147
|
+
let dom_selection: globalThis.Selection | null = null;
|
|
1148
|
+
let focus_node: Node | null;
|
|
1149
|
+
let anchor_node: Node | null;
|
|
1150
|
+
let focus_offset_in_node: number; // anchor_offset_in_node;
|
|
1158
1151
|
|
|
1159
1152
|
if (range) {
|
|
1160
1153
|
// When range is provided, use it directly
|
|
@@ -1173,23 +1166,24 @@ ${fallback_html}`;
|
|
|
1173
1166
|
range = dom_selection.getRangeAt(0);
|
|
1174
1167
|
}
|
|
1175
1168
|
|
|
1176
|
-
function get_text_root(node) {
|
|
1169
|
+
function get_text_root(node: Node | null): HTMLElement | null {
|
|
1177
1170
|
if (!node) return null;
|
|
1178
1171
|
|
|
1179
|
-
if (node
|
|
1180
|
-
return
|
|
1181
|
-
node.closest?.('[data-path][data-type="text"]') ?? null
|
|
1182
|
-
);
|
|
1172
|
+
if (node instanceof Element) {
|
|
1173
|
+
return node.closest<HTMLElement>('[data-path][data-type="text"]');
|
|
1183
1174
|
}
|
|
1184
1175
|
|
|
1185
|
-
return
|
|
1186
|
-
node.parentElement?.closest('[data-path][data-type="text"]') ?? null
|
|
1187
|
-
);
|
|
1176
|
+
return node.parentElement?.closest<HTMLElement>('[data-path][data-type="text"]') ?? null;
|
|
1188
1177
|
}
|
|
1189
1178
|
|
|
1190
|
-
let focus_root
|
|
1179
|
+
let focus_root: HTMLElement | null;
|
|
1180
|
+
let anchor_root: HTMLElement | null;
|
|
1191
1181
|
|
|
1192
|
-
if (
|
|
1182
|
+
if (
|
|
1183
|
+
focus_node === anchor_node &&
|
|
1184
|
+
focus_node instanceof HTMLElement &&
|
|
1185
|
+
focus_node.dataset.type === 'text'
|
|
1186
|
+
) {
|
|
1193
1187
|
// EDGE CASE 1: Either text node is empty (only a <br> is present), or caret is after a <br> at the very end of the text node
|
|
1194
1188
|
focus_root = anchor_root = focus_node;
|
|
1195
1189
|
} else {
|
|
@@ -1210,7 +1204,7 @@ ${fallback_html}`;
|
|
|
1210
1204
|
|
|
1211
1205
|
// EDGE CASE 1B: Caret after trailing <br> at end of text
|
|
1212
1206
|
//
|
|
1213
|
-
//
|
|
1207
|
+
// TextProperty renders a trailing <br> for non-empty or non-focused text.
|
|
1214
1208
|
// When the user places their caret after this <br>, focusNode is the container
|
|
1215
1209
|
// element (not a text node), and normal processing would return position 0.
|
|
1216
1210
|
// We detect this and return the current DOM text length instead.
|
|
@@ -1250,7 +1244,7 @@ ${fallback_html}`;
|
|
|
1250
1244
|
}
|
|
1251
1245
|
}
|
|
1252
1246
|
|
|
1253
|
-
function get_text_offset(container, offset) {
|
|
1247
|
+
function get_text_offset(container: Node, offset: number): number {
|
|
1254
1248
|
const offset_range = window.document.createRange();
|
|
1255
1249
|
offset_range.setStart(focus_root, 0);
|
|
1256
1250
|
offset_range.setEnd(container, offset);
|
|
@@ -1277,7 +1271,7 @@ ${fallback_html}`;
|
|
|
1277
1271
|
};
|
|
1278
1272
|
}
|
|
1279
1273
|
|
|
1280
|
-
function __get_node_element(node_array_path, node_offset) {
|
|
1274
|
+
function __get_node_element(node_array_path: DocumentPath, node_offset: number): Element | null {
|
|
1281
1275
|
if (!canvas_el) return null;
|
|
1282
1276
|
return canvas_el.querySelector(
|
|
1283
1277
|
`[data-path="${serialize_path([...node_array_path, node_offset])}"][data-type="node"]`
|
|
@@ -1291,14 +1285,14 @@ ${fallback_html}`;
|
|
|
1291
1285
|
* node clipped only by an inner scroll container still counts visible.
|
|
1292
1286
|
* @param {Element | null | undefined} el
|
|
1293
1287
|
*/
|
|
1294
|
-
function __intersects_viewport(el) {
|
|
1288
|
+
function __intersects_viewport(el: Element | null | undefined): boolean {
|
|
1295
1289
|
if (!el) return false;
|
|
1296
1290
|
const r = el.getBoundingClientRect();
|
|
1297
1291
|
return r.bottom > 0 && r.top < window.innerHeight && r.right > 0 && r.left < window.innerWidth;
|
|
1298
1292
|
}
|
|
1299
1293
|
|
|
1300
|
-
function __render_node_selection() {
|
|
1301
|
-
const selection =
|
|
1294
|
+
function __render_node_selection(should_scroll_selection_into_view = true) {
|
|
1295
|
+
const selection = session.selection as NodeSelection;
|
|
1302
1296
|
const node_array_path = selection.path;
|
|
1303
1297
|
const node_array_path_str = serialize_path(node_array_path);
|
|
1304
1298
|
const is_collapsed = is_selection_collapsed(selection);
|
|
@@ -1312,7 +1306,7 @@ ${fallback_html}`;
|
|
|
1312
1306
|
const dom_selection = window.getSelection();
|
|
1313
1307
|
const range = window.document.createRange();
|
|
1314
1308
|
|
|
1315
|
-
const gap_selector = (offset) =>
|
|
1309
|
+
const gap_selector = (offset: number) =>
|
|
1316
1310
|
`[data-gap-array-path="${node_array_path_str}"][data-gap-offset="${offset}"]`;
|
|
1317
1311
|
|
|
1318
1312
|
if (is_collapsed) {
|
|
@@ -1336,7 +1330,10 @@ ${fallback_html}`;
|
|
|
1336
1330
|
if (!anchor_sel || !focus_sel) return;
|
|
1337
1331
|
|
|
1338
1332
|
if (is_backward) {
|
|
1339
|
-
|
|
1333
|
+
// setBaseAndExtent replaces the current selection, so no
|
|
1334
|
+
// removeAllRanges is needed — every selection-API call forces a
|
|
1335
|
+
// synchronous layout when the DOM is dirty, and this runs right
|
|
1336
|
+
// after the per-change reconcile on every apply.
|
|
1340
1337
|
dom_selection.setBaseAndExtent(anchor_sel, 1, focus_sel, 1);
|
|
1341
1338
|
} else {
|
|
1342
1339
|
range.setStart(anchor_sel, 1);
|
|
@@ -1345,6 +1342,7 @@ ${fallback_html}`;
|
|
|
1345
1342
|
dom_selection.addRange(range);
|
|
1346
1343
|
}
|
|
1347
1344
|
}
|
|
1345
|
+
if (!should_scroll_selection_into_view) return;
|
|
1348
1346
|
|
|
1349
1347
|
// Scroll the cursor into view, but only when it is genuinely
|
|
1350
1348
|
// off-screen. This runs on every node-selection re-render — a
|
|
@@ -1352,28 +1350,49 @@ ${fallback_html}`;
|
|
|
1352
1350
|
// — so an unconditional scroll would yank the viewport on each of
|
|
1353
1351
|
// them. cursor_offset is a gap offset and the gap has no box of
|
|
1354
1352
|
// its own, so visibility is judged from the nodes flanking it.
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1353
|
+
const node_array = session.get(node_array_path);
|
|
1354
|
+
if (!node_array?.nodes) return;
|
|
1355
|
+
|
|
1356
|
+
// Collapsed: anchor === focus, so focus is the gap offset.
|
|
1357
|
+
// Range: cursor sits at the focus end (anchor when backward).
|
|
1358
|
+
const cursor_offset = is_backward ? selection.anchor_offset : selection.focus_offset;
|
|
1359
|
+
const array_length = node_array.nodes.length;
|
|
1360
|
+
|
|
1361
|
+
// node_before is null at offset 0: cursor_offset - 1 would be -1,
|
|
1362
|
+
// and serialize_path rejects a negative index.
|
|
1363
|
+
const node_before =
|
|
1364
|
+
cursor_offset > 0 ? __get_node_element(node_array_path, cursor_offset - 1) : null;
|
|
1365
|
+
const node_after = __get_node_element(node_array_path, cursor_offset);
|
|
1366
|
+
|
|
1367
|
+
let scroll_target = null;
|
|
1368
|
+
let scroll_array_to_start = false;
|
|
1369
|
+
let scroll_array_to_end = false;
|
|
1370
|
+
|
|
1371
|
+
if (cursor_offset === 0) {
|
|
1372
|
+
scroll_array_to_start = true;
|
|
1373
|
+
} else if (cursor_offset >= array_length) {
|
|
1374
|
+
scroll_array_to_end = true;
|
|
1375
|
+
} else {
|
|
1376
|
+
// A range selection's selected node IS node_before; scrolling
|
|
1377
|
+
// node_after would reveal the following node and leave the
|
|
1378
|
+
// selection itself off-screen. For a collapsed caret node_after's
|
|
1379
|
+
// leading edge is the cursor, so that stays the right target.
|
|
1380
|
+
scroll_target = is_collapsed ? node_after : node_before;
|
|
1381
|
+
}
|
|
1360
1382
|
|
|
1383
|
+
setTimeout(() => {
|
|
1361
1384
|
// If either node flanking the cursor is already (even
|
|
1362
1385
|
// partially) on screen, the cursor is visible — keep the
|
|
1363
|
-
// viewport stable and scroll nothing.
|
|
1364
|
-
//
|
|
1365
|
-
// rejects a negative index.
|
|
1366
|
-
const node_before =
|
|
1367
|
-
cursor_offset > 0 ? __get_node_element(node_array_path, cursor_offset - 1) : null;
|
|
1368
|
-
const node_after = __get_node_element(node_array_path, cursor_offset);
|
|
1386
|
+
// viewport stable and scroll nothing. Do this layout read in the
|
|
1387
|
+
// deferred callback, after Svelte/browser layout has settled.
|
|
1369
1388
|
if (__intersects_viewport(node_before) || __intersects_viewport(node_after)) return;
|
|
1370
1389
|
|
|
1371
|
-
if (
|
|
1390
|
+
if (scroll_array_to_start) {
|
|
1372
1391
|
node_array_el.scrollLeft = 0;
|
|
1373
1392
|
node_array_el.scrollTop = 0;
|
|
1374
1393
|
return;
|
|
1375
1394
|
}
|
|
1376
|
-
if (
|
|
1395
|
+
if (scroll_array_to_end) {
|
|
1377
1396
|
const max_left = Math.max(0, node_array_el.scrollWidth - node_array_el.clientWidth);
|
|
1378
1397
|
const max_top = Math.max(0, node_array_el.scrollHeight - node_array_el.clientHeight);
|
|
1379
1398
|
node_array_el.scrollLeft = max_left;
|
|
@@ -1383,19 +1402,14 @@ ${fallback_html}`;
|
|
|
1383
1402
|
}
|
|
1384
1403
|
return;
|
|
1385
1404
|
}
|
|
1386
|
-
|
|
1387
|
-
// node_after would reveal the following node and leave the
|
|
1388
|
-
// selection itself off-screen. For a collapsed caret node_after's
|
|
1389
|
-
// leading edge is the cursor, so that stays the right target.
|
|
1390
|
-
const target = is_collapsed ? node_after : node_before;
|
|
1391
|
-
target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1405
|
+
scroll_target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1392
1406
|
}, 0);
|
|
1393
1407
|
}
|
|
1394
1408
|
|
|
1395
|
-
function __render_property_selection() {
|
|
1409
|
+
function __render_property_selection(should_scroll_selection_into_view = true) {
|
|
1396
1410
|
const selection = session.selection;
|
|
1397
1411
|
// The element that holds the property
|
|
1398
|
-
const el = canvas_el.querySelector(
|
|
1412
|
+
const el = canvas_el.querySelector<HTMLElement>(
|
|
1399
1413
|
`[data-path="${serialize_path(selection.path)}"][data-type="property"]`
|
|
1400
1414
|
);
|
|
1401
1415
|
|
|
@@ -1409,36 +1423,33 @@ ${fallback_html}`;
|
|
|
1409
1423
|
dom_selection.removeAllRanges();
|
|
1410
1424
|
dom_selection.addRange(range);
|
|
1411
1425
|
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1426
|
+
if (should_scroll_selection_into_view) {
|
|
1427
|
+
setTimeout(() => {
|
|
1428
|
+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1429
|
+
}, 0);
|
|
1430
|
+
}
|
|
1416
1431
|
}
|
|
1417
1432
|
|
|
1418
|
-
function __render_text_selection() {
|
|
1419
|
-
const selection =
|
|
1433
|
+
function __render_text_selection(should_scroll_selection_into_view = true) {
|
|
1434
|
+
const selection = session.selection as TextSelection;
|
|
1420
1435
|
// The element that holds the annotated string
|
|
1421
|
-
const el = canvas_el.querySelector(
|
|
1436
|
+
const el = canvas_el.querySelector<HTMLElement>(
|
|
1422
1437
|
`[data-path="${serialize_path(selection.path)}"][data-type="text"]`
|
|
1423
1438
|
);
|
|
1424
|
-
const empty_text = session.get(selection.path).
|
|
1439
|
+
const empty_text = session.get(selection.path).content.length === 0;
|
|
1425
1440
|
const dom_selection = window.getSelection();
|
|
1426
1441
|
let current_offset = 0;
|
|
1427
|
-
|
|
1428
|
-
let
|
|
1429
|
-
|
|
1430
|
-
let
|
|
1431
|
-
/** @type {number} */
|
|
1432
|
-
let anchor_node_offset;
|
|
1433
|
-
/** @type {number} */
|
|
1434
|
-
let focus_node_offset;
|
|
1442
|
+
let anchor_node: HTMLElement | Text;
|
|
1443
|
+
let focus_node: HTMLElement | Text;
|
|
1444
|
+
let anchor_node_offset: number;
|
|
1445
|
+
let focus_node_offset: number;
|
|
1435
1446
|
const is_backward = selection.anchor_offset > selection.focus_offset;
|
|
1436
1447
|
const start_offset = Math.min(selection.anchor_offset, selection.focus_offset);
|
|
1437
1448
|
const end_offset = Math.max(selection.anchor_offset, selection.focus_offset);
|
|
1438
1449
|
|
|
1439
1450
|
// Helper function to process each node
|
|
1440
|
-
function process_node(node) {
|
|
1441
|
-
if (node
|
|
1451
|
+
function process_node(node: Node) {
|
|
1452
|
+
if (node instanceof Text) {
|
|
1442
1453
|
const node_text = node.textContent;
|
|
1443
1454
|
const node_char_length = get_char_length(node_text);
|
|
1444
1455
|
|
|
@@ -1473,7 +1484,7 @@ ${fallback_html}`;
|
|
|
1473
1484
|
}
|
|
1474
1485
|
}
|
|
1475
1486
|
current_offset += node_char_length;
|
|
1476
|
-
} else if (node
|
|
1487
|
+
} else if (node instanceof HTMLElement) {
|
|
1477
1488
|
for (const child_node of node.childNodes) {
|
|
1478
1489
|
if (process_node(child_node)) return true; // Stop iteration if end found
|
|
1479
1490
|
}
|
|
@@ -1498,8 +1509,10 @@ ${fallback_html}`;
|
|
|
1498
1509
|
|
|
1499
1510
|
// Set the range if both start and end were found
|
|
1500
1511
|
if (anchor_node && focus_node) {
|
|
1501
|
-
|
|
1502
|
-
//
|
|
1512
|
+
// NOTE: Only using setBaseAndExtent() will preserve selection direction.
|
|
1513
|
+
// It also replaces the current selection, so no removeAllRanges is
|
|
1514
|
+
// needed — every selection-API call forces a synchronous layout when
|
|
1515
|
+
// the DOM is dirty, and this runs on every apply.
|
|
1503
1516
|
dom_selection.setBaseAndExtent(
|
|
1504
1517
|
anchor_node,
|
|
1505
1518
|
anchor_node_offset,
|
|
@@ -1507,20 +1520,27 @@ ${fallback_html}`;
|
|
|
1507
1520
|
focus_node_offset
|
|
1508
1521
|
);
|
|
1509
1522
|
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1523
|
+
if (should_scroll_selection_into_view) {
|
|
1524
|
+
// Scroll the rendered selection into view. Capture the target now:
|
|
1525
|
+
// the browser selection is live and may be cleared before this
|
|
1526
|
+
// deferred callback runs, e.g. when app UI focuses an external input.
|
|
1527
|
+
const selected_element = focus_node.parentElement;
|
|
1528
|
+
setTimeout(() => {
|
|
1529
|
+
if (selected_element?.isConnected) {
|
|
1530
|
+
selected_element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1531
|
+
}
|
|
1532
|
+
}, 0);
|
|
1533
|
+
}
|
|
1517
1534
|
}
|
|
1518
1535
|
}
|
|
1519
1536
|
|
|
1520
1537
|
// Utils
|
|
1521
1538
|
// --------------------------
|
|
1522
1539
|
|
|
1523
|
-
function __remove_native_composition_text(
|
|
1540
|
+
function __remove_native_composition_text(
|
|
1541
|
+
selection: TextSelection | null,
|
|
1542
|
+
inserted_text: string
|
|
1543
|
+
) {
|
|
1524
1544
|
if (!selection || selection.type !== 'text' || !inserted_text) return;
|
|
1525
1545
|
|
|
1526
1546
|
const text_el = canvas_el.querySelector(
|
|
@@ -1528,11 +1548,14 @@ ${fallback_html}`;
|
|
|
1528
1548
|
);
|
|
1529
1549
|
if (!text_el) return;
|
|
1530
1550
|
|
|
1531
|
-
const model_text = session.get(selection.path).
|
|
1551
|
+
const model_text = session.get(selection.path).content;
|
|
1532
1552
|
if ((text_el.textContent ?? '') === model_text) return;
|
|
1533
1553
|
|
|
1534
1554
|
let current_offset = 0;
|
|
1535
|
-
function get_dom_text_position(
|
|
1555
|
+
function get_dom_text_position(
|
|
1556
|
+
root: Node,
|
|
1557
|
+
target_offset: number
|
|
1558
|
+
): { node: Node; offset: number } | null {
|
|
1536
1559
|
for (const node of root.childNodes) {
|
|
1537
1560
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
1538
1561
|
const node_text = node.textContent ?? '';
|
|
@@ -1598,7 +1621,11 @@ ${fallback_html}`;
|
|
|
1598
1621
|
const dom_driven = selection_source_is_dom;
|
|
1599
1622
|
selection_source_is_dom = false;
|
|
1600
1623
|
if (!canvas_focused) return;
|
|
1601
|
-
|
|
1624
|
+
const selection_snapshot = JSON.stringify(session.selection);
|
|
1625
|
+
const should_scroll_selection_into_view =
|
|
1626
|
+
!dom_driven && selection_snapshot !== last_rendered_selection_snapshot;
|
|
1627
|
+
render_selection(dom_driven, should_scroll_selection_into_view);
|
|
1628
|
+
last_rendered_selection_snapshot = selection_snapshot;
|
|
1602
1629
|
});
|
|
1603
1630
|
</script>
|
|
1604
1631
|
|
|
@@ -1625,7 +1652,7 @@ ${fallback_html}`;
|
|
|
1625
1652
|
-->
|
|
1626
1653
|
<div
|
|
1627
1654
|
class="svedit-canvas {css_class}"
|
|
1628
|
-
class:hide-selection={session.selection?.type === 'node'}
|
|
1655
|
+
class:hide-selection={editable && session.selection?.type === 'node'}
|
|
1629
1656
|
class:node-caret={session.selection?.type === 'node' &&
|
|
1630
1657
|
session.selection.anchor_offset === session.selection.focus_offset}
|
|
1631
1658
|
class:property-selection={session.selection?.type === 'property'}
|