@difizen/libro-shared-model 0.0.2-alpha.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.
package/src/ymodels.ts ADDED
@@ -0,0 +1,1603 @@
1
+ import type {
2
+ IAttachments,
3
+ IBaseCellMetadata,
4
+ PartialJSONValue,
5
+ IBaseCell,
6
+ IOutput,
7
+ ICodeCell,
8
+ IRawCell,
9
+ IMarkdownCell,
10
+ INotebookMetadata,
11
+ CellType,
12
+ } from '@difizen/libro-common';
13
+ import { deepCopy, deepEqual } from '@difizen/libro-common';
14
+ import type { Event } from '@difizen/mana-common';
15
+ import { Emitter } from '@difizen/mana-common';
16
+ import { v4 } from 'uuid';
17
+ import { Awareness } from 'y-protocols/awareness';
18
+ import * as Y from 'yjs';
19
+
20
+ import type {
21
+ CellChange,
22
+ CellTypeAdaptor,
23
+ Delta,
24
+ DocumentChange,
25
+ FileChange,
26
+ IListChange,
27
+ IMapChange,
28
+ ISharedAttachmentsCell,
29
+ ISharedBaseCell,
30
+ ISharedCell,
31
+ ISharedCodeCell,
32
+ ISharedDocument,
33
+ ISharedFile,
34
+ ISharedMarkdownCell,
35
+ ISharedNotebook,
36
+ ISharedRawCell,
37
+ ISharedText,
38
+ NotebookChange,
39
+ SharedCell,
40
+ StateChange,
41
+ } from './api.js';
42
+
43
+ /**
44
+ * Abstract interface to define Shared Models that can be bound to a text editor using any existing
45
+ * Yjs-based editor binding.
46
+ */
47
+ export interface IYText extends ISharedText {
48
+ /**
49
+ * Shareable text
50
+ */
51
+ readonly ysource: Y.Text;
52
+ /**
53
+ * Shareable awareness
54
+ */
55
+ readonly awareness: Awareness | null;
56
+ /**
57
+ * Undo manager
58
+ */
59
+ readonly undoManager: Y.UndoManager | null;
60
+ }
61
+
62
+ /**
63
+ * Generic shareable document.
64
+ */
65
+ export class YDocument<T extends DocumentChange> implements ISharedDocument {
66
+ constructor() {
67
+ this.ystate.observe(this.onStateChanged);
68
+ }
69
+
70
+ /**
71
+ * YJS document
72
+ */
73
+ readonly ydoc = new Y.Doc();
74
+ /**
75
+ * Shared state
76
+ */
77
+ readonly ystate: Y.Map<any> = this.ydoc.getMap('state');
78
+ /**
79
+ * YJS document undo manager
80
+ */
81
+ readonly undoManager = new Y.UndoManager([], {
82
+ trackedOrigins: new Set([this]),
83
+ doc: this.ydoc,
84
+ });
85
+ /**
86
+ * Shared awareness
87
+ */
88
+ readonly awareness = new Awareness(this.ydoc);
89
+
90
+ /**
91
+ * The changed signal.
92
+ */
93
+ get changed(): Event<T> {
94
+ return this._changed;
95
+ }
96
+
97
+ /**
98
+ * Whether the document is disposed or not.
99
+ */
100
+ get isDisposed(): boolean {
101
+ return this._isDisposed;
102
+ }
103
+
104
+ /**
105
+ * Whether the object can undo changes.
106
+ */
107
+ canUndo(): boolean {
108
+ return this.undoManager.undoStack.length > 0;
109
+ }
110
+
111
+ /**
112
+ * Whether the object can redo changes.
113
+ */
114
+ canRedo(): boolean {
115
+ return this.undoManager.redoStack.length > 0;
116
+ }
117
+
118
+ /**
119
+ * Dispose of the resources.
120
+ */
121
+ dispose(): void {
122
+ if (this._isDisposed) {
123
+ return;
124
+ }
125
+ this._isDisposed = true;
126
+ this.ystate.unobserve(this.onStateChanged);
127
+ this.awareness.destroy();
128
+ this.undoManager.destroy();
129
+ this.ydoc.destroy();
130
+ }
131
+
132
+ /**
133
+ * Undo an operation.
134
+ */
135
+ undo(): void {
136
+ this.undoManager.undo();
137
+ }
138
+
139
+ /**
140
+ * Redo an operation.
141
+ */
142
+ redo(): void {
143
+ this.undoManager.redo();
144
+ }
145
+
146
+ /**
147
+ * Clear the change stack.
148
+ */
149
+ clearUndoHistory(): void {
150
+ this.undoManager.clear();
151
+ }
152
+
153
+ /**
154
+ * Perform a transaction. While the function f is called, all changes to the shared
155
+ * document are bundled into a single event.
156
+ */
157
+ transact(f: () => void, undoable = true): void {
158
+ this.ydoc.transact(f, undoable ? this : null);
159
+ }
160
+
161
+ /**
162
+ * Handle a change to the ystate.
163
+ */
164
+ protected onStateChanged = (event: Y.YMapEvent<any>): void => {
165
+ const stateChange = new Array<StateChange<any>>();
166
+ event.keysChanged.forEach((key) => {
167
+ const change = event.changes.keys.get(key);
168
+ if (change) {
169
+ stateChange.push({
170
+ name: key,
171
+ oldValue: change.oldValue,
172
+ newValue: this.ystate.get(key),
173
+ });
174
+ }
175
+ });
176
+
177
+ this._changedEmitter.fire({ stateChange } as any);
178
+ };
179
+
180
+ protected _changedEmitter = new Emitter<T>();
181
+ protected _changed = this._changedEmitter.event;
182
+ private _isDisposed = false;
183
+ }
184
+
185
+ /**
186
+ * Shareable text file.
187
+ */
188
+ export class YFile
189
+ extends YDocument<FileChange>
190
+ implements ISharedFile, ISharedText, IYText
191
+ {
192
+ /**
193
+ * Instantiate a new shareable file.
194
+ *
195
+ * @param source The initial file content
196
+ *
197
+ * @returns The file model
198
+ */
199
+ static create(source?: string): YFile {
200
+ const model = new YFile();
201
+ if (source) {
202
+ model.source = source;
203
+ }
204
+ return model;
205
+ }
206
+
207
+ constructor() {
208
+ super();
209
+ this.undoManager.addToScope(this.ysource);
210
+ this.ysource.observe(this._modelObserver);
211
+ }
212
+
213
+ /**
214
+ * YJS file text.
215
+ */
216
+ readonly ysource = this.ydoc.getText('source');
217
+
218
+ /**
219
+ * File text
220
+ */
221
+ get source(): string {
222
+ return this.getSource();
223
+ }
224
+ set source(v: string) {
225
+ this.setSource(v);
226
+ }
227
+
228
+ /**
229
+ * Dispose of the resources.
230
+ */
231
+ override dispose(): void {
232
+ if (this.isDisposed) {
233
+ return;
234
+ }
235
+ this.ysource.unobserve(this._modelObserver);
236
+ super.dispose();
237
+ }
238
+
239
+ /**
240
+ * Get the file text.
241
+ *
242
+ * @returns File text.
243
+ */
244
+ getSource(): string {
245
+ return this.ysource.toString();
246
+ }
247
+
248
+ /**
249
+ * Set the file text.
250
+ *
251
+ * @param value New text
252
+ */
253
+ setSource(value: string): void {
254
+ this.transact(() => {
255
+ const ytext = this.ysource;
256
+ ytext.delete(0, ytext.length);
257
+ ytext.insert(0, value);
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Replace content from `start' to `end` with `value`.
263
+ *
264
+ * @param start: The start index of the range to replace (inclusive).
265
+ * @param end: The end index of the range to replace (exclusive).
266
+ * @param value: New source (optional).
267
+ */
268
+ updateSource(start: number, end: number, value = ''): void {
269
+ this.transact(() => {
270
+ const ysource = this.ysource;
271
+ // insert and then delete.
272
+ // This ensures that the cursor position is adjusted after the replaced content.
273
+ ysource.insert(start, value);
274
+ ysource.delete(start + value.length, end - start);
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Handle a change to the ymodel.
280
+ */
281
+ private _modelObserver = (event: Y.YTextEvent) => {
282
+ this._changedEmitter.fire({ sourceChange: event.changes.delta as Delta<string> });
283
+ };
284
+ }
285
+
286
+ export const defaultCellTypeAdaptor: CellTypeAdaptor = (cell_Type: CellType) =>
287
+ cell_Type as 'code' | 'markdown' | 'raw';
288
+
289
+ /**
290
+ * Create a new shared cell model given the YJS shared type.
291
+ */
292
+ const createCellModelFromSharedType = (
293
+ type: Y.Map<any>,
294
+ options: SharedCell.IOptions = {},
295
+ cellTypeAdaptor = defaultCellTypeAdaptor,
296
+ ): YCellType => {
297
+ switch (cellTypeAdaptor(type.get('cell_type'))) {
298
+ case 'code':
299
+ return new YCodeCell(type, type.get('source'), type.get('outputs'), options);
300
+ case 'markdown':
301
+ return new YMarkdownCell(type, type.get('source'), options);
302
+ case 'raw':
303
+ return new YRawCell(type, type.get('source'), options);
304
+ default:
305
+ throw new Error('Found unknown cell type');
306
+ }
307
+ };
308
+
309
+ /**
310
+ * Create a new cell that can be inserted in an existing shared model.
311
+ *
312
+ * If no notebook is specified the cell will be standalone.
313
+ *
314
+ * @param cell Cell JSON representation
315
+ * @param notebook Notebook to which the cell will be added
316
+ */
317
+ const createCell = (
318
+ cell: SharedCell.Cell,
319
+ notebook?: YNotebook,
320
+ cellTypeAdaptor = defaultCellTypeAdaptor,
321
+ ): YCodeCell | YMarkdownCell | YRawCell => {
322
+ const ymodel = new Y.Map();
323
+ const ysource = new Y.Text();
324
+ ymodel.set('source', ysource);
325
+ ymodel.set('metadata', {});
326
+ ymodel.set('cell_type', cell.cell_type);
327
+ ymodel.set('id', cell.id ?? v4());
328
+
329
+ let ycell: YCellType;
330
+ switch (cellTypeAdaptor(cell.cell_type)) {
331
+ case 'markdown': {
332
+ ycell = new YMarkdownCell(ymodel, ysource, { notebook });
333
+ if (cell.attachments !== null) {
334
+ ycell.setAttachments(cell.attachments as IAttachments);
335
+ }
336
+ break;
337
+ }
338
+ case 'code': {
339
+ const youtputs = new Y.Array();
340
+ ymodel.set('outputs', youtputs);
341
+ ycell = new YCodeCell(ymodel, ysource, youtputs, {
342
+ notebook,
343
+ });
344
+ const cCell = cell as Partial<ICodeCell>;
345
+ ycell.execution_count = cCell.execution_count ?? null;
346
+ if (cCell.outputs) {
347
+ ycell.setOutputs(cCell.outputs);
348
+ }
349
+ break;
350
+ }
351
+ default: {
352
+ // raw
353
+ ycell = new YRawCell(ymodel, ysource, { notebook });
354
+ if (cell.attachments) {
355
+ ycell.setAttachments(cell.attachments as IAttachments);
356
+ }
357
+ break;
358
+ }
359
+ }
360
+
361
+ if (cell.metadata !== null) {
362
+ ycell.setMetadata(cell.metadata);
363
+ }
364
+ if (cell.source !== null) {
365
+ ycell.setSource(
366
+ typeof cell.source === 'string' ? cell.source : cell.source.join('\n'),
367
+ );
368
+ }
369
+ return ycell;
370
+ };
371
+
372
+ /**
373
+ * Create a new cell that cannot be inserted in an existing shared model.
374
+ *
375
+ * @param cell Cell JSON representation
376
+ */
377
+ export const createStandaloneCell = (
378
+ cell: SharedCell.Cell,
379
+ cellTypeAdaptor?: CellTypeAdaptor,
380
+ ): YCellType => createCell(cell, undefined, cellTypeAdaptor);
381
+
382
+ export class YBaseCell<Metadata extends IBaseCellMetadata>
383
+ implements ISharedBaseCell<Metadata>, IYText
384
+ {
385
+ /**
386
+ * Create a new YCell that works standalone. It cannot be
387
+ * inserted into a YNotebook because the Yjs model is already
388
+ * attached to an anonymous Y.Doc instance.
389
+ */
390
+ static createStandalone(id?: string): YBaseCell<any> {
391
+ const cell = createCell({
392
+ id,
393
+ cell_type: this.prototype.cell_type,
394
+ source: '',
395
+ metadata: {},
396
+ });
397
+ return cell;
398
+ }
399
+
400
+ /**
401
+ * Base cell constructor
402
+ *
403
+ * ### Notes
404
+ * Don't use the constructor directly - prefer using ``YNotebook.insertCell``
405
+ *
406
+ * The ``ysource`` is needed because ``ymodel.get('source')`` will
407
+ * not return the real source if the model is not yet attached to
408
+ * a document. Requesting it explicitly allows to introspect a non-empty
409
+ * source before the cell is attached to the document.
410
+ *
411
+ * @param ymodel Cell map
412
+ * @param ysource Cell source
413
+ * @param options { notebook?: The notebook the cell is attached to }
414
+ */
415
+ constructor(ymodel: Y.Map<any>, ysource: Y.Text, options: SharedCell.IOptions = {}) {
416
+ this.ymodel = ymodel;
417
+ this._ysource = ysource;
418
+ this._prevSourceLength = ysource ? ysource.length : 0;
419
+ this._notebook = null;
420
+ this._awareness = null;
421
+ this._undoManager = null;
422
+ if (options.notebook) {
423
+ this._notebook = options.notebook as YNotebook;
424
+ // We cannot create a undo manager with the cell not yet attached in the notebook
425
+ // so we defer that to the notebook insertCell method
426
+ } else {
427
+ // Standalone cell
428
+ const doc = new Y.Doc();
429
+ doc.getArray().insert(0, [this.ymodel]);
430
+ this._awareness = new Awareness(doc);
431
+ this._undoManager = new Y.UndoManager([this.ymodel], {
432
+ trackedOrigins: new Set([this]),
433
+ });
434
+ }
435
+
436
+ this.ymodel.observeDeep(this._modelObserver);
437
+ }
438
+
439
+ /**
440
+ * Cell notebook awareness or null if the cell is standalone.
441
+ */
442
+ get awareness(): Awareness | null {
443
+ return this._awareness ?? this.notebook?.awareness ?? null;
444
+ }
445
+
446
+ /**
447
+ * The type of the cell.
448
+ */
449
+ get cell_type(): any {
450
+ throw new Error('A YBaseCell must not be constructed');
451
+ }
452
+
453
+ /**
454
+ * The changed signal.
455
+ */
456
+ get changed(): Event<CellChange<Metadata>> {
457
+ return this._changed;
458
+ }
459
+
460
+ /**
461
+ * Cell id
462
+ */
463
+ get id(): string {
464
+ return this.getId();
465
+ }
466
+
467
+ /**
468
+ * Whether the model has been disposed or not.
469
+ */
470
+ get isDisposed(): boolean {
471
+ return this._isDisposed;
472
+ }
473
+
474
+ /**
475
+ * Whether the cell is standalone or not.
476
+ *
477
+ * If the cell is standalone. It cannot be
478
+ * inserted into a YNotebook because the Yjs model is already
479
+ * attached to an anonymous Y.Doc instance.
480
+ */
481
+ get isStandalone(): boolean {
482
+ return this._notebook !== null;
483
+ }
484
+
485
+ /**
486
+ * Cell metadata.
487
+ */
488
+ get metadata(): Partial<Metadata> {
489
+ return this.getMetadata();
490
+ }
491
+ set metadata(v: Partial<Metadata>) {
492
+ this.setMetadata(v);
493
+ }
494
+
495
+ /**
496
+ * Signal triggered when the cell metadata changes.
497
+ */
498
+ get metadataChanged(): Event<IMapChange> {
499
+ return this._metadataChanged;
500
+ }
501
+
502
+ /**
503
+ * The notebook that this cell belongs to.
504
+ */
505
+ get notebook(): YNotebook | null {
506
+ return this._notebook;
507
+ }
508
+
509
+ /**
510
+ * Cell input content.
511
+ */
512
+ get source(): string {
513
+ return this.getSource();
514
+ }
515
+ set source(v: string) {
516
+ this.setSource(v);
517
+ }
518
+
519
+ /**
520
+ * The cell undo manager.
521
+ */
522
+ get undoManager(): Y.UndoManager | null {
523
+ if (!this.notebook) {
524
+ return this._undoManager;
525
+ }
526
+ return this.notebook?.disableDocumentWideUndoRedo
527
+ ? this._undoManager
528
+ : this.notebook.undoManager;
529
+ }
530
+
531
+ /**
532
+ * Defer setting the undo manager as it requires the
533
+ * cell to be attached to the notebook Y document.
534
+ */
535
+ setUndoManager(): void {
536
+ if (this._undoManager) {
537
+ throw new Error('The cell undo manager is already set.');
538
+ }
539
+
540
+ if (this._notebook && this._notebook.disableDocumentWideUndoRedo) {
541
+ this._undoManager = new Y.UndoManager([this.ymodel], {
542
+ trackedOrigins: new Set([this]),
543
+ });
544
+ }
545
+ }
546
+
547
+ readonly ymodel: Y.Map<any>;
548
+
549
+ get ysource(): Y.Text {
550
+ return this._ysource;
551
+ }
552
+
553
+ /**
554
+ * Whether the object can undo changes.
555
+ */
556
+ canUndo(): boolean {
557
+ return !!this.undoManager && this.undoManager.undoStack.length > 0;
558
+ }
559
+
560
+ /**
561
+ * Whether the object can redo changes.
562
+ */
563
+ canRedo(): boolean {
564
+ return !!this.undoManager && this.undoManager.redoStack.length > 0;
565
+ }
566
+
567
+ /**
568
+ * Clear the change stack.
569
+ */
570
+ clearUndoHistory(): void {
571
+ this.undoManager?.clear();
572
+ }
573
+
574
+ /**
575
+ * Undo an operation.
576
+ */
577
+ undo(): void {
578
+ this.undoManager?.undo();
579
+ }
580
+
581
+ /**
582
+ * Redo an operation.
583
+ */
584
+ redo(): void {
585
+ this.undoManager?.redo();
586
+ }
587
+
588
+ /**
589
+ * Dispose of the resources.
590
+ */
591
+ dispose(): void {
592
+ if (this._isDisposed) {
593
+ return;
594
+ }
595
+ this._isDisposed = true;
596
+ this.ymodel.unobserveDeep(this._modelObserver);
597
+
598
+ if (this._awareness) {
599
+ // A new document is created for standalone cell.
600
+ const doc = this._awareness.doc;
601
+ this._awareness.destroy();
602
+ doc.destroy();
603
+ }
604
+ if (this._undoManager) {
605
+ // Be sure to not destroy the document undo manager.
606
+ if (this._undoManager === this.notebook?.undoManager) {
607
+ this._undoManager = null;
608
+ } else {
609
+ this._undoManager.destroy();
610
+ }
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Get cell id.
616
+ *
617
+ * @returns Cell id
618
+ */
619
+ getId(): string {
620
+ return this.ymodel.get('id');
621
+ }
622
+
623
+ /**
624
+ * Gets cell's source.
625
+ *
626
+ * @returns Cell's source.
627
+ */
628
+ getSource(): string {
629
+ return this.ysource.toString();
630
+ }
631
+
632
+ /**
633
+ * Sets cell's source.
634
+ *
635
+ * @param value: New source.
636
+ */
637
+ setSource(value: string): void {
638
+ this.transact(() => {
639
+ this.ysource.delete(0, this.ysource.length);
640
+ this.ysource.insert(0, value);
641
+ });
642
+ // @todo Do we need proper replace semantic? This leads to issues in editor bindings because they don't switch source.
643
+ // this.ymodel.set('source', new Y.Text(value));
644
+ }
645
+
646
+ /**
647
+ * Replace content from `start' to `end` with `value`.
648
+ *
649
+ * @param start: The start index of the range to replace (inclusive).
650
+ *
651
+ * @param end: The end index of the range to replace (exclusive).
652
+ *
653
+ * @param value: New source (optional).
654
+ */
655
+ updateSource(start: number, end: number, value = ''): void {
656
+ this.transact(() => {
657
+ const ysource = this.ysource;
658
+ // insert and then delete.
659
+ // This ensures that the cursor position is adjusted after the replaced content.
660
+ ysource.insert(start, value);
661
+ ysource.delete(start + value.length, end - start);
662
+ });
663
+ }
664
+
665
+ /**
666
+ * Delete a metadata cell.
667
+ *
668
+ * @param key The key to delete
669
+ */
670
+ deleteMetadata(key: string): void {
671
+ const allMetadata = deepCopy(this.ymodel.get('metadata'));
672
+ delete allMetadata[key];
673
+ this.setMetadata(allMetadata);
674
+ }
675
+
676
+ /**
677
+ * Returns the metadata associated with the cell.
678
+ *
679
+ * @param key
680
+ * @returns Cell metadata.
681
+ */
682
+ getMetadata(key?: string): Partial<Metadata> {
683
+ const metadata = this.ymodel.get('metadata');
684
+
685
+ if (typeof key === 'string') {
686
+ return deepCopy(metadata[key]);
687
+ } else {
688
+ return deepCopy(metadata);
689
+ }
690
+ }
691
+
692
+ /**
693
+ * Sets some cell metadata.
694
+ *
695
+ * If only one argument is provided, it will override all cell metadata.
696
+ * Otherwise a single key will be set to a new value.
697
+ *
698
+ * @param metadata Cell's metadata or key.
699
+ * @param value Metadata value
700
+ */
701
+ setMetadata(metadata: Partial<Metadata> | string, value?: PartialJSONValue): void {
702
+ if (typeof metadata === 'string') {
703
+ if (typeof value === 'undefined') {
704
+ throw new TypeError(
705
+ `Metadata value for ${metadata} cannot be 'undefined'; use deleteMetadata.`,
706
+ );
707
+ }
708
+ const key = metadata;
709
+ // eslint-disable-next-line no-param-reassign
710
+ metadata = this.getMetadata();
711
+ // @ts-expect-error metadata type is changed at runtime.
712
+ metadata[key] = value;
713
+ }
714
+
715
+ const clone = deepCopy(metadata) as any;
716
+ if (clone.collapsed !== null) {
717
+ clone.jupyter = clone.jupyter || {};
718
+ clone.jupyter.outputs_hidden = clone.collapsed;
719
+ } else if (clone?.jupyter?.outputs_hidden !== null) {
720
+ clone.collapsed = clone.jupyter.outputs_hidden;
721
+ }
722
+ if (this.ymodel.doc === null || !deepEqual(clone, this.getMetadata())) {
723
+ this.transact(() => {
724
+ this.ymodel.set('metadata', clone);
725
+ });
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Serialize the model to JSON.
731
+ */
732
+ toJSON(): IBaseCell {
733
+ return {
734
+ id: this.getId(),
735
+ cell_type: this.cell_type,
736
+ source: this.getSource(),
737
+ metadata: this.getMetadata(),
738
+ };
739
+ }
740
+
741
+ /**
742
+ * Perform a transaction. While the function f is called, all changes to the shared
743
+ * document are bundled into a single event.
744
+ */
745
+ transact(f: () => void, undoable = true): void {
746
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
747
+ this.notebook && undoable
748
+ ? this.notebook.transact(f)
749
+ : this.ymodel.doc === null
750
+ ? f()
751
+ : this.ymodel.doc.transact(f, this);
752
+ }
753
+
754
+ /**
755
+ * Extract changes from YJS events
756
+ *
757
+ * @param events YJS events
758
+ * @returns Cell changes
759
+ */
760
+ protected getChanges(events: Y.YEvent<any>[]): Partial<CellChange<Metadata>> {
761
+ const changes: CellChange<Metadata> = {};
762
+
763
+ const sourceEvent = events.find(
764
+ (event) => event.target === this.ymodel.get('source'),
765
+ );
766
+ if (sourceEvent) {
767
+ changes.sourceChange = sourceEvent.changes.delta as any;
768
+ }
769
+
770
+ const modelEvent = events.find((event) => event.target === this.ymodel) as
771
+ | undefined
772
+ | Y.YMapEvent<any>;
773
+ if (modelEvent && modelEvent.keysChanged.has('metadata')) {
774
+ const change = modelEvent.changes.keys.get('metadata');
775
+ const metadataChange = (changes.metadataChange = {
776
+ oldValue: change?.oldValue ? change.oldValue : undefined,
777
+ newValue: this.getMetadata(),
778
+ });
779
+
780
+ const oldValue = metadataChange.oldValue ?? {};
781
+ const oldKeys = Object.keys(oldValue);
782
+ const newKeys = Object.keys(metadataChange.newValue);
783
+ for (const key of new Set(oldKeys.concat(newKeys))) {
784
+ if (!oldKeys.includes(key)) {
785
+ this._metadataChangedEmitter.fire({
786
+ key,
787
+ newValue: metadataChange.newValue[key],
788
+ type: 'add',
789
+ });
790
+ } else if (!newKeys.includes(key)) {
791
+ this._metadataChangedEmitter.fire({
792
+ key,
793
+ oldValue: metadataChange.oldValue[key],
794
+ type: 'remove',
795
+ });
796
+ } else if (!deepEqual(oldValue[key], metadataChange.newValue[key]!)) {
797
+ this._metadataChangedEmitter.fire({
798
+ key,
799
+ newValue: metadataChange.newValue[key],
800
+ oldValue: metadataChange.oldValue[key],
801
+ type: 'change',
802
+ });
803
+ }
804
+ }
805
+ }
806
+
807
+ // The model allows us to replace the complete source with a new string. We express this in the Delta format
808
+ // as a replace of the complete string.
809
+ const ysource = this.ymodel.get('source');
810
+ if (modelEvent && modelEvent.keysChanged.has('source')) {
811
+ changes.sourceChange = [
812
+ { delete: this._prevSourceLength },
813
+ { insert: ysource.toString() },
814
+ ];
815
+ }
816
+ this._prevSourceLength = ysource.length;
817
+
818
+ return changes;
819
+ }
820
+
821
+ /**
822
+ * Handle a change to the ymodel.
823
+ */
824
+ private _modelObserver = (events: Y.YEvent<any>[]) => {
825
+ this._changedEmitter.fire(this.getChanges(events));
826
+ };
827
+
828
+ protected _metadataChangedEmitter = new Emitter<IMapChange>();
829
+ protected _metadataChanged = this._metadataChangedEmitter.event;
830
+ /**
831
+ * The notebook that this cell belongs to.
832
+ */
833
+ protected _notebook: YNotebook | null = null;
834
+ private _awareness: Awareness | null;
835
+ private _changedEmitter = new Emitter<CellChange<Metadata>>();
836
+ private _changed = this._changedEmitter.event;
837
+ private _isDisposed = false;
838
+ private _prevSourceLength: number;
839
+ private _undoManager: Y.UndoManager | null = null;
840
+ private _ysource: Y.Text;
841
+ }
842
+
843
+ /**
844
+ * Shareable code cell.
845
+ */
846
+ export class YCodeCell extends YBaseCell<IBaseCellMetadata> implements ISharedCodeCell {
847
+ /**
848
+ * Create a new YCodeCell that works standalone. It cannot be
849
+ * inserted into a YNotebook because the Yjs model is already
850
+ * attached to an anonymous Y.Doc instance.
851
+ */
852
+ static override createStandalone(id?: string): YCodeCell {
853
+ return super.createStandalone(id) as YCodeCell;
854
+ }
855
+
856
+ /**
857
+ * Code cell constructor
858
+ *
859
+ * ### Notes
860
+ * Don't use the constructor directly - prefer using ``YNotebook.insertCell``
861
+ *
862
+ * The ``ysource`` is needed because ``ymodel.get('source')`` will
863
+ * not return the real source if the model is not yet attached to
864
+ * a document. Requesting it explicitly allows to introspect a non-empty
865
+ * source before the cell is attached to the document.
866
+ *
867
+ * @param ymodel Cell map
868
+ * @param ysource Cell source
869
+ * @param youtputs Code cell outputs
870
+ * @param options { notebook?: The notebook the cell is attached to }
871
+ */
872
+ constructor(
873
+ ymodel: Y.Map<any>,
874
+ ysource: Y.Text,
875
+ youtputs: Y.Array<any>,
876
+ options: SharedCell.IOptions = {},
877
+ ) {
878
+ super(ymodel, ysource, options);
879
+ this._youtputs = youtputs;
880
+ }
881
+
882
+ /**
883
+ * The type of the cell.
884
+ */
885
+ override get cell_type(): string {
886
+ return this.ymodel.get('cell_type');
887
+ }
888
+
889
+ /**
890
+ * The code cell's prompt number. Will be null if the cell has not been run.
891
+ */
892
+ get execution_count(): number | null {
893
+ return this.ymodel.get('execution_count') || null;
894
+ }
895
+ set execution_count(count: number | null) {
896
+ // Do not use `this.execution_count`. When initializing the
897
+ // cell, we need to set execution_count to `null` if we compare
898
+ // using `this.execution_count` it will return `null` and we will
899
+ // never initialize it
900
+ if (this.ymodel.get('execution_count') !== count) {
901
+ this.transact(() => {
902
+ this.ymodel.set('execution_count', count);
903
+ });
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Cell outputs.
909
+ */
910
+ get outputs(): IOutput[] {
911
+ return this.getOutputs();
912
+ }
913
+ set outputs(v: IOutput[]) {
914
+ this.setOutputs(v);
915
+ }
916
+
917
+ /**
918
+ * Execution, display, or stream outputs.
919
+ */
920
+ getOutputs(): IOutput[] {
921
+ return deepCopy(this._youtputs.toArray());
922
+ }
923
+
924
+ /**
925
+ * Replace all outputs.
926
+ */
927
+ setOutputs(outputs: IOutput[]): void {
928
+ this.transact(() => {
929
+ this._youtputs.delete(0, this._youtputs.length);
930
+ this._youtputs.insert(0, outputs);
931
+ }, false);
932
+ }
933
+
934
+ /**
935
+ * Replace content from `start' to `end` with `outputs`.
936
+ *
937
+ * @param start: The start index of the range to replace (inclusive).
938
+ *
939
+ * @param end: The end index of the range to replace (exclusive).
940
+ *
941
+ * @param outputs: New outputs (optional).
942
+ */
943
+ updateOutputs(start: number, end: number, outputs: IOutput[] = []): void {
944
+ const fin =
945
+ end < this._youtputs.length ? end - start : this._youtputs.length - start;
946
+ this.transact(() => {
947
+ this._youtputs.delete(start, fin);
948
+ this._youtputs.insert(start, outputs);
949
+ }, false);
950
+ }
951
+
952
+ /**
953
+ * Serialize the model to JSON.
954
+ */
955
+ override toJSON(): ICodeCell {
956
+ return {
957
+ ...(super.toJSON() as ICodeCell),
958
+ outputs: this.getOutputs(),
959
+ execution_count: this.execution_count,
960
+ };
961
+ }
962
+
963
+ /**
964
+ * Extract changes from YJS events
965
+ *
966
+ * @param events YJS events
967
+ * @returns Cell changes
968
+ */
969
+ protected override getChanges(
970
+ events: Y.YEvent<any>[],
971
+ ): Partial<CellChange<IBaseCellMetadata>> {
972
+ const changes = super.getChanges(events);
973
+
974
+ const outputEvent = events.find(
975
+ (event) => event.target === this.ymodel.get('outputs'),
976
+ );
977
+ if (outputEvent) {
978
+ changes.outputsChange = outputEvent.changes.delta as any;
979
+ }
980
+
981
+ const modelEvent = events.find((event) => event.target === this.ymodel) as
982
+ | undefined
983
+ | Y.YMapEvent<any>;
984
+
985
+ if (modelEvent && modelEvent.keysChanged.has('execution_count')) {
986
+ const change = modelEvent.changes.keys.get('execution_count');
987
+ changes.executionCountChange = {
988
+ oldValue: change!.oldValue,
989
+ newValue: this.ymodel.get('execution_count'),
990
+ };
991
+ }
992
+
993
+ return changes;
994
+ }
995
+
996
+ private _youtputs: Y.Array<IOutput>;
997
+ }
998
+
999
+ class YAttachmentCell
1000
+ extends YBaseCell<IBaseCellMetadata>
1001
+ implements ISharedAttachmentsCell
1002
+ {
1003
+ /**
1004
+ * Cell attachments
1005
+ */
1006
+ get attachments(): IAttachments | undefined {
1007
+ return this.getAttachments();
1008
+ }
1009
+ set attachments(v: IAttachments | undefined) {
1010
+ this.setAttachments(v);
1011
+ }
1012
+
1013
+ /**
1014
+ * Gets the cell attachments.
1015
+ *
1016
+ * @returns The cell attachments.
1017
+ */
1018
+ getAttachments(): IAttachments | undefined {
1019
+ return this.ymodel.get('attachments');
1020
+ }
1021
+
1022
+ /**
1023
+ * Sets the cell attachments
1024
+ *
1025
+ * @param attachments: The cell attachments.
1026
+ */
1027
+ setAttachments(attachments: IAttachments | undefined): void {
1028
+ this.transact(() => {
1029
+ if (attachments === null) {
1030
+ this.ymodel.delete('attachments');
1031
+ } else {
1032
+ this.ymodel.set('attachments', attachments);
1033
+ }
1034
+ });
1035
+ }
1036
+
1037
+ /**
1038
+ * Extract changes from YJS events
1039
+ *
1040
+ * @param events YJS events
1041
+ * @returns Cell changes
1042
+ */
1043
+ protected override getChanges(
1044
+ events: Y.YEvent<any>[],
1045
+ ): Partial<CellChange<IBaseCellMetadata>> {
1046
+ const changes = super.getChanges(events);
1047
+
1048
+ const modelEvent = events.find((event) => event.target === this.ymodel) as
1049
+ | undefined
1050
+ | Y.YMapEvent<any>;
1051
+
1052
+ if (modelEvent && modelEvent.keysChanged.has('attachments')) {
1053
+ const change = modelEvent.changes.keys.get('attachments');
1054
+ changes.executionCountChange = {
1055
+ oldValue: change!.oldValue,
1056
+ newValue: this.ymodel.get('attachments'),
1057
+ };
1058
+ }
1059
+
1060
+ return changes;
1061
+ }
1062
+ }
1063
+
1064
+ /**
1065
+ * Shareable raw cell.
1066
+ */
1067
+ export class YRawCell extends YAttachmentCell implements ISharedRawCell {
1068
+ /**
1069
+ * Create a new YRawCell that works standalone. It cannot be
1070
+ * inserted into a YNotebook because the Yjs model is already
1071
+ * attached to an anonymous Y.Doc instance.
1072
+ */
1073
+ static override createStandalone(id?: string): YRawCell {
1074
+ return super.createStandalone(id) as YRawCell;
1075
+ }
1076
+
1077
+ /**
1078
+ * String identifying the type of cell.
1079
+ */
1080
+ override get cell_type(): 'raw' {
1081
+ return 'raw';
1082
+ }
1083
+
1084
+ /**
1085
+ * Serialize the model to JSON.
1086
+ */
1087
+ override toJSON(): IRawCell {
1088
+ return {
1089
+ id: this.getId(),
1090
+ cell_type: 'raw',
1091
+ source: this.getSource(),
1092
+ metadata: this.getMetadata(),
1093
+ attachments: this.getAttachments(),
1094
+ };
1095
+ }
1096
+ }
1097
+
1098
+ /**
1099
+ * Shareable markdown cell.
1100
+ */
1101
+ export class YMarkdownCell extends YAttachmentCell implements ISharedMarkdownCell {
1102
+ /**
1103
+ * Create a new YMarkdownCell that works standalone. It cannot be
1104
+ * inserted into a YNotebook because the Yjs model is already
1105
+ * attached to an anonymous Y.Doc instance.
1106
+ */
1107
+ static override createStandalone(id?: string): YMarkdownCell {
1108
+ return super.createStandalone(id) as YMarkdownCell;
1109
+ }
1110
+
1111
+ /**
1112
+ * String identifying the type of cell.
1113
+ */
1114
+ override get cell_type(): 'markdown' {
1115
+ return 'markdown';
1116
+ }
1117
+
1118
+ /**
1119
+ * Serialize the model to JSON.
1120
+ */
1121
+ override toJSON(): IMarkdownCell {
1122
+ return {
1123
+ id: this.getId(),
1124
+ cell_type: 'markdown',
1125
+ source: this.getSource(),
1126
+ metadata: this.getMetadata(),
1127
+ attachments: this.getAttachments(),
1128
+ };
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Cell type.
1134
+ */
1135
+ export type YCellType = YRawCell | YCodeCell | YMarkdownCell;
1136
+
1137
+ /**
1138
+ * Shared implementation of the Shared Document types.
1139
+ *
1140
+ * Shared cells can be inserted into a SharedNotebook.
1141
+ * Shared cells only start emitting events when they are connected to a SharedNotebook.
1142
+ *
1143
+ * "Standalone" cells must not be inserted into a (Shared)Notebook.
1144
+ * Standalone cells emit events immediately after they have been created, but they must not
1145
+ * be included into a (Shared)Notebook.
1146
+ */
1147
+ export class YNotebook extends YDocument<NotebookChange> implements ISharedNotebook {
1148
+ protected _undoChangedEmitter = new Emitter<boolean>();
1149
+ /**
1150
+ * Signal triggered when a undo stack changed.
1151
+ */
1152
+ get undoChanged(): Event<boolean> {
1153
+ return this._undoChangedEmitter.event;
1154
+ }
1155
+ protected _redoChangedEmitter = new Emitter<boolean>();
1156
+ /**
1157
+ * Signal triggered when a undo stack changed.
1158
+ */
1159
+ get redoChanged(): Event<boolean> {
1160
+ return this._redoChangedEmitter.event;
1161
+ }
1162
+
1163
+ /**
1164
+ * Create a new YNotebook.
1165
+ */
1166
+ static create(options: ISharedNotebook.IOptions = {}): ISharedNotebook {
1167
+ return new YNotebook(options);
1168
+ }
1169
+
1170
+ protected _canRedo = false;
1171
+ protected _canUndo = false;
1172
+ constructor(options: ISharedNotebook.IOptions = {}) {
1173
+ super();
1174
+ this._disableDocumentWideUndoRedo = options.disableDocumentWideUndoRedo ?? false;
1175
+ this.cellTypeAdaptor = options.cellTypeAdaptor ?? defaultCellTypeAdaptor;
1176
+ this.cells = this._ycells.toArray().map((ycell) => {
1177
+ if (!this._ycellMapping.has(ycell)) {
1178
+ this._ycellMapping.set(
1179
+ ycell,
1180
+ createCellModelFromSharedType(
1181
+ ycell,
1182
+ { notebook: this },
1183
+ this.cellTypeAdaptor,
1184
+ ),
1185
+ );
1186
+ }
1187
+ return this._ycellMapping.get(ycell) as YCellType;
1188
+ });
1189
+
1190
+ this.undoManager.addToScope(this._ycells);
1191
+ this._ycells.observe(this._onYCellsChanged);
1192
+ this.ymeta.observe(this._onMetaChanged);
1193
+ this.undoManager.on('stack-item-updated', this.handleUndoChanged);
1194
+ this.undoManager.on('stack-item-added', this.handleUndoChanged);
1195
+ this.undoManager.on('stack-item-popped', this.handleUndoChanged);
1196
+ this.undoManager.on('stack-cleared', this.handleUndoChanged);
1197
+ this._canRedo = this.undoManager.canRedo();
1198
+ this._canUndo = this.undoManager.canUndo();
1199
+ }
1200
+
1201
+ protected handleUndoChanged = () => {
1202
+ const canRedo = this.undoManager.canRedo();
1203
+ if (this._canRedo !== canRedo) {
1204
+ this._canRedo = canRedo;
1205
+ this._redoChangedEmitter.fire(canRedo);
1206
+ }
1207
+ const canUndo = this.undoManager.canUndo();
1208
+ if (this._canUndo !== canUndo) {
1209
+ this._canUndo = canUndo;
1210
+ this._undoChangedEmitter.fire(canUndo);
1211
+ }
1212
+ };
1213
+
1214
+ cellTypeAdaptor: CellTypeAdaptor = defaultCellTypeAdaptor;
1215
+
1216
+ /**
1217
+ * YJS map for the notebook metadata
1218
+ */
1219
+ readonly ymeta: Y.Map<any> = this.ydoc.getMap('meta');
1220
+ /**
1221
+ * Cells list
1222
+ */
1223
+ readonly cells: YCellType[];
1224
+
1225
+ /**
1226
+ * Signal triggered when the cells list changes.
1227
+ */
1228
+ get cellsChanged(): Event<IListChange> {
1229
+ return this._cellsChanged;
1230
+ }
1231
+
1232
+ /**
1233
+ * Wether the undo/redo logic should be
1234
+ * considered on the full document across all cells.
1235
+ *
1236
+ * Default: false
1237
+ */
1238
+ get disableDocumentWideUndoRedo(): boolean {
1239
+ return this._disableDocumentWideUndoRedo;
1240
+ }
1241
+
1242
+ /**
1243
+ * Notebook metadata
1244
+ */
1245
+ get metadata(): INotebookMetadata {
1246
+ return this.getMetadata();
1247
+ }
1248
+ set metadata(v: INotebookMetadata) {
1249
+ this.setMetadata(v);
1250
+ }
1251
+
1252
+ /**
1253
+ * Signal triggered when a metadata changes.
1254
+ */
1255
+ get metadataChanged(): Event<IMapChange> {
1256
+ return this._metadataChanged;
1257
+ }
1258
+
1259
+ /**
1260
+ * nbformat major version
1261
+ */
1262
+ get nbformat(): number {
1263
+ return this.ymeta.get('nbformat');
1264
+ }
1265
+ set nbformat(value: number) {
1266
+ this.transact(() => {
1267
+ this.ymeta.set('nbformat', value);
1268
+ }, false);
1269
+ }
1270
+
1271
+ /**
1272
+ * nbformat minor version
1273
+ */
1274
+ get nbformat_minor(): number {
1275
+ return this.ymeta.get('nbformat_minor');
1276
+ }
1277
+ set nbformat_minor(value: number) {
1278
+ this.transact(() => {
1279
+ this.ymeta.set('nbformat_minor', value);
1280
+ }, false);
1281
+ }
1282
+
1283
+ /**
1284
+ * Dispose of the resources.
1285
+ */
1286
+ override dispose(): void {
1287
+ if (this.isDisposed) {
1288
+ return;
1289
+ }
1290
+ this._ycells.unobserve(this._onYCellsChanged);
1291
+ this.ymeta.unobserve(this._onMetaChanged);
1292
+ this.undoManager.off('stack-item-updated', this.handleUndoChanged);
1293
+ this.undoManager.off('stack-item-added', this.handleUndoChanged);
1294
+ this.undoManager.off('stack-item-popped', this.handleUndoChanged);
1295
+ this.undoManager.off('stack-cleared', this.handleUndoChanged);
1296
+ super.dispose();
1297
+ }
1298
+
1299
+ /**
1300
+ * Get a shared cell by index.
1301
+ *
1302
+ * @param index: Cell's position.
1303
+ *
1304
+ * @returns The requested shared cell.
1305
+ */
1306
+ getCell(index: number): YCellType {
1307
+ return this.cells[index];
1308
+ }
1309
+
1310
+ /**
1311
+ * Add a shared cell at the notebook bottom.
1312
+ *
1313
+ * @param cell Cell to add.
1314
+ *
1315
+ * @returns The added cell.
1316
+ */
1317
+ addCell(cell: SharedCell.Cell): YBaseCell<IBaseCellMetadata> {
1318
+ return this.insertCell(this._ycells.length, cell);
1319
+ }
1320
+
1321
+ /**
1322
+ * Insert a shared cell into a specific position.
1323
+ *
1324
+ * @param index: Cell's position.
1325
+ * @param cell: Cell to insert.
1326
+ *
1327
+ * @returns The inserted cell.
1328
+ */
1329
+ insertCell(index: number, cell: SharedCell.Cell): YBaseCell<IBaseCellMetadata> {
1330
+ return this.insertCells(index, [cell])[0];
1331
+ }
1332
+
1333
+ /**
1334
+ * Insert a list of shared cells into a specific position.
1335
+ *
1336
+ * @param index: Position to insert the cells.
1337
+ * @param cells: Array of shared cells to insert.
1338
+ *
1339
+ * @returns The inserted cells.
1340
+ */
1341
+ insertCells(index: number, cells: SharedCell.Cell[]): YBaseCell<IBaseCellMetadata>[] {
1342
+ const yCells = cells.map((c) => {
1343
+ const cell = createCell(c, this);
1344
+ this._ycellMapping.set(cell.ymodel, cell);
1345
+ return cell;
1346
+ });
1347
+
1348
+ this.transact(() => {
1349
+ this._ycells.insert(
1350
+ index,
1351
+ yCells.map((cell) => cell.ymodel),
1352
+ );
1353
+ });
1354
+
1355
+ yCells.forEach((c) => {
1356
+ c.setUndoManager();
1357
+ });
1358
+
1359
+ return yCells;
1360
+ }
1361
+
1362
+ /**
1363
+ * Move a cell.
1364
+ *
1365
+ * @param fromIndex: Index of the cell to move.
1366
+ * @param toIndex: New position of the cell.
1367
+ */
1368
+ moveCell(fromIndex: number, toIndex: number): void {
1369
+ this.transact(() => {
1370
+ // FIXME we need to use yjs move feature to preserve undo history
1371
+ const clone = createCell(this.getCell(fromIndex).toJSON(), this);
1372
+ this._ycells.delete(fromIndex, 1);
1373
+ this._ycells.insert(toIndex, [clone.ymodel]);
1374
+ });
1375
+ }
1376
+
1377
+ /**
1378
+ * Remove a cell.
1379
+ *
1380
+ * @param index: Index of the cell to remove.
1381
+ */
1382
+ deleteCell(index: number): void {
1383
+ this.deleteCellRange(index, index + 1);
1384
+ }
1385
+
1386
+ /**
1387
+ * Remove a range of cells.
1388
+ *
1389
+ * @param from: The start index of the range to remove (inclusive).
1390
+ * @param to: The end index of the range to remove (exclusive).
1391
+ */
1392
+ deleteCellRange(from: number, to: number): void {
1393
+ // Cells will be removed from the mapping in the model event listener.
1394
+ this.transact(() => {
1395
+ this._ycells.delete(from, to - from);
1396
+ });
1397
+ }
1398
+
1399
+ /**
1400
+ * Delete a metadata notebook.
1401
+ *
1402
+ * @param key The key to delete
1403
+ */
1404
+ deleteMetadata(key: string): void {
1405
+ const allMetadata = deepCopy(this.ymeta.get('metadata'));
1406
+ delete allMetadata[key];
1407
+ this.setMetadata(allMetadata);
1408
+ }
1409
+
1410
+ /**
1411
+ * Returns some metadata associated with the notebook.
1412
+ *
1413
+ * If no `key` is provided, it will return all metadata.
1414
+ * Else it will return the value for that key.
1415
+ *
1416
+ * @param key Key to get from the metadata
1417
+ * @returns Notebook's metadata.
1418
+ */
1419
+ getMetadata(key?: string): INotebookMetadata {
1420
+ const meta = this.ymeta.get('metadata');
1421
+
1422
+ if (typeof key === 'string') {
1423
+ return deepCopy(meta[key]);
1424
+ } else {
1425
+ return deepCopy(meta ?? {});
1426
+ }
1427
+ }
1428
+
1429
+ /**
1430
+ * Sets some metadata associated with the notebook.
1431
+ *
1432
+ * If only one argument is provided, it will override all notebook metadata.
1433
+ * Otherwise a single key will be set to a new value.
1434
+ *
1435
+ * @param metadata All Notebook's metadata or the key to set.
1436
+ * @param value New metadata value
1437
+ */
1438
+ setMetadata(metadata: INotebookMetadata | string, value?: PartialJSONValue): void {
1439
+ if (typeof metadata === 'string') {
1440
+ if (typeof value === 'undefined') {
1441
+ throw new TypeError(
1442
+ `Metadata value for ${metadata} cannot be 'undefined'; use deleteMetadata.`,
1443
+ );
1444
+ }
1445
+ const update: Partial<INotebookMetadata> = {};
1446
+ update[metadata] = value;
1447
+ this.updateMetadata(update);
1448
+ } else {
1449
+ this.ymeta.set('metadata', deepCopy(metadata));
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * Updates the metadata associated with the notebook.
1455
+ *
1456
+ * @param value: Metadata's attribute to update.
1457
+ */
1458
+ updateMetadata(value: Partial<INotebookMetadata>): void {
1459
+ // TODO: Maybe modify only attributes instead of replacing the whole metadata?
1460
+ this.ymeta.set('metadata', { ...this.getMetadata(), ...value });
1461
+ }
1462
+
1463
+ /**
1464
+ * Handle a change to the ystate.
1465
+ */
1466
+ private _onMetaChanged = (event: Y.YMapEvent<any>) => {
1467
+ if (event.keysChanged.has('metadata')) {
1468
+ const change = event.changes.keys.get('metadata');
1469
+ const metadataChange = {
1470
+ oldValue: change?.oldValue ? change.oldValue : undefined,
1471
+ newValue: this.getMetadata(),
1472
+ };
1473
+
1474
+ const oldValue = metadataChange.oldValue ?? {};
1475
+ const oldKeys = Object.keys(oldValue);
1476
+ const newKeys = Object.keys(metadataChange.newValue);
1477
+ for (const key of new Set(oldKeys.concat(newKeys))) {
1478
+ if (!oldKeys.includes(key)) {
1479
+ this._metadataChangedEmitter.fire({
1480
+ key,
1481
+ newValue: metadataChange.newValue[key],
1482
+ type: 'add',
1483
+ });
1484
+ } else if (!newKeys.includes(key)) {
1485
+ this._metadataChangedEmitter.fire({
1486
+ key,
1487
+ oldValue: metadataChange.oldValue[key],
1488
+ type: 'remove',
1489
+ });
1490
+ } else if (!deepEqual(oldValue[key], metadataChange.newValue[key]!)) {
1491
+ this._metadataChangedEmitter.fire({
1492
+ key,
1493
+ newValue: metadataChange.newValue[key],
1494
+ oldValue: metadataChange.oldValue[key],
1495
+ type: 'change',
1496
+ });
1497
+ }
1498
+ }
1499
+
1500
+ this._changedEmitter.fire({ metadataChange });
1501
+ }
1502
+
1503
+ if (event.keysChanged.has('nbformat')) {
1504
+ const change = event.changes.keys.get('nbformat');
1505
+ const nbformatChanged = {
1506
+ key: 'nbformat',
1507
+ oldValue: change?.oldValue ? change.oldValue : undefined,
1508
+ newValue: this.nbformat,
1509
+ };
1510
+ this._changedEmitter.fire({ nbformatChanged });
1511
+ }
1512
+
1513
+ if (event.keysChanged.has('nbformat_minor')) {
1514
+ const change = event.changes.keys.get('nbformat_minor');
1515
+ const nbformatChanged = {
1516
+ key: 'nbformat_minor',
1517
+ oldValue: change?.oldValue ? change.oldValue : undefined,
1518
+ newValue: this.nbformat_minor,
1519
+ };
1520
+ this._changedEmitter.fire({ nbformatChanged });
1521
+ }
1522
+ };
1523
+
1524
+ /**
1525
+ * Handle a change to the list of cells.
1526
+ */
1527
+ private _onYCellsChanged = (event: Y.YArrayEvent<Y.Map<any>>) => {
1528
+ // update the type cell mapping by iterating through the added/removed types
1529
+ event.changes.added.forEach((item) => {
1530
+ const type = (item.content as Y.ContentType).type as Y.Map<any>;
1531
+ if (!this._ycellMapping.has(type)) {
1532
+ const c = createCellModelFromSharedType(
1533
+ type,
1534
+ { notebook: this },
1535
+ this.cellTypeAdaptor,
1536
+ );
1537
+ c.setUndoManager();
1538
+ this._ycellMapping.set(type, c);
1539
+ }
1540
+ });
1541
+ event.changes.deleted.forEach((item) => {
1542
+ const type = (item.content as Y.ContentType).type as Y.Map<any>;
1543
+ const model = this._ycellMapping.get(type);
1544
+ if (model) {
1545
+ model.dispose();
1546
+ this._ycellMapping.delete(type);
1547
+ }
1548
+ });
1549
+ let index = 0;
1550
+
1551
+ // this reflects the event.changes.delta, but replaces the content of delta.insert with ycells
1552
+ const cellsChange: Delta<ISharedCell[]> = [];
1553
+ event.changes.delta.forEach((d: any) => {
1554
+ if (d.insert !== null) {
1555
+ const insertedCells = d.insert.map((ycell: Y.Map<any>) =>
1556
+ this._ycellMapping.get(ycell),
1557
+ );
1558
+ cellsChange.push({ insert: insertedCells });
1559
+ this.cells.splice(index, 0, ...insertedCells);
1560
+
1561
+ this._cellsChangedEmitter.fire({
1562
+ type: 'add',
1563
+ newIndex: index,
1564
+ newValues: insertedCells,
1565
+ oldIndex: -2,
1566
+ oldValues: [],
1567
+ });
1568
+
1569
+ index += d.insert.length;
1570
+ } else if (d.delete !== null) {
1571
+ cellsChange.push(d);
1572
+ const oldValues = this.cells.splice(index, d.delete);
1573
+
1574
+ this._cellsChangedEmitter.fire({
1575
+ type: 'remove',
1576
+ newIndex: -1,
1577
+ newValues: [],
1578
+ oldIndex: index,
1579
+ oldValues,
1580
+ });
1581
+ } else if (d.retain !== null) {
1582
+ cellsChange.push(d);
1583
+ index += d.retain;
1584
+ }
1585
+ });
1586
+
1587
+ this._changedEmitter.fire({
1588
+ cellsChange: cellsChange,
1589
+ });
1590
+ };
1591
+
1592
+ protected _cellsChangedEmitter = new Emitter<IListChange>();
1593
+ protected _cellsChanged = this._cellsChangedEmitter.event;
1594
+ protected _metadataChangedEmitter = new Emitter<IMapChange>();
1595
+ protected _metadataChanged = this._metadataChangedEmitter.event;
1596
+ /**
1597
+ * Internal Yjs cells list
1598
+ */
1599
+ protected readonly _ycells: Y.Array<Y.Map<any>> = this.ydoc.getArray('cells');
1600
+
1601
+ private _disableDocumentWideUndoRedo: boolean;
1602
+ private _ycellMapping: WeakMap<Y.Map<any>, YCellType> = new WeakMap();
1603
+ }