svedit 0.13.0 → 0.15.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 +155 -1
- package/dist/NodeCaret.svelte +1 -1
- package/dist/NodeGapMarkers.svelte +17 -4
- package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
- package/dist/NodeSelectionMarkers.svelte +14 -3
- package/dist/Session.svelte.d.ts.map +1 -1
- package/dist/Session.svelte.js +17 -9
- package/dist/Svedit.svelte +101 -29
- package/dist/Svedit.svelte.d.ts.map +1 -1
- package/dist/TextProperty.svelte +5 -5
- package/dist/Transaction.svelte.d.ts +6 -2
- package/dist/Transaction.svelte.d.ts.map +1 -1
- package/dist/Transaction.svelte.js +7 -6
- 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 +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/styles/svedit-colors.css +8 -5
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +28 -26
- package/src/lib/NodeCaret.svelte +1 -1
- package/src/lib/NodeGapMarkers.svelte +17 -4
- package/src/lib/NodeSelectionMarkers.svelte +14 -3
- package/src/lib/Session.svelte.ts +16 -11
- package/src/lib/Svedit.svelte +101 -29
- package/src/lib/TextProperty.svelte +5 -5
- package/src/lib/Transaction.svelte.ts +12 -6
- 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 +15 -0
- package/src/lib/styles/svedit-colors.css +8 -5
- package/src/lib/types.ts +24 -1
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svedit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
|
+
"homepage": "https://svedit.dev",
|
|
4
5
|
"license": "MIT",
|
|
5
6
|
"files": [
|
|
6
7
|
"dist",
|
|
@@ -26,34 +27,35 @@
|
|
|
26
27
|
"svelte": "^5.0.0"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
|
-
"@eslint/compat": "^2.
|
|
30
|
+
"@eslint/compat": "^2.1.1",
|
|
30
31
|
"@eslint/js": "^10.0.1",
|
|
31
|
-
"@sveltejs/adapter-
|
|
32
|
-
"@sveltejs/kit": "^
|
|
33
|
-
"@sveltejs/
|
|
34
|
-
"@sveltejs/
|
|
35
|
-
"@
|
|
36
|
-
"@
|
|
37
|
-
"@typescript-eslint/
|
|
38
|
-
"@
|
|
39
|
-
"@vitest/browser-preview": "^
|
|
40
|
-
"eslint": "^10.0
|
|
32
|
+
"@sveltejs/adapter-static": "^4.0.0",
|
|
33
|
+
"@sveltejs/kit": "^3.0.0",
|
|
34
|
+
"@sveltejs/load-config": "^0.2.3",
|
|
35
|
+
"@sveltejs/package": "^3.0.0",
|
|
36
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
37
|
+
"@types/node": "^26.6.4",
|
|
38
|
+
"@typescript-eslint/eslint-plugin": "^8.71.0",
|
|
39
|
+
"@typescript-eslint/parser": "^8.71.0",
|
|
40
|
+
"@vitest/browser-preview": "^5.0.3",
|
|
41
|
+
"eslint": "^10.11.0",
|
|
41
42
|
"eslint-config-prettier": "^10.1.8",
|
|
42
|
-
"eslint-plugin-svelte": "^3.
|
|
43
|
-
"globals": "^17.
|
|
44
|
-
"nanoid": "^
|
|
45
|
-
"prettier": "^3.
|
|
46
|
-
"prettier-plugin-svelte": "^
|
|
47
|
-
"publint": "^0.3.
|
|
48
|
-
"svelte": "^5.
|
|
49
|
-
"svelte-check": "^4.
|
|
50
|
-
"typescript": "^
|
|
51
|
-
"vite": "^8.
|
|
52
|
-
"vitest": "^
|
|
53
|
-
"vitest-browser-svelte": "^
|
|
43
|
+
"eslint-plugin-svelte": "^3.23.0",
|
|
44
|
+
"globals": "^17.13.0",
|
|
45
|
+
"nanoid": "^6.0.1",
|
|
46
|
+
"prettier": "^3.9.9",
|
|
47
|
+
"prettier-plugin-svelte": "^4.1.1",
|
|
48
|
+
"publint": "^0.3.25",
|
|
49
|
+
"svelte": "^5.57.1",
|
|
50
|
+
"svelte-check": "^4.7.6",
|
|
51
|
+
"typescript": "^6.0.3",
|
|
52
|
+
"vite": "^8.3.2",
|
|
53
|
+
"vitest": "^5.0.3",
|
|
54
|
+
"vitest-browser-svelte": "^3.1.0"
|
|
54
55
|
},
|
|
55
|
-
"
|
|
56
|
-
"
|
|
56
|
+
"imports": {
|
|
57
|
+
"#lib": "./src/lib/index.js",
|
|
58
|
+
"#lib/*": "./src/lib/*"
|
|
57
59
|
},
|
|
58
60
|
"scripts": {
|
|
59
61
|
"dev": "vite dev",
|
package/src/lib/NodeCaret.svelte
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
--_cp: var(--node-caret-row-inline-position, 50%);
|
|
31
31
|
content: '';
|
|
32
32
|
position: absolute;
|
|
33
|
-
background: var(--node-caret-bg, var(--
|
|
33
|
+
background: var(--node-caret-bg, var(--editing));
|
|
34
34
|
/* Increase the visibility of the caret by contrasting it with a box shadow that's the inverted brightness of the current text color. */
|
|
35
35
|
/* Component developers must set their background color and text color on the node itself, not a child element, for this to work. */
|
|
36
36
|
box-shadow: var(--node-caret-shadow, 0 0 0 0.5px oklch(from currentColor calc(1 - l) c h));
|
|
@@ -27,11 +27,20 @@
|
|
|
27
27
|
* Reads visibility state directly from the registry. SvelteSet's per-key
|
|
28
28
|
* tracking on `.has()` and iteration means this component only
|
|
29
29
|
* re-evaluates when THIS array's visible-index set changes.
|
|
30
|
+
*
|
|
31
|
+
* The active marker (the one carrying the caret) additionally renders the
|
|
32
|
+
* consumer's `system_components.node_gap_tools` component when configured
|
|
33
|
+
* — an extension point for gap-scoped tools (e.g. an insert button).
|
|
34
|
+
* Rendering inside the marker means the tool inherits --row for
|
|
35
|
+
* orientation-aware styling and shows/hides together with the caret.
|
|
36
|
+
* The marker has pointer-events: none — interactive tools must set
|
|
37
|
+
* pointer-events: auto themselves.
|
|
30
38
|
*/
|
|
31
39
|
|
|
32
40
|
let { path, count }: { path: DocumentPath; count: number } = $props();
|
|
33
41
|
|
|
34
42
|
const svedit = getContext<SveditRenderContext>('svedit');
|
|
43
|
+
let NodeGapTools = $derived(svedit.session.config.system_components?.node_gap_tools);
|
|
35
44
|
let path_str = $derived(serialize_path(path));
|
|
36
45
|
let visible = $derived(svedit.visibility_registry.get_array_indices(path_str));
|
|
37
46
|
|
|
@@ -163,6 +172,9 @@
|
|
|
163
172
|
>
|
|
164
173
|
{#if gap.key === caret_gap_key}
|
|
165
174
|
<NodeCaret />
|
|
175
|
+
{#if NodeGapTools}
|
|
176
|
+
<NodeGapTools {path} offset={gap.offset} is_first={gap.is_first} is_last={gap.is_last} />
|
|
177
|
+
{/if}
|
|
166
178
|
{/if}
|
|
167
179
|
</div>
|
|
168
180
|
{/each}
|
|
@@ -170,6 +182,7 @@
|
|
|
170
182
|
<style>
|
|
171
183
|
/*
|
|
172
184
|
* Public customization tokens (set on an ancestor or this component):
|
|
185
|
+
* --editing-stroke
|
|
173
186
|
* --node-caret-gap-color
|
|
174
187
|
* --node-caret-symbol-size
|
|
175
188
|
* --node-caret-symbol-stroke
|
|
@@ -611,8 +624,8 @@
|
|
|
611
624
|
calc(var(--_mi) + var(--_R) * 9999999px),
|
|
612
625
|
calc(50% + var(--_C) * 9999999px)
|
|
613
626
|
);
|
|
614
|
-
border-top: calc(var(--_C) * 1px) dashed var(--node-caret-gap-color, var(--
|
|
615
|
-
border-left: calc(var(--_R) * 1px) dashed var(--node-caret-gap-color, var(--
|
|
627
|
+
border-top: calc(var(--_C) * 1px) dashed var(--node-caret-gap-color, var(--editing-stroke));
|
|
628
|
+
border-left: calc(var(--_R) * 1px) dashed var(--node-caret-gap-color, var(--editing-stroke));
|
|
616
629
|
transform:
|
|
617
630
|
translateY(calc(var(--_C) * -0.5px))
|
|
618
631
|
translateX(calc(var(--_R) * -0.5px));
|
|
@@ -626,7 +639,7 @@
|
|
|
626
639
|
/* Empty array marker (dashed outline for discoverability). */
|
|
627
640
|
&.gap-empty::before {
|
|
628
641
|
inset: 0px;
|
|
629
|
-
border: var(--node-caret-empty-border, 1px dashed var(--node-caret-gap-color, var(--
|
|
642
|
+
border: var(--node-caret-empty-border, 1px dashed var(--node-caret-gap-color, var(--editing-stroke)));
|
|
630
643
|
border-radius: var(--node-caret-empty-border-radius, 3px);
|
|
631
644
|
}
|
|
632
645
|
|
|
@@ -639,7 +652,7 @@
|
|
|
639
652
|
top: 50%;
|
|
640
653
|
left: 50%;
|
|
641
654
|
transform: translate(-50%, -50%);
|
|
642
|
-
background: var(--node-caret-symbol-bg, var(--node-caret-gap-color, var(--
|
|
655
|
+
background: var(--node-caret-symbol-bg, var(--node-caret-gap-color, var(--editing-stroke)));
|
|
643
656
|
mask: var(--node-caret-symbol-mask,
|
|
644
657
|
linear-gradient(black, black) center / 100% var(--node-caret-symbol-stroke, 1px) no-repeat,
|
|
645
658
|
linear-gradient(black, black) center / var(--node-caret-symbol-stroke, 1px) 100% no-repeat
|
|
@@ -24,13 +24,18 @@
|
|
|
24
24
|
{#if svedit.session.selection?.type === 'property'}
|
|
25
25
|
<div
|
|
26
26
|
class="selected-property-overlay"
|
|
27
|
+
class:unfocused={!svedit.canvas_focused}
|
|
27
28
|
style="position-anchor: --{serialize_path(svedit.session.selection.path)};"
|
|
28
29
|
></div>
|
|
29
30
|
{/if}
|
|
30
31
|
|
|
31
32
|
{#if selected_node_paths}
|
|
32
33
|
{#each selected_node_paths as path (serialize_path(path))}
|
|
33
|
-
<div
|
|
34
|
+
<div
|
|
35
|
+
class="selected-node-overlay"
|
|
36
|
+
class:unfocused={!svedit.canvas_focused}
|
|
37
|
+
style="position-anchor: --{serialize_path(path)};"
|
|
38
|
+
></div>
|
|
34
39
|
{/each}
|
|
35
40
|
{/if}
|
|
36
41
|
|
|
@@ -50,8 +55,8 @@
|
|
|
50
55
|
Performance note: outlines don't participate in layout, so
|
|
51
56
|
selection changes can't trigger reflows. */
|
|
52
57
|
position: absolute;
|
|
53
|
-
background: var(--
|
|
54
|
-
outline: 1px solid var(--
|
|
58
|
+
background: var(--editing-muted);
|
|
59
|
+
outline: 1px solid var(--editing);
|
|
55
60
|
outline-offset: -0.5px;
|
|
56
61
|
border-radius: 1px;
|
|
57
62
|
top: anchor(top);
|
|
@@ -61,4 +66,10 @@
|
|
|
61
66
|
pointer-events: none;
|
|
62
67
|
z-index: 12;
|
|
63
68
|
}
|
|
69
|
+
|
|
70
|
+
.selected-node-overlay.unfocused,
|
|
71
|
+
.selected-property-overlay.unfocused {
|
|
72
|
+
background: oklch(from var(--editing-muted) l 0 h / alpha);
|
|
73
|
+
outline-color: oklch(from var(--editing) l 0 h);
|
|
74
|
+
}
|
|
64
75
|
</style>
|
|
@@ -444,18 +444,23 @@ export default class Session<S extends DocumentSchema = DocumentSchema> {
|
|
|
444
444
|
* @returns The next node insert caret selection, or null if none is available
|
|
445
445
|
*/
|
|
446
446
|
get_next_node_insert_caret(selection: Selection | null = this.selection): Selection | null {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
447
|
+
if (!selection) return null;
|
|
448
|
+
|
|
449
|
+
// Array indexes identify insertion positions. Single-node references can
|
|
450
|
+
// occur between arrays, so removing a fixed pair of segments is unsafe.
|
|
451
|
+
for (let index = selection.path.length - 1; index >= 1; index--) {
|
|
452
|
+
const node_index = selection.path[index];
|
|
453
|
+
if (typeof node_index !== 'number') continue;
|
|
454
|
+
const parent_path = selection.path.slice(0, index);
|
|
455
|
+
if (this.inspect(parent_path).type !== 'node_array') continue;
|
|
456
|
+
return {
|
|
457
|
+
type: 'node',
|
|
458
|
+
path: parent_path,
|
|
459
|
+
anchor_offset: node_index + 1,
|
|
460
|
+
focus_offset: node_index + 1
|
|
461
|
+
};
|
|
450
462
|
}
|
|
451
|
-
|
|
452
|
-
const node_offset = (selection.path.at(-2) as number) + 1;
|
|
453
|
-
return {
|
|
454
|
-
type: 'node',
|
|
455
|
-
path: selection.path.slice(0, -2),
|
|
456
|
-
anchor_offset: node_offset,
|
|
457
|
-
focus_offset: node_offset
|
|
458
|
-
};
|
|
463
|
+
return null;
|
|
459
464
|
}
|
|
460
465
|
|
|
461
466
|
get_selected_text(): (Text & { nodes: Record<string, DocumentNode> }) | null {
|
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);
|
|
@@ -1683,7 +1755,7 @@ ${fallback_html}`;
|
|
|
1683
1755
|
|
|
1684
1756
|
<style>
|
|
1685
1757
|
.svedit-canvas {
|
|
1686
|
-
caret-color: var(--
|
|
1758
|
+
caret-color: var(--editing);
|
|
1687
1759
|
caret-shape: bar;
|
|
1688
1760
|
/* Default to vertical/ column flow with: --row: 0; (the most common case)
|
|
1689
1761
|
Prevents silent failures when developers forget to set the row property in their top level node component.
|
|
@@ -1699,7 +1771,7 @@ ${fallback_html}`;
|
|
|
1699
1771
|
the :where(), but the wrapper zeroes its specificity contribution.
|
|
1700
1772
|
Final specificity is just (0,0,1), trivially beatable. */
|
|
1701
1773
|
:where(.svedit-canvas) :global(::selection) {
|
|
1702
|
-
background: var(--
|
|
1774
|
+
background: var(--editing-muted);
|
|
1703
1775
|
}
|
|
1704
1776
|
|
|
1705
1777
|
@media not (pointer: coarse) {
|
|
@@ -186,9 +186,9 @@
|
|
|
186
186
|
display: inline-block;
|
|
187
187
|
/* we use box-shadow to draw the caret shape, matching the native caret */
|
|
188
188
|
box-shadow:
|
|
189
|
-
0 -0.4cap 0 0.65px var(--
|
|
190
|
-
0 0 0 0.65px var(--
|
|
191
|
-
0 0.4cap 0 0.65px var(--
|
|
189
|
+
0 -0.4cap 0 0.65px var(--editing, AccentColor),
|
|
190
|
+
0 0 0 0.65px var(--editing, AccentColor),
|
|
191
|
+
0 0.4cap 0 0.65px var(--editing, AccentColor);
|
|
192
192
|
animation: var(
|
|
193
193
|
--node-caret-animation,
|
|
194
194
|
node-caret-blink var(--node-caret-blink-duration, 1.1s) ease-in-out infinite
|
|
@@ -207,12 +207,12 @@
|
|
|
207
207
|
|
|
208
208
|
/* Dim the selection highlight when canvas loses native focus */
|
|
209
209
|
:global(.svedit-canvas:not(:focus-within)) .selection-highlight {
|
|
210
|
-
background: oklch(from var(--
|
|
210
|
+
background: oklch(from var(--editing-muted) l 0 h / alpha);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
/* Make a collapsed caret visible */
|
|
214
214
|
:global(.svedit-canvas:not(:focus-within)) .selection-highlight:empty {
|
|
215
215
|
background: none;
|
|
216
|
-
outline: 0.5px solid oklch(from var(--
|
|
216
|
+
outline: 0.5px solid oklch(from var(--editing) l 0 h / alpha);
|
|
217
217
|
}
|
|
218
218
|
</style>
|
|
@@ -313,15 +313,20 @@ 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> = {};
|
|
322
327
|
|
|
323
328
|
for (const node of depth_first_nodes) {
|
|
324
|
-
const new_id = this.generate_id();
|
|
329
|
+
const new_id = keep_ids ? node.id : this.generate_id();
|
|
325
330
|
id_map[node.id] = new_id;
|
|
326
331
|
let new_node: DocumentNode = { ...node, id: new_id };
|
|
327
332
|
const node_schema = this.schema[node.type];
|
|
@@ -762,7 +767,8 @@ export default class Transaction {
|
|
|
762
767
|
node_ids: NodeId[],
|
|
763
768
|
marks: Array<Mark> = [],
|
|
764
769
|
annotations: Array<Annotation> = [],
|
|
765
|
-
nodes: Record<NodeId, DocumentNode> = {}
|
|
770
|
+
nodes: Record<NodeId, DocumentNode> = {},
|
|
771
|
+
{ preserve_ids = false }: { preserve_ids?: boolean } = {}
|
|
766
772
|
): this {
|
|
767
773
|
if (this.selection.type !== 'node') return this;
|
|
768
774
|
|
|
@@ -803,7 +809,7 @@ export default class Transaction {
|
|
|
803
809
|
const mark_node = nodes[mark.node_id];
|
|
804
810
|
if (!property_definition.mark_types?.includes(mark_node?.type)) return null;
|
|
805
811
|
|
|
806
|
-
const new_mark_node_id = this.build(mark.node_id, nodes);
|
|
812
|
+
const new_mark_node_id = this.build(mark.node_id, nodes, { preserve_ids });
|
|
807
813
|
return {
|
|
808
814
|
start_offset: start + mark.start_offset,
|
|
809
815
|
end_offset: start + mark.end_offset,
|
|
@@ -828,7 +834,7 @@ export default class Transaction {
|
|
|
828
834
|
const annotation_node = nodes[annotation.node_id];
|
|
829
835
|
if (!property_definition.annotation_types?.includes(annotation_node?.type)) return null;
|
|
830
836
|
|
|
831
|
-
const new_annotation_node_id = this.build(annotation.node_id, nodes);
|
|
837
|
+
const new_annotation_node_id = this.build(annotation.node_id, nodes, { preserve_ids });
|
|
832
838
|
return {
|
|
833
839
|
start_offset: start + annotation.start_offset,
|
|
834
840
|
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
|
+
}
|