svedit 0.11.1 → 0.13.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 (93) hide show
  1. package/README.md +260 -82
  2. package/dist/Command.svelte.d.ts +54 -29
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +286 -283
  5. package/dist/CustomProperty.svelte +3 -5
  6. package/dist/CustomProperty.svelte.d.ts +3 -6
  7. package/dist/CustomProperty.svelte.d.ts.map +1 -1
  8. package/dist/KeyMapper.svelte.d.ts +19 -6
  9. package/dist/KeyMapper.svelte.d.ts.map +1 -1
  10. package/dist/KeyMapper.svelte.js +95 -112
  11. package/dist/Node.svelte +40 -9
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +142 -18
  15. package/dist/NodeArrayProperty.svelte.d.ts +3 -5
  16. package/dist/NodeArrayProperty.svelte.d.ts.map +1 -1
  17. package/dist/NodeCaret.svelte +1 -1
  18. package/dist/NodeCaret.svelte.d.ts +6 -14
  19. package/dist/NodeCaret.svelte.d.ts.map +1 -1
  20. package/dist/NodeGap.svelte +60 -12
  21. package/dist/NodeGap.svelte.d.ts +7 -14
  22. package/dist/NodeGap.svelte.d.ts.map +1 -1
  23. package/dist/NodeGapMarkers.svelte +19 -10
  24. package/dist/NodeGapMarkers.svelte.d.ts +6 -11
  25. package/dist/NodeGapMarkers.svelte.d.ts.map +1 -1
  26. package/dist/NodeSelectionMarkers.svelte +20 -6
  27. package/dist/NodeSelectionMarkers.svelte.d.ts +2 -5
  28. package/dist/NodeSelectionMarkers.svelte.d.ts.map +1 -1
  29. package/dist/Session.svelte.d.ts +85 -116
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +543 -571
  32. package/dist/Svedit.svelte +243 -216
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  36. package/dist/TextProperty.svelte.d.ts +5 -0
  37. package/dist/TextProperty.svelte.d.ts.map +1 -0
  38. package/dist/Transaction.svelte.d.ts +101 -126
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +843 -827
  41. package/dist/UnknownNode.svelte +4 -3
  42. package/dist/UnknownNode.svelte.d.ts +5 -9
  43. package/dist/UnknownNode.svelte.d.ts.map +1 -1
  44. package/dist/doc_utils.d.ts +109 -122
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +726 -698
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +2 -18
  50. package/dist/node_visibility.svelte.d.ts +132 -5
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +535 -642
  53. package/dist/paste_utils.d.ts +11 -43
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -149
  56. package/dist/transforms.svelte.d.ts +9 -7
  57. package/dist/transforms.svelte.d.ts.map +1 -1
  58. package/dist/transforms.svelte.js +126 -152
  59. package/dist/types.d.ts +329 -291
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +1 -0
  62. package/dist/utils.d.ts +79 -95
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +392 -305
  65. package/package.json +69 -67
  66. package/src/lib/{Command.svelte.js → Command.svelte.ts} +111 -41
  67. package/src/lib/CustomProperty.svelte +3 -5
  68. package/src/lib/{KeyMapper.svelte.js → KeyMapper.svelte.ts} +28 -11
  69. package/src/lib/Node.svelte +40 -9
  70. package/src/lib/NodeArrayProperty.svelte +142 -18
  71. package/src/lib/NodeCaret.svelte +1 -1
  72. package/src/lib/NodeGap.svelte +60 -12
  73. package/src/lib/NodeGapMarkers.svelte +19 -10
  74. package/src/lib/NodeSelectionMarkers.svelte +20 -6
  75. package/src/lib/Session.svelte.ts +656 -0
  76. package/src/lib/Svedit.svelte +243 -216
  77. package/src/lib/{AnnotatedTextProperty.svelte → TextProperty.svelte} +71 -56
  78. package/src/lib/Transaction.svelte.ts +1020 -0
  79. package/src/lib/UnknownNode.svelte +4 -3
  80. package/src/lib/doc_utils.ts +1120 -0
  81. package/src/lib/{index.js → index.ts} +48 -1
  82. package/src/lib/node_visibility.svelte.ts +658 -0
  83. package/src/lib/{paste_utils.js → paste_utils.ts} +51 -59
  84. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +44 -27
  85. package/src/lib/{types.d.ts → types.ts} +250 -103
  86. package/src/lib/utils.ts +660 -0
  87. package/dist/AnnotatedTextProperty.svelte.d.ts +0 -7
  88. package/dist/AnnotatedTextProperty.svelte.d.ts.map +0 -1
  89. package/src/lib/Session.svelte.js +0 -575
  90. package/src/lib/Transaction.svelte.js +0 -846
  91. package/src/lib/doc_utils.js +0 -807
  92. package/src/lib/node_visibility.svelte.js +0 -718
  93. package/src/lib/utils.js +0 -465
@@ -0,0 +1,656 @@
1
+ import Transaction from './Transaction.svelte.js';
2
+ import { char_slice, traverse, traverse_ids } from './utils.js';
3
+ import {
4
+ get as doc_get,
5
+ property_type as doc_property_type,
6
+ kind as doc_kind,
7
+ inspect as doc_inspect,
8
+ create_document_draft,
9
+ apply_op_to_draft,
10
+ count_references as doc_count_references,
11
+ validate_document_schema,
12
+ validate_document,
13
+ validate_config_components,
14
+ validate_node,
15
+ is_id_valid,
16
+ get_referencing_node_ids,
17
+ get_selected_marks,
18
+ get_selected_annotations,
19
+ validate_selection
20
+ } from './doc_utils.js';
21
+ import type { SelectedAttachment } from './doc_utils.js';
22
+ import type {
23
+ NodeId,
24
+ DocumentPath,
25
+ Selection,
26
+ Attachment,
27
+ NodeKind,
28
+ DocumentSchema,
29
+ DocumentNode,
30
+ Document,
31
+ AnyNode,
32
+ Text,
33
+ NodeArray,
34
+ CommandRegistry,
35
+ Inspection,
36
+ SessionConfig,
37
+ SveditContext
38
+ } from './types.js';
39
+ import type { Keymap } from './KeyMapper.svelte.js';
40
+
41
+ const BATCH_WINDOW_MS = 1000; // 1 second
42
+
43
+ type HistoryEntry = {
44
+ ops: Transaction['ops'];
45
+ inverse_ops: Transaction['inverse_ops'];
46
+ selection_before: Selection | null;
47
+ selection_after: Selection | null;
48
+ };
49
+
50
+ export default class Session<S extends DocumentSchema = DocumentSchema> {
51
+ #selection: Selection | null = $state.raw(null);
52
+
53
+ schema: S = $state.raw() as S;
54
+
55
+ doc: Document = $state.raw() as Document;
56
+
57
+ config: SessionConfig = $state.raw({});
58
+
59
+ history: HistoryEntry[] = $state.raw([]);
60
+ history_index = $state.raw(-1);
61
+ last_batch_started: number | undefined = $state.raw(undefined); // Timestamp for debounced batching
62
+
63
+ // Commands and keymap - initialized by Svedit when ready
64
+ // NOTE: Assumes single Svedit instance per session
65
+ commands: CommandRegistry = $state.raw({});
66
+ keymap: Keymap = $state.raw({});
67
+
68
+ // Reactive helpers for UI state
69
+ can_undo = $derived(this.history_index >= 0);
70
+ can_redo = $derived(this.history_index < this.history.length - 1);
71
+
72
+ // Reactive variable for selected node
73
+ selected_node: AnyNode<S> | null = $derived(this.get_selected_node());
74
+ available_mark_types = $derived(this.get_available_mark_types());
75
+ available_annotation_types = $derived(this.get_available_annotation_types());
76
+ selected_marks = $derived(get_selected_marks(this.schema, this.doc, this.selection));
77
+ active_mark: SelectedAttachment | null = $derived(
78
+ this.selected_marks.length === 1 ? this.selected_marks[0] : null
79
+ );
80
+ selected_annotations = $derived(get_selected_annotations(this.schema, this.doc, this.selection));
81
+ active_annotation: SelectedAttachment | null = $derived(
82
+ this.selected_annotations.length === 1 ? this.selected_annotations[0] : null
83
+ );
84
+
85
+ /**
86
+ * @param schema - The document schema
87
+ * @param doc - The document
88
+ * @param config - configuration object
89
+ * @param options - Optional settings (e.g. initial selection state)
90
+ */
91
+ constructor(
92
+ schema: S,
93
+ doc: Document,
94
+ config: SessionConfig,
95
+ options: { selection?: Selection | null } = {}
96
+ ) {
97
+ // Validate the schema first
98
+ validate_document_schema(schema);
99
+
100
+ this.schema = schema;
101
+ this.doc = doc;
102
+ this.config = config;
103
+ validate_document(this.doc, this.schema);
104
+ validate_config_components(this.schema, this.config);
105
+
106
+ // Set selection after doc is initialized so validation can work properly
107
+ this.selection = options.selection ?? null;
108
+ }
109
+
110
+ /**
111
+ * Gets the current selection
112
+ */
113
+ get selection(): Selection | null {
114
+ return this.#selection;
115
+ }
116
+
117
+ /**
118
+ * Sets the selection with validation
119
+ * @throws {Error} Throws if the selection is invalid
120
+ */
121
+ set selection(value: Selection | null) {
122
+ this._validate_selection(value);
123
+ this.#selection = value;
124
+ }
125
+
126
+ /**
127
+ * Validates that a selection is within bounds and refers to valid paths.
128
+ *
129
+ * @throws {Error} Throws if the selection is invalid
130
+ */
131
+ private _validate_selection(selection: Selection | null): void {
132
+ validate_selection(selection, this);
133
+ }
134
+
135
+ /**
136
+ * Gets the document_id from the doc
137
+ */
138
+ get document_id(): string {
139
+ return this.doc.document_id;
140
+ }
141
+
142
+ /**
143
+ * Validates the parts of a transaction result that can be affected by the transaction.
144
+ *
145
+ * This is intentionally narrower than full document validation. It validates all
146
+ * created/modified nodes, plus nodes that still reference created, modified, or
147
+ * deleted nodes. That catches local node invariants and dangling references left
148
+ * behind by deletes without scanning every node on every apply.
149
+ *
150
+ * @throws {Error} Throws if the transaction result is invalid
151
+ */
152
+ validate_transaction_result(transaction: Transaction): void {
153
+ const doc = transaction.doc;
154
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local dedup set.
155
+ const affected_node_ids: Set<NodeId> = new Set([
156
+ ...transaction.created_node_ids,
157
+ ...transaction.modified_node_ids
158
+ ]);
159
+
160
+ // A full-document referrer scan is only needed when a reference held
161
+ // by an UNTOUCHED node can have become invalid:
162
+ // - deletions can leave dangling references behind
163
+ // - a 'type' change can violate a referrer's node/mark/annotation type
164
+ // constraint
165
+ // References to created nodes can only live in nodes that were
166
+ // themselves created or modified in this transaction (the reference
167
+ // did not exist before), and modifying a node's other properties
168
+ // cannot invalidate its referrers — so those cases are already
169
+ // covered by the affected set and need no scan.
170
+ if (transaction.deleted_node_ids.length > 0 || transaction.changed_node_types) {
171
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local dedup set.
172
+ const scan_targets = new Set([
173
+ ...transaction.created_node_ids,
174
+ ...transaction.modified_node_ids,
175
+ ...transaction.deleted_node_ids
176
+ ]);
177
+ for (const node_id of get_referencing_node_ids(this.schema, doc, scan_targets)) {
178
+ affected_node_ids.add(node_id);
179
+ }
180
+ }
181
+
182
+ for (const node_id of affected_node_ids) {
183
+ const node = doc.nodes[node_id];
184
+ if (node) validate_node(node, this.schema, doc.nodes);
185
+ }
186
+ }
187
+
188
+ generate_id(): string {
189
+ const id = this.config?.generate_id ? this.config.generate_id() : `node_${crypto.randomUUID()}`;
190
+ if (!is_id_valid(id)) {
191
+ throw new Error(
192
+ `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 "__".`
193
+ );
194
+ }
195
+ return id;
196
+ }
197
+
198
+ /**
199
+ * Initialize commands and keymap for this session.
200
+ * Called by Svedit component when it has the necessary context.
201
+ *
202
+ * NOTE: This assumes a single Svedit instance per session.
203
+ * For multiple editors on the same document, this architecture would need
204
+ * to be refactored to support multiple sessions per document.
205
+ *
206
+ * @param context - The svedit context with session, editable, canvas, etc.
207
+ */
208
+ initialize_commands(context: SveditContext<S>): void {
209
+ if (this.config?.create_commands_and_keymap) {
210
+ const { commands, keymap } = this.config.create_commands_and_keymap(context);
211
+ this.commands = commands;
212
+ this.keymap = keymap;
213
+ }
214
+ }
215
+
216
+ get_available_mark_types(): string[] {
217
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
218
+ const property_definition = this.inspect(this.selection.path);
219
+ return property_definition.mark_types || [];
220
+ }
221
+
222
+ get_available_annotation_types(): string[] {
223
+ if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
224
+ const property_definition = this.inspect(this.selection.path);
225
+ return property_definition.annotation_types || [];
226
+ }
227
+
228
+ // Helper function to get the currently selected node
229
+ get_selected_node(): AnyNode<S> | null {
230
+ if (!this.selection) return null;
231
+
232
+ if (this.selection.type === 'node') {
233
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
234
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
235
+ // Only consider selection of a single node
236
+ if (end - start !== 1) return null;
237
+ const node_array: NodeArray = this.get(this.selection.path);
238
+ const node_id = node_array.nodes[start];
239
+ return node_id ? this.get(node_id) : null;
240
+ } else {
241
+ // we are assuming we are either in a text or property (=custom) selection
242
+ const owner_node_path = this.selection?.path?.slice(0, -1);
243
+ if (!owner_node_path) return null;
244
+ const owner_node = this.get(owner_node_path);
245
+ return owner_node;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Creates a new transaction for making atomic changes to the document.
251
+ */
252
+ get tr(): Transaction {
253
+ // We create a copy of the current state to avoid modifying the original
254
+ return new Transaction(this.schema, this.doc, this.selection, this.config);
255
+ }
256
+
257
+ /**
258
+ * Applies a transaction to the document.
259
+ * Auto-batches history entries with debounced behavior (max one entry per second) when batch is true.
260
+ *
261
+ * @param transaction - The transaction to apply
262
+ * @param options - Optional configuration (batch: whether to allow batching with previous transaction)
263
+ */
264
+ apply(transaction: Transaction, { batch = false }: { batch?: boolean } = {}): this {
265
+ this.validate_transaction_result(transaction);
266
+ // Only swap the doc when something changed. The transaction draft is
267
+ // always a fresh object, and assigning it for an ops-less transaction
268
+ // (e.g. selection-only) would needlessly invalidate every doc-dependent
269
+ // derived in the component tree.
270
+ if (transaction.ops.length > 0) {
271
+ this.doc = transaction.doc;
272
+ }
273
+ // Make sure selection gets a new reference (is rerendered)
274
+ this.selection = structuredClone(transaction.selection);
275
+ if (this.history_index < this.history.length - 1) {
276
+ this.history = this.history.slice(0, this.history_index + 1);
277
+ }
278
+
279
+ const now = Date.now();
280
+ const should_batch =
281
+ batch &&
282
+ this.last_batch_started !== undefined &&
283
+ now - this.last_batch_started < BATCH_WINDOW_MS;
284
+
285
+ if (should_batch) {
286
+ // Append to existing history entry (within the batch window)
287
+ const last_entry = this.history[this.history_index];
288
+ last_entry.ops.push(...transaction.ops);
289
+ last_entry.inverse_ops.push(...transaction.inverse_ops);
290
+ last_entry.selection_after = this.selection;
291
+ // Trigger update
292
+ this.history = [...this.history];
293
+ } else {
294
+ // Create new history entry (batch window elapsed, first edit, or batch not requested)
295
+ this.history = [
296
+ ...this.history,
297
+ {
298
+ ops: transaction.ops,
299
+ inverse_ops: transaction.inverse_ops,
300
+ selection_before: transaction.selection_before,
301
+ selection_after: this.selection
302
+ }
303
+ ];
304
+ this.history_index = this.history_index + 1;
305
+ // Only set last_batch_started if batching was requested
306
+ if (batch) {
307
+ this.last_batch_started = now;
308
+ } else {
309
+ this.last_batch_started = undefined;
310
+ }
311
+ }
312
+
313
+ return this;
314
+ }
315
+
316
+ undo(): this | undefined {
317
+ if (this.history_index < 0) {
318
+ return;
319
+ }
320
+ const change = this.history[this.history_index];
321
+ // One draft for the whole change set — ops mutate the draft with
322
+ // node-level copy-on-write instead of copying the nodes map per op.
323
+ const doc = create_document_draft(this.doc);
324
+ change.inverse_ops
325
+ .slice()
326
+ .reverse()
327
+ .forEach((op) => {
328
+ apply_op_to_draft(doc, op);
329
+ });
330
+ this.doc = doc;
331
+ this.selection = change.selection_before;
332
+ this.history_index = this.history_index - 1;
333
+ // History navigation ends any open typing batch, so the next batched
334
+ // apply starts a fresh entry instead of appending to a stale one.
335
+ this.last_batch_started = undefined;
336
+ return this;
337
+ }
338
+
339
+ redo(): this | undefined {
340
+ if (this.history_index >= this.history.length - 1) {
341
+ return;
342
+ }
343
+ this.history_index = this.history_index + 1;
344
+ const change = this.history[this.history_index];
345
+ const doc = create_document_draft(this.doc);
346
+ change.ops.forEach((op) => {
347
+ apply_op_to_draft(doc, op);
348
+ });
349
+ this.doc = doc;
350
+ this.selection = change.selection_after;
351
+ // Redo ends any open typing batch, matching undo().
352
+ this.last_batch_started = undefined;
353
+ return this;
354
+ }
355
+
356
+ /**
357
+ * Gets a node instance or property value at the specified path.
358
+ *
359
+ * The result type defaults to `any` because a path can address a node or
360
+ * any property value. Annotate the target to type the result.
361
+ *
362
+ * @example
363
+ * // Get a node by ID
364
+ * session.get('list_1') // => { type: 'list', id: 'list_1', ... }
365
+ *
366
+ * @example
367
+ * // Get a node array property
368
+ * session.get(['list_1', 'list_items']) // => [ 'list_item_1', 'list_item_2' ]
369
+ *
370
+ * @example
371
+ * // Get a specific node from an array
372
+ * session.get(['page_1', 'body', 3, 'list_items', 0]) // => { type: 'list_item', id: 'list_item_1', ... }
373
+ *
374
+ * @example
375
+ * // Get a text property
376
+ * session.get(['page_1', 'cover', 'title']) // => {content: 'Hello world', marks: [], annotations: []}
377
+ */
378
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
379
+ get<T = any>(path: DocumentPath | string): T {
380
+ return doc_get(this.schema, this.doc, path);
381
+ }
382
+
383
+ /**
384
+ * While .get gives you the value of a path, inspect gives you
385
+ * the type info of that value.
386
+ *
387
+ * @todo The layout of these should be improved and more explictly typed
388
+ *
389
+ * @example
390
+ * session.inspect(['page_1', 'body']) => {
391
+ * kind: 'property',
392
+ * name: 'body',
393
+ * type: 'node_array',
394
+ * node_types: ['text', 'story', 'list'],
395
+ * default_node_type: 'text'
396
+ * }
397
+ *
398
+ * @example
399
+ * session.inspect(['page_1', 'body', 1]) => {
400
+ * kind: 'node',
401
+ * id: 'paragraph_234',
402
+ * type: 'paragraph',
403
+ * properties: {...}
404
+ * }
405
+ */
406
+ inspect(path: DocumentPath): Inspection {
407
+ return doc_inspect(this.schema, this.doc, path);
408
+ }
409
+
410
+ /**
411
+ * Determines the kind of a node ('document' for root nodes, 'block' for
412
+ * structured blocks, 'text' for pure text nodes, 'mark' for mark nodes or
413
+ * 'annotation' for annotation nodes).
414
+ */
415
+ kind(node: DocumentNode): NodeKind {
416
+ return doc_kind(this.schema, node);
417
+ }
418
+
419
+ /**
420
+ * Determines whether a node type can be inserted at a given selection.
421
+ *
422
+ * @param node_type - The type of node to insert.
423
+ * @param selection - The selection at which to insert the node.
424
+ * @returns True if the node type can be inserted, false otherwise.
425
+ */
426
+ can_insert(node_type: string, selection: Selection | null = this.selection): boolean {
427
+ if (selection?.type === 'node') {
428
+ const property_definition = this.inspect(selection.path);
429
+ if (property_definition.node_types.includes(node_type)) {
430
+ return true;
431
+ }
432
+ }
433
+
434
+ // No insert position found yet, and root not reached, try one level up if possible
435
+ let next_node_insert_caret = this.get_next_node_insert_caret(selection);
436
+ if (!next_node_insert_caret) return false;
437
+ return this.can_insert(node_type, next_node_insert_caret);
438
+ }
439
+
440
+ /**
441
+ * Compute next possible insert position from a given selection
442
+ *
443
+ * @param selection - Reference selection
444
+ * @returns The next node insert caret selection, or null if none is available
445
+ */
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;
450
+ }
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
+ };
459
+ }
460
+
461
+ get_selected_text(): (Text & { nodes: Record<string, DocumentNode> }) | null {
462
+ if (this.selection?.type !== 'text') return null;
463
+
464
+ const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
465
+ const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
466
+ const text_value: Text = this.get(this.selection.path);
467
+ const selected_text = char_slice(text_value.content, selection_start, selection_end);
468
+ const nodes: Record<string, DocumentNode> = {};
469
+
470
+ const clip_ranges = (ranges: Attachment[]) =>
471
+ ranges
472
+ .map((range) => {
473
+ if (selection_start < range.end_offset && selection_end > range.start_offset) {
474
+ const sub_graph = this.traverse(range.node_id);
475
+ for (const node of sub_graph) {
476
+ if (!nodes[node.id]) {
477
+ nodes[node.id] = node;
478
+ }
479
+ }
480
+ return {
481
+ start_offset: Math.max(range.start_offset - selection_start, 0),
482
+ end_offset: Math.min(
483
+ range.end_offset - selection_start,
484
+ selection_end - selection_start
485
+ ),
486
+ node_id: range.node_id
487
+ };
488
+ } else {
489
+ return null;
490
+ }
491
+ })
492
+ .filter((range): range is Attachment => range !== null);
493
+
494
+ const marks = clip_ranges(text_value.marks);
495
+ const annotations = clip_ranges(text_value.annotations);
496
+
497
+ return { content: selected_text, marks, annotations, nodes };
498
+ }
499
+
500
+ get_selected_annotated_nodes(): {
501
+ nodes: Record<string, DocumentNode>;
502
+ main_nodes: NodeId[];
503
+ marks: Attachment[];
504
+ annotations: Attachment[];
505
+ } | null {
506
+ if (this.selection?.type !== 'node') return null;
507
+
508
+ const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
509
+ const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
510
+ const node_array: NodeArray = this.get(this.selection.path);
511
+ const main_nodes = node_array.nodes.slice(selection_start, selection_end);
512
+ const nodes: Record<string, DocumentNode> = {};
513
+
514
+ const add_subgraph = (node_id: NodeId) => {
515
+ for (const node of this.traverse(node_id)) {
516
+ if (!nodes[node.id]) nodes[node.id] = node;
517
+ }
518
+ };
519
+
520
+ for (const node_id of main_nodes) add_subgraph(node_id);
521
+
522
+ const clip_ranges = (ranges: Attachment[]) =>
523
+ ranges
524
+ .map((range) => {
525
+ if (selection_start >= range.end_offset || selection_end <= range.start_offset) {
526
+ return null;
527
+ }
528
+
529
+ add_subgraph(range.node_id);
530
+ return {
531
+ start_offset: Math.max(range.start_offset - selection_start, 0),
532
+ end_offset: Math.min(
533
+ range.end_offset - selection_start,
534
+ selection_end - selection_start
535
+ ),
536
+ node_id: range.node_id
537
+ };
538
+ })
539
+ .filter((range): range is Attachment => range !== null);
540
+
541
+ const marks = clip_ranges(node_array.marks);
542
+ const annotations = clip_ranges(node_array.annotations);
543
+
544
+ return { nodes, main_nodes, marks, annotations };
545
+ }
546
+
547
+ // TODO: think about ways how we can also turn a node selection into plain text.
548
+ get_selected_plain_text(): string | null {
549
+ if (this.selection?.type !== 'text') return null;
550
+
551
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
552
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
553
+ const text: Text = this.get(this.selection.path);
554
+ return char_slice(text.content, start, end);
555
+ }
556
+
557
+ get_selected_nodes(): NodeId[] | null {
558
+ if (this.selection?.type !== 'node') return null;
559
+
560
+ const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
561
+ const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
562
+ const node_array: NodeArray = this.get(this.selection.path);
563
+ // node_array.nodes is a plain array of id strings (doc is $state.raw),
564
+ // so a slice is already a safe fresh copy.
565
+ return node_array.nodes.slice(start, end);
566
+ }
567
+
568
+ select_parent(): void {
569
+ if (!this.selection) return;
570
+ if (['text', 'property'].includes(this.selection.type)) {
571
+ // For text and property selections (e.g. ['page_1', 'body', 0, 'image']), we need to go up two levels
572
+ // in the path
573
+ if (this.selection.path.length > 3) {
574
+ const parent_path = this.selection.path.slice(0, -2);
575
+ const current_index = this.selection.path[this.selection.path.length - 2] as number;
576
+ this.selection = {
577
+ type: 'node',
578
+ path: parent_path,
579
+ anchor_offset: current_index,
580
+ focus_offset: current_index + 1
581
+ };
582
+ } else {
583
+ this.selection = null;
584
+ }
585
+ } else if (this.selection.type === 'node') {
586
+ // For node selections, we go up one level
587
+ if (this.selection.path.length > 3) {
588
+ const parent_path = this.selection.path.slice(0, -2);
589
+ const current_index = this.selection.path[this.selection.path.length - 2] as number;
590
+
591
+ this.selection = {
592
+ type: 'node',
593
+ path: parent_path,
594
+ anchor_offset: current_index,
595
+ focus_offset: current_index + 1
596
+ };
597
+ } else {
598
+ this.selection = null;
599
+ }
600
+ } else {
601
+ this.selection = null;
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Traverses the document and returns a list of nodes in depth-first order.
607
+ *
608
+ * The traversal order is:
609
+ * 1. Leaf nodes first
610
+ * 2. Branch nodes second
611
+ * 3. Root node (entry point) last
612
+ *
613
+ * Nodes that are not reachable from the entry point node will not be included.
614
+ */
615
+ traverse(node_id: string): Array<DocumentNode> {
616
+ // doc is $state.raw, so doc.nodes is a plain (non-proxied) object —
617
+ // traverse() reads it directly and deep-clones each visited node.
618
+ // The previous $state.snapshot(this.doc.nodes) here deep-cloned the
619
+ // ENTIRE document per call, which made copying M nodes O(M·N).
620
+ return traverse(node_id, this.schema, this.doc.nodes);
621
+ }
622
+
623
+ /**
624
+ * Convert the document to a clean format for persistence.
625
+ *
626
+ * We make a traversal to ensure that orphaned nodes are not included,
627
+ * and that leaf nodes go first, followed by branches and the root node at last.
628
+ */
629
+ to_json(): Document {
630
+ // this will order the nodes (depth-first traversal)
631
+ const nodes_array = this.traverse(this.document_id);
632
+ // convert nodes array to object with node IDs as keys
633
+ const nodes = Object.fromEntries(nodes_array.map((node) => [node.id, node]));
634
+ return {
635
+ document_id: this.document_id,
636
+ nodes
637
+ };
638
+ }
639
+
640
+ // property_type('page', 'body') => 'node_array'
641
+ // property_type('paragraph', 'content') => 'text'
642
+ property_type(type: string, property: string): string {
643
+ return doc_property_type(this.schema, type, property);
644
+ }
645
+
646
+ // Count how many times a node is referenced in the document
647
+ count_references(node_id: NodeId): number {
648
+ return doc_count_references(this.schema, this.doc, node_id);
649
+ }
650
+
651
+ // Get all nodes referenced by a given node (recursively)
652
+ get_referenced_nodes(node_id: NodeId): NodeId[] {
653
+ // Id-only traversal — no need to deep-clone the subgraph for ids.
654
+ return traverse_ids(node_id, this.schema, this.doc.nodes).slice(0, -1);
655
+ }
656
+ }