UNPKG

@jupyter/ydoc

Version:

Jupyter document structures for collaborative editing using YJS

513 lines 18.4 kB
/* ----------------------------------------------------------------------------- | Copyright (c) Jupyter Development Team. | Distributed under the terms of the Modified BSD License. |----------------------------------------------------------------------------*/ import { JSONExt } from '@lumino/coreutils'; import { Signal } from '@lumino/signaling'; import * as Y from 'yjs'; import { YDocument } from './ydocument.js'; import { createCell, createCellModelFromSharedType } 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 { /** * Create a new notebook * * #### Notes * The document is empty and must be populated * * @param options */ constructor(options = {}) { var _a; super(); /** * Document version */ this.version = '2.0.0'; /** * YJS map for the notebook metadata */ this.ymeta = this.ydoc.getMap('meta'); /** * Handle a change to the ystate. */ this._onMetaChanged = (events) => { const metadataEvents = events.find(event => event.target === this.ymeta.get('metadata')); if (metadataEvents) { const metadataChange = metadataEvents.changes.keys; const ymetadata = this.ymeta.get('metadata'); 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); 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 === null || change === void 0 ? void 0 : change.action) === 'add' && !change.oldValue) { const metadataChange = new Map(); 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 === null || change === void 0 ? void 0 : 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 === null || change === void 0 ? void 0 : change.oldValue) ? change.oldValue : undefined, newValue: this.nbformat_minor }; this._changed.emit({ nbformatChanged }); } }; /** * Handle a change to the list of cells. */ this._onYCellsChanged = (event) => { // update the type cell mapping by iterating through the added/removed types event.changes.added.forEach(item => { const type = item.content.type; 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.type; 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 = []; event.changes.delta.forEach((d) => { if (d.insert != null) { const insertedCells = d.insert.map((ycell) => 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 }); }; this._metadataChanged = new Signal(this); /** * Internal Yjs cells list */ this._ycells = this.ydoc.getArray('cells'); this._ycellMapping = new WeakMap(); this._disableDocumentWideUndoRedo = (_a = options.disableDocumentWideUndoRedo) !== null && _a !== void 0 ? _a : 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); }); this.undoManager.addToScope(this._ycells); this._ycells.observe(this._onYCellsChanged); this.ymeta.observeDeep(this._onMetaChanged); } /** * Creates a standalone YNotebook * * Note: This method is useful when we need to initialize * the YNotebook from the JavaScript side. */ static create(options = {}) { var _a, _b, _c, _d, _e, _f, _g, _h, _j; const ynotebook = new YNotebook({ disableDocumentWideUndoRedo: (_a = options.disableDocumentWideUndoRedo) !== null && _a !== void 0 ? _a : false }); const data = { cells: (_c = (_b = options.data) === null || _b === void 0 ? void 0 : _b.cells) !== null && _c !== void 0 ? _c : [], nbformat: (_e = (_d = options.data) === null || _d === void 0 ? void 0 : _d.nbformat) !== null && _e !== void 0 ? _e : 4, nbformat_minor: (_g = (_f = options.data) === null || _f === void 0 ? void 0 : _f.nbformat_minor) !== null && _g !== void 0 ? _g : 5, metadata: (_j = (_h = options.data) === null || _h === void 0 ? void 0 : _h.metadata) !== null && _j !== void 0 ? _j : {} }; ynotebook.fromJSON(data); return ynotebook; } /** * Wether the undo/redo logic should be * considered on the full document across all cells. * * Default: false */ get disableDocumentWideUndoRedo() { return this._disableDocumentWideUndoRedo; } /** * Notebook metadata */ get metadata() { return this.getMetadata(); } set metadata(v) { this.setMetadata(v); } /** * Signal triggered when a metadata changes. */ get metadataChanged() { return this._metadataChanged; } /** * nbformat major version */ get nbformat() { return this.ymeta.get('nbformat'); } set nbformat(value) { this.transact(() => { this.ymeta.set('nbformat', value); }, false); } /** * nbformat minor version */ get nbformat_minor() { return this.ymeta.get('nbformat_minor'); } set nbformat_minor(value) { this.transact(() => { this.ymeta.set('nbformat_minor', value); }, false); } /** * Dispose of the resources. */ dispose() { 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) { return this.cells[index]; } /** * Add a shared cell at the notebook bottom. * * @param cell Cell to add. * * @returns The added cell. */ addCell(cell) { 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, cell) { 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, cells) { 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, toIndex) { 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, toIndex, n = 1) { // 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) { 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, to) { // 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) { if (typeof this.getMetadata(key) === 'undefined') { return; } const allMetadata = this.metadata; delete allMetadata[key]; this.setMetadata(allMetadata); } getMetadata(key) { const ymetadata = 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()); } } setMetadata(metadata, value) { var _a; if (typeof metadata === 'string') { if (typeof value === 'undefined') { throw new TypeError(`Metadata value for ${metadata} cannot be 'undefined'; use deleteMetadata.`); } if (JSONExt.deepEqual((_a = this.getMetadata(metadata)) !== null && _a !== void 0 ? _a : null, value)) { return; } const update = {}; update[metadata] = value; this.updateMetadata(update); } else { if (!this.metadata || !JSONExt.deepEqual(this.metadata, metadata)) { const clone = JSONExt.deepCopy(metadata); const ymetadata = 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) { // TODO: Maybe modify only attributes instead of replacing the whole metadata? const clone = JSONExt.deepCopy(value); const ymetadata = 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() { return this.toJSON(); } /** * Set the notebook source * * @param value The notebook */ setSource(value) { this.fromJSON(value); } /** * Override the notebook with a JSON-serialized document. * * @param value The notebook */ fromJSON(value) { 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() { // 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; }) }; } } //# sourceMappingURL=ynotebook.js.map