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