@jupyter/ydoc
Version:
Jupyter document structures for collaborative editing using YJS
638 lines (576 loc) • 17.4 kB
text/typescript
/* -----------------------------------------------------------------------------
| Copyright (c) Jupyter Development Team.
| Distributed under the terms of the Modified BSD License.
|----------------------------------------------------------------------------*/
import type * as nbformat from '@jupyterlab/nbformat';
import { JSONExt, JSONValue, PartialJSONValue } from '@lumino/coreutils';
import { ISignal, Signal } from '@lumino/signaling';
import * as Y from 'yjs';
import type {
Delta,
IMapChange,
ISharedCell,
ISharedNotebook,
MapChanges,
NotebookChange,
SharedCell
} from './api.js';
import { YDocument } from './ydocument.js';
import {
createCell,
createCellModelFromSharedType,
YBaseCell,
YCellType
} from './ycell.js';
/**
* Shared implementation of the Shared Document types.
*
* Shared cells can be inserted into a SharedNotebook.
* Shared cells only start emitting events when they are connected to a SharedNotebook.
*
* "Standalone" cells must not be inserted into a (Shared)Notebook.
* Standalone cells emit events immediately after they have been created, but they must not
* be included into a (Shared)Notebook.
*/
export class YNotebook
extends YDocument<NotebookChange>
implements ISharedNotebook
{
/**
* Create a new notebook
*
* #### Notes
* The document is empty and must be populated
*
* @param options
*/
constructor(options: Omit<ISharedNotebook.IOptions, 'data'> = {}) {
super();
this._disableDocumentWideUndoRedo =
options.disableDocumentWideUndoRedo ?? false;
this.cells = this._ycells.toArray().map(ycell => {
if (!this._ycellMapping.has(ycell)) {
this._ycellMapping.set(
ycell,
createCellModelFromSharedType(ycell, { notebook: this })
);
}
return this._ycellMapping.get(ycell) as YCellType;
});
this.undoManager.addToScope(this._ycells);
this._ycells.observe(this._onYCellsChanged);
this.ymeta.observeDeep(this._onMetaChanged);
}
/**
* Document version
*/
readonly version: string = '2.0.0';
/**
* Creates a standalone YNotebook
*
* Note: This method is useful when we need to initialize
* the YNotebook from the JavaScript side.
*/
static create(options: ISharedNotebook.IOptions = {}): YNotebook {
const ynotebook = new YNotebook({
disableDocumentWideUndoRedo: options.disableDocumentWideUndoRedo ?? false
});
const data: nbformat.INotebookContent = {
cells: options.data?.cells ?? [],
nbformat: options.data?.nbformat ?? 4,
nbformat_minor: options.data?.nbformat_minor ?? 5,
metadata: options.data?.metadata ?? {}
};
ynotebook.fromJSON(data);
return ynotebook;
}
/**
* YJS map for the notebook metadata
*/
readonly ymeta: Y.Map<any> = this.ydoc.getMap('meta');
/**
* Cells list
*/
readonly cells: YCellType[];
/**
* Wether the undo/redo logic should be
* considered on the full document across all cells.
*
* Default: false
*/
get disableDocumentWideUndoRedo(): boolean {
return this._disableDocumentWideUndoRedo;
}
/**
* Notebook metadata
*/
get metadata(): nbformat.INotebookMetadata {
return this.getMetadata();
}
set metadata(v: nbformat.INotebookMetadata) {
this.setMetadata(v);
}
/**
* Signal triggered when a metadata changes.
*/
get metadataChanged(): ISignal<this, IMapChange> {
return this._metadataChanged;
}
/**
* nbformat major version
*/
get nbformat(): number {
return this.ymeta.get('nbformat');
}
set nbformat(value: number) {
this.transact(() => {
this.ymeta.set('nbformat', value);
}, false);
}
/**
* nbformat minor version
*/
get nbformat_minor(): number {
return this.ymeta.get('nbformat_minor');
}
set nbformat_minor(value: number) {
this.transact(() => {
this.ymeta.set('nbformat_minor', value);
}, false);
}
/**
* Dispose of the resources.
*/
dispose(): void {
if (this.isDisposed) {
return;
}
this._ycells.unobserve(this._onYCellsChanged);
this.ymeta.unobserveDeep(this._onMetaChanged);
super.dispose();
}
/**
* Get a shared cell by index.
*
* @param index: Cell's position.
*
* @returns The requested shared cell.
*/
getCell(index: number): YCellType {
return this.cells[index];
}
/**
* Add a shared cell at the notebook bottom.
*
* @param cell Cell to add.
*
* @returns The added cell.
*/
addCell(cell: SharedCell.Cell): YBaseCell<nbformat.IBaseCellMetadata> {
return this.insertCell(this._ycells.length, cell);
}
/**
* Insert a shared cell into a specific position.
*
* @param index: Cell's position.
* @param cell: Cell to insert.
*
* @returns The inserted cell.
*/
insertCell(
index: number,
cell: SharedCell.Cell
): YBaseCell<nbformat.IBaseCellMetadata> {
return this.insertCells(index, [cell])[0];
}
/**
* Insert a list of shared cells into a specific position.
*
* @param index: Position to insert the cells.
* @param cells: Array of shared cells to insert.
*
* @returns The inserted cells.
*/
insertCells(
index: number,
cells: SharedCell.Cell[]
): YBaseCell<nbformat.IBaseCellMetadata>[] {
const yCells = cells.map(c => {
const cell = createCell(c, this);
this._ycellMapping.set(cell.ymodel, cell);
return cell;
});
this.transact(() => {
this._ycells.insert(
index,
yCells.map(cell => cell.ymodel)
);
});
return yCells;
}
/**
* Move a cell.
*
* @param fromIndex: Index of the cell to move.
* @param toIndex: New position of the cell.
*/
moveCell(fromIndex: number, toIndex: number): void {
this.moveCells(fromIndex, toIndex);
}
/**
* Move cells.
*
* @param fromIndex: Index of the first cells to move.
* @param toIndex: New position of the first cell (in the current array).
* @param n: Number of cells to move (default 1)
*/
moveCells(fromIndex: number, toIndex: number, n = 1): void {
// FIXME we need to use yjs move feature to preserve undo history
const clones = new Array(n)
.fill(true)
.map((_, idx) => this.getCell(fromIndex + idx).toJSON());
this.transact(() => {
this._ycells.delete(fromIndex, n);
this._ycells.insert(
fromIndex > toIndex ? toIndex : toIndex - n + 1,
clones.map(clone => createCell(clone, this).ymodel)
);
});
}
/**
* Remove a cell.
*
* @param index: Index of the cell to remove.
*/
deleteCell(index: number): void {
this.deleteCellRange(index, index + 1);
}
/**
* Remove a range of cells.
*
* @param from: The start index of the range to remove (inclusive).
* @param to: The end index of the range to remove (exclusive).
*/
deleteCellRange(from: number, to: number): void {
// Cells will be removed from the mapping in the model event listener.
this.transact(() => {
this._ycells.delete(from, to - from);
});
}
/**
* Delete a metadata notebook.
*
* @param key The key to delete
*/
deleteMetadata(key: string): void {
if (typeof this.getMetadata(key) === 'undefined') {
return;
}
const allMetadata = this.metadata;
delete allMetadata[key];
this.setMetadata(allMetadata);
}
/**
* Returns some metadata associated with the notebook.
*
* If no `key` is provided, it will return all metadata.
* Else it will return the value for that key.
*
* @param key Key to get from the metadata
* @returns Notebook's metadata.
*/
getMetadata(): nbformat.INotebookMetadata;
getMetadata(key: string): PartialJSONValue | undefined;
getMetadata(
key?: string
): nbformat.INotebookMetadata | PartialJSONValue | undefined {
const ymetadata: Y.Map<any> | undefined = this.ymeta.get('metadata');
// Transiently the metadata can be missing - like during destruction
if (ymetadata === undefined) {
return undefined;
}
if (typeof key === 'string') {
const value = ymetadata.get(key);
return typeof value === 'undefined'
? undefined // undefined is converted to `{}` by `JSONExt.deepCopy`
: JSONExt.deepCopy(value);
} else {
return JSONExt.deepCopy(ymetadata.toJSON());
}
}
/**
* Sets some metadata associated with the notebook.
*
* If only one argument is provided, it will override all notebook metadata.
* Otherwise a single key will be set to a new value.
*
* @param metadata All Notebook's metadata or the key to set.
* @param value New metadata value
*/
setMetadata(metadata: nbformat.INotebookMetadata): void;
setMetadata(metadata: string, value: PartialJSONValue): void;
setMetadata(
metadata: nbformat.INotebookMetadata | string,
value?: PartialJSONValue
): void {
if (typeof metadata === 'string') {
if (typeof value === 'undefined') {
throw new TypeError(
`Metadata value for ${metadata} cannot be 'undefined'; use deleteMetadata.`
);
}
if (JSONExt.deepEqual(this.getMetadata(metadata) ?? null, value)) {
return;
}
const update: Partial<nbformat.INotebookMetadata> = {};
update[metadata] = value;
this.updateMetadata(update);
} else {
if (!this.metadata || !JSONExt.deepEqual(this.metadata, metadata)) {
const clone = JSONExt.deepCopy(metadata);
const ymetadata: Y.Map<any> = this.ymeta.get('metadata');
// Transiently the metadata can be missing - like during destruction
if (ymetadata === undefined) {
return undefined;
}
this.transact(() => {
ymetadata.clear();
for (const [key, value] of Object.entries(clone)) {
ymetadata.set(key, value);
}
});
}
}
}
/**
* Updates the metadata associated with the notebook.
*
* @param value: Metadata's attribute to update.
*/
updateMetadata(value: Partial<nbformat.INotebookMetadata>): void {
// TODO: Maybe modify only attributes instead of replacing the whole metadata?
const clone = JSONExt.deepCopy(value);
const ymetadata: Y.Map<any> = this.ymeta.get('metadata');
// Transiently the metadata can be missing - like during destruction
if (ymetadata === undefined) {
return undefined;
}
this.transact(() => {
for (const [key, value] of Object.entries(clone)) {
ymetadata.set(key, value);
}
});
}
/**
* Get the notebook source
*
* @returns The notebook
*/
getSource(): JSONValue {
return this.toJSON() as JSONValue;
}
/**
* Set the notebook source
*
* @param value The notebook
*/
setSource(value: JSONValue): void {
this.fromJSON(value as nbformat.INotebookContent);
}
/**
* Override the notebook with a JSON-serialized document.
*
* @param value The notebook
*/
fromJSON(value: nbformat.INotebookContent): void {
this.transact(() => {
this.nbformat = value.nbformat;
this.nbformat_minor = value.nbformat_minor;
const metadata = value.metadata;
if (metadata['orig_nbformat'] !== undefined) {
delete metadata['orig_nbformat'];
}
if (!this.metadata) {
const ymetadata = new Y.Map();
for (const [key, value] of Object.entries(metadata)) {
ymetadata.set(key, value);
}
this.ymeta.set('metadata', ymetadata);
} else {
this.metadata = metadata;
}
const useId = value.nbformat === 4 && value.nbformat_minor >= 5;
const ycells = value.cells.map(cell => {
if (!useId) {
delete cell.id;
}
return cell;
});
this.insertCells(this.cells.length, ycells);
this.deleteCellRange(0, this.cells.length);
});
}
/**
* Serialize the model to JSON.
*/
toJSON(): nbformat.INotebookContent {
// strip cell ids if we have notebook format 4.0-4.4
const pruneCellId = this.nbformat === 4 && this.nbformat_minor <= 4;
return {
metadata: this.metadata,
nbformat_minor: this.nbformat_minor,
nbformat: this.nbformat,
cells: this.cells.map(c => {
const raw = c.toJSON();
if (pruneCellId) {
delete raw.id;
}
return raw;
})
};
}
/**
* Handle a change to the ystate.
*/
private _onMetaChanged = (events: Y.YEvent<any>[]) => {
const metadataEvents = events.find(
event => event.target === this.ymeta.get('metadata')
);
if (metadataEvents) {
const metadataChange = metadataEvents.changes.keys;
const ymetadata = this.ymeta.get('metadata') as Y.Map<any>;
metadataEvents.changes.keys.forEach((change, key) => {
switch (change.action) {
case 'add':
this._metadataChanged.emit({
key,
type: 'add',
newValue: ymetadata.get(key)
});
break;
case 'delete':
this._metadataChanged.emit({
key,
type: 'remove',
oldValue: change.oldValue
});
break;
case 'update':
{
const newValue = ymetadata.get(key);
const oldValue = change.oldValue;
let equal = true;
if (typeof oldValue == 'object' && typeof newValue == 'object') {
equal = JSONExt.deepEqual(oldValue, newValue);
} else {
equal = oldValue === newValue;
}
if (!equal) {
this._metadataChanged.emit({
key,
type: 'change',
oldValue,
newValue
});
}
}
break;
}
});
this._changed.emit({ metadataChange });
}
const metaEvent = events.find(event => event.target === this.ymeta) as
| undefined
| Y.YMapEvent<any>;
if (!metaEvent) {
return;
}
if (metaEvent.keysChanged.has('metadata')) {
// Handle metadata change when adding/removing the YMap
const change = metaEvent.changes.keys.get('metadata');
if (change?.action === 'add' && !change.oldValue) {
const metadataChange: MapChanges = new Map<string, any>();
for (const key of Object.keys(this.metadata)) {
metadataChange.set(key, {
action: 'add',
oldValue: undefined
});
this._metadataChanged.emit({
key,
type: 'add',
newValue: this.getMetadata(key)
});
}
this._changed.emit({ metadataChange });
}
}
if (metaEvent.keysChanged.has('nbformat')) {
const change = metaEvent.changes.keys.get('nbformat');
const nbformatChanged = {
key: 'nbformat',
oldValue: change?.oldValue ? change!.oldValue : undefined,
newValue: this.nbformat
};
this._changed.emit({ nbformatChanged });
}
if (metaEvent.keysChanged.has('nbformat_minor')) {
const change = metaEvent.changes.keys.get('nbformat_minor');
const nbformatChanged = {
key: 'nbformat_minor',
oldValue: change?.oldValue ? change!.oldValue : undefined,
newValue: this.nbformat_minor
};
this._changed.emit({ nbformatChanged });
}
};
/**
* Handle a change to the list of cells.
*/
private _onYCellsChanged = (event: Y.YArrayEvent<Y.Map<any>>) => {
// update the type cell mapping by iterating through the added/removed types
event.changes.added.forEach(item => {
const type = (item.content as Y.ContentType).type as Y.Map<any>;
if (!this._ycellMapping.has(type)) {
const c = createCellModelFromSharedType(type, { notebook: this });
this._ycellMapping.set(type, c);
}
});
event.changes.deleted.forEach(item => {
const type = (item.content as Y.ContentType).type as Y.Map<any>;
const model = this._ycellMapping.get(type);
if (model) {
model.dispose();
this._ycellMapping.delete(type);
}
});
let index = 0;
// this reflects the event.changes.delta, but replaces the content of delta.insert with ycells
const cellsChange: Delta<ISharedCell[]> = [];
event.changes.delta.forEach((d: any) => {
if (d.insert != null) {
const insertedCells = d.insert.map((ycell: Y.Map<any>) =>
this._ycellMapping.get(ycell)
);
cellsChange.push({ insert: insertedCells });
this.cells.splice(index, 0, ...insertedCells);
index += d.insert.length;
} else if (d.delete != null) {
cellsChange.push(d);
this.cells.splice(index, d.delete);
} else if (d.retain != null) {
cellsChange.push(d);
index += d.retain;
}
});
this._changed.emit({
cellsChange: cellsChange
});
};
protected _metadataChanged = new Signal<this, IMapChange>(this);
/**
* Internal Yjs cells list
*/
protected readonly _ycells: Y.Array<Y.Map<any>> = this.ydoc.getArray('cells');
private _disableDocumentWideUndoRedo: boolean;
private _ycellMapping: WeakMap<Y.Map<any>, YCellType> = new WeakMap();
}