svedit 0.11.0 → 0.12.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 +230 -64
- package/dist/Command.svelte.d.ts +21 -5
- package/dist/Command.svelte.d.ts.map +1 -1
- package/dist/Command.svelte.js +86 -30
- package/dist/KeyMapper.svelte.d.ts.map +1 -1
- package/dist/KeyMapper.svelte.js +18 -6
- package/dist/Node.svelte +29 -1
- package/dist/Node.svelte.d.ts.map +1 -1
- package/dist/NodeArrayProperty.svelte +119 -12
- package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
- package/dist/NodeGap.svelte +48 -6
- package/dist/NodeGap.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.d.ts +31 -14
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +155 -77
- package/dist/Svedit.svelte +110 -89
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
- package/dist/TextProperty.svelte.d.ts +7 -0
- package/dist/TextProperty.svelte.d.ts.map +1 -0
- package/dist/Transaction.svelte.d.ts +78 -40
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +412 -234
- package/dist/doc_utils.d.ts +96 -21
- package/dist/doc_utils.d.ts.map +1 -1
- package/dist/doc_utils.js +440 -168
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node_visibility.svelte.d.ts +30 -4
- package/dist/node_visibility.svelte.d.ts.map +1 -1
- package/dist/node_visibility.svelte.js +327 -363
- package/dist/paste_utils.d.ts +3 -2
- package/dist/paste_utils.d.ts.map +1 -1
- package/dist/paste_utils.js +16 -13
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +5 -6
- package/dist/types.d.ts +112 -42
- package/dist/utils.d.ts +89 -13
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +320 -85
- package/package.json +4 -1
- package/src/lib/Command.svelte.js +86 -30
- package/src/lib/KeyMapper.svelte.js +18 -6
- package/src/lib/Node.svelte +29 -1
- package/src/lib/NodeArrayProperty.svelte +119 -12
- package/src/lib/NodeGap.svelte +48 -6
- package/src/lib/Session.svelte.js +155 -77
- package/src/lib/Svedit.svelte +110 -89
- package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
- package/src/lib/Transaction.svelte.js +412 -234
- package/src/lib/doc_utils.js +440 -168
- package/src/lib/index.js +1 -1
- package/src/lib/node_visibility.svelte.js +327 -363
- package/src/lib/paste_utils.js +16 -13
- package/src/lib/transforms.svelte.js +5 -6
- package/src/lib/types.d.ts +112 -42
- package/src/lib/utils.js +320 -85
- package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
- package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
package/dist/Svedit.svelte
CHANGED
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
// Test-only hook: expose the svedit context so test specs can read
|
|
121
|
-
//
|
|
121
|
+
// array_indices / edge_map for diagnostics. Gated on `import.meta.env.MODE`
|
|
122
122
|
// — Vite replaces the literal so the comparison is constant-false in
|
|
123
123
|
// production and the branch is dead-code-eliminated. Wrapped in an
|
|
124
124
|
// effect so unmount clears the reference and tests don't leak state
|
|
@@ -185,21 +185,21 @@
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
if (event.inputType === 'formatBold' && session.selection?.type === 'text') {
|
|
188
|
-
session.apply(session.tr.
|
|
188
|
+
session.apply(session.tr.toggle_mark('strong'));
|
|
189
189
|
event.preventDefault();
|
|
190
190
|
event.stopPropagation();
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
if (event.inputType === 'formatItalic' && session.selection?.type === 'text') {
|
|
194
|
-
session.apply(session.tr.
|
|
194
|
+
session.apply(session.tr.toggle_mark('emphasis'));
|
|
195
195
|
event.preventDefault();
|
|
196
196
|
event.stopPropagation();
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
// NOTE: underline doesn't make much sense as a semantic
|
|
199
|
+
// NOTE: underline doesn't make much sense as a semantic mark,
|
|
200
200
|
// so we rewire `cmd + u` to toggle highlights
|
|
201
201
|
if (event.inputType === 'formatUnderline' && session.selection?.type === 'text') {
|
|
202
|
-
session.apply(session.tr.
|
|
202
|
+
session.apply(session.tr.toggle_mark('highlight'));
|
|
203
203
|
event.preventDefault();
|
|
204
204
|
event.stopPropagation();
|
|
205
205
|
}
|
|
@@ -398,14 +398,14 @@ ${fallback_html}`;
|
|
|
398
398
|
for (const [prop_name, prop_value] of Object.entries(node)) {
|
|
399
399
|
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
400
400
|
const property_definition = node_schema.properties[prop_name];
|
|
401
|
-
// Check if this is
|
|
402
|
-
if (property_definition.type === '
|
|
403
|
-
const text_content = prop_value.
|
|
401
|
+
// Check if this is a text property.
|
|
402
|
+
if (property_definition.type === 'text') {
|
|
403
|
+
const text_content = prop_value.content;
|
|
404
404
|
if (text_content.trim()) {
|
|
405
405
|
html += `<p>${text_content}</p>`;
|
|
406
406
|
}
|
|
407
407
|
} else if (property_definition.type === 'node_array') {
|
|
408
|
-
for (const child_id of prop_value) {
|
|
408
|
+
for (const child_id of prop_value.nodes) {
|
|
409
409
|
const child = session.get(child_id);
|
|
410
410
|
const child_exporter = html_exporters[child.type] || default_node_html_exporter;
|
|
411
411
|
html += child_exporter(child, session, html_exporters);
|
|
@@ -421,13 +421,13 @@ ${fallback_html}`;
|
|
|
421
421
|
for (const [prop_name, prop_value] of Object.entries(node)) {
|
|
422
422
|
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
423
423
|
|
|
424
|
-
// Check if this is
|
|
424
|
+
// Check if this is a text property value.
|
|
425
425
|
if (
|
|
426
426
|
typeof prop_value === 'object' &&
|
|
427
427
|
prop_value !== null &&
|
|
428
|
-
typeof prop_value.
|
|
428
|
+
typeof prop_value.content === 'string'
|
|
429
429
|
) {
|
|
430
|
-
const text_content = prop_value.
|
|
430
|
+
const text_content = prop_value.content;
|
|
431
431
|
if (text_content.trim()) {
|
|
432
432
|
plain_text += `${text_content.trim()}\n\n`;
|
|
433
433
|
}
|
|
@@ -468,28 +468,6 @@ ${fallback_html}`;
|
|
|
468
468
|
return plain_text.trim();
|
|
469
469
|
}
|
|
470
470
|
|
|
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
471
|
/**
|
|
494
472
|
* @param {ClipboardEvent} event
|
|
495
473
|
* @param {boolean} delete_selection - used by oncut()
|
|
@@ -502,24 +480,23 @@ ${fallback_html}`;
|
|
|
502
480
|
event.preventDefault();
|
|
503
481
|
event.stopPropagation();
|
|
504
482
|
|
|
505
|
-
let plain_text,
|
|
483
|
+
let plain_text, text, html;
|
|
506
484
|
|
|
507
485
|
if (session.selection?.type === 'text') {
|
|
508
486
|
plain_text = session.get_selected_plain_text();
|
|
509
|
-
|
|
510
|
-
const fallback_html = `<span>${
|
|
487
|
+
text = session.get_selected_text();
|
|
488
|
+
const fallback_html = `<span>${text.content}</span>`;
|
|
511
489
|
|
|
512
490
|
// console.log('Text copy:', {
|
|
513
|
-
//
|
|
491
|
+
// text,
|
|
514
492
|
// plain_text,
|
|
515
493
|
// html
|
|
516
494
|
// });
|
|
517
495
|
|
|
518
|
-
html = create_svedit_html_format(
|
|
496
|
+
html = create_svedit_html_format(text, fallback_html);
|
|
519
497
|
} else if (session.selection?.type === 'node') {
|
|
520
|
-
const
|
|
521
|
-
const { nodes, main_nodes } =
|
|
522
|
-
const json_data = { nodes, main_nodes };
|
|
498
|
+
const json_data = session.get_selected_annotated_nodes();
|
|
499
|
+
const { nodes, main_nodes } = json_data;
|
|
523
500
|
|
|
524
501
|
// console.log('Node copy:', {
|
|
525
502
|
// selected_nodes,
|
|
@@ -567,8 +544,6 @@ ${fallback_html}`;
|
|
|
567
544
|
oncopy(event, true);
|
|
568
545
|
}
|
|
569
546
|
|
|
570
|
-
|
|
571
|
-
|
|
572
547
|
/**
|
|
573
548
|
* @returns {NodeSelection|null}
|
|
574
549
|
*/
|
|
@@ -587,13 +562,13 @@ ${fallback_html}`;
|
|
|
587
562
|
|
|
588
563
|
const node_array_path = [...path, preferred_property_name];
|
|
589
564
|
const node_array = session.get(node_array_path);
|
|
590
|
-
if (!Array.isArray(node_array)) return null;
|
|
565
|
+
if (!node_array || !Array.isArray(node_array.nodes)) return null;
|
|
591
566
|
|
|
592
567
|
return {
|
|
593
568
|
type: /** @type {const} */ ('node'),
|
|
594
569
|
path: node_array_path,
|
|
595
|
-
anchor_offset: node_array.length,
|
|
596
|
-
focus_offset: node_array.length
|
|
570
|
+
anchor_offset: node_array.nodes.length,
|
|
571
|
+
focus_offset: node_array.nodes.length
|
|
597
572
|
};
|
|
598
573
|
}
|
|
599
574
|
|
|
@@ -639,7 +614,7 @@ ${fallback_html}`;
|
|
|
639
614
|
* @returns {boolean} True if the paste operation was successful, false otherwise
|
|
640
615
|
*/
|
|
641
616
|
function try_node_paste(pasted_json, selection) {
|
|
642
|
-
const { nodes, main_nodes } = pasted_json || {};
|
|
617
|
+
const { nodes, main_nodes, marks = [], annotations = [] } = pasted_json || {};
|
|
643
618
|
if (!nodes || !main_nodes?.length) return false;
|
|
644
619
|
|
|
645
620
|
let tr = session.tr;
|
|
@@ -652,7 +627,10 @@ ${fallback_html}`;
|
|
|
652
627
|
if (property_definition?.type !== 'node_array') return false;
|
|
653
628
|
|
|
654
629
|
const default_text_node_type = get_default_text_node(property_definition, session.schema);
|
|
655
|
-
const target_text_property_name = get_text_property_name(
|
|
630
|
+
const target_text_property_name = get_text_property_name(
|
|
631
|
+
default_text_node_type,
|
|
632
|
+
session.schema
|
|
633
|
+
);
|
|
656
634
|
|
|
657
635
|
const nodes_to_insert = [];
|
|
658
636
|
let rejected = false;
|
|
@@ -674,7 +652,11 @@ ${fallback_html}`;
|
|
|
674
652
|
the_node: {
|
|
675
653
|
id: 'the_node',
|
|
676
654
|
type: default_text_node_type,
|
|
677
|
-
[target_text_property_name]: text_content || {
|
|
655
|
+
[target_text_property_name]: text_content || {
|
|
656
|
+
content: '',
|
|
657
|
+
marks: [],
|
|
658
|
+
annotations: []
|
|
659
|
+
}
|
|
678
660
|
}
|
|
679
661
|
});
|
|
680
662
|
nodes_to_insert.push(new_node_id);
|
|
@@ -689,7 +671,7 @@ ${fallback_html}`;
|
|
|
689
671
|
}
|
|
690
672
|
|
|
691
673
|
if (!rejected) {
|
|
692
|
-
tr.insert_nodes(nodes_to_insert);
|
|
674
|
+
tr.insert_nodes(nodes_to_insert, marks, annotations, nodes);
|
|
693
675
|
session.apply(tr);
|
|
694
676
|
return true;
|
|
695
677
|
}
|
|
@@ -766,7 +748,7 @@ ${fallback_html}`;
|
|
|
766
748
|
|
|
767
749
|
if (session.selection?.type === 'text') {
|
|
768
750
|
const property_definition = session.inspect(session.selection.path);
|
|
769
|
-
if (property_definition?.type === '
|
|
751
|
+
if (property_definition?.type === 'text' && !property_definition.allow_newlines) {
|
|
770
752
|
plain_text = normalize_plain_text_for_single_line_property(plain_text);
|
|
771
753
|
}
|
|
772
754
|
|
|
@@ -777,7 +759,10 @@ ${fallback_html}`;
|
|
|
777
759
|
const node_array_property_definition = session.inspect(
|
|
778
760
|
session.selection.path.slice(0, -2)
|
|
779
761
|
);
|
|
780
|
-
const default_text_node_type = get_default_text_node(
|
|
762
|
+
const default_text_node_type = get_default_text_node(
|
|
763
|
+
node_array_property_definition,
|
|
764
|
+
session.schema
|
|
765
|
+
);
|
|
781
766
|
const node_insert_caret = get_node_insert_caret_after_text_selection(session.selection);
|
|
782
767
|
const plain_text_nodes_payload = create_plain_text_nodes_payload(
|
|
783
768
|
plain_text_fragments,
|
|
@@ -796,7 +781,10 @@ ${fallback_html}`;
|
|
|
796
781
|
const target_node_insert_caret = get_target_node_insert_caret(session.selection);
|
|
797
782
|
if (target_node_insert_caret) {
|
|
798
783
|
const node_array_property_definition = session.inspect(target_node_insert_caret.path);
|
|
799
|
-
const default_text_node_type = get_default_text_node(
|
|
784
|
+
const default_text_node_type = get_default_text_node(
|
|
785
|
+
node_array_property_definition,
|
|
786
|
+
session.schema
|
|
787
|
+
);
|
|
800
788
|
pasted_json = create_plain_text_nodes_payload(
|
|
801
789
|
plain_text_fragments,
|
|
802
790
|
default_text_node_type,
|
|
@@ -831,10 +819,15 @@ ${fallback_html}`;
|
|
|
831
819
|
session.apply(session.tr.set(session.selection.path, pasted_json.value));
|
|
832
820
|
}
|
|
833
821
|
}
|
|
834
|
-
} else if (session.selection?.type === 'text' && pasted_json?.
|
|
822
|
+
} else if (session.selection?.type === 'text' && pasted_json?.content) {
|
|
835
823
|
// Paste text at a text selection
|
|
836
824
|
session.apply(
|
|
837
|
-
session.tr.insert_text(
|
|
825
|
+
session.tr.insert_text(
|
|
826
|
+
pasted_json.content,
|
|
827
|
+
pasted_json.marks,
|
|
828
|
+
pasted_json.annotations,
|
|
829
|
+
pasted_json.nodes
|
|
830
|
+
)
|
|
838
831
|
);
|
|
839
832
|
} else if (
|
|
840
833
|
session.selection?.type === 'text' &&
|
|
@@ -848,7 +841,12 @@ ${fallback_html}`;
|
|
|
848
841
|
);
|
|
849
842
|
if (text_property) {
|
|
850
843
|
session.apply(
|
|
851
|
-
session.tr.insert_text(
|
|
844
|
+
session.tr.insert_text(
|
|
845
|
+
text_property.content,
|
|
846
|
+
text_property.marks,
|
|
847
|
+
text_property.annotations,
|
|
848
|
+
pasted_json.nodes
|
|
849
|
+
)
|
|
852
850
|
);
|
|
853
851
|
}
|
|
854
852
|
} else if (['text', 'property'].includes(session.selection?.type) && pasted_json?.nodes) {
|
|
@@ -881,7 +879,7 @@ ${fallback_html}`;
|
|
|
881
879
|
// reuses gap elements with shifted data-gap-offset), so for those
|
|
882
880
|
// we always rerender to scroll the cursor into view.
|
|
883
881
|
const is_empty_text_selection =
|
|
884
|
-
selection.type === 'text' && session.get(selection.path).
|
|
882
|
+
selection.type === 'text' && session.get(selection.path).content.length === 0;
|
|
885
883
|
if (dom_driven && !is_empty_text_selection) {
|
|
886
884
|
const prev_selection = __get_selection_from_dom();
|
|
887
885
|
if (
|
|
@@ -985,7 +983,7 @@ ${fallback_html}`;
|
|
|
985
983
|
const empty_placeholder_path = deserialize_path(focus_empty_placeholder.dataset.path);
|
|
986
984
|
const array_path = empty_placeholder_path.slice(0, -1);
|
|
987
985
|
const node_array = session.get(array_path);
|
|
988
|
-
if (
|
|
986
|
+
if (node_array?.nodes?.length === 0) {
|
|
989
987
|
return {
|
|
990
988
|
type: 'node',
|
|
991
989
|
path: array_path,
|
|
@@ -1210,7 +1208,7 @@ ${fallback_html}`;
|
|
|
1210
1208
|
|
|
1211
1209
|
// EDGE CASE 1B: Caret after trailing <br> at end of text
|
|
1212
1210
|
//
|
|
1213
|
-
//
|
|
1211
|
+
// TextProperty renders a trailing <br> for non-empty or non-focused text.
|
|
1214
1212
|
// When the user places their caret after this <br>, focusNode is the container
|
|
1215
1213
|
// element (not a text node), and normal processing would return position 0.
|
|
1216
1214
|
// We detect this and return the current DOM text length instead.
|
|
@@ -1336,7 +1334,10 @@ ${fallback_html}`;
|
|
|
1336
1334
|
if (!anchor_sel || !focus_sel) return;
|
|
1337
1335
|
|
|
1338
1336
|
if (is_backward) {
|
|
1339
|
-
|
|
1337
|
+
// setBaseAndExtent replaces the current selection, so no
|
|
1338
|
+
// removeAllRanges is needed — every selection-API call forces a
|
|
1339
|
+
// synchronous layout when the DOM is dirty, and this runs right
|
|
1340
|
+
// after the per-change reconcile on every apply.
|
|
1340
1341
|
dom_selection.setBaseAndExtent(anchor_sel, 1, focus_sel, 1);
|
|
1341
1342
|
} else {
|
|
1342
1343
|
range.setStart(anchor_sel, 1);
|
|
@@ -1352,28 +1353,49 @@ ${fallback_html}`;
|
|
|
1352
1353
|
// — so an unconditional scroll would yank the viewport on each of
|
|
1353
1354
|
// them. cursor_offset is a gap offset and the gap has no box of
|
|
1354
1355
|
// its own, so visibility is judged from the nodes flanking it.
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1356
|
+
const node_array = session.get(node_array_path);
|
|
1357
|
+
if (!node_array?.nodes) return;
|
|
1358
|
+
|
|
1359
|
+
// Collapsed: anchor === focus, so focus is the gap offset.
|
|
1360
|
+
// Range: cursor sits at the focus end (anchor when backward).
|
|
1361
|
+
const cursor_offset = is_backward ? selection.anchor_offset : selection.focus_offset;
|
|
1362
|
+
const array_length = node_array.nodes.length;
|
|
1363
|
+
|
|
1364
|
+
// node_before is null at offset 0: cursor_offset - 1 would be -1,
|
|
1365
|
+
// and serialize_path 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);
|
|
1369
|
+
|
|
1370
|
+
let scroll_target = null;
|
|
1371
|
+
let scroll_array_to_start = false;
|
|
1372
|
+
let scroll_array_to_end = false;
|
|
1373
|
+
|
|
1374
|
+
if (cursor_offset === 0) {
|
|
1375
|
+
scroll_array_to_start = true;
|
|
1376
|
+
} else if (cursor_offset >= array_length) {
|
|
1377
|
+
scroll_array_to_end = true;
|
|
1378
|
+
} else {
|
|
1379
|
+
// A range selection's selected node IS node_before; scrolling
|
|
1380
|
+
// node_after would reveal the following node and leave the
|
|
1381
|
+
// selection itself off-screen. For a collapsed caret node_after's
|
|
1382
|
+
// leading edge is the cursor, so that stays the right target.
|
|
1383
|
+
scroll_target = is_collapsed ? node_after : node_before;
|
|
1384
|
+
}
|
|
1360
1385
|
|
|
1386
|
+
setTimeout(() => {
|
|
1361
1387
|
// If either node flanking the cursor is already (even
|
|
1362
1388
|
// 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);
|
|
1389
|
+
// viewport stable and scroll nothing. Do this layout read in the
|
|
1390
|
+
// deferred callback, after Svelte/browser layout has settled.
|
|
1369
1391
|
if (__intersects_viewport(node_before) || __intersects_viewport(node_after)) return;
|
|
1370
1392
|
|
|
1371
|
-
if (
|
|
1393
|
+
if (scroll_array_to_start) {
|
|
1372
1394
|
node_array_el.scrollLeft = 0;
|
|
1373
1395
|
node_array_el.scrollTop = 0;
|
|
1374
1396
|
return;
|
|
1375
1397
|
}
|
|
1376
|
-
if (
|
|
1398
|
+
if (scroll_array_to_end) {
|
|
1377
1399
|
const max_left = Math.max(0, node_array_el.scrollWidth - node_array_el.clientWidth);
|
|
1378
1400
|
const max_top = Math.max(0, node_array_el.scrollHeight - node_array_el.clientHeight);
|
|
1379
1401
|
node_array_el.scrollLeft = max_left;
|
|
@@ -1383,12 +1405,7 @@ ${fallback_html}`;
|
|
|
1383
1405
|
}
|
|
1384
1406
|
return;
|
|
1385
1407
|
}
|
|
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' });
|
|
1408
|
+
scroll_target?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1392
1409
|
}, 0);
|
|
1393
1410
|
}
|
|
1394
1411
|
|
|
@@ -1421,7 +1438,7 @@ ${fallback_html}`;
|
|
|
1421
1438
|
const el = canvas_el.querySelector(
|
|
1422
1439
|
`[data-path="${serialize_path(selection.path)}"][data-type="text"]`
|
|
1423
1440
|
);
|
|
1424
|
-
const empty_text = session.get(selection.path).
|
|
1441
|
+
const empty_text = session.get(selection.path).content.length === 0;
|
|
1425
1442
|
const dom_selection = window.getSelection();
|
|
1426
1443
|
let current_offset = 0;
|
|
1427
1444
|
/** @type {HTMLElement | Text} */
|
|
@@ -1498,8 +1515,10 @@ ${fallback_html}`;
|
|
|
1498
1515
|
|
|
1499
1516
|
// Set the range if both start and end were found
|
|
1500
1517
|
if (anchor_node && focus_node) {
|
|
1501
|
-
|
|
1502
|
-
//
|
|
1518
|
+
// NOTE: Only using setBaseAndExtent() will preserve selection direction.
|
|
1519
|
+
// It also replaces the current selection, so no removeAllRanges is
|
|
1520
|
+
// needed — every selection-API call forces a synchronous layout when
|
|
1521
|
+
// the DOM is dirty, and this runs on every apply.
|
|
1503
1522
|
dom_selection.setBaseAndExtent(
|
|
1504
1523
|
anchor_node,
|
|
1505
1524
|
anchor_node_offset,
|
|
@@ -1507,11 +1526,13 @@ ${fallback_html}`;
|
|
|
1507
1526
|
focus_node_offset
|
|
1508
1527
|
);
|
|
1509
1528
|
|
|
1510
|
-
// Scroll the selection into view
|
|
1529
|
+
// Scroll the rendered selection into view. Capture the target now:
|
|
1530
|
+
// the browser selection is live and may be cleared before this
|
|
1531
|
+
// deferred callback runs, e.g. when app UI focuses an external input.
|
|
1532
|
+
const selected_element = focus_node.parentElement;
|
|
1511
1533
|
setTimeout(() => {
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
selectedElement.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1534
|
+
if (selected_element?.isConnected) {
|
|
1535
|
+
selected_element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
1515
1536
|
}
|
|
1516
1537
|
}, 0);
|
|
1517
1538
|
}
|
|
@@ -1528,7 +1549,7 @@ ${fallback_html}`;
|
|
|
1528
1549
|
);
|
|
1529
1550
|
if (!text_el) return;
|
|
1530
1551
|
|
|
1531
|
-
const model_text = session.get(selection.path).
|
|
1552
|
+
const model_text = session.get(selection.path).content;
|
|
1532
1553
|
if ((text_el.textContent ?? '') === model_text) return;
|
|
1533
1554
|
|
|
1534
1555
|
let current_offset = 0;
|
|
@@ -1625,7 +1646,7 @@ ${fallback_html}`;
|
|
|
1625
1646
|
-->
|
|
1626
1647
|
<div
|
|
1627
1648
|
class="svedit-canvas {css_class}"
|
|
1628
|
-
class:hide-selection={session.selection?.type === 'node'}
|
|
1649
|
+
class:hide-selection={editable && session.selection?.type === 'node'}
|
|
1629
1650
|
class:node-caret={session.selection?.type === 'node' &&
|
|
1630
1651
|
session.selection.anchor_offset === session.selection.focus_offset}
|
|
1631
1652
|
class:property-selection={session.selection?.type === 'property'}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"Svedit.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Svedit.svelte.js"],"names":[],"mappings":";;;;;;;AA+nDA;;eAAqD;iCAnlD1C,cAAc"}
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
get_char_length,
|
|
6
6
|
paths_equal,
|
|
7
7
|
serialize_path,
|
|
8
|
-
get_selection_range
|
|
8
|
+
get_selection_range,
|
|
9
|
+
calculate_fragment_ranges
|
|
9
10
|
} from './utils.js';
|
|
10
11
|
|
|
11
|
-
/** @import {
|
|
12
|
+
/** @import { TextPropertyProps, Mark, Fragment, SelectionRange } from './types.d.ts'; */
|
|
12
13
|
|
|
13
14
|
const svedit = getContext('svedit');
|
|
14
15
|
|
|
15
|
-
/** @type {
|
|
16
|
+
/** @type {TextPropertyProps} */
|
|
16
17
|
let { path, class: css_class, placeholder = '', tag = 'div', style = '', ...rest } = $props();
|
|
17
18
|
|
|
18
19
|
let path_str = $derived(serialize_path(path));
|
|
@@ -23,16 +24,17 @@
|
|
|
23
24
|
);
|
|
24
25
|
});
|
|
25
26
|
|
|
26
|
-
let plain_text = $derived(svedit.session.get(path).
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
)
|
|
27
|
+
let plain_text = $derived(svedit.session.get(path).content);
|
|
28
|
+
// A string has zero grapheme clusters iff it has zero code units, so a
|
|
29
|
+
// plain length check avoids a full Intl.Segmenter pass (this derived
|
|
30
|
+
// re-runs for every text property on every document change).
|
|
31
|
+
let is_empty = $derived(plain_text.length === 0 && !(svedit.is_composing && is_focused));
|
|
30
32
|
|
|
31
33
|
let is_collapsed = $derived(
|
|
32
34
|
is_focused && svedit.session.selection?.anchor_offset == svedit.session.selection?.focus_offset
|
|
33
35
|
);
|
|
34
36
|
|
|
35
|
-
// Get selection highlight range if not
|
|
37
|
+
// Get selection highlight range if it does not touch marks.
|
|
36
38
|
// Only render selection highlight when canvas is NOT focused.
|
|
37
39
|
// This avoids DOM mutations (splitting text nodes for highlight spans)
|
|
38
40
|
// while the user is actively selecting, which would cause selection
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
if (svedit.canvas_focused) return null;
|
|
42
44
|
if (is_collapsed) return null;
|
|
43
45
|
if (!is_focused) return null;
|
|
44
|
-
if (svedit.session.
|
|
46
|
+
if (svedit.session.selected_marks.length > 0) return null;
|
|
45
47
|
const sel = svedit.session.selection;
|
|
46
48
|
if (!sel || sel.type !== 'text') return null;
|
|
47
49
|
return get_selection_range(sel);
|
|
@@ -49,61 +51,58 @@
|
|
|
49
51
|
|
|
50
52
|
let fragments = $derived(
|
|
51
53
|
get_fragments(
|
|
52
|
-
svedit.session.get(path).
|
|
53
|
-
svedit.session.get(path).
|
|
54
|
+
svedit.session.get(path).content,
|
|
55
|
+
svedit.session.get(path).marks,
|
|
54
56
|
selection_highlight_range
|
|
55
57
|
)
|
|
56
58
|
);
|
|
57
59
|
|
|
58
60
|
/**
|
|
59
|
-
* Converts text with
|
|
61
|
+
* Converts text with marks into renderable fragments for display.
|
|
62
|
+
* Annotations never fragment the text; they are data-only.
|
|
60
63
|
* @param {string} text - The plain text content
|
|
61
|
-
* @param {Array<
|
|
64
|
+
* @param {Array<Mark>} marks - Marks to render in-place
|
|
62
65
|
* @param {SelectionRange} [selection_highlight_range] - Optional selection highlight range
|
|
63
66
|
* @returns {Array<Fragment>} Array of fragments
|
|
64
67
|
*/
|
|
65
|
-
function get_fragments(text,
|
|
68
|
+
function get_fragments(text, marks, selection_highlight_range) {
|
|
69
|
+
// Fast path: no marks and no selection highlight means the whole text
|
|
70
|
+
// is a single content fragment. This runs for EVERY text property on
|
|
71
|
+
// EVERY document change, so skipping the two full Intl.Segmenter passes
|
|
72
|
+
// (get_char_length + char_slice over the whole text) matters at scale.
|
|
73
|
+
if (marks.length === 0 && !selection_highlight_range) {
|
|
74
|
+
return text.length > 0 ? [text] : [];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ranges = calculate_fragment_ranges(
|
|
78
|
+
get_char_length(text),
|
|
79
|
+
marks,
|
|
80
|
+
selection_highlight_range
|
|
81
|
+
);
|
|
66
82
|
/** @type {Array<Fragment>} */
|
|
67
83
|
let fragments = [];
|
|
68
|
-
let last_index = 0;
|
|
69
|
-
|
|
70
|
-
// Merge annotations with selection highlight and sort by start offset
|
|
71
|
-
const ranges = [
|
|
72
|
-
...annotations,
|
|
73
|
-
...(selection_highlight_range ? [selection_highlight_range] : [])
|
|
74
|
-
].sort((a, b) => a.start_offset - b.start_offset);
|
|
75
84
|
|
|
76
85
|
for (const range of ranges) {
|
|
77
|
-
// Add text before this range
|
|
78
|
-
if (range.start_offset > last_index) {
|
|
79
|
-
fragments.push(char_slice(text, last_index, range.start_offset));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
86
|
const content = char_slice(text, range.start_offset, range.end_offset);
|
|
83
87
|
|
|
84
|
-
if ('
|
|
88
|
+
if (range.type === 'mark') {
|
|
85
89
|
const node = svedit.session.get(range.node_id);
|
|
86
|
-
if (!node) throw new Error(`Node not found for
|
|
90
|
+
if (!node) throw new Error(`Node not found for mark ${range.node_id}`);
|
|
87
91
|
|
|
88
92
|
fragments.push({
|
|
89
|
-
type: '
|
|
93
|
+
type: 'mark',
|
|
90
94
|
node,
|
|
91
95
|
content,
|
|
92
|
-
|
|
96
|
+
mark_index: range.mark_index
|
|
93
97
|
});
|
|
94
|
-
} else {
|
|
98
|
+
} else if (range.type === 'selection_highlight') {
|
|
95
99
|
fragments.push({
|
|
96
100
|
type: 'selection_highlight',
|
|
97
101
|
content
|
|
98
102
|
});
|
|
103
|
+
} else {
|
|
104
|
+
fragments.push(content);
|
|
99
105
|
}
|
|
100
|
-
|
|
101
|
-
last_index = range.end_offset;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Add any remaining text
|
|
105
|
-
if (last_index < get_char_length(text)) {
|
|
106
|
-
fragments.push(char_slice(text, last_index));
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
return fragments;
|
|
@@ -130,12 +129,14 @@
|
|
|
130
129
|
{#if typeof fragment === 'string'}{fragment}{:else if fragment.type === 'selection_highlight'}<span
|
|
131
130
|
class="selection-highlight"
|
|
132
131
|
style="anchor-name: --selection-highlight;">{fragment.content}</span
|
|
133
|
-
>{:else if fragment.type === '
|
|
134
|
-
{@const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
132
|
+
>{:else if fragment.type === 'mark'}
|
|
133
|
+
{@const MarkComponent = svedit.session.config.node_components[fragment.node.type]}
|
|
134
|
+
{#if MarkComponent}
|
|
135
|
+
<MarkComponent
|
|
136
|
+
path={[...path, 'marks', fragment.mark_index, 'node_id']}
|
|
137
|
+
content={fragment.content}
|
|
138
|
+
/>
|
|
139
|
+
{:else}<span class="mark-{fragment.node.type}">{fragment.content}</span>{/if}
|
|
139
140
|
{/if}
|
|
140
141
|
{/each}<!--
|
|
141
142
|
-->{#if !is_focused || !is_empty}<br />{/if}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export default TextProperty;
|
|
2
|
+
type TextProperty = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<TextPropertyProps>): void;
|
|
5
|
+
};
|
|
6
|
+
declare const TextProperty: import("svelte").Component<import("./types.d.ts").TextPropertyProps, {}, "">;
|
|
7
|
+
//# sourceMappingURL=TextProperty.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TextProperty.svelte.d.ts","sourceRoot":"","sources":["../src/lib/TextProperty.svelte.js"],"names":[],"mappings":";;;;;AAyIA,yGAA2D"}
|