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