svedit 0.14.0 → 0.15.1
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 +155 -0
- package/dist/NodeSelectionMarkers.svelte +12 -1
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +17 -9
- package/dist/Svedit.svelte +99 -27
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.d.ts +6 -2
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +15 -7
- package/dist/html_export.d.ts +11 -0
- package/dist/html_export.d.ts.map +1 -0
- package/dist/html_export.js +25 -0
- package/dist/html_paste.d.ts +59 -0
- package/dist/html_paste.d.ts.map +1 -0
- package/dist/html_paste.js +229 -0
- package/dist/html_utils.d.ts +4 -0
- package/dist/html_utils.d.ts.map +1 -0
- package/dist/html_utils.js +16 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/transforms.svelte.d.ts.map +1 -1
- package/dist/transforms.svelte.js +12 -0
- package/dist/types.d.ts +6 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +27 -26
- package/src/lib/NodeSelectionMarkers.svelte +12 -1
- package/src/lib/Session.svelte.ts +16 -11
- package/src/lib/Svedit.svelte +99 -27
- package/src/lib/Transaction.svelte.ts +20 -7
- package/src/lib/html_export.ts +37 -0
- package/src/lib/html_paste.ts +279 -0
- package/src/lib/html_utils.ts +17 -0
- package/src/lib/index.ts +14 -0
- package/src/lib/transforms.svelte.ts +14 -0
- package/src/lib/types.ts +6 -1
package/src/lib/Svedit.svelte
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
get_default_text_node,
|
|
20
20
|
create_plain_text_nodes_payload
|
|
21
21
|
} from './paste_utils.js';
|
|
22
|
+
import { export_text_html } from './html_export.js';
|
|
23
|
+
import { escape_html } from './html_utils.js';
|
|
24
|
+
import { parse_html_paste, map_html_text, map_html_blocks } from './html_paste.js';
|
|
22
25
|
import { create_node_visibility } from './node_visibility.svelte.js';
|
|
23
26
|
import DefaultNodeSelectionMarkers from './NodeSelectionMarkers.svelte';
|
|
24
27
|
import './styles/svedit-colors.css';
|
|
@@ -31,6 +34,7 @@
|
|
|
31
34
|
NodeSelection,
|
|
32
35
|
TextSelection,
|
|
33
36
|
PropertySelection,
|
|
37
|
+
PropertyDefinition,
|
|
34
38
|
DocumentNode,
|
|
35
39
|
DocumentPath,
|
|
36
40
|
DynamicRecord,
|
|
@@ -394,12 +398,12 @@ ${fallback_html}`;
|
|
|
394
398
|
if (prop_name === 'id' || prop_name === 'type') continue;
|
|
395
399
|
const property_definition = node_schema.properties[prop_name];
|
|
396
400
|
// Check if this is a text property.
|
|
397
|
-
if (property_definition
|
|
401
|
+
if (property_definition?.type === 'text') {
|
|
398
402
|
const text_content = prop_value.content;
|
|
399
403
|
if (text_content.trim()) {
|
|
400
|
-
html += `<p>${
|
|
404
|
+
html += `<p>${export_text_html(prop_value, session)}</p>`;
|
|
401
405
|
}
|
|
402
|
-
} else if (property_definition
|
|
406
|
+
} else if (property_definition?.type === 'node_array') {
|
|
403
407
|
for (const child_id of prop_value.nodes) {
|
|
404
408
|
const child = session.get(child_id);
|
|
405
409
|
const child_exporter = html_exporters[child.type] || default_node_html_exporter;
|
|
@@ -480,7 +484,7 @@ ${fallback_html}`;
|
|
|
480
484
|
if (session.selection?.type === 'text') {
|
|
481
485
|
plain_text = session.get_selected_plain_text();
|
|
482
486
|
text = session.get_selected_text();
|
|
483
|
-
const fallback_html = `<span>${text.
|
|
487
|
+
const fallback_html = `<span>${export_text_html(text, session, text.nodes)}</span>`;
|
|
484
488
|
|
|
485
489
|
// console.log('Text copy:', {
|
|
486
490
|
// text,
|
|
@@ -517,8 +521,12 @@ ${fallback_html}`;
|
|
|
517
521
|
type: property_definition.type,
|
|
518
522
|
value
|
|
519
523
|
};
|
|
520
|
-
|
|
521
|
-
|
|
524
|
+
const fallback_html =
|
|
525
|
+
property_definition.type === 'text'
|
|
526
|
+
? export_text_html(value, session)
|
|
527
|
+
: escape_html(String(value));
|
|
528
|
+
html = create_svedit_html_format(json_data, `<span>${fallback_html}</span>`);
|
|
529
|
+
plain_text = property_definition.type === 'text' ? value.content : String(value);
|
|
522
530
|
}
|
|
523
531
|
|
|
524
532
|
// Write to clipboard using event.clipboardData
|
|
@@ -611,21 +619,22 @@ ${fallback_html}`;
|
|
|
611
619
|
const { nodes, main_nodes, marks = [], annotations = [] } = pasted_json || {};
|
|
612
620
|
if (!nodes || !main_nodes?.length) return false;
|
|
613
621
|
|
|
614
|
-
|
|
615
|
-
if (
|
|
616
|
-
|
|
617
|
-
}
|
|
618
|
-
if (tr.selection?.type !== 'node') return false;
|
|
619
|
-
|
|
620
|
-
const property_definition = session.inspect(tr.selection.path);
|
|
622
|
+
const target_selection = selection || session.selection;
|
|
623
|
+
if (target_selection?.type !== 'node') return false;
|
|
624
|
+
const property_definition = session.inspect(target_selection.path);
|
|
621
625
|
if (property_definition?.type !== 'node_array') return false;
|
|
622
626
|
|
|
627
|
+
const tr = session.tr;
|
|
628
|
+
if (selection) tr.set_selection(selection);
|
|
629
|
+
|
|
623
630
|
const default_text_node_type = get_default_text_node(property_definition, session.schema);
|
|
624
631
|
const target_text_property_name = get_text_property_name(
|
|
625
632
|
default_text_node_type,
|
|
626
633
|
session.schema
|
|
627
634
|
);
|
|
628
635
|
|
|
636
|
+
// Decide for the whole clipboard graph before creating any nodes or replacing the selection.
|
|
637
|
+
const preserve_ids = Object.values(nodes).every((node: DocumentNode) => !tr.doc.nodes[node.id]);
|
|
629
638
|
const nodes_to_insert = [];
|
|
630
639
|
let rejected = false;
|
|
631
640
|
for (const node_id of main_nodes) {
|
|
@@ -642,30 +651,36 @@ ${fallback_html}`;
|
|
|
642
651
|
default_text_node_type &&
|
|
643
652
|
target_text_property_name
|
|
644
653
|
) {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
654
|
+
// Keep the clipboard graph so converted text retains its marks and annotations.
|
|
655
|
+
const new_node_id = tr.build(
|
|
656
|
+
node_id,
|
|
657
|
+
{
|
|
658
|
+
...nodes,
|
|
659
|
+
[node_id]: {
|
|
660
|
+
id: node_id,
|
|
661
|
+
type: default_text_node_type,
|
|
662
|
+
[target_text_property_name]: text_content || {
|
|
663
|
+
content: '',
|
|
664
|
+
marks: [],
|
|
665
|
+
annotations: []
|
|
666
|
+
}
|
|
653
667
|
}
|
|
654
|
-
}
|
|
655
|
-
|
|
668
|
+
},
|
|
669
|
+
{ preserve_ids }
|
|
670
|
+
);
|
|
656
671
|
nodes_to_insert.push(new_node_id);
|
|
657
672
|
} else {
|
|
658
673
|
rejected = true;
|
|
659
674
|
break;
|
|
660
675
|
}
|
|
661
676
|
} else {
|
|
662
|
-
const new_node_id = tr.build(node_id, nodes);
|
|
677
|
+
const new_node_id = tr.build(node_id, nodes, { preserve_ids });
|
|
663
678
|
nodes_to_insert.push(new_node_id);
|
|
664
679
|
}
|
|
665
680
|
}
|
|
666
681
|
|
|
667
682
|
if (!rejected) {
|
|
668
|
-
tr.insert_nodes(nodes_to_insert, marks, annotations, nodes);
|
|
683
|
+
tr.insert_nodes(nodes_to_insert, marks, annotations, nodes, { preserve_ids });
|
|
669
684
|
session.apply(tr);
|
|
670
685
|
return true;
|
|
671
686
|
}
|
|
@@ -679,13 +694,62 @@ ${fallback_html}`;
|
|
|
679
694
|
return false;
|
|
680
695
|
}
|
|
681
696
|
|
|
697
|
+
function try_html_paste(html: string): boolean {
|
|
698
|
+
const config = session.config.html_paste;
|
|
699
|
+
const selection = session.selection;
|
|
700
|
+
if (!config || !selection) return false;
|
|
701
|
+
const blocks = parse_html_paste(html);
|
|
702
|
+
if (!blocks.length) return false;
|
|
703
|
+
if (selection.type === 'text') {
|
|
704
|
+
const owner = session.get(selection.path.slice(0, -1));
|
|
705
|
+
const insert_as_nodes =
|
|
706
|
+
blocks.length > 1 ||
|
|
707
|
+
blocks.some(
|
|
708
|
+
(block) =>
|
|
709
|
+
'items' in block && (config.lists?.[block.tag] || block.items.flat().length > 1)
|
|
710
|
+
);
|
|
711
|
+
if (insert_as_nodes && owner && session.kind(owner) === 'text') {
|
|
712
|
+
const caret = get_node_insert_caret_after_text_selection(selection);
|
|
713
|
+
if (caret) {
|
|
714
|
+
const payload = map_html_blocks(
|
|
715
|
+
blocks,
|
|
716
|
+
config,
|
|
717
|
+
session.schema,
|
|
718
|
+
session.inspect(caret.path) as unknown as PropertyDefinition
|
|
719
|
+
);
|
|
720
|
+
if (payload && try_node_paste(payload, caret)) return true;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const nodes: Record<string, DocumentNode> = {};
|
|
724
|
+
const text = map_html_text(
|
|
725
|
+
blocks,
|
|
726
|
+
config,
|
|
727
|
+
session.schema,
|
|
728
|
+
session.inspect(selection.path) as unknown as PropertyDefinition,
|
|
729
|
+
nodes
|
|
730
|
+
);
|
|
731
|
+
session.apply(session.tr.insert_text(text.content, text.marks, [], nodes));
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
const caret = get_target_node_insert_caret(selection);
|
|
735
|
+
if (!caret) return false;
|
|
736
|
+
const payload = map_html_blocks(
|
|
737
|
+
blocks,
|
|
738
|
+
config,
|
|
739
|
+
session.schema,
|
|
740
|
+
session.inspect(caret.path) as unknown as PropertyDefinition
|
|
741
|
+
);
|
|
742
|
+
return !!payload && try_node_paste(payload, caret);
|
|
743
|
+
}
|
|
744
|
+
|
|
682
745
|
async function onpaste(event: ClipboardEvent) {
|
|
683
746
|
// Only handle paste events if editable and focus is within the canvas
|
|
684
747
|
if (!editable) return;
|
|
685
748
|
if (!canvas_el?.contains(document.activeElement)) return;
|
|
686
749
|
event.preventDefault();
|
|
687
750
|
|
|
688
|
-
let
|
|
751
|
+
let html_content,
|
|
752
|
+
plain_text,
|
|
689
753
|
pasted_json,
|
|
690
754
|
pasted_media = [];
|
|
691
755
|
|
|
@@ -718,7 +782,7 @@ ${fallback_html}`;
|
|
|
718
782
|
} else {
|
|
719
783
|
// First try to extract svedit data from HTML format
|
|
720
784
|
try {
|
|
721
|
-
|
|
785
|
+
html_content = event.clipboardData?.getData('text/html');
|
|
722
786
|
if (html_content) {
|
|
723
787
|
pasted_json = extract_svedit_data_from_html(html_content);
|
|
724
788
|
}
|
|
@@ -733,6 +797,14 @@ ${fallback_html}`;
|
|
|
733
797
|
console.error('Failed to paste any content:', e);
|
|
734
798
|
}
|
|
735
799
|
|
|
800
|
+
if (!pasted_json && html_content && session.config.html_paste) {
|
|
801
|
+
try {
|
|
802
|
+
if (try_html_paste(html_content)) return;
|
|
803
|
+
} catch (error) {
|
|
804
|
+
console.warn('Failed to import pasted HTML; falling back to plain text:', error);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
736
808
|
// Try to construct a node payload from plain text when applicable
|
|
737
809
|
if (!pasted_json && typeof plain_text === 'string') {
|
|
738
810
|
plain_text = normalize_line_endings(plain_text);
|
|
@@ -313,15 +313,22 @@ export default class Transaction {
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
// Takes a subgraph and constructs new nodes from it
|
|
316
|
-
//
|
|
316
|
+
// IDs are regenerated by default; paste can preserve a collision-free subgraph.
|
|
317
317
|
// NOTE: Omitted properties will be populated with default values.
|
|
318
|
-
build(
|
|
318
|
+
build(
|
|
319
|
+
node_id: NodeId,
|
|
320
|
+
nodes: Record<string, DocumentNode>,
|
|
321
|
+
{ preserve_ids = false }: { preserve_ids?: boolean } = {}
|
|
322
|
+
): NodeId {
|
|
319
323
|
const depth_first_nodes = traverse(node_id, this.schema, nodes);
|
|
324
|
+
const keep_ids = preserve_ids && depth_first_nodes.every((node) => !this.doc.nodes[node.id]);
|
|
320
325
|
// This maps original ids to newly generated ids
|
|
321
326
|
const id_map: Record<string, string> = {};
|
|
327
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Transaction-local ownership lookup.
|
|
328
|
+
const used_attachment_ids = new Set<NodeId>();
|
|
322
329
|
|
|
323
330
|
for (const node of depth_first_nodes) {
|
|
324
|
-
const new_id = this.generate_id();
|
|
331
|
+
const new_id = keep_ids ? node.id : this.generate_id();
|
|
325
332
|
id_map[node.id] = new_id;
|
|
326
333
|
let new_node: DocumentNode = { ...node, id: new_id };
|
|
327
334
|
const node_schema = this.schema[node.type];
|
|
@@ -333,7 +340,12 @@ export default class Transaction {
|
|
|
333
340
|
|
|
334
341
|
const remap_ranges = (ranges: Attachment[] | undefined) =>
|
|
335
342
|
(ranges ?? []).map(({ start_offset, end_offset, node_id }) => {
|
|
336
|
-
|
|
343
|
+
// Clipboard subtrees may contain legacy shared attachments; detach each occurrence.
|
|
344
|
+
const next_id = used_attachment_ids.has(node_id)
|
|
345
|
+
? this.build(node_id, nodes)
|
|
346
|
+
: id_map[node_id] || node_id;
|
|
347
|
+
used_attachment_ids.add(node_id);
|
|
348
|
+
return { start_offset, end_offset, node_id: next_id };
|
|
337
349
|
});
|
|
338
350
|
|
|
339
351
|
if (prop_type === 'node_array' && value && typeof value === 'object') {
|
|
@@ -762,7 +774,8 @@ export default class Transaction {
|
|
|
762
774
|
node_ids: NodeId[],
|
|
763
775
|
marks: Array<Mark> = [],
|
|
764
776
|
annotations: Array<Annotation> = [],
|
|
765
|
-
nodes: Record<NodeId, DocumentNode> = {}
|
|
777
|
+
nodes: Record<NodeId, DocumentNode> = {},
|
|
778
|
+
{ preserve_ids = false }: { preserve_ids?: boolean } = {}
|
|
766
779
|
): this {
|
|
767
780
|
if (this.selection.type !== 'node') return this;
|
|
768
781
|
|
|
@@ -803,7 +816,7 @@ export default class Transaction {
|
|
|
803
816
|
const mark_node = nodes[mark.node_id];
|
|
804
817
|
if (!property_definition.mark_types?.includes(mark_node?.type)) return null;
|
|
805
818
|
|
|
806
|
-
const new_mark_node_id = this.build(mark.node_id, nodes);
|
|
819
|
+
const new_mark_node_id = this.build(mark.node_id, nodes, { preserve_ids });
|
|
807
820
|
return {
|
|
808
821
|
start_offset: start + mark.start_offset,
|
|
809
822
|
end_offset: start + mark.end_offset,
|
|
@@ -828,7 +841,7 @@ export default class Transaction {
|
|
|
828
841
|
const annotation_node = nodes[annotation.node_id];
|
|
829
842
|
if (!property_definition.annotation_types?.includes(annotation_node?.type)) return null;
|
|
830
843
|
|
|
831
|
-
const new_annotation_node_id = this.build(annotation.node_id, nodes);
|
|
844
|
+
const new_annotation_node_id = this.build(annotation.node_id, nodes, { preserve_ids });
|
|
832
845
|
return {
|
|
833
846
|
start_offset: start + annotation.start_offset,
|
|
834
847
|
end_offset: start + annotation.end_offset,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DocumentNode, SessionConfig, Text } from './types.js';
|
|
2
|
+
import { escape_html } from './html_utils.js';
|
|
3
|
+
|
|
4
|
+
/** Receives already escaped inline HTML. Exporters must escape attribute values. */
|
|
5
|
+
export type MarkHtmlExporter = (node: DocumentNode, content: string) => string;
|
|
6
|
+
|
|
7
|
+
/** Export exclusive marks as inline HTML; data-only annotations remain in the native payload. */
|
|
8
|
+
export function export_text_html(
|
|
9
|
+
text: Text,
|
|
10
|
+
session: { doc: { nodes: Record<string, DocumentNode> }; config: SessionConfig },
|
|
11
|
+
nodes = session.doc.nodes
|
|
12
|
+
): string {
|
|
13
|
+
const characters = [
|
|
14
|
+
...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(text.content)
|
|
15
|
+
].map((segment) => segment.segment);
|
|
16
|
+
const slice = (start: number, end: number) =>
|
|
17
|
+
escape_html(characters.slice(start, end).join('')).replace(/\r\n|\r|\n/g, '<br>');
|
|
18
|
+
let html = '';
|
|
19
|
+
let offset = 0;
|
|
20
|
+
for (const mark of [...text.marks].sort((a, b) => a.start_offset - b.start_offset)) {
|
|
21
|
+
if (
|
|
22
|
+
!Number.isInteger(mark.start_offset) ||
|
|
23
|
+
!Number.isInteger(mark.end_offset) ||
|
|
24
|
+
mark.start_offset < offset ||
|
|
25
|
+
mark.end_offset > characters.length ||
|
|
26
|
+
mark.end_offset <= mark.start_offset
|
|
27
|
+
)
|
|
28
|
+
continue;
|
|
29
|
+
html += slice(offset, mark.start_offset);
|
|
30
|
+
const content = slice(mark.start_offset, mark.end_offset);
|
|
31
|
+
const node = nodes[mark.node_id];
|
|
32
|
+
const exporter = node && session.config.mark_html_exporters?.[node.type];
|
|
33
|
+
html += exporter ? exporter(node, content) : content;
|
|
34
|
+
offset = mark.end_offset;
|
|
35
|
+
}
|
|
36
|
+
return html + slice(offset, characters.length);
|
|
37
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { safe_html_href } from './html_utils.js';
|
|
2
|
+
import type { DocumentNode, DocumentSchema, PropertyDefinition, Text } from './types.js';
|
|
3
|
+
import { get_default_text_node, get_text_property_name } from './paste_utils.js';
|
|
4
|
+
|
|
5
|
+
export type HtmlBlockTag = 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
|
6
|
+
export type HtmlBlockMapping = {
|
|
7
|
+
type: string;
|
|
8
|
+
text_property: string;
|
|
9
|
+
properties?: Record<string, unknown>;
|
|
10
|
+
};
|
|
11
|
+
export type HtmlListTag = 'ul' | 'ol';
|
|
12
|
+
export type HtmlContainerMapping = {
|
|
13
|
+
type: string;
|
|
14
|
+
children_property: string;
|
|
15
|
+
properties?: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
export type HtmlListMapping = HtmlContainerMapping & { item: HtmlBlockMapping };
|
|
18
|
+
export type HtmlMarkMapping = { type: string; properties?: Record<string, unknown> };
|
|
19
|
+
export type HtmlPasteConfig = {
|
|
20
|
+
blocks?: Partial<
|
|
21
|
+
Record<
|
|
22
|
+
HtmlBlockTag,
|
|
23
|
+
HtmlBlockMapping | ((context: { tag: HtmlBlockTag }) => HtmlBlockMapping | null)
|
|
24
|
+
>
|
|
25
|
+
>;
|
|
26
|
+
lists?: Partial<Record<HtmlListTag, HtmlListMapping>>;
|
|
27
|
+
wrapper?: HtmlContainerMapping;
|
|
28
|
+
marks?: {
|
|
29
|
+
bold?: HtmlMarkMapping;
|
|
30
|
+
link?: HtmlMarkMapping | ((context: { href: string }) => HtmlMarkMapping | null);
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
type InlineStyle = { bold?: boolean; href?: string };
|
|
34
|
+
type Run = InlineStyle & { text: string };
|
|
35
|
+
type HtmlTextBlock = { tag: HtmlBlockTag; runs: Run[] };
|
|
36
|
+
type HtmlListBlock = { tag: HtmlListTag; items: HtmlTextBlock[][] };
|
|
37
|
+
export type HtmlPasteBlock = HtmlTextBlock | HtmlListBlock;
|
|
38
|
+
|
|
39
|
+
/** Parse a detached document; never insert clipboard HTML into the editor DOM. */
|
|
40
|
+
export function parse_html_paste(html: string): HtmlPasteBlock[] {
|
|
41
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
42
|
+
const blocks: HtmlPasteBlock[] = [];
|
|
43
|
+
let current: HtmlTextBlock = { tag: 'p', runs: [] };
|
|
44
|
+
let current_list: HtmlListBlock | null = null;
|
|
45
|
+
let current_item: HtmlTextBlock[] | null = null;
|
|
46
|
+
const flush = () => {
|
|
47
|
+
// HTML whitespace collapses across inline elements, but explicit breaks survive.
|
|
48
|
+
while (current.runs.length && !current.runs[0].text.replace(/^ +/, '')) current.runs.shift();
|
|
49
|
+
if (current.runs.length) current.runs[0].text = current.runs[0].text.replace(/^ +/, '');
|
|
50
|
+
while (current.runs.length && !current.runs.at(-1)!.text.replace(/ +$/, '')) current.runs.pop();
|
|
51
|
+
if (current.runs.length)
|
|
52
|
+
current.runs.at(-1)!.text = current.runs.at(-1)!.text.replace(/ +$/, '');
|
|
53
|
+
// Drop empty paragraphs such as <p><br></p> or Word's <p> </p>.
|
|
54
|
+
if (current.runs.some((run) => run.text.trim())) (current_item || blocks).push(current);
|
|
55
|
+
current = { tag: 'p', runs: [] };
|
|
56
|
+
};
|
|
57
|
+
const walk = (node: Node, style: InlineStyle) => {
|
|
58
|
+
if (node.nodeType === 3) {
|
|
59
|
+
let text = (node.textContent || '').replace(/[\t\n\r\f ]+/g, ' ');
|
|
60
|
+
if (!current.runs.length || /[ \n]$/.test(current.runs.at(-1)!.text))
|
|
61
|
+
text = text.replace(/^ /, '');
|
|
62
|
+
if (text) current.runs.push({ ...style, text });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (node.nodeType !== 1) return;
|
|
66
|
+
const element = node as Element;
|
|
67
|
+
const tag = element.localName;
|
|
68
|
+
if (
|
|
69
|
+
['script', 'style', 'template', 'noscript', 'iframe', 'object', 'svg', 'math'].includes(tag)
|
|
70
|
+
)
|
|
71
|
+
return;
|
|
72
|
+
if (tag === 'br') {
|
|
73
|
+
current.runs.push({ ...style, text: '\n' });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const next_style = { ...style };
|
|
77
|
+
if (tag === 'b' || tag === 'strong') next_style.bold = true;
|
|
78
|
+
// Inline font-weight wins over the tag: Google Docs wraps the whole clipboard in
|
|
79
|
+
// <b style="font-weight:normal">, and Docs and Word express bold as styled spans.
|
|
80
|
+
const font_weight = (element as HTMLElement).style?.fontWeight;
|
|
81
|
+
if (font_weight) {
|
|
82
|
+
next_style.bold = ['bold', 'bolder'].includes(font_weight) || Number(font_weight) >= 600;
|
|
83
|
+
}
|
|
84
|
+
if (tag === 'a') next_style.href = safe_html_href(element.getAttribute('href') || '');
|
|
85
|
+
if ((tag === 'ul' || tag === 'ol') && !current_list) {
|
|
86
|
+
flush();
|
|
87
|
+
const list: HtmlListBlock = { tag, items: [] };
|
|
88
|
+
current_list = list;
|
|
89
|
+
for (const child of element.childNodes) walk(child, next_style);
|
|
90
|
+
flush();
|
|
91
|
+
current_list = null;
|
|
92
|
+
list.items = list.items.filter((item) => item.length);
|
|
93
|
+
if (list.items.length) blocks.push(list);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Nested items join the outer list while retaining their own text blocks.
|
|
97
|
+
if (tag === 'li' && current_list) {
|
|
98
|
+
flush();
|
|
99
|
+
const parent_item = current_item;
|
|
100
|
+
current_item = [];
|
|
101
|
+
current_list.items.push(current_item);
|
|
102
|
+
for (const child of element.childNodes) walk(child, next_style);
|
|
103
|
+
flush();
|
|
104
|
+
current_item = parent_item;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const is_block =
|
|
108
|
+
/^(p|h[1-6]|div|section|article|header|footer|main|aside|blockquote|pre|ul|ol|li|table|tr|td|th)$/.test(
|
|
109
|
+
tag
|
|
110
|
+
);
|
|
111
|
+
if (is_block) {
|
|
112
|
+
flush();
|
|
113
|
+
current.tag = /^h[1-6]$/.test(tag) ? (tag as HtmlBlockTag) : 'p';
|
|
114
|
+
}
|
|
115
|
+
for (const child of element.childNodes) walk(child, next_style);
|
|
116
|
+
if (is_block) flush();
|
|
117
|
+
};
|
|
118
|
+
for (const child of doc.body.childNodes) walk(child, {});
|
|
119
|
+
flush();
|
|
120
|
+
return blocks;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Map runs to exclusive Svedit marks. Supported links take precedence over bold. */
|
|
124
|
+
export function map_html_text(
|
|
125
|
+
blocks: HtmlPasteBlock[],
|
|
126
|
+
config: HtmlPasteConfig,
|
|
127
|
+
schema: DocumentSchema,
|
|
128
|
+
property: PropertyDefinition,
|
|
129
|
+
nodes: Record<string, DocumentNode>
|
|
130
|
+
): Text {
|
|
131
|
+
const result: Text = { content: '', marks: [], annotations: [] };
|
|
132
|
+
const allowed = property.type === 'text' ? property.mark_types || [] : [];
|
|
133
|
+
let previous_key = '';
|
|
134
|
+
const supported = (mapping: HtmlMarkMapping | null | undefined) =>
|
|
135
|
+
mapping && schema[mapping.type]?.kind === 'mark' && allowed.includes(mapping.type)
|
|
136
|
+
? mapping
|
|
137
|
+
: null;
|
|
138
|
+
const text_blocks = blocks.flatMap((block) => ('items' in block ? block.items.flat() : [block]));
|
|
139
|
+
for (const [index, block] of text_blocks.entries()) {
|
|
140
|
+
if (index) {
|
|
141
|
+
result.content += property.type === 'text' && property.allow_newlines ? '\n\n' : ' ';
|
|
142
|
+
previous_key = '';
|
|
143
|
+
}
|
|
144
|
+
for (const run of block.runs) {
|
|
145
|
+
let text =
|
|
146
|
+
property.type === 'text' && property.allow_newlines
|
|
147
|
+
? run.text
|
|
148
|
+
: run.text.replace(/[ \t\r\n\f]+/g, ' ');
|
|
149
|
+
if (property.type === 'text' && !property.allow_newlines && result.content.endsWith(' ')) {
|
|
150
|
+
text = text.replace(/^ +/, '');
|
|
151
|
+
}
|
|
152
|
+
const start_offset = result.content.length;
|
|
153
|
+
result.content += text;
|
|
154
|
+
const end_offset = result.content.length;
|
|
155
|
+
const link = config.marks?.link;
|
|
156
|
+
const mapping =
|
|
157
|
+
(run.href && supported(typeof link === 'function' ? link({ href: run.href }) : link)) ||
|
|
158
|
+
(run.bold && supported(config.marks?.bold));
|
|
159
|
+
const key = mapping ? JSON.stringify(mapping) : '';
|
|
160
|
+
if (mapping && end_offset > start_offset) {
|
|
161
|
+
const previous = result.marks.at(-1);
|
|
162
|
+
if (previous && previous_key === key && previous.end_offset === start_offset)
|
|
163
|
+
previous.end_offset = end_offset;
|
|
164
|
+
else {
|
|
165
|
+
let id_index = Object.keys(nodes).length;
|
|
166
|
+
while (nodes[`html_mark_${id_index}`]) id_index++;
|
|
167
|
+
const id = `html_mark_${id_index}`;
|
|
168
|
+
nodes[id] = { ...mapping.properties, id, type: mapping.type };
|
|
169
|
+
result.marks.push({ node_id: id, start_offset, end_offset });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
previous_key = key;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Convert UTF-16 ranges only after the complete string is assembled: a grapheme
|
|
176
|
+
// (such as a combining accent or emoji) can span adjacent HTML elements.
|
|
177
|
+
const boundaries = [
|
|
178
|
+
...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(result.content)
|
|
179
|
+
].map((segment) => segment.index);
|
|
180
|
+
boundaries.push(result.content.length);
|
|
181
|
+
let offset = 0;
|
|
182
|
+
for (const mark of result.marks) {
|
|
183
|
+
while (boundaries[offset + 1] <= mark.start_offset) offset++;
|
|
184
|
+
mark.start_offset = offset;
|
|
185
|
+
while (boundaries[offset + 1] <= mark.end_offset) offset++;
|
|
186
|
+
mark.end_offset = offset;
|
|
187
|
+
}
|
|
188
|
+
result.marks = result.marks.filter((mark) => {
|
|
189
|
+
if (mark.end_offset > mark.start_offset) return true;
|
|
190
|
+
delete nodes[mark.node_id];
|
|
191
|
+
return false;
|
|
192
|
+
});
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function map_html_blocks(
|
|
197
|
+
blocks: HtmlPasteBlock[],
|
|
198
|
+
config: HtmlPasteConfig,
|
|
199
|
+
schema: DocumentSchema,
|
|
200
|
+
property: PropertyDefinition
|
|
201
|
+
) {
|
|
202
|
+
if (property.type !== 'node_array') return null;
|
|
203
|
+
const nodes: Record<string, DocumentNode> = {};
|
|
204
|
+
let node_index = 0;
|
|
205
|
+
const add_node = (type: string, properties: Record<string, unknown>) => {
|
|
206
|
+
const id = `html_block_${node_index++}`;
|
|
207
|
+
nodes[id] = { ...properties, id, type };
|
|
208
|
+
return id;
|
|
209
|
+
};
|
|
210
|
+
const add_text = (blocks: HtmlTextBlock[], mapping: HtmlBlockMapping) =>
|
|
211
|
+
add_node(mapping.type, {
|
|
212
|
+
...mapping.properties,
|
|
213
|
+
[mapping.text_property]: map_html_text(
|
|
214
|
+
blocks,
|
|
215
|
+
config,
|
|
216
|
+
schema,
|
|
217
|
+
schema[mapping.type].properties[mapping.text_property],
|
|
218
|
+
nodes
|
|
219
|
+
)
|
|
220
|
+
});
|
|
221
|
+
const add_container = (mapping: HtmlContainerMapping, children: string[]) =>
|
|
222
|
+
add_node(mapping.type, {
|
|
223
|
+
...mapping.properties,
|
|
224
|
+
[mapping.children_property]: { nodes: children, marks: [], annotations: [] }
|
|
225
|
+
});
|
|
226
|
+
const map_blocks = (blocks: HtmlPasteBlock[], target: PropertyDefinition): string[] | null => {
|
|
227
|
+
if (target.type !== 'node_array') return null;
|
|
228
|
+
const ids: string[] = [];
|
|
229
|
+
for (const block of blocks) {
|
|
230
|
+
if ('items' in block) {
|
|
231
|
+
const mapping = config.lists?.[block.tag];
|
|
232
|
+
const children = mapping && schema[mapping.type]?.properties[mapping.children_property];
|
|
233
|
+
if (
|
|
234
|
+
mapping &&
|
|
235
|
+
target.node_types.includes(mapping.type) &&
|
|
236
|
+
children?.type === 'node_array' &&
|
|
237
|
+
children.node_types.includes(mapping.item.type) &&
|
|
238
|
+
schema[mapping.item.type]?.kind === 'text' &&
|
|
239
|
+
schema[mapping.item.type].properties[mapping.item.text_property]?.type === 'text'
|
|
240
|
+
) {
|
|
241
|
+
ids.push(
|
|
242
|
+
add_container(
|
|
243
|
+
mapping,
|
|
244
|
+
block.items.map((item) => add_text(item, mapping.item))
|
|
245
|
+
)
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
const items = map_blocks(block.items.flat(), target);
|
|
249
|
+
if (!items) return null;
|
|
250
|
+
ids.push(...items);
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const rule = config.blocks?.[block.tag];
|
|
255
|
+
let mapping = typeof rule === 'function' ? rule({ tag: block.tag }) : rule;
|
|
256
|
+
if (
|
|
257
|
+
!mapping ||
|
|
258
|
+
!target.node_types.includes(mapping.type) ||
|
|
259
|
+
schema[mapping.type]?.kind !== 'text' ||
|
|
260
|
+
schema[mapping.type]?.properties[mapping.text_property]?.type !== 'text'
|
|
261
|
+
) {
|
|
262
|
+
const type = get_default_text_node(target, schema);
|
|
263
|
+
const text_property = get_text_property_name(type, schema);
|
|
264
|
+
if (!type || !text_property || !target.node_types.includes(type)) return null;
|
|
265
|
+
mapping = { type, text_property };
|
|
266
|
+
}
|
|
267
|
+
ids.push(add_text([block], mapping));
|
|
268
|
+
}
|
|
269
|
+
return ids.length ? ids : null;
|
|
270
|
+
};
|
|
271
|
+
let main_nodes = map_blocks(blocks, property);
|
|
272
|
+
const wrapper = config.wrapper;
|
|
273
|
+
if (!main_nodes && wrapper && property.node_types.includes(wrapper.type)) {
|
|
274
|
+
const children = schema[wrapper.type]?.properties[wrapper.children_property];
|
|
275
|
+
const ids = children && map_blocks(blocks, children);
|
|
276
|
+
if (ids) main_nodes = [add_container(wrapper, ids)];
|
|
277
|
+
}
|
|
278
|
+
return main_nodes ? { nodes, main_nodes } : null;
|
|
279
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Escape text or a quoted HTML attribute value. */
|
|
2
|
+
export function escape_html(value: string): string {
|
|
3
|
+
return value
|
|
4
|
+
.replace(/&/g, '&')
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, ''');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function safe_html_href(value: string): string | undefined {
|
|
12
|
+
const href = value.trim();
|
|
13
|
+
if (!href || [...href].some((char) => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127))
|
|
14
|
+
return;
|
|
15
|
+
const scheme = href.match(/^([a-z][a-z\d+.-]*):/i)?.[1].toLowerCase();
|
|
16
|
+
return !scheme || ['http', 'https', 'mailto', 'tel'].includes(scheme) ? href : undefined;
|
|
17
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -85,3 +85,17 @@ export { KeyMapper, define_keymap } from './KeyMapper.svelte.js';
|
|
|
85
85
|
// Transforms and utilities
|
|
86
86
|
export * from './transforms.svelte.js';
|
|
87
87
|
export * from './utils.js';
|
|
88
|
+
|
|
89
|
+
export type {
|
|
90
|
+
HtmlPasteConfig,
|
|
91
|
+
HtmlBlockTag,
|
|
92
|
+
HtmlBlockMapping,
|
|
93
|
+
HtmlMarkMapping,
|
|
94
|
+
HtmlListTag,
|
|
95
|
+
HtmlListMapping,
|
|
96
|
+
HtmlContainerMapping
|
|
97
|
+
} from './html_paste.js';
|
|
98
|
+
|
|
99
|
+
export { export_text_html } from './html_export.js';
|
|
100
|
+
export type { MarkHtmlExporter } from './html_export.js';
|
|
101
|
+
export { escape_html, safe_html_href } from './html_utils.js';
|
|
@@ -55,6 +55,20 @@ export function break_text_node(tr: Transaction): boolean {
|
|
|
55
55
|
const content = tr.get(selection.path);
|
|
56
56
|
const [left_text, right_text] = split_text(content, split_at_position);
|
|
57
57
|
|
|
58
|
+
// Each attachment occurrence belongs to one range after the paragraph split.
|
|
59
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- Transaction-local ownership lookup.
|
|
60
|
+
const used_attachment_ids = new Set(
|
|
61
|
+
[...left_text.marks, ...left_text.annotations].map((range) => range.node_id)
|
|
62
|
+
);
|
|
63
|
+
for (const range of [...right_text.marks, ...right_text.annotations]) {
|
|
64
|
+
const original_id = range.node_id;
|
|
65
|
+
if (used_attachment_ids.has(original_id)) {
|
|
66
|
+
range.node_id = tr.build(original_id, tr.doc.nodes);
|
|
67
|
+
} else {
|
|
68
|
+
used_attachment_ids.add(original_id);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
tr.set([node.id, 'content'], left_text);
|
|
59
73
|
|
|
60
74
|
const node_insert_position: Selection = {
|
package/src/lib/types.ts
CHANGED
|
@@ -21,7 +21,12 @@ export type NodeId = string;
|
|
|
21
21
|
|
|
22
22
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
23
|
export type DynamicRecord = Record<string, any>;
|
|
24
|
-
export type SessionConfig = DynamicRecord
|
|
24
|
+
export type SessionConfig = DynamicRecord & {
|
|
25
|
+
html_paste?: import('./html_paste.js').HtmlPasteConfig;
|
|
26
|
+
mark_html_exporters?: Record<string, import('./html_export.js').MarkHtmlExporter>;
|
|
27
|
+
view_classes?: boolean;
|
|
28
|
+
visibility_culling?: boolean;
|
|
29
|
+
};
|
|
25
30
|
export type CommandRegistry = DynamicRecord;
|
|
26
31
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
32
|
export type DocumentOperation = [string, ...any[]];
|