svedit 0.11.1 → 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.
Files changed (56) hide show
  1. package/README.md +230 -64
  2. package/dist/Command.svelte.d.ts +21 -4
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +85 -27
  5. package/dist/Node.svelte +29 -1
  6. package/dist/Node.svelte.d.ts.map +1 -1
  7. package/dist/NodeArrayProperty.svelte +119 -12
  8. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  9. package/dist/NodeGap.svelte +48 -6
  10. package/dist/NodeGap.svelte.d.ts.map +1 -1
  11. package/dist/Session.svelte.d.ts +31 -14
  12. package/dist/Session.svelte.d.ts.map +1 -1
  13. package/dist/Session.svelte.js +155 -77
  14. package/dist/Svedit.svelte +110 -89
  15. package/dist/Svedit.svelte.d.ts.map +1 -1
  16. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  17. package/dist/TextProperty.svelte.d.ts +7 -0
  18. package/dist/TextProperty.svelte.d.ts.map +1 -0
  19. package/dist/Transaction.svelte.d.ts +78 -40
  20. package/dist/Transaction.svelte.d.ts.map +1 -1
  21. package/dist/Transaction.svelte.js +412 -234
  22. package/dist/doc_utils.d.ts +96 -21
  23. package/dist/doc_utils.d.ts.map +1 -1
  24. package/dist/doc_utils.js +440 -168
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/node_visibility.svelte.d.ts +30 -4
  28. package/dist/node_visibility.svelte.d.ts.map +1 -1
  29. package/dist/node_visibility.svelte.js +327 -363
  30. package/dist/paste_utils.d.ts +3 -2
  31. package/dist/paste_utils.d.ts.map +1 -1
  32. package/dist/paste_utils.js +16 -13
  33. package/dist/transforms.svelte.d.ts.map +1 -1
  34. package/dist/transforms.svelte.js +5 -6
  35. package/dist/types.d.ts +112 -42
  36. package/dist/utils.d.ts +75 -12
  37. package/dist/utils.d.ts.map +1 -1
  38. package/dist/utils.js +292 -78
  39. package/package.json +4 -1
  40. package/src/lib/Command.svelte.js +85 -27
  41. package/src/lib/Node.svelte +29 -1
  42. package/src/lib/NodeArrayProperty.svelte +119 -12
  43. package/src/lib/NodeGap.svelte +48 -6
  44. package/src/lib/Session.svelte.js +155 -77
  45. package/src/lib/Svedit.svelte +110 -89
  46. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +45 -44
  47. package/src/lib/Transaction.svelte.js +412 -234
  48. package/src/lib/doc_utils.js +440 -168
  49. package/src/lib/index.js +1 -1
  50. package/src/lib/node_visibility.svelte.js +327 -363
  51. package/src/lib/paste_utils.js +16 -13
  52. package/src/lib/transforms.svelte.js +5 -6
  53. package/src/lib/types.d.ts +112 -42
  54. package/src/lib/utils.js +292 -78
  55. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  56. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
@@ -1,11 +1,11 @@
1
1
  <script>
2
2
  import { getContext, setContext } from 'svelte';
3
3
  import UnknownNode from './UnknownNode.svelte';
4
- import { serialize_path } from './utils.js';
4
+ import { serialize_path, calculate_fragment_ranges } from './utils.js';
5
5
  import DefaultNodeGap from './NodeGap.svelte';
6
6
  import DefaultNodeGapMarkers from './NodeGapMarkers.svelte';
7
7
 
8
- /** @import { NodeArrayPropertyProps } from './types.d.ts'; */
8
+ /** @import { NodeArrayPropertyProps, NodeArrayAttachmentContext } from './types.d.ts'; */
9
9
 
10
10
  const svedit = getContext('svedit');
11
11
  let NodeGap = $derived(svedit.session.config.system_components?.node_gap ?? DefaultNodeGap);
@@ -20,10 +20,85 @@
20
20
  // NodeGap's should_position_gap call to avoid N+1 joins per render.
21
21
  let path_str = $derived(serialize_path(path));
22
22
 
23
- let node_ids = $derived(svedit.session.get(path));
24
- let nodes = $derived(node_ids.map((id) => svedit.session.get(id)));
23
+ let raw_value = $derived(svedit.session.get(path));
24
+ let node_ids = $derived(raw_value.nodes);
25
+ let marks = $derived(raw_value.marks);
26
+ let annotations = $derived(raw_value.annotations);
25
27
 
26
- // Mirrors the AnnotatedTextProperty pattern: a `focused` class follows
28
+ let fragments = $derived(get_fragments(node_ids, marks));
29
+
30
+ function get_fragments(node_ids, marks) {
31
+ const ranges = calculate_fragment_ranges(node_ids.length, marks);
32
+ let fragments = [];
33
+
34
+ for (const range of ranges) {
35
+ const nodes_slice = node_ids.slice(range.start_offset, range.end_offset);
36
+
37
+ if (range.type === 'mark') {
38
+ const node = svedit.session.get(range.node_id);
39
+ fragments.push({
40
+ type: 'mark',
41
+ node,
42
+ nodes: nodes_slice,
43
+ start_index: range.start_offset,
44
+ mark_index: range.mark_index
45
+ });
46
+ } else if (range.type === 'content') {
47
+ fragments.push({
48
+ type: 'nodes',
49
+ nodes: nodes_slice,
50
+ start_index: range.start_offset
51
+ });
52
+ }
53
+ }
54
+ return fragments;
55
+ }
56
+
57
+ function get_node_key(node_id, index) {
58
+ let occurrence = 0;
59
+ for (let current_index = 0; current_index < index; current_index++) {
60
+ if (node_ids[current_index] === node_id) occurrence += 1;
61
+ }
62
+ return `${node_id}:${occurrence}`;
63
+ }
64
+
65
+ /**
66
+ * @returns {NodeArrayAttachmentContext}
67
+ */
68
+ function get_range_context(range, index, node_index) {
69
+ const node = svedit.session.get(range.node_id);
70
+ const is_start = node_index === range.start_offset;
71
+ const is_end = node_index === range.end_offset - 1;
72
+
73
+ return {
74
+ ...range,
75
+ index,
76
+ node,
77
+ is_start,
78
+ is_middle: !is_start && !is_end,
79
+ is_end
80
+ };
81
+ }
82
+
83
+ function get_covering_ranges(ranges, node_index) {
84
+ return ranges
85
+ .map((range, index) => ({ range, index }))
86
+ .filter(({ range }) => range.start_offset <= node_index && node_index < range.end_offset)
87
+ .map(({ range, index }) => get_range_context(range, index, node_index));
88
+ }
89
+
90
+ function get_annotation_contexts(node_index) {
91
+ return get_covering_ranges(annotations, node_index);
92
+ }
93
+
94
+ function get_mark(node_index) {
95
+ // The single mark wrapping this node, or null. Mark exclusivity
96
+ // guarantees at most one. Annotations never wrap and are exposed via
97
+ // `annotations` instead.
98
+ return get_covering_ranges(marks, node_index)[0] ?? null;
99
+ }
100
+
101
+ // Mirrors the TextProperty pattern: a `focused` class follows
27
102
  // the model selection. Used by the CSS rule below to hide the empty-array
28
103
  // NodeGap while the caret is in the placeholder, so its <br> isn't a
29
104
  // second arrow-key stop (issue #260).
@@ -35,6 +110,15 @@
35
110
  setContext('node_array_meta', {
36
111
  get length() {
37
112
  return node_ids.length;
113
+ },
114
+ // Lets Node self-serve the marks and annotations covering a child, so
115
+ // node wrappers can carry range classes without every node component
116
+ // having to thread the `mark`/`annotations` props through.
117
+ mark_for(node_index) {
118
+ return get_mark(node_index);
119
+ },
120
+ annotations_for(node_index) {
121
+ return get_annotation_contexts(node_index);
38
122
  }
39
123
  });
40
124
  </script>
@@ -48,6 +132,7 @@
48
132
  data-path={path_str}
49
133
  style="anchor-name: --{path_str};{style ? ` ${style}` : ''}"
50
134
  {...rest}
135
+ {@attach svedit.visibility_registry.track_array(path_str)}
51
136
  >
52
137
  {#if node_ids.length === 0 && svedit.editable}
53
138
  <div
@@ -55,17 +140,39 @@
55
140
  data-path={serialize_path([...path, 0])}
56
141
  data-type="node"
57
142
  style="anchor-name: --{serialize_path([...path, 0])};"
143
+ {@attach svedit.visibility_registry.track_node(serialize_path([...path, 0]))}
58
144
  >
59
145
  <NodeGap array_path={path} offset={0} count={0} empty />
60
146
  </div>
61
147
  {/if}
62
- {#each nodes as node, index (index)}
63
- <NodeGap array_path={path} offset={index} count={nodes.length} />
64
- {@const Component = svedit.session.config.node_components[node.type]}
65
- {#if Component}
66
- <Component path={[...path, index]} />
67
- {:else}
68
- <UnknownNode path={[...path, index]} />
148
+ {#snippet render_nodes(nodes_slice, start_index)}
149
+ {#each nodes_slice as id, slice_index (get_node_key(id, start_index + slice_index))}
150
+ {@const index = start_index + slice_index}
151
+ {@const node = svedit.session.get(id)}
152
+ {@const mark = get_mark(index)}
153
+ {@const annotations = get_annotation_contexts(index)}
154
+ <NodeGap array_path={path} offset={index} count={node_ids.length} />
155
+ {@const Component = svedit.session.config.node_components[node.type]}
156
+ {#if Component}
157
+ <Component path={[...path, index]} {mark} {annotations} />
158
+ {:else}
159
+ <UnknownNode path={[...path, index]} />
160
+ {/if}
161
+ {/each}
162
+ {/snippet}
163
+
164
+ {#each fragments as fragment, fragment_index (fragment_index)}
165
+ {#if fragment.type === 'nodes'}
166
+ {@render render_nodes(fragment.nodes, fragment.start_index)}
167
+ {:else if fragment.type === 'mark'}
168
+ {@const MarkComponent = svedit.session.config.node_components[fragment.node.type]}
169
+ {#if MarkComponent}
170
+ <MarkComponent path={[...path, 'marks', fragment.mark_index, 'node_id']}>
171
+ {@render render_nodes(fragment.nodes, fragment.start_index)}
172
+ </MarkComponent>
173
+ {:else}
174
+ {@render render_nodes(fragment.nodes, fragment.start_index)}
175
+ {/if}
69
176
  {/if}
70
177
  {/each}
71
178
  {#if node_ids.length > 0}
@@ -13,6 +13,7 @@
13
13
  <script>
14
14
  import { serialize_path } from './utils.js';
15
15
  import { getContext } from 'svelte';
16
+ import { should_position_gap } from './node_visibility.svelte.js';
16
17
 
17
18
  /**
18
19
  * ┌─────────────────────────────────────────────────────────────────┐
@@ -37,10 +38,13 @@
37
38
  *
38
39
  * Always present in the DOM when editable (stable structure for
39
40
  * selection anchoring, scrollTo, etc.). Anchor positioning is
40
- * activated lazily via the `.positioned` class, toggled imperatively
41
- * by the IO callback / MO / bootstrap in node_visibility — keeps
42
- * NodeGap allocation-free of per-instance Svelte signals, which
43
- * matters at 1000+ NodeGaps per scroll frame.
41
+ * activated lazily via the `.positioned` class, derived reactively
42
+ * from the visibility registry's array_indices / edge_map. Svelte's
43
+ * reactive collections track reads at per-key granularity, so a
44
+ * registry write only
45
+ * re-evaluates the adjacent gaps. Applying the class declaratively
46
+ * (instead of imperative classList toggles) means it survives DOM
47
+ * recreation without a document change (e.g. dev-mode HMR).
44
48
  *
45
49
  * `position-visibility: anchors-visible` does NOT skip anchor
46
50
  * resolution — the browser resolves all anchor() then hides the
@@ -62,6 +66,22 @@
62
66
  let is_last = $derived(offset === count);
63
67
  let type = $derived(is_first ? 'gap-before' : 'gap-after');
64
68
 
69
+ let path_str = $derived(serialize_path(array_path));
70
+ // Two-derived split is load-bearing: get_array_indices lazily
71
+ // creates the set, and Svelte doesn't track deps on state created
72
+ // inside the reading derived — so acquire here, read in `positioned`.
73
+ let near_indices = $derived(svedit.visibility_registry.get_array_indices(path_str));
74
+ let positioned = $derived(
75
+ is_editable &&
76
+ should_position_gap(
77
+ near_indices,
78
+ svedit.visibility_registry.edge_map.get(path_str),
79
+ offset,
80
+ is_last,
81
+ empty
82
+ )
83
+ );
84
+
65
85
  let gap_style = $derived.by(() => {
66
86
  if (!is_editable) return '';
67
87
  const prev_idx = offset - 1;
@@ -87,8 +107,9 @@
87
107
  class:gap-after={!is_first}
88
108
  class:empty
89
109
  class:last={is_last}
110
+ class:positioned
90
111
  data-type={type}
91
- data-gap-array-path={serialize_path(array_path)}
112
+ data-gap-array-path={path_str}
92
113
  data-gap-offset={offset}
93
114
  style={gap_style}
94
115
  >
@@ -111,9 +132,30 @@
111
132
  }
112
133
 
113
134
  /* ------------------------------------------------------------------ */
114
- /* Un-positioned: minimal in-DOM presence, no anchor resolution */
135
+ /* Un-positioned: no layout box at all */
115
136
  /* ------------------------------------------------------------------ */
116
137
 
138
+ /*
139
+ * display: none is load-bearing for large documents. A zero-size
140
+ * absolutely-positioned selectable still belongs to the containing
141
+ * block's out-of-flow list, and the browser lays out EVERY such box
142
+ * on EVERY layout pass — measured ~430ms per pass at 2000 nodes
143
+ * (~3500 gaps) in Chrome vs ~23ms with the boxes removed. That cost
144
+ * hits every keystroke (the per-change reconcile reads a rect for
145
+ * every node, and selection rendering forces layout), every window
146
+ * resize frame, and every scroll-triggered layout.
147
+ *
148
+ * Off-screen gaps therefore contribute no layout box. DOM structure
149
+ * stays stable (the .node-gap wrapper and selectable elements remain),
150
+ * and DOM Ranges may still point into display:none elements, so
151
+ * programmatic node selections targeting off-screen gaps keep working.
152
+ * Empty-array gaps are excluded: they must stay clickable/visible even
153
+ * before reconcile positions them.
154
+ */
155
+ :global(.node-gap:not(.positioned):not(.empty) .svedit-selectable) {
156
+ display: none;
157
+ }
158
+
117
159
  :global(.node-gap:not(.positioned) .svedit-selectable) {
118
160
  position: absolute;
119
161
  pointer-events: none;
@@ -1,17 +1,21 @@
1
1
  import Transaction from './Transaction.svelte.js';
2
- import { char_slice, traverse } from './utils.js';
2
+ import { char_slice, traverse, traverse_ids } from './utils.js';
3
3
  import {
4
4
  get as doc_get,
5
5
  property_type as doc_property_type,
6
6
  kind as doc_kind,
7
7
  inspect as doc_inspect,
8
- apply_op,
8
+ create_document_draft,
9
+ apply_op_to_draft,
9
10
  count_references as doc_count_references,
10
11
  validate_document_schema,
11
12
  validate_document,
13
+ validate_config_components,
12
14
  validate_node,
15
+ is_id_valid,
13
16
  get_referencing_node_ids,
14
- get_active_annotation,
17
+ get_selected_marks,
18
+ get_selected_annotations,
15
19
  validate_selection
16
20
  } from './doc_utils.js';
17
21
 
@@ -20,6 +24,8 @@ import {
20
24
  * NodeId,
21
25
  * DocumentPath,
22
26
  * Selection,
27
+ * Attachment,
28
+ * Mark,
23
29
  * Annotation,
24
30
  * NodeKind,
25
31
  * DocumentSchema,
@@ -63,8 +69,14 @@ export default class Session {
63
69
 
64
70
  // Reactive variable for selected node
65
71
  selected_node = $derived(this.get_selected_node());
72
+ available_mark_types = $derived(this.get_available_mark_types());
66
73
  available_annotation_types = $derived(this.get_available_annotation_types());
67
-
74
+ selected_marks = $derived(get_selected_marks(this.schema, this.doc, this.selection));
75
+ active_mark = $derived(this.selected_marks.length === 1 ? this.selected_marks[0] : null);
76
+ selected_annotations = $derived(get_selected_annotations(this.schema, this.doc, this.selection));
77
+ active_annotation = $derived(
78
+ this.selected_annotations.length === 1 ? this.selected_annotations[0] : null
79
+ );
68
80
 
69
81
  /**
70
82
  * @param {DocumentSchema} schema - The document schema
@@ -81,6 +93,7 @@ export default class Session {
81
93
  this.doc = doc;
82
94
  this.config = config;
83
95
  validate_document(this.doc, this.schema);
96
+ validate_config_components(this.schema, this.config);
84
97
 
85
98
  // Set selection after doc is initialized so validation can work properly
86
99
  this.selection = options.selection ?? null;
@@ -136,23 +149,33 @@ export default class Session {
136
149
  */
137
150
  validate_transaction_result(transaction) {
138
151
  const doc = transaction.doc;
139
- /** @type {NodeId[]} */
140
- const affected_node_ids = [];
141
-
142
- function add_node_id(node_id) {
143
- if (!affected_node_ids.includes(node_id)) affected_node_ids.push(node_id);
144
- }
145
-
146
- for (const node_id of transaction.created_node_ids) add_node_id(node_id);
147
- for (const node_id of transaction.modified_node_ids) add_node_id(node_id);
148
- for (const node_id of get_referencing_node_ids(this.schema, doc, transaction.created_node_ids)) {
149
- add_node_id(node_id);
150
- }
151
- for (const node_id of get_referencing_node_ids(this.schema, doc, transaction.modified_node_ids)) {
152
- add_node_id(node_id);
153
- }
154
- for (const node_id of get_referencing_node_ids(this.schema, doc, transaction.deleted_node_ids)) {
155
- add_node_id(node_id);
152
+ /** @type {Set<NodeId>} */
153
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local dedup set.
154
+ const affected_node_ids = new Set([
155
+ ...transaction.created_node_ids,
156
+ ...transaction.modified_node_ids
157
+ ]);
158
+
159
+ // A full-document referrer scan is only needed when a reference held
160
+ // by an UNTOUCHED node can have become invalid:
161
+ // - deletions can leave dangling references behind
162
+ // - a 'type' change can violate a referrer's node/mark/annotation type
163
+ // constraint
164
+ // References to created nodes can only live in nodes that were
165
+ // themselves created or modified in this transaction (the reference
166
+ // did not exist before), and modifying a node's other properties
167
+ // cannot invalidate its referrers — so those cases are already
168
+ // covered by the affected set and need no scan.
169
+ if (transaction.deleted_node_ids.length > 0 || transaction.changed_node_types) {
170
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local dedup set.
171
+ const scan_targets = new Set([
172
+ ...transaction.created_node_ids,
173
+ ...transaction.modified_node_ids,
174
+ ...transaction.deleted_node_ids
175
+ ]);
176
+ for (const node_id of get_referencing_node_ids(this.schema, doc, scan_targets)) {
177
+ affected_node_ids.add(node_id);
178
+ }
156
179
  }
157
180
 
158
181
  for (const node_id of affected_node_ids) {
@@ -162,11 +185,13 @@ export default class Session {
162
185
  }
163
186
 
164
187
  generate_id() {
165
- if (this.config?.generate_id) {
166
- return this.config.generate_id();
167
- } else {
168
- return crypto.randomUUID();
188
+ const id = this.config?.generate_id ? this.config.generate_id() : `node_${crypto.randomUUID()}`;
189
+ if (!is_id_valid(id)) {
190
+ throw new Error(
191
+ `Generated node id ${JSON.stringify(id)} is invalid. Node ids must be non-empty strings that start with a letter or underscore, contain only letters, numbers, underscores, or dashes, and must not contain "__".`
192
+ );
169
193
  }
194
+ return id;
170
195
  }
171
196
 
172
197
  /**
@@ -187,11 +212,16 @@ export default class Session {
187
212
  }
188
213
  }
189
214
 
215
+ get_available_mark_types() {
216
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
217
+ const property_definition = this.inspect(this.selection.path);
218
+ return property_definition.mark_types || [];
219
+ }
220
+
190
221
  get_available_annotation_types() {
191
- if (this.selection?.type !== 'text') return [];
192
- const path = this.selection.path;
193
- const property_definition = this.inspect(path);
194
- return property_definition.node_types || [];
222
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
223
+ const property_definition = this.inspect(this.selection.path);
224
+ return property_definition.annotation_types || [];
195
225
  }
196
226
 
197
227
  // Helper function to get the currently selected node
@@ -204,7 +234,7 @@ export default class Session {
204
234
  // Only consider selection of a single node
205
235
  if (end - start !== 1) return null;
206
236
  const node_array = this.get(this.selection.path);
207
- const node_id = node_array[start];
237
+ const node_id = node_array.nodes[start];
208
238
  return node_id ? this.get(node_id) : null;
209
239
  } else {
210
240
  // we are assuming we are either in a text or property (=custom) selection
@@ -235,7 +265,13 @@ export default class Session {
235
265
  */
236
266
  apply(transaction, { batch = false } = {}) {
237
267
  this.validate_transaction_result(transaction);
238
- this.doc = transaction.doc;
268
+ // Only swap the doc when something changed. The transaction draft is
269
+ // always a fresh object, and assigning it for an ops-less transaction
270
+ // (e.g. selection-only) would needlessly invalidate every doc-dependent
271
+ // derived in the component tree.
272
+ if (transaction.ops.length > 0) {
273
+ this.doc = transaction.doc;
274
+ }
239
275
  // Make sure selection gets a new reference (is rerendered)
240
276
  this.selection = structuredClone(transaction.selection);
241
277
  if (this.history_index < this.history.length - 1) {
@@ -284,12 +320,14 @@ export default class Session {
284
320
  return;
285
321
  }
286
322
  const change = this.history[this.history_index];
287
- let doc = this.doc;
323
+ // One draft for the whole change set — ops mutate the draft with
324
+ // node-level copy-on-write instead of copying the nodes map per op.
325
+ const doc = create_document_draft(this.doc);
288
326
  change.inverse_ops
289
327
  .slice()
290
328
  .reverse()
291
329
  .forEach((op) => {
292
- doc = apply_op(doc, op);
330
+ apply_op_to_draft(doc, op);
293
331
  });
294
332
  this.doc = doc;
295
333
  this.selection = change.selection_before;
@@ -303,9 +341,9 @@ export default class Session {
303
341
  }
304
342
  this.history_index = this.history_index + 1;
305
343
  const change = this.history[this.history_index];
306
- let doc = this.doc;
344
+ const doc = create_document_draft(this.doc);
307
345
  change.ops.forEach((op) => {
308
- doc = apply_op(doc, op);
346
+ apply_op_to_draft(doc, op);
309
347
  });
310
348
  this.doc = doc;
311
349
  this.selection = change.selection_after;
@@ -329,8 +367,8 @@ export default class Session {
329
367
  * session.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
330
368
  *
331
369
  * @example
332
- * // Get an annotated text property
333
- * session.get(['page_1', 'cover', 'title']) // => {text: 'Hello world', annotations: []}
370
+ * // Get a text property
371
+ * session.get(['page_1', 'cover', 'title']) // => {content: 'Hello world', marks: [], annotations: []}
334
372
  */
335
373
  get(path) {
336
374
  return doc_get(this.schema, this.doc, path);
@@ -368,7 +406,7 @@ export default class Session {
368
406
 
369
407
  /**
370
408
  * Determines the kind of a node ('block' for structured blocks, 'text' for pure
371
- * text nodes or 'annotation' for annotation nodes.
409
+ * text nodes, 'mark' for mark nodes or 'annotation' for annotation nodes.
372
410
  * @param {any} node
373
411
  * @returns {NodeKind}
374
412
  */
@@ -417,46 +455,82 @@ export default class Session {
417
455
  };
418
456
  }
419
457
 
420
- /**
421
- * Returns the annotation object that is currently "under the cursor".
422
- * NOTE: Annotations in Svedit are exclusive, so there can only be one active_annotation
423
- *
424
- * @param {string} [annotation_type] Optional annotation type to filter by
425
- * @returns {Annotation|null}
426
- */
427
- active_annotation(annotation_type) {
428
- return get_active_annotation(this.schema, this.doc, this.selection, annotation_type);
429
- }
430
-
431
- get_selected_annotated_text() {
458
+ get_selected_text() {
432
459
  if (this.selection?.type !== 'text') return null;
433
460
 
434
461
  const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
435
462
  const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
436
- const annotated_text = this.get(this.selection.path);
437
- const text = char_slice(annotated_text.text, selection_start, selection_end);
463
+ const text_value = this.get(this.selection.path);
464
+ const selected_text = char_slice(text_value.content, selection_start, selection_end);
438
465
  const nodes = {};
439
- const annotations = annotated_text.annotations
440
- .map((a) => {
441
- if (selection_start < a.end_offset && selection_end > a.start_offset) {
442
- const sub_graph = this.traverse(a.node_id);
443
- for (const node of sub_graph) {
444
- if (!nodes[node.id]) {
445
- nodes[node.id] = node;
466
+
467
+ const clip_ranges = (ranges) =>
468
+ ranges
469
+ .map((range) => {
470
+ if (selection_start < range.end_offset && selection_end > range.start_offset) {
471
+ const sub_graph = this.traverse(range.node_id);
472
+ for (const node of sub_graph) {
473
+ if (!nodes[node.id]) {
474
+ nodes[node.id] = node;
475
+ }
446
476
  }
477
+ return {
478
+ start_offset: Math.max(range.start_offset - selection_start, 0),
479
+ end_offset: Math.min(
480
+ range.end_offset - selection_start,
481
+ selection_end - selection_start
482
+ ),
483
+ node_id: range.node_id
484
+ };
485
+ } else {
486
+ return null;
447
487
  }
488
+ })
489
+ .filter(Boolean);
490
+
491
+ const marks = clip_ranges(text_value.marks);
492
+ const annotations = clip_ranges(text_value.annotations);
493
+
494
+ return { content: selected_text, marks, annotations, nodes };
495
+ }
496
+
497
+ get_selected_annotated_nodes() {
498
+ if (this.selection?.type !== 'node') return null;
499
+
500
+ const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
501
+ const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
502
+ const node_array = this.get(this.selection.path);
503
+ const main_nodes = node_array.nodes.slice(selection_start, selection_end);
504
+ const nodes = {};
505
+
506
+ const add_subgraph = (node_id) => {
507
+ for (const node of this.traverse(node_id)) {
508
+ if (!nodes[node.id]) nodes[node.id] = node;
509
+ }
510
+ };
511
+
512
+ for (const node_id of main_nodes) add_subgraph(node_id);
513
+
514
+ const clip_ranges = (ranges) =>
515
+ ranges
516
+ .map((range) => {
517
+ if (selection_start >= range.end_offset || selection_end <= range.start_offset) {
518
+ return null;
519
+ }
520
+
521
+ add_subgraph(range.node_id);
448
522
  return {
449
- start_offset: Math.max(a.start_offset - selection_start, 0),
450
- end_offset: Math.min(a.end_offset - selection_start, selection_end - selection_start),
451
- node_id: a.node_id
523
+ start_offset: Math.max(range.start_offset - selection_start, 0),
524
+ end_offset: Math.min(range.end_offset - selection_start, selection_end - selection_start),
525
+ node_id: range.node_id
452
526
  };
453
- } else {
454
- return null;
455
- }
456
- })
457
- .filter(Boolean);
527
+ })
528
+ .filter(Boolean);
458
529
 
459
- return { text, annotations, nodes };
530
+ const marks = clip_ranges(node_array.marks);
531
+ const annotations = clip_ranges(node_array.annotations);
532
+
533
+ return { nodes, main_nodes, marks, annotations };
460
534
  }
461
535
 
462
536
  // TODO: think about ways how we can also turn a node selection into plain text.
@@ -465,8 +539,8 @@ export default class Session {
465
539
 
466
540
  const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
467
541
  const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
468
- const annotated_text = this.get(this.selection.path);
469
- return char_slice(annotated_text.text, start, end);
542
+ const text = this.get(this.selection.path);
543
+ return char_slice(text.content, start, end);
470
544
  }
471
545
 
472
546
  get_selected_nodes() {
@@ -475,7 +549,9 @@ export default class Session {
475
549
  const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
476
550
  const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
477
551
  const node_array = this.get(this.selection.path);
478
- return $state.snapshot(node_array.slice(start, end));
552
+ // node_array.nodes is a plain array of id strings (doc is $state.raw),
553
+ // so a slice is already a safe fresh copy.
554
+ return node_array.nodes.slice(start, end);
479
555
  }
480
556
 
481
557
  select_parent() {
@@ -528,7 +604,11 @@ export default class Session {
528
604
  * @note Nodes that are not reachable from the entry point node will not be included
529
605
  */
530
606
  traverse(node_id) {
531
- return traverse(node_id, this.schema, $state.snapshot(this.doc.nodes));
607
+ // doc is $state.raw, so doc.nodes is a plain (non-proxied) object —
608
+ // traverse() reads it directly and deep-clones each visited node.
609
+ // The previous $state.snapshot(this.doc.nodes) here deep-cloned the
610
+ // ENTIRE document per call, which made copying M nodes O(M·N).
611
+ return traverse(node_id, this.schema, this.doc.nodes);
532
612
  }
533
613
 
534
614
  /**
@@ -551,7 +631,7 @@ export default class Session {
551
631
  }
552
632
 
553
633
  // property_type('page', 'body') => 'node_array'
554
- // property_type('paragraph', 'content') => 'annotated_text'
634
+ // property_type('paragraph', 'content') => 'text'
555
635
  property_type(type, property) {
556
636
  return doc_property_type(this.schema, type, property);
557
637
  }
@@ -567,9 +647,7 @@ export default class Session {
567
647
  * @returns {NodeId[]}
568
648
  */
569
649
  get_referenced_nodes(node_id) {
570
- const traversed_nodes = this.traverse(node_id);
571
-
572
- // Extract IDs and exclude the last element (root node)
573
- return traversed_nodes.slice(0, -1).map((node) => node.id);
650
+ // Id-only traversal — no need to deep-clone the subgraph for ids.
651
+ return traverse_ids(node_id, this.schema, this.doc.nodes).slice(0, -1);
574
652
  }
575
653
  }