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.
Files changed (42) hide show
  1. package/README.md +155 -1
  2. package/dist/NodeCaret.svelte +1 -1
  3. package/dist/NodeGapMarkers.svelte +17 -4
  4. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  5. package/dist/NodeSelectionMarkers.svelte +14 -3
  6. package/dist/Session.svelte.d.ts.map +1 -1
  7. package/dist/Session.svelte.js +17 -9
  8. package/dist/Svedit.svelte +101 -29
  9. package/dist/Svedit.svelte.d.ts.map +1 -1
  10. package/dist/TextProperty.svelte +5 -5
  11. package/dist/Transaction.svelte.d.ts +6 -2
  12. package/dist/Transaction.svelte.d.ts.map +1 -1
  13. package/dist/Transaction.svelte.js +7 -6
  14. package/dist/html_export.d.ts +11 -0
  15. package/dist/html_export.d.ts.map +1 -0
  16. package/dist/html_export.js +25 -0
  17. package/dist/html_paste.d.ts +59 -0
  18. package/dist/html_paste.d.ts.map +1 -0
  19. package/dist/html_paste.js +229 -0
  20. package/dist/html_utils.d.ts +4 -0
  21. package/dist/html_utils.d.ts.map +1 -0
  22. package/dist/html_utils.js +16 -0
  23. package/dist/index.d.ts +5 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2 -0
  26. package/dist/styles/svedit-colors.css +8 -5
  27. package/dist/types.d.ts +23 -1
  28. package/dist/types.d.ts.map +1 -1
  29. package/package.json +28 -26
  30. package/src/lib/NodeCaret.svelte +1 -1
  31. package/src/lib/NodeGapMarkers.svelte +17 -4
  32. package/src/lib/NodeSelectionMarkers.svelte +14 -3
  33. package/src/lib/Session.svelte.ts +16 -11
  34. package/src/lib/Svedit.svelte +101 -29
  35. package/src/lib/TextProperty.svelte +5 -5
  36. package/src/lib/Transaction.svelte.ts +12 -6
  37. package/src/lib/html_export.ts +37 -0
  38. package/src/lib/html_paste.ts +279 -0
  39. package/src/lib/html_utils.ts +17 -0
  40. package/src/lib/index.ts +15 -0
  41. package/src/lib/styles/svedit-colors.css +8 -5
  42. package/src/lib/types.ts +24 -1
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "svedit",
3
- "version": "0.13.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.0.3",
30
+ "@eslint/compat": "^2.1.1",
30
31
  "@eslint/js": "^10.0.1",
31
- "@sveltejs/adapter-auto": "^7.0.0",
32
- "@sveltejs/kit": "^2.55.0",
33
- "@sveltejs/package": "^2.5.7",
34
- "@sveltejs/vite-plugin-svelte": "^7.0.0",
35
- "@types/node": "^25.6.0",
36
- "@typescript-eslint/eslint-plugin": "^8.57.0",
37
- "@typescript-eslint/parser": "^8.57.0",
38
- "@vitest/browser": "^4.1.0",
39
- "@vitest/browser-preview": "^4.1.0",
40
- "eslint": "^10.0.3",
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.15.1",
43
- "globals": "^17.4.0",
44
- "nanoid": "^5.1.6",
45
- "prettier": "^3.8.1",
46
- "prettier-plugin-svelte": "^3.5.1",
47
- "publint": "^0.3.18",
48
- "svelte": "^5.53.12",
49
- "svelte-check": "^4.4.5",
50
- "typescript": "^5.9.3",
51
- "vite": "^8.0.0",
52
- "vitest": "^4.1.0",
53
- "vitest-browser-svelte": "^2.1.0"
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
- "allowScripts": {
56
- "fsevents@2.3.3": true
56
+ "imports": {
57
+ "#lib": "./src/lib/index.js",
58
+ "#lib/*": "./src/lib/*"
57
59
  },
58
60
  "scripts": {
59
61
  "dev": "vite dev",
@@ -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(--svedit-editing-stroke));
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(--svedit-canvas-stroke));
615
- border-left: calc(var(--_R) * 1px) dashed var(--node-caret-gap-color, var(--svedit-canvas-stroke));
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(--svedit-canvas-stroke)));
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(--svedit-canvas-stroke)));
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 class="selected-node-overlay" style="position-anchor: --{serialize_path(path)};"></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(--svedit-editing-fill);
54
- outline: 1px solid var(--svedit-editing-stroke);
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
- // There's no parent path to insert into
448
- if (!selection || selection.path.length <= 2) {
449
- return null;
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 {
@@ -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.type === 'text') {
401
+ if (property_definition?.type === 'text') {
398
402
  const text_content = prop_value.content;
399
403
  if (text_content.trim()) {
400
- html += `<p>${text_content}</p>`;
404
+ html += `<p>${export_text_html(prop_value, session)}</p>`;
401
405
  }
402
- } else if (property_definition.type === 'node_array') {
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.content}</span>`;
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
- html = create_svedit_html_format(json_data, `<span>${value}</span>`);
521
- plain_text = String(value);
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
- let tr = session.tr;
615
- if (selection) {
616
- tr.set_selection(selection);
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
- const new_node_id = tr.build('the_node', {
646
- the_node: {
647
- id: 'the_node',
648
- type: default_text_node_type,
649
- [target_text_property_name]: text_content || {
650
- content: '',
651
- marks: [],
652
- annotations: []
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 plain_text,
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
- const html_content = event.clipboardData?.getData('text/html');
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(--svedit-caret-color);
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(--svedit-editing-fill);
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(--svedit-caret-color, AccentColor),
190
- 0 0 0 0.65px var(--svedit-caret-color, AccentColor),
191
- 0 0.4cap 0 0.65px var(--svedit-caret-color, AccentColor);
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(--svedit-editing-fill) l 0 h / alpha);
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(--svedit-editing-stroke) l 0 h / alpha);
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
- // NOTE: all ids will be mapped to new unique ids.
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(node_id: NodeId, nodes: Record<string, DocumentNode>): NodeId {
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
+ }