svedit 0.12.0 → 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 (86) hide show
  1. package/README.md +33 -21
  2. package/dist/Command.svelte.d.ts +38 -30
  3. package/dist/Command.svelte.d.ts.map +1 -1
  4. package/dist/Command.svelte.js +271 -326
  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 +16 -13
  12. package/dist/Node.svelte.d.ts +3 -5
  13. package/dist/Node.svelte.d.ts.map +1 -1
  14. package/dist/NodeArrayProperty.svelte +39 -22
  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 +12 -6
  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 +81 -129
  30. package/dist/Session.svelte.d.ts.map +1 -1
  31. package/dist/Session.svelte.js +542 -648
  32. package/dist/Svedit.svelte +144 -138
  33. package/dist/Svedit.svelte.d.ts +30 -10
  34. package/dist/Svedit.svelte.d.ts.map +1 -1
  35. package/dist/TextProperty.svelte +32 -18
  36. package/dist/TextProperty.svelte.d.ts +3 -5
  37. package/dist/TextProperty.svelte.d.ts.map +1 -1
  38. package/dist/Transaction.svelte.d.ts +64 -127
  39. package/dist/Transaction.svelte.d.ts.map +1 -1
  40. package/dist/Transaction.svelte.js +839 -1001
  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 +67 -155
  45. package/dist/doc_utils.d.ts.map +1 -1
  46. package/dist/doc_utils.js +619 -863
  47. package/dist/index.d.ts +20 -17
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -17
  50. package/dist/node_visibility.svelte.d.ts +110 -9
  51. package/dist/node_visibility.svelte.d.ts.map +1 -1
  52. package/dist/node_visibility.svelte.js +473 -544
  53. package/dist/paste_utils.d.ts +11 -44
  54. package/dist/paste_utils.d.ts.map +1 -1
  55. package/dist/paste_utils.js +111 -152
  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 +125 -150
  59. package/dist/types.d.ts +277 -309
  60. package/dist/types.d.ts.map +1 -0
  61. package/dist/types.js +1 -0
  62. package/dist/utils.d.ts +40 -119
  63. package/dist/utils.d.ts.map +1 -1
  64. package/dist/utils.js +346 -473
  65. package/package.json +69 -70
  66. package/src/lib/{Command.svelte.js → Command.svelte.ts} +29 -17
  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 +16 -13
  70. package/src/lib/NodeArrayProperty.svelte +39 -22
  71. package/src/lib/NodeCaret.svelte +1 -1
  72. package/src/lib/NodeGap.svelte +12 -6
  73. package/src/lib/NodeGapMarkers.svelte +19 -10
  74. package/src/lib/NodeSelectionMarkers.svelte +20 -6
  75. package/src/lib/{Session.svelte.js → Session.svelte.ts} +130 -127
  76. package/src/lib/Svedit.svelte +144 -138
  77. package/src/lib/TextProperty.svelte +32 -18
  78. package/src/lib/{Transaction.svelte.js → Transaction.svelte.ts} +139 -143
  79. package/src/lib/UnknownNode.svelte +4 -3
  80. package/src/lib/{doc_utils.js → doc_utils.ts} +247 -206
  81. package/src/lib/{index.js → index.ts} +47 -0
  82. package/src/lib/{node_visibility.svelte.js → node_visibility.svelte.ts} +68 -92
  83. package/src/lib/{paste_utils.js → paste_utils.ts} +47 -58
  84. package/src/lib/{transforms.svelte.js → transforms.svelte.ts} +39 -21
  85. package/src/lib/{types.d.ts → types.ts} +143 -66
  86. package/src/lib/{utils.js → utils.ts} +110 -129
@@ -18,74 +18,82 @@ import {
18
18
  get_selected_annotations,
19
19
  validate_selection
20
20
  } from './doc_utils.js';
21
-
22
- /**
23
- * @import {
24
- * NodeId,
25
- * DocumentPath,
26
- * Selection,
27
- * Attachment,
28
- * Mark,
29
- * Annotation,
30
- * NodeKind,
31
- * DocumentSchema,
32
- * DocumentNode,
33
- * Document
34
- * } from './types.d.ts';
35
- */
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';
36
40
 
37
41
  const BATCH_WINDOW_MS = 1000; // 1 second
38
42
 
39
- /**
40
- * @typedef {Object} SessionOptions
41
- * @property {Selection} [selection] - Initial selection state
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
+ };
43
49
 
44
- export default class Session {
45
- /** @type {Selection | null} */
46
- #selection = $state.raw(null);
50
+ export default class Session<S extends DocumentSchema = DocumentSchema> {
51
+ #selection: Selection | null = $state.raw(null);
47
52
 
48
- /** @type {DocumentSchema} */
49
- schema = $state.raw();
53
+ schema: S = $state.raw() as S;
50
54
 
51
- /** @type {Document} */
52
- doc = $state.raw();
55
+ doc: Document = $state.raw() as Document;
53
56
 
54
- /** @type {any} */
55
- config = $state.raw();
57
+ config: SessionConfig = $state.raw({});
56
58
 
57
- history = $state.raw([]);
59
+ history: HistoryEntry[] = $state.raw([]);
58
60
  history_index = $state.raw(-1);
59
- last_batch_started = $state.raw(undefined); // Timestamp for debounced batching
61
+ last_batch_started: number | undefined = $state.raw(undefined); // Timestamp for debounced batching
60
62
 
61
63
  // Commands and keymap - initialized by Svedit when ready
62
64
  // NOTE: Assumes single Svedit instance per session
63
- commands = $state.raw({});
64
- keymap = $state.raw({});
65
+ commands: CommandRegistry = $state.raw({});
66
+ keymap: Keymap = $state.raw({});
65
67
 
66
68
  // Reactive helpers for UI state
67
69
  can_undo = $derived(this.history_index >= 0);
68
70
  can_redo = $derived(this.history_index < this.history.length - 1);
69
71
 
70
72
  // Reactive variable for selected node
71
- selected_node = $derived(this.get_selected_node());
73
+ selected_node: AnyNode<S> | null = $derived(this.get_selected_node());
72
74
  available_mark_types = $derived(this.get_available_mark_types());
73
75
  available_annotation_types = $derived(this.get_available_annotation_types());
74
76
  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);
77
+ active_mark: SelectedAttachment | null = $derived(
78
+ this.selected_marks.length === 1 ? this.selected_marks[0] : null
79
+ );
76
80
  selected_annotations = $derived(get_selected_annotations(this.schema, this.doc, this.selection));
77
- active_annotation = $derived(
81
+ active_annotation: SelectedAttachment | null = $derived(
78
82
  this.selected_annotations.length === 1 ? this.selected_annotations[0] : null
79
83
  );
80
84
 
81
85
  /**
82
- * @param {DocumentSchema} schema - The document schema
83
- * @param {Document} doc - The document
84
- * @param {object} config - configuration object
85
- * @param {Object} [options]
86
- * @param {Selection} [options.selection] - Initial selection state
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)
87
90
  */
88
- constructor(schema, doc, config, options = {}) {
91
+ constructor(
92
+ schema: S,
93
+ doc: Document,
94
+ config: SessionConfig,
95
+ options: { selection?: Selection | null } = {}
96
+ ) {
89
97
  // Validate the schema first
90
98
  validate_document_schema(schema);
91
99
 
@@ -101,18 +109,16 @@ export default class Session {
101
109
 
102
110
  /**
103
111
  * Gets the current selection
104
- * @returns {Selection | null}
105
112
  */
106
- get selection() {
113
+ get selection(): Selection | null {
107
114
  return this.#selection;
108
115
  }
109
116
 
110
117
  /**
111
118
  * Sets the selection with validation
112
- * @param {Selection | null} value - The new selection
113
119
  * @throws {Error} Throws if the selection is invalid
114
120
  */
115
- set selection(value) {
121
+ set selection(value: Selection | null) {
116
122
  this._validate_selection(value);
117
123
  this.#selection = value;
118
124
  }
@@ -120,19 +126,16 @@ export default class Session {
120
126
  /**
121
127
  * Validates that a selection is within bounds and refers to valid paths.
122
128
  *
123
- * @param {Selection} selection - The selection to validate
124
129
  * @throws {Error} Throws if the selection is invalid
125
- * @private
126
130
  */
127
- _validate_selection(selection) {
131
+ private _validate_selection(selection: Selection | null): void {
128
132
  validate_selection(selection, this);
129
133
  }
130
134
 
131
135
  /**
132
136
  * Gets the document_id from the doc
133
- * @returns {string}
134
137
  */
135
- get document_id() {
138
+ get document_id(): string {
136
139
  return this.doc.document_id;
137
140
  }
138
141
 
@@ -144,14 +147,12 @@ export default class Session {
144
147
  * deleted nodes. That catches local node invariants and dangling references left
145
148
  * behind by deletes without scanning every node on every apply.
146
149
  *
147
- * @param {Transaction} transaction - The transaction result to validate
148
150
  * @throws {Error} Throws if the transaction result is invalid
149
151
  */
150
- validate_transaction_result(transaction) {
152
+ validate_transaction_result(transaction: Transaction): void {
151
153
  const doc = transaction.doc;
152
- /** @type {Set<NodeId>} */
153
154
  // eslint-disable-next-line svelte/prefer-svelte-reactivity -- Non-reactive local dedup set.
154
- const affected_node_ids = new Set([
155
+ const affected_node_ids: Set<NodeId> = new Set([
155
156
  ...transaction.created_node_ids,
156
157
  ...transaction.modified_node_ids
157
158
  ]);
@@ -184,7 +185,7 @@ export default class Session {
184
185
  }
185
186
  }
186
187
 
187
- generate_id() {
188
+ generate_id(): string {
188
189
  const id = this.config?.generate_id ? this.config.generate_id() : `node_${crypto.randomUUID()}`;
189
190
  if (!is_id_valid(id)) {
190
191
  throw new Error(
@@ -202,9 +203,9 @@ export default class Session {
202
203
  * For multiple editors on the same document, this architecture would need
203
204
  * to be refactored to support multiple sessions per document.
204
205
  *
205
- * @param {object} context - The svedit context with session, editable, canvas, etc.
206
+ * @param context - The svedit context with session, editable, canvas, etc.
206
207
  */
207
- initialize_commands(context) {
208
+ initialize_commands(context: SveditContext<S>): void {
208
209
  if (this.config?.create_commands_and_keymap) {
209
210
  const { commands, keymap } = this.config.create_commands_and_keymap(context);
210
211
  this.commands = commands;
@@ -212,20 +213,20 @@ export default class Session {
212
213
  }
213
214
  }
214
215
 
215
- get_available_mark_types() {
216
+ get_available_mark_types(): string[] {
216
217
  if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
217
218
  const property_definition = this.inspect(this.selection.path);
218
219
  return property_definition.mark_types || [];
219
220
  }
220
221
 
221
- get_available_annotation_types() {
222
+ get_available_annotation_types(): string[] {
222
223
  if (this.selection?.type !== 'text' && this.selection?.type !== 'node') return [];
223
224
  const property_definition = this.inspect(this.selection.path);
224
225
  return property_definition.annotation_types || [];
225
226
  }
226
227
 
227
228
  // Helper function to get the currently selected node
228
- get_selected_node() {
229
+ get_selected_node(): AnyNode<S> | null {
229
230
  if (!this.selection) return null;
230
231
 
231
232
  if (this.selection.type === 'node') {
@@ -233,7 +234,7 @@ export default class Session {
233
234
  const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
234
235
  // Only consider selection of a single node
235
236
  if (end - start !== 1) return null;
236
- const node_array = this.get(this.selection.path);
237
+ const node_array: NodeArray = this.get(this.selection.path);
237
238
  const node_id = node_array.nodes[start];
238
239
  return node_id ? this.get(node_id) : null;
239
240
  } else {
@@ -247,23 +248,20 @@ export default class Session {
247
248
 
248
249
  /**
249
250
  * Creates a new transaction for making atomic changes to the document.
250
- *
251
- * @returns {Transaction} A new transaction instance
252
251
  */
253
- get tr() {
252
+ get tr(): Transaction {
254
253
  // We create a copy of the current state to avoid modifying the original
255
254
  return new Transaction(this.schema, this.doc, this.selection, this.config);
256
255
  }
257
256
 
258
257
  /**
259
258
  * Applies a transaction to the document.
260
- * Auto-batches history entries with debounced behavior (max one entry per 2 seconds) when batch is true.
259
+ * Auto-batches history entries with debounced behavior (max one entry per second) when batch is true.
261
260
  *
262
- * @param {Transaction} transaction - The transaction to apply
263
- * @param {object} [options] - Optional configuration
264
- * @param {boolean} [options.batch=false] - Whether to allow batching with previous transaction
261
+ * @param transaction - The transaction to apply
262
+ * @param options - Optional configuration (batch: whether to allow batching with previous transaction)
265
263
  */
266
- apply(transaction, { batch = false } = {}) {
264
+ apply(transaction: Transaction, { batch = false }: { batch?: boolean } = {}): this {
267
265
  this.validate_transaction_result(transaction);
268
266
  // Only swap the doc when something changed. The transaction draft is
269
267
  // always a fresh object, and assigning it for an ops-less transaction
@@ -285,7 +283,7 @@ export default class Session {
285
283
  now - this.last_batch_started < BATCH_WINDOW_MS;
286
284
 
287
285
  if (should_batch) {
288
- // Append to existing history entry (within 2s of batch start)
286
+ // Append to existing history entry (within the batch window)
289
287
  const last_entry = this.history[this.history_index];
290
288
  last_entry.ops.push(...transaction.ops);
291
289
  last_entry.inverse_ops.push(...transaction.inverse_ops);
@@ -293,7 +291,7 @@ export default class Session {
293
291
  // Trigger update
294
292
  this.history = [...this.history];
295
293
  } else {
296
- // Create new history entry (more than 2s since batch started, or first edit, or batch not requested)
294
+ // Create new history entry (batch window elapsed, first edit, or batch not requested)
297
295
  this.history = [
298
296
  ...this.history,
299
297
  {
@@ -315,7 +313,7 @@ export default class Session {
315
313
  return this;
316
314
  }
317
315
 
318
- undo() {
316
+ undo(): this | undefined {
319
317
  if (this.history_index < 0) {
320
318
  return;
321
319
  }
@@ -332,10 +330,13 @@ export default class Session {
332
330
  this.doc = doc;
333
331
  this.selection = change.selection_before;
334
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;
335
336
  return this;
336
337
  }
337
338
 
338
- redo() {
339
+ redo(): this | undefined {
339
340
  if (this.history_index >= this.history.length - 1) {
340
341
  return;
341
342
  }
@@ -347,13 +348,17 @@ export default class Session {
347
348
  });
348
349
  this.doc = doc;
349
350
  this.selection = change.selection_after;
351
+ // Redo ends any open typing batch, matching undo().
352
+ this.last_batch_started = undefined;
350
353
  return this;
351
354
  }
352
355
 
353
356
  /**
354
357
  * Gets a node instance or property value at the specified path.
355
- * @param {DocumentPath|string} path - Path to the node or property
356
- * @returns {any} Either a node instance object or the value of a property
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
+ *
357
362
  * @example
358
363
  * // Get a node by ID
359
364
  * session.get('list_1') // => { type: 'list', id: 'list_1', ... }
@@ -370,7 +375,8 @@ export default class Session {
370
375
  * // Get a text property
371
376
  * session.get(['page_1', 'cover', 'title']) // => {content: 'Hello world', marks: [], annotations: []}
372
377
  */
373
- get(path) {
378
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
379
+ get<T = any>(path: DocumentPath | string): T {
374
380
  return doc_get(this.schema, this.doc, path);
375
381
  }
376
382
 
@@ -396,31 +402,28 @@ export default class Session {
396
402
  * type: 'paragraph',
397
403
  * properties: {...}
398
404
  * }
399
- *
400
- * @param {DocumentPath} path
401
- * @returns {{kind: 'property'|'node', [key: string]: any}}
402
405
  */
403
- inspect(path) {
406
+ inspect(path: DocumentPath): Inspection {
404
407
  return doc_inspect(this.schema, this.doc, path);
405
408
  }
406
409
 
407
410
  /**
408
- * Determines the kind of a node ('block' for structured blocks, 'text' for pure
409
- * text nodes, 'mark' for mark nodes or 'annotation' for annotation nodes.
410
- * @param {any} node
411
- * @returns {NodeKind}
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).
412
414
  */
413
- kind(node) {
415
+ kind(node: DocumentNode): NodeKind {
414
416
  return doc_kind(this.schema, node);
415
417
  }
416
418
 
417
419
  /**
418
420
  * Determines whether a node type can be inserted at a given selection.
419
- * @param {string} node_type - The type of node to insert.
420
- * @param {Selection} [selection] - The selection at which to insert the node.
421
- * @returns {boolean} True if the node type can be inserted, false otherwise.
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.
422
425
  */
423
- can_insert(node_type, selection = this.selection) {
426
+ can_insert(node_type: string, selection: Selection | null = this.selection): boolean {
424
427
  if (selection?.type === 'node') {
425
428
  const property_definition = this.inspect(selection.path);
426
429
  if (property_definition.node_types.includes(node_type)) {
@@ -437,16 +440,16 @@ export default class Session {
437
440
  /**
438
441
  * Compute next possible insert position from a given selection
439
442
  *
440
- * @param {Selection} [selection] - Reference selection
441
- * @returns {Selection|null} The next node insert caret selection, or null if none is available
443
+ * @param selection - Reference selection
444
+ * @returns The next node insert caret selection, or null if none is available
442
445
  */
443
- get_next_node_insert_caret(selection = this.selection) {
446
+ get_next_node_insert_caret(selection: Selection | null = this.selection): Selection | null {
444
447
  // There's no parent path to insert into
445
448
  if (!selection || selection.path.length <= 2) {
446
449
  return null;
447
450
  }
448
451
 
449
- const node_offset = parseInt(String(selection.path.at(-2)), 10) + 1;
452
+ const node_offset = (selection.path.at(-2) as number) + 1;
450
453
  return {
451
454
  type: 'node',
452
455
  path: selection.path.slice(0, -2),
@@ -455,16 +458,16 @@ export default class Session {
455
458
  };
456
459
  }
457
460
 
458
- get_selected_text() {
461
+ get_selected_text(): (Text & { nodes: Record<string, DocumentNode> }) | null {
459
462
  if (this.selection?.type !== 'text') return null;
460
463
 
461
464
  const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
462
465
  const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
463
- const text_value = this.get(this.selection.path);
466
+ const text_value: Text = this.get(this.selection.path);
464
467
  const selected_text = char_slice(text_value.content, selection_start, selection_end);
465
- const nodes = {};
468
+ const nodes: Record<string, DocumentNode> = {};
466
469
 
467
- const clip_ranges = (ranges) =>
470
+ const clip_ranges = (ranges: Attachment[]) =>
468
471
  ranges
469
472
  .map((range) => {
470
473
  if (selection_start < range.end_offset && selection_end > range.start_offset) {
@@ -486,7 +489,7 @@ export default class Session {
486
489
  return null;
487
490
  }
488
491
  })
489
- .filter(Boolean);
492
+ .filter((range): range is Attachment => range !== null);
490
493
 
491
494
  const marks = clip_ranges(text_value.marks);
492
495
  const annotations = clip_ranges(text_value.annotations);
@@ -494,16 +497,21 @@ export default class Session {
494
497
  return { content: selected_text, marks, annotations, nodes };
495
498
  }
496
499
 
497
- get_selected_annotated_nodes() {
500
+ get_selected_annotated_nodes(): {
501
+ nodes: Record<string, DocumentNode>;
502
+ main_nodes: NodeId[];
503
+ marks: Attachment[];
504
+ annotations: Attachment[];
505
+ } | null {
498
506
  if (this.selection?.type !== 'node') return null;
499
507
 
500
508
  const selection_start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
501
509
  const selection_end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
502
- const node_array = this.get(this.selection.path);
510
+ const node_array: NodeArray = this.get(this.selection.path);
503
511
  const main_nodes = node_array.nodes.slice(selection_start, selection_end);
504
- const nodes = {};
512
+ const nodes: Record<string, DocumentNode> = {};
505
513
 
506
- const add_subgraph = (node_id) => {
514
+ const add_subgraph = (node_id: NodeId) => {
507
515
  for (const node of this.traverse(node_id)) {
508
516
  if (!nodes[node.id]) nodes[node.id] = node;
509
517
  }
@@ -511,7 +519,7 @@ export default class Session {
511
519
 
512
520
  for (const node_id of main_nodes) add_subgraph(node_id);
513
521
 
514
- const clip_ranges = (ranges) =>
522
+ const clip_ranges = (ranges: Attachment[]) =>
515
523
  ranges
516
524
  .map((range) => {
517
525
  if (selection_start >= range.end_offset || selection_end <= range.start_offset) {
@@ -521,11 +529,14 @@ export default class Session {
521
529
  add_subgraph(range.node_id);
522
530
  return {
523
531
  start_offset: Math.max(range.start_offset - selection_start, 0),
524
- end_offset: Math.min(range.end_offset - selection_start, selection_end - selection_start),
532
+ end_offset: Math.min(
533
+ range.end_offset - selection_start,
534
+ selection_end - selection_start
535
+ ),
525
536
  node_id: range.node_id
526
537
  };
527
538
  })
528
- .filter(Boolean);
539
+ .filter((range): range is Attachment => range !== null);
529
540
 
530
541
  const marks = clip_ranges(node_array.marks);
531
542
  const annotations = clip_ranges(node_array.annotations);
@@ -534,34 +545,34 @@ export default class Session {
534
545
  }
535
546
 
536
547
  // TODO: think about ways how we can also turn a node selection into plain text.
537
- get_selected_plain_text() {
548
+ get_selected_plain_text(): string | null {
538
549
  if (this.selection?.type !== 'text') return null;
539
550
 
540
551
  const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
541
552
  const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
542
- const text = this.get(this.selection.path);
553
+ const text: Text = this.get(this.selection.path);
543
554
  return char_slice(text.content, start, end);
544
555
  }
545
556
 
546
- get_selected_nodes() {
557
+ get_selected_nodes(): NodeId[] | null {
547
558
  if (this.selection?.type !== 'node') return null;
548
559
 
549
560
  const start = Math.min(this.selection.anchor_offset, this.selection.focus_offset);
550
561
  const end = Math.max(this.selection.anchor_offset, this.selection.focus_offset);
551
- const node_array = this.get(this.selection.path);
562
+ const node_array: NodeArray = this.get(this.selection.path);
552
563
  // node_array.nodes is a plain array of id strings (doc is $state.raw),
553
564
  // so a slice is already a safe fresh copy.
554
565
  return node_array.nodes.slice(start, end);
555
566
  }
556
567
 
557
- select_parent() {
568
+ select_parent(): void {
558
569
  if (!this.selection) return;
559
- if (['text', 'property'].includes(this.selection?.type)) {
570
+ if (['text', 'property'].includes(this.selection.type)) {
560
571
  // For text and property selections (e.g. ['page_1', 'body', 0, 'image']), we need to go up two levels
561
572
  // in the path
562
573
  if (this.selection.path.length > 3) {
563
574
  const parent_path = this.selection.path.slice(0, -2);
564
- const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
575
+ const current_index = this.selection.path[this.selection.path.length - 2] as number;
565
576
  this.selection = {
566
577
  type: 'node',
567
578
  path: parent_path,
@@ -575,7 +586,7 @@ export default class Session {
575
586
  // For node selections, we go up one level
576
587
  if (this.selection.path.length > 3) {
577
588
  const parent_path = this.selection.path.slice(0, -2);
578
- const current_index = parseInt(String(this.selection.path[this.selection.path.length - 2]));
589
+ const current_index = this.selection.path[this.selection.path.length - 2] as number;
579
590
 
580
591
  this.selection = {
581
592
  type: 'node',
@@ -599,11 +610,9 @@ export default class Session {
599
610
  * 2. Branch nodes second
600
611
  * 3. Root node (entry point) last
601
612
  *
602
- * @param {string} node_id - The ID of the node to start traversing from
603
- * @returns {Array<DocumentNode>} Array of nodes in depth-first order
604
- * @note Nodes that are not reachable from the entry point node will not be included
613
+ * Nodes that are not reachable from the entry point node will not be included.
605
614
  */
606
- traverse(node_id) {
615
+ traverse(node_id: string): Array<DocumentNode> {
607
616
  // doc is $state.raw, so doc.nodes is a plain (non-proxied) object —
608
617
  // traverse() reads it directly and deep-clones each visited node.
609
618
  // The previous $state.snapshot(this.doc.nodes) here deep-cloned the
@@ -616,10 +625,8 @@ export default class Session {
616
625
  *
617
626
  * We make a traversal to ensure that orphaned nodes are not included,
618
627
  * and that leaf nodes go first, followed by branches and the root node at last.
619
- *
620
- * @returns {Document} The document
621
628
  */
622
- to_json() {
629
+ to_json(): Document {
623
630
  // this will order the nodes (depth-first traversal)
624
631
  const nodes_array = this.traverse(this.document_id);
625
632
  // convert nodes array to object with node IDs as keys
@@ -632,21 +639,17 @@ export default class Session {
632
639
 
633
640
  // property_type('page', 'body') => 'node_array'
634
641
  // property_type('paragraph', 'content') => 'text'
635
- property_type(type, property) {
642
+ property_type(type: string, property: string): string {
636
643
  return doc_property_type(this.schema, type, property);
637
644
  }
638
645
 
639
646
  // Count how many times a node is referenced in the document
640
- count_references(node_id) {
647
+ count_references(node_id: NodeId): number {
641
648
  return doc_count_references(this.schema, this.doc, node_id);
642
649
  }
643
650
 
644
651
  // Get all nodes referenced by a given node (recursively)
645
- /**
646
- * @param {NodeId} node_id
647
- * @returns {NodeId[]}
648
- */
649
- get_referenced_nodes(node_id) {
652
+ get_referenced_nodes(node_id: NodeId): NodeId[] {
650
653
  // Id-only traversal — no need to deep-clone the subgraph for ids.
651
654
  return traverse_ids(node_id, this.schema, this.doc.nodes).slice(0, -1);
652
655
  }