svedit 0.3.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 +39 -21
- package/dist/{AnnotatedStringProperty.svelte → AnnotatedTextProperty.svelte} +28 -40
- package/dist/AnnotatedTextProperty.svelte.d.ts +7 -0
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +1 -0
- package/dist/Document.svelte.d.ts +39 -16
- package/dist/Document.svelte.d.ts.map +1 -1
- package/dist/Document.svelte.js +192 -80
- package/dist/Svedit.svelte +149 -97
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +4 -4
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +60 -77
- package/dist/commands.svelte.d.ts.map +1 -1
- package/dist/commands.svelte.js +18 -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 +16 -16
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +33 -34
- package/package.json +1 -1
- package/src/lib/{AnnotatedStringProperty.svelte → AnnotatedTextProperty.svelte} +28 -40
- package/src/lib/Document.svelte.js +192 -80
- package/src/lib/Svedit.svelte +149 -97
- package/src/lib/Transaction.svelte.js +60 -77
- package/src/lib/commands.svelte.js +18 -10
- package/src/lib/index.js +1 -1
- package/src/lib/types.d.ts +66 -21
- package/src/lib/util.js +33 -34
- package/dist/AnnotatedStringProperty.svelte.d.ts +0 -7
- package/dist/AnnotatedStringProperty.svelte.d.ts.map +0 -1
package/src/lib/Svedit.svelte
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
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;
|
|
@@ -27,13 +29,26 @@
|
|
|
27
29
|
// Detect Chrome on desktop (not mobile) - only available in browser
|
|
28
30
|
let is_chrome_desktop = $state(false);
|
|
29
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
|
+
|
|
30
47
|
$effect(() => {
|
|
31
48
|
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
|
|
32
49
|
const user_agent = navigator.userAgent;
|
|
33
50
|
const is_chrome = user_agent.includes('Chrome') && !user_agent.includes('Edg');
|
|
34
|
-
const is_mobile =
|
|
35
|
-
('ontouchstart' in window) ||
|
|
36
|
-
(navigator.maxTouchPoints > 0);
|
|
51
|
+
const is_mobile = is_mobile_browser();
|
|
37
52
|
is_chrome_desktop = is_chrome && !is_mobile;
|
|
38
53
|
}
|
|
39
54
|
});
|
|
@@ -86,7 +101,7 @@
|
|
|
86
101
|
|
|
87
102
|
function commit_input() {
|
|
88
103
|
if (input_selection?.type !== 'text') return;
|
|
89
|
-
const model_text = doc.get(input_selection.path)
|
|
104
|
+
const model_text = doc.get(input_selection.path).text;
|
|
90
105
|
const dom_text_element = document.querySelector(`[data-path="${input_selection.path.join('.')}"]`);
|
|
91
106
|
const dom_text = dom_text_element.textContent;
|
|
92
107
|
const op = diff_text(model_text, dom_text);
|
|
@@ -142,25 +157,98 @@
|
|
|
142
157
|
|
|
143
158
|
// Only take input when in a valid text selection inside the canvas
|
|
144
159
|
if (
|
|
145
|
-
!(canvas_ref?.contains(document.activeElement))
|
|
146
|
-
(doc.selection?.type !== 'text')
|
|
160
|
+
!(canvas_ref?.contains(document.activeElement))
|
|
147
161
|
) {
|
|
148
162
|
event.preventDefault();
|
|
149
163
|
return;
|
|
150
164
|
}
|
|
151
165
|
|
|
152
|
-
if (event.inputType === 'formatBold') {
|
|
166
|
+
if (event.inputType === 'formatBold' && doc.selection?.type === 'text') {
|
|
153
167
|
doc.apply(doc.tr.annotate_text('strong'));
|
|
154
168
|
event.preventDefault();
|
|
155
169
|
event.stopPropagation();
|
|
156
170
|
}
|
|
157
171
|
|
|
158
|
-
if (event.inputType === 'formatItalic') {
|
|
172
|
+
if (event.inputType === 'formatItalic' && doc.selection?.type === 'text') {
|
|
159
173
|
doc.apply(doc.tr.annotate_text('emphasis'));
|
|
160
174
|
event.preventDefault();
|
|
161
175
|
event.stopPropagation();
|
|
162
176
|
}
|
|
163
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
|
+
|
|
164
252
|
|
|
165
253
|
// We remember the "input selection", because in some mobile browsers (Samsung Android) the onbeforeinput event
|
|
166
254
|
// is fired on a different selection than the input event.
|
|
@@ -302,9 +390,9 @@ ${fallback_html}`;
|
|
|
302
390
|
for (const [prop_name, prop_value] of Object.entries(node)) {
|
|
303
391
|
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
304
392
|
|
|
305
|
-
// Check if this is an
|
|
306
|
-
if (
|
|
307
|
-
const text_content = prop_value
|
|
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;
|
|
308
396
|
if (text_content.trim()) {
|
|
309
397
|
html += `<p>${text_content}</p>\n`;
|
|
310
398
|
has_content = true;
|
|
@@ -313,7 +401,7 @@ ${fallback_html}`;
|
|
|
313
401
|
}
|
|
314
402
|
|
|
315
403
|
if (!has_content) {
|
|
316
|
-
// Generic fallback for unknown node types with no
|
|
404
|
+
// Generic fallback for unknown node types with no annotated_text content
|
|
317
405
|
html += `<div data-node-type="${node.type}">Content from ${node.type}</div>\n`;
|
|
318
406
|
}
|
|
319
407
|
|
|
@@ -389,7 +477,7 @@ ${fallback_html}`;
|
|
|
389
477
|
|
|
390
478
|
console.log('Copy operation:', {
|
|
391
479
|
selected_nodes,
|
|
392
|
-
|
|
480
|
+
nodes,
|
|
393
481
|
total_nodes: Object.keys(nodes).length,
|
|
394
482
|
operation: delete_selection ? 'cut' : 'copy'
|
|
395
483
|
});
|
|
@@ -453,8 +541,6 @@ ${fallback_html}`;
|
|
|
453
541
|
pasted_json = undefined;
|
|
454
542
|
}
|
|
455
543
|
|
|
456
|
-
|
|
457
|
-
|
|
458
544
|
if (pasted_json) {
|
|
459
545
|
const tr = doc.tr;
|
|
460
546
|
|
|
@@ -478,12 +564,18 @@ ${fallback_html}`;
|
|
|
478
564
|
for (const [property, value] of Object.entries(new_node)) {
|
|
479
565
|
if (property === 'id' || property === 'type') continue;
|
|
480
566
|
|
|
481
|
-
const prop_type = doc.schema[node.type]?.[property]?.type;
|
|
567
|
+
const prop_type = doc.schema[node.type]?.properties?.[property]?.type;
|
|
482
568
|
|
|
483
569
|
if (prop_type === 'node_array' && Array.isArray(value)) {
|
|
484
570
|
new_node[property] = value.map(ref_id => id_mapping[ref_id] || ref_id);
|
|
485
571
|
} else if (prop_type === 'node' && typeof value === 'string') {
|
|
486
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};
|
|
487
579
|
}
|
|
488
580
|
}
|
|
489
581
|
|
|
@@ -588,37 +680,39 @@ ${fallback_html}`;
|
|
|
588
680
|
const is_collapsed = selection?.anchor_offset === selection?.focus_offset;
|
|
589
681
|
|
|
590
682
|
if (
|
|
591
|
-
(e.key === 'ArrowRight' && e.altKey && e.ctrlKey && doc.
|
|
592
|
-
(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)
|
|
593
685
|
) {
|
|
594
|
-
const node = doc.
|
|
686
|
+
const node = doc.layout_node;
|
|
595
687
|
const layout_count = doc.config.node_layouts[node.type];
|
|
596
688
|
|
|
597
689
|
if (layout_count > 1 && node?.layout) {
|
|
598
690
|
const next_layout = (node.layout % layout_count) + 1;
|
|
599
691
|
console.log('layout / count / next_layout', node.layout, layout_count, next_layout);
|
|
600
692
|
const tr = doc.tr;
|
|
601
|
-
tr.set([doc.
|
|
693
|
+
tr.set([doc.layout_node?.id, 'layout'], next_layout);
|
|
602
694
|
doc.apply(tr);
|
|
603
695
|
}
|
|
696
|
+
e.preventDefault();
|
|
604
697
|
} else if (
|
|
605
|
-
(e.key === 'ArrowLeft' && e.altKey && e.ctrlKey && doc.
|
|
606
|
-
(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)
|
|
607
700
|
) {
|
|
608
|
-
const node = doc.
|
|
701
|
+
const node = doc.layout_node;
|
|
609
702
|
const layout_count = doc.config.node_layouts[node.type];
|
|
610
703
|
if (layout_count > 1 && node?.layout) {
|
|
611
704
|
const prev_layout = ((node.layout - 2 + layout_count) % layout_count) + 1;
|
|
612
705
|
const tr = doc.tr;
|
|
613
|
-
tr.set([doc.
|
|
706
|
+
tr.set([doc.layout_node?.id, 'layout'], prev_layout);
|
|
614
707
|
doc.apply(tr);
|
|
615
708
|
console.log('layout / count / prev_layout', node.layout, layout_count, prev_layout);
|
|
616
709
|
}
|
|
710
|
+
e.preventDefault();
|
|
617
711
|
} else if (
|
|
618
|
-
(e.key === 'ArrowDown' && e.altKey && e.ctrlKey && doc.
|
|
619
|
-
(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 )
|
|
620
714
|
) {
|
|
621
|
-
const node = doc.
|
|
715
|
+
const node = doc.layout_node;
|
|
622
716
|
|
|
623
717
|
if (doc.selection.type !== 'node') {
|
|
624
718
|
doc.select_parent();
|
|
@@ -636,11 +730,12 @@ ${fallback_html}`;
|
|
|
636
730
|
doc.config.inserters[next_type](tr);
|
|
637
731
|
tr.set_selection(old_selection);
|
|
638
732
|
doc.apply(tr);
|
|
733
|
+
e.preventDefault();
|
|
639
734
|
} else if (
|
|
640
|
-
(e.key === 'ArrowUp' && e.altKey && e.ctrlKey && doc.
|
|
641
|
-
(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)
|
|
642
737
|
) {
|
|
643
|
-
const node = doc.
|
|
738
|
+
const node = doc.layout_node;
|
|
644
739
|
|
|
645
740
|
if (doc.selection.type !== 'node') {
|
|
646
741
|
doc.select_parent();
|
|
@@ -660,6 +755,7 @@ ${fallback_html}`;
|
|
|
660
755
|
doc.config.inserters[prev_type](tr);
|
|
661
756
|
tr.set_selection(old_selection);
|
|
662
757
|
doc.apply(tr);
|
|
758
|
+
e.preventDefault();
|
|
663
759
|
|
|
664
760
|
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
|
665
761
|
const tr = doc.tr;
|
|
@@ -676,76 +772,32 @@ ${fallback_html}`;
|
|
|
676
772
|
doc.redo();
|
|
677
773
|
e.preventDefault();
|
|
678
774
|
e.stopPropagation();
|
|
679
|
-
} else if (e.key === 'Enter' && (e.shiftKey)) {
|
|
775
|
+
} else if (e.key === 'Enter' && (e.shiftKey) && !is_mobile_browser() && selection?.type === 'text' && doc.inspect(selection.path).allow_newlines) {
|
|
680
776
|
doc.apply(doc.tr.insert_text('\n'));
|
|
681
777
|
e.preventDefault();
|
|
682
778
|
e.stopPropagation();
|
|
683
|
-
} else if (e.key === '
|
|
684
|
-
doc.apply(doc.tr.annotate_text('
|
|
685
|
-
href: window.prompt('Enter the URL', 'https://example.com')
|
|
686
|
-
}));
|
|
779
|
+
} else if (e.key === 'b' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
|
|
780
|
+
doc.apply(doc.tr.annotate_text('strong'));
|
|
687
781
|
e.preventDefault();
|
|
688
782
|
e.stopPropagation();
|
|
689
|
-
} else if (e.key === '
|
|
690
|
-
|
|
691
|
-
// For property selections, clear the property value only if it's not already falsy
|
|
692
|
-
if (doc.get(selection.path)) {
|
|
693
|
-
const tr = doc.tr;
|
|
694
|
-
tr.set(selection.path, '');
|
|
695
|
-
doc.apply(tr);
|
|
696
|
-
}
|
|
697
|
-
} else {
|
|
698
|
-
if (doc.selection?.type === 'text' && doc.selection?.anchor_offset === 0 && doc.selection?.focus_offset === 0) {
|
|
699
|
-
const tr = doc.tr;
|
|
700
|
-
join_text_node(tr);
|
|
701
|
-
doc.apply(tr);
|
|
702
|
-
} else {
|
|
703
|
-
// TODO: Things are not yet properly composable, I think we need to
|
|
704
|
-
// realize delete_selection as a command as well.
|
|
705
|
-
// See https://prosemirror.net/docs/guide/#commands for inspiration.
|
|
706
|
-
// For other selections, use the normal delete behavior
|
|
707
|
-
doc.apply(doc.tr.delete_selection());
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
783
|
+
} else if (e.key === 'i' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
|
|
784
|
+
doc.apply(doc.tr.annotate_text('emphasis'));
|
|
711
785
|
e.preventDefault();
|
|
712
786
|
e.stopPropagation();
|
|
713
|
-
} else if (e.key === '
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
}
|
|
787
|
+
} else if (e.key === 'u' && (e.ctrlKey || e.metaKey) && selection?.type === 'text') {
|
|
788
|
+
doc.apply(doc.tr.annotate_text('highlight'));
|
|
789
|
+
e.preventDefault();
|
|
790
|
+
e.stopPropagation();
|
|
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'));
|
|
796
|
+
} else {
|
|
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 }));
|
|
749
801
|
}
|
|
750
802
|
}
|
|
751
803
|
|
|
@@ -1023,7 +1075,7 @@ ${fallback_html}`;
|
|
|
1023
1075
|
// 2. Last child node is a <br> (the visual representation of that \n)
|
|
1024
1076
|
// 3. Cursor is positioned at or near the end
|
|
1025
1077
|
// And returns the correct text.length position instead.
|
|
1026
|
-
const text_content = doc.get(path)
|
|
1078
|
+
const text_content = doc.get(path).text;
|
|
1027
1079
|
const text_length = get_char_length(text_content);
|
|
1028
1080
|
if (text_length > 0) {
|
|
1029
1081
|
const last_char = get_char_at(text_content, text_length - 1);
|
|
@@ -1208,7 +1260,7 @@ ${fallback_html}`;
|
|
|
1208
1260
|
const selection = /** @type {any} */ (doc.selection);
|
|
1209
1261
|
// The element that holds the annotated string
|
|
1210
1262
|
const el = canvas_ref.querySelector(`[data-path="${selection.path.join('.')}"][data-type="text"]`);
|
|
1211
|
-
const empty_text = doc.get(selection.path)
|
|
1263
|
+
const empty_text = doc.get(selection.path).text.length === 0;
|
|
1212
1264
|
|
|
1213
1265
|
const range = window.document.createRange();
|
|
1214
1266
|
const dom_selection = window.getSelection();
|
|
@@ -1385,8 +1437,8 @@ ${fallback_html}`;
|
|
|
1385
1437
|
{oncompositionstart}
|
|
1386
1438
|
{oncompositionend}
|
|
1387
1439
|
contenteditable={editable ? 'true' : 'false'}
|
|
1388
|
-
autocapitalize
|
|
1389
|
-
spellcheck
|
|
1440
|
+
{autocapitalize}
|
|
1441
|
+
{spellcheck}
|
|
1390
1442
|
{...{
|
|
1391
1443
|
// NOTE: Autocomplete and autocorrect make immense troubles
|
|
1392
1444
|
// on Desktop Chrome, so we disable them only for Chrome desktop.
|
|
@@ -67,7 +67,7 @@ export default class Transaction {
|
|
|
67
67
|
* @example
|
|
68
68
|
* ```js
|
|
69
69
|
* tr.set(["list_1", "list_items"], [1, 2, 3]);
|
|
70
|
-
* tr.set(["page_1", "body", "0", "description"],
|
|
70
|
+
* tr.set(["page_1", "body", "0", "description"], {text: "Hello world", annotations: []});
|
|
71
71
|
* ```
|
|
72
72
|
*/
|
|
73
73
|
set (path, value) {
|
|
@@ -152,10 +152,10 @@ export default class Transaction {
|
|
|
152
152
|
*
|
|
153
153
|
* @param {Selection} selection - The new selection object
|
|
154
154
|
* @returns {Transaction} This transaction instance for method chaining
|
|
155
|
-
* @
|
|
155
|
+
* @throws {Error} Throws if the selection is invalid or out of bounds
|
|
156
156
|
*/
|
|
157
157
|
set_selection(selection) {
|
|
158
|
-
|
|
158
|
+
this.doc._validate_selection(selection);
|
|
159
159
|
this.doc.selection = selection;
|
|
160
160
|
return this;
|
|
161
161
|
}
|
|
@@ -168,7 +168,7 @@ export default class Transaction {
|
|
|
168
168
|
* when conflicting types are applied.
|
|
169
169
|
*
|
|
170
170
|
* @param {any} annotation_type - The type of annotation (e.g., 'link', 'bold', 'italic')
|
|
171
|
-
* @param {any}
|
|
171
|
+
* @param {any} annotation_properties - Additional data for the annotation (e.g., href for links)
|
|
172
172
|
* @returns {Transaction} This transaction instance for method chaining
|
|
173
173
|
*
|
|
174
174
|
* @example
|
|
@@ -180,64 +180,25 @@ export default class Transaction {
|
|
|
180
180
|
* tr.annotate_text('emphasis', {});
|
|
181
181
|
* ```
|
|
182
182
|
*/
|
|
183
|
-
annotate_text(annotation_type,
|
|
183
|
+
annotate_text(annotation_type, annotation_properties) {
|
|
184
184
|
if (this.doc.selection.type !== 'text') return this;
|
|
185
185
|
|
|
186
186
|
const { start, end } = this.doc.get_selection_range();
|
|
187
|
-
const
|
|
188
|
-
const annotations =
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
// Special annotation type handling should probably be done in a separate function.
|
|
192
|
-
// The goal is to keep the core logic simple and allow developer to extend and pick only what they need.
|
|
193
|
-
// It could also be abstracted to not check for type (e.g. "link") but for a special attribute
|
|
194
|
-
// e.g. "zero-range-updatable" for annotations that are updatable without a range selection change.
|
|
195
|
-
|
|
196
|
-
// Special handling for links when there's no selection range
|
|
197
|
-
// Links should be updatable by just clicking on them without a range selection
|
|
198
|
-
if (annotation_type === 'link' && start === end && existing_annotations) {
|
|
199
|
-
|
|
200
|
-
// Use findIndex for deep comparison of annotation properties (comparison of annotation properties rather than object reference via indexOf)
|
|
201
|
-
const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
|
|
202
|
-
anno[0] === existing_annotations[0] &&
|
|
203
|
-
anno[1] === existing_annotations[1] &&
|
|
204
|
-
anno[2] === existing_annotations[2]
|
|
205
|
-
);
|
|
206
|
-
// const index = annotations.indexOf(existing_annotations);
|
|
207
|
-
|
|
208
|
-
if (index !== -1) {
|
|
209
|
-
if (annotation_data.href === '') {
|
|
210
|
-
// Remove the annotation if the href is empty
|
|
211
|
-
annotations.splice(index, 1);
|
|
212
|
-
} else {
|
|
213
|
-
annotations[index] = [
|
|
214
|
-
existing_annotations[0],
|
|
215
|
-
existing_annotations[1],
|
|
216
|
-
'link',
|
|
217
|
-
{ ...existing_annotations[3], ...annotation_data }
|
|
218
|
-
];
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
this.set(this.doc.selection.path, annotated_string);
|
|
222
|
-
return this;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Regular annotation handling
|
|
227
|
-
if (start === end) {
|
|
228
|
-
// For non-link annotations: You can not annotate text if the selection is collapsed.
|
|
229
|
-
return this;
|
|
230
|
-
}
|
|
187
|
+
const annotated_text = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
|
|
188
|
+
const annotations = annotated_text.annotations;
|
|
189
|
+
const existing_annotation = this.doc.active_annotation();
|
|
190
|
+
const existing_annotation_same_type = this.doc.active_annotation(annotation_type);
|
|
231
191
|
|
|
232
|
-
if (
|
|
192
|
+
if (existing_annotation) {
|
|
233
193
|
// If there's an existing annotation of the same type, remove it
|
|
234
|
-
if (
|
|
194
|
+
if (existing_annotation_same_type) {
|
|
235
195
|
const index = annotations.findIndex(/** @param {any} anno */ (anno) =>
|
|
236
|
-
anno
|
|
237
|
-
anno
|
|
238
|
-
anno[2] === existing_annotations[2]
|
|
196
|
+
anno.start_offset === existing_annotation.start_offset &&
|
|
197
|
+
anno.end_offset === existing_annotation.end_offset
|
|
239
198
|
);
|
|
240
199
|
if (index !== -1) {
|
|
200
|
+
// Remove the annotation node from the graph
|
|
201
|
+
this.delete(annotations[index].node_id);
|
|
241
202
|
annotations.splice(index, 1);
|
|
242
203
|
}
|
|
243
204
|
} else {
|
|
@@ -245,14 +206,23 @@ export default class Transaction {
|
|
|
245
206
|
return this;
|
|
246
207
|
}
|
|
247
208
|
} else {
|
|
209
|
+
if (!this.doc.available_annotation_types.includes(annotation_type)) {
|
|
210
|
+
console.log(`Annotation type ${annotation_type} is not allowed here.`);
|
|
211
|
+
return this;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const new_annotation_node = {
|
|
215
|
+
id: this.doc.generate_id(),
|
|
216
|
+
type: annotation_type,
|
|
217
|
+
...annotation_properties,
|
|
218
|
+
}
|
|
219
|
+
this.create(new_annotation_node);
|
|
248
220
|
// If there's no existing annotation, add the new one
|
|
249
|
-
annotations.push(
|
|
221
|
+
annotations.push({start_offset: start, end_offset: end, node_id: new_annotation_node.id});
|
|
250
222
|
}
|
|
251
223
|
|
|
252
|
-
// Update the annotated
|
|
253
|
-
this.set(this.doc.selection.path,
|
|
254
|
-
// Not needed anymore as doc.apply makes sure selection is rerendered
|
|
255
|
-
// this.doc.selection = { ...this.doc.selection };
|
|
224
|
+
// Update the annotated text
|
|
225
|
+
this.set(this.doc.selection.path, annotated_text);
|
|
256
226
|
return this;
|
|
257
227
|
}
|
|
258
228
|
|
|
@@ -309,13 +279,15 @@ export default class Transaction {
|
|
|
309
279
|
let text = structuredClone($state.snapshot(this.doc.get(path)));
|
|
310
280
|
|
|
311
281
|
// Update the text content using character-based operations
|
|
312
|
-
const original_text = text
|
|
313
|
-
text
|
|
282
|
+
const original_text = text.text;
|
|
283
|
+
text.text = char_slice(original_text, 0, start) + char_slice(original_text, end, get_char_length(original_text));
|
|
314
284
|
|
|
285
|
+
// To mark annotation nodes for deletion.
|
|
286
|
+
const _deleted_nodes = [];
|
|
315
287
|
// Update annotation offsets for deletion
|
|
316
288
|
const deletion_length = end - start;
|
|
317
|
-
const new_annotations = text
|
|
318
|
-
const
|
|
289
|
+
const new_annotations = text.annotations.map(/** @param {any} annotation */ (annotation) => {
|
|
290
|
+
const {start_offset: annotation_start, end_offset: annotation_end, node_id} = annotation;
|
|
319
291
|
|
|
320
292
|
// Adjust start offset
|
|
321
293
|
let new_start = annotation_start;
|
|
@@ -341,16 +313,24 @@ export default class Transaction {
|
|
|
341
313
|
|
|
342
314
|
// Remove annotation if it becomes invalid (start >= end)
|
|
343
315
|
if (new_start >= new_end) {
|
|
316
|
+
console.log('deleting annotation');
|
|
317
|
+
_deleted_nodes.push(node_id);
|
|
344
318
|
return false;
|
|
345
319
|
}
|
|
346
320
|
|
|
347
|
-
return
|
|
321
|
+
return {start_offset: new_start, end_offset: new_end, node_id};
|
|
348
322
|
}).filter(Boolean);
|
|
349
323
|
|
|
350
|
-
text
|
|
324
|
+
text.annotations = new_annotations;
|
|
351
325
|
|
|
352
326
|
// Update the text in the entry (this implicitly records an op via this.set)
|
|
353
327
|
this.set(path, text);
|
|
328
|
+
|
|
329
|
+
// Remove annotation nodes that were implicitly deleted.
|
|
330
|
+
for (const node_id of _deleted_nodes) {
|
|
331
|
+
this.delete(node_id);
|
|
332
|
+
}
|
|
333
|
+
|
|
354
334
|
this.set_selection({
|
|
355
335
|
type: 'text',
|
|
356
336
|
path,
|
|
@@ -442,18 +422,18 @@ export default class Transaction {
|
|
|
442
422
|
insert_text(replaced_text) {
|
|
443
423
|
if (this.doc.selection.type !== 'text') return this;
|
|
444
424
|
|
|
445
|
-
const
|
|
425
|
+
const annotated_text = structuredClone($state.snapshot(this.doc.get(this.doc.selection.path)));
|
|
446
426
|
const { start, end } = this.doc.get_selection_range();
|
|
447
427
|
|
|
448
428
|
// Transform the plain text string using character-based operations
|
|
449
|
-
const text =
|
|
450
|
-
|
|
429
|
+
const text = annotated_text.text;
|
|
430
|
+
annotated_text.text = char_slice(text, 0, start) + replaced_text + char_slice(text, end, get_char_length(text));
|
|
451
431
|
|
|
452
|
-
// Transform the annotations (
|
|
453
|
-
// NOTE: Annotations are stored as
|
|
432
|
+
// Transform the annotations (annotated_text.annotations)
|
|
433
|
+
// NOTE: Annotations are stored as {start_offset, end_offset, node_id}
|
|
454
434
|
const delta = get_char_length(replaced_text) - (end - start);
|
|
455
|
-
const new_annotations =
|
|
456
|
-
const
|
|
435
|
+
const new_annotations = annotated_text.annotations.map(/** @param {any} annotation */ (annotation) => {
|
|
436
|
+
const {start_offset: annotation_start, end_offset: annotation_end, node_id} = annotation;
|
|
457
437
|
|
|
458
438
|
// Case 1: annotation is wrapped in start and end (remove it)
|
|
459
439
|
if (start <= annotation_start && end >= annotation_end) {
|
|
@@ -462,7 +442,7 @@ export default class Transaction {
|
|
|
462
442
|
|
|
463
443
|
// Case 2: text inserted before the annotation
|
|
464
444
|
if (end <= annotation_start) {
|
|
465
|
-
return
|
|
445
|
+
return {start_offset: annotation_start + delta, end_offset: annotation_end + delta, node_id};
|
|
466
446
|
}
|
|
467
447
|
|
|
468
448
|
// Case 3: text inserted after the annotation
|
|
@@ -474,19 +454,19 @@ export default class Transaction {
|
|
|
474
454
|
// NOTE: replaced_text will not be part of the annotation, we treat it the same as
|
|
475
455
|
// a cursor right after the annotation
|
|
476
456
|
if (start > annotation_start && start < annotation_end && end >= annotation_end) {
|
|
477
|
-
return
|
|
457
|
+
return {start_offset: annotation_start, end_offset: start, node_id};
|
|
478
458
|
}
|
|
479
459
|
|
|
480
460
|
// Case 5: text inserted inside an annotation
|
|
481
461
|
if (start >= annotation_start && start <= annotation_end && end < annotation_end && end >= annotation_start) {
|
|
482
|
-
return
|
|
462
|
+
return {start_offset: annotation_start, end_offset: annotation_end + delta, node_id};
|
|
483
463
|
}
|
|
484
464
|
|
|
485
465
|
// Case 6: annotation is partly selected towards left
|
|
486
466
|
// NOTE: replaced_text will not be part of the annotation, we treat it the same as
|
|
487
467
|
// a cursor right before the annotation
|
|
488
468
|
if (start < annotation_start && end > annotation_start && end < annotation_end) {
|
|
489
|
-
return
|
|
469
|
+
return {start_offset: start + get_char_length(replaced_text), end_offset: annotation_end + delta, node_id};
|
|
490
470
|
}
|
|
491
471
|
|
|
492
472
|
// Unhandled edge case:
|
|
@@ -496,7 +476,8 @@ export default class Transaction {
|
|
|
496
476
|
// return annotation;
|
|
497
477
|
}).filter(Boolean);
|
|
498
478
|
|
|
499
|
-
|
|
479
|
+
annotated_text.annotations = new_annotations;
|
|
480
|
+
this.set(this.doc.selection.path, annotated_text); // this will update the current state and create a history entry
|
|
500
481
|
|
|
501
482
|
// Setting the selection automatically triggers a re-render of the corresponding DOMSelection.
|
|
502
483
|
const new_selection = {
|
|
@@ -578,6 +559,8 @@ export default class Transaction {
|
|
|
578
559
|
count += value.filter(id => id === target_node_id).length;
|
|
579
560
|
} else if (prop_type === 'node' && value === target_node_id) {
|
|
580
561
|
count += 1;
|
|
562
|
+
} else if (prop_type === 'annotated_text' && value && value.annotations) {
|
|
563
|
+
count += value.annotations.filter(annotation => annotation.node_id === target_node_id).length;
|
|
581
564
|
}
|
|
582
565
|
}
|
|
583
566
|
}
|