UNPKG

@jupyter/ydoc

Version:

Jupyter document structures for collaborative editing using YJS

875 lines 28.7 kB
/* ----------------------------------------------------------------------------- | Copyright (c) Jupyter Development Team. | Distributed under the terms of the Modified BSD License. |----------------------------------------------------------------------------*/ import { JSONExt, UUID } from '@lumino/coreutils'; import { Signal } from '@lumino/signaling'; import { Awareness } from 'y-protocols/awareness'; import * as Y from 'yjs'; /** * Create a new shared cell model given the YJS shared type. */ export const createCellModelFromSharedType = (type, options = {}) => { switch (type.get('cell_type')) { case 'code': return new YCodeCell(type, type.get('source'), type.get('outputs'), options); case 'markdown': return new YMarkdownCell(type, type.get('source'), options); case 'raw': return new YRawCell(type, type.get('source'), options); default: throw new Error('Found unknown cell type'); } }; /** * Create a new cell that can be inserted in an existing shared model. * * If no notebook is specified the cell will be standalone. * * @param cell Cell JSON representation * @param notebook Notebook to which the cell will be added */ export const createCell = (cell, notebook) => { var _a, _b; const ymodel = new Y.Map(); const ysource = new Y.Text(); const ymetadata = new Y.Map(); ymodel.set('source', ysource); ymodel.set('metadata', ymetadata); ymodel.set('cell_type', cell.cell_type); ymodel.set('id', (_a = cell.id) !== null && _a !== void 0 ? _a : UUID.uuid4()); let ycell; switch (cell.cell_type) { case 'markdown': { ycell = new YMarkdownCell(ymodel, ysource, { notebook }, ymetadata); if (cell.attachments != null) { ycell.setAttachments(cell.attachments); } break; } case 'code': { const youtputs = new Y.Array(); ymodel.set('outputs', youtputs); ycell = new YCodeCell(ymodel, ysource, youtputs, { notebook }, ymetadata); const cCell = cell; ycell.execution_count = (_b = cCell.execution_count) !== null && _b !== void 0 ? _b : null; if (cCell.outputs) { ycell.setOutputs(cCell.outputs); } break; } default: { // raw ycell = new YRawCell(ymodel, ysource, { notebook }, ymetadata); if (cell.attachments) { ycell.setAttachments(cell.attachments); } break; } } if (cell.metadata != null) { ycell.setMetadata(cell.metadata); } if (cell.source != null) { ycell.setSource(typeof cell.source === 'string' ? cell.source : cell.source.join('')); } return ycell; }; /** * Create a new cell that cannot be inserted in an existing shared model. * * @param cell Cell JSON representation */ export const createStandaloneCell = (cell) => createCell(cell); export class YBaseCell { /** * Create a new YCell that works standalone. It cannot be * inserted into a YNotebook because the Yjs model is already * attached to an anonymous Y.Doc instance. */ static create(id) { return createCell({ id, cell_type: this.prototype.cell_type }); } /** * Base cell constructor * * ### Notes * Don't use the constructor directly - prefer using ``YNotebook.insertCell`` * * The ``ysource`` is needed because ``ymodel.get('source')`` will * not return the real source if the model is not yet attached to * a document. Requesting it explicitly allows to introspect a non-empty * source before the cell is attached to the document. * * @param ymodel Cell map * @param ysource Cell source * @param options \{ notebook?: The notebook the cell is attached to \} * @param ymetadata Cell metadata */ constructor(ymodel, ysource, options = {}, ymetadata) { /** * Handle a change to the ymodel. */ this._modelObserver = (events, transaction) => { if (transaction.origin !== 'silent-change') { this._changed.emit(this.getChanges(events)); } }; this._metadataChanged = new Signal(this); /** * The notebook that this cell belongs to. */ this._notebook = null; this._changed = new Signal(this); this._disposed = new Signal(this); this._isDisposed = false; this._undoManager = null; this.ymodel = ymodel; this._ysource = ysource; this._ymetadata = ymetadata !== null && ymetadata !== void 0 ? ymetadata : this.ymodel.get('metadata'); this._prevSourceLength = ysource ? ysource.length : 0; this._notebook = null; this._awareness = null; this._undoManager = null; if (options.notebook) { this._notebook = options.notebook; if (this._notebook.disableDocumentWideUndoRedo) { this._undoManager = new Y.UndoManager([this.ymodel], { trackedOrigins: new Set([this]), doc: this._notebook.ydoc }); } } else { // Standalone cell const doc = new Y.Doc(); doc.getArray().insert(0, [this.ymodel]); this._awareness = new Awareness(doc); this._undoManager = new Y.UndoManager([this.ymodel], { trackedOrigins: new Set([this]) }); } this.ymodel.observeDeep(this._modelObserver); } /** * Cell notebook awareness or null. */ get awareness() { var _a, _b, _c; return (_c = (_a = this._awareness) !== null && _a !== void 0 ? _a : (_b = this.notebook) === null || _b === void 0 ? void 0 : _b.awareness) !== null && _c !== void 0 ? _c : null; } /** * The type of the cell. */ get cell_type() { throw new Error('A YBaseCell must not be constructed'); } /** * The changed signal. */ get changed() { return this._changed; } /** * Signal emitted when the cell is disposed. */ get disposed() { return this._disposed; } /** * Cell id */ get id() { return this.getId(); } /** * Whether the model has been disposed or not. */ get isDisposed() { return this._isDisposed; } /** * Whether the cell is standalone or not. * * If the cell is standalone. It cannot be * inserted into a YNotebook because the Yjs model is already * attached to an anonymous Y.Doc instance. */ get isStandalone() { return this._notebook !== null; } /** * Cell metadata. * * #### Notes * You should prefer to access and modify the specific key of interest. */ get metadata() { return this.getMetadata(); } set metadata(v) { this.setMetadata(v); } /** * Signal triggered when the cell metadata changes. */ get metadataChanged() { return this._metadataChanged; } /** * The notebook that this cell belongs to. */ get notebook() { return this._notebook; } /** * Cell input content. */ get source() { return this.getSource(); } set source(v) { this.setSource(v); } /** * The cell undo manager. */ get undoManager() { var _a; if (!this.notebook) { return this._undoManager; } return ((_a = this.notebook) === null || _a === void 0 ? void 0 : _a.disableDocumentWideUndoRedo) ? this._undoManager : this.notebook.undoManager; } get ysource() { return this._ysource; } /** * Whether the object can undo changes. */ canUndo() { return !!this.undoManager && this.undoManager.undoStack.length > 0; } /** * Whether the object can redo changes. */ canRedo() { return !!this.undoManager && this.undoManager.redoStack.length > 0; } /** * Clear the change stack. */ clearUndoHistory() { var _a; (_a = this.undoManager) === null || _a === void 0 ? void 0 : _a.clear(); } /** * Undo an operation. */ undo() { var _a; (_a = this.undoManager) === null || _a === void 0 ? void 0 : _a.undo(); } /** * Redo an operation. */ redo() { var _a; (_a = this.undoManager) === null || _a === void 0 ? void 0 : _a.redo(); } /** * Dispose of the resources. */ dispose() { var _a; if (this._isDisposed) return; this._isDisposed = true; this.ymodel.unobserveDeep(this._modelObserver); if (this._awareness) { // A new document is created for standalone cell. const doc = this._awareness.doc; this._awareness.destroy(); doc.destroy(); } if (this._undoManager) { // Be sure to not destroy the document undo manager. if (this._undoManager === ((_a = this.notebook) === null || _a === void 0 ? void 0 : _a.undoManager)) { this._undoManager = null; } else { this._undoManager.destroy(); } } this._disposed.emit(); Signal.clearData(this); } /** * Get cell id. * * @returns Cell id */ getId() { return this.ymodel.get('id'); } /** * Gets cell's source. * * @returns Cell's source. */ getSource() { return this.ysource.toString(); } /** * Sets cell's source. * * @param value: New source. */ setSource(value) { this.transact(() => { this.ysource.delete(0, this.ysource.length); this.ysource.insert(0, value); }); // @todo Do we need proper replace semantic? This leads to issues in editor bindings because they don't switch source. // this.ymodel.set('source', new Y.Text(value)); } /** * Replace content from `start' to `end` with `value`. * * @param start: The start index of the range to replace (inclusive). * * @param end: The end index of the range to replace (exclusive). * * @param value: New source (optional). */ updateSource(start, end, value = '') { this.transact(() => { const ysource = this.ysource; // insert and then delete. // This ensures that the cursor position is adjusted after the replaced content. ysource.insert(start, value); ysource.delete(start + value.length, end - start); }); } /** * Delete a metadata cell. * * @param key The key to delete */ deleteMetadata(key) { if (typeof this.getMetadata(key) === 'undefined') { return; } this.transact(() => { this._ymetadata.delete(key); const jupyter = this.getMetadata('jupyter'); if (key === 'collapsed' && jupyter) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { outputs_hidden, ...others } = jupyter; if (Object.keys(others).length === 0) { this._ymetadata.delete('jupyter'); } else { this._ymetadata.set('jupyter', others); } } else if (key === 'jupyter') { this._ymetadata.delete('collapsed'); } }, false); } getMetadata(key) { const metadata = this._ymetadata; // Transiently the metadata can be missing - like during destruction if (metadata === undefined) { return undefined; } if (typeof key === 'string') { const value = metadata.get(key); return typeof value === 'undefined' ? undefined // undefined is converted to `{}` by `JSONExt.deepCopy` : JSONExt.deepCopy(metadata.get(key)); } else { return JSONExt.deepCopy(metadata.toJSON()); } } setMetadata(metadata, value) { var _a, _b; if (typeof metadata === 'string') { if (typeof value === 'undefined') { throw new TypeError(`Metadata value for ${metadata} cannot be 'undefined'; use deleteMetadata.`); } const key = metadata; // Only set metadata if we change something to avoid infinite // loop of signal changes. if (JSONExt.deepEqual((_a = this.getMetadata(key)) !== null && _a !== void 0 ? _a : null, value)) { return; } this.transact(() => { var _a; this._ymetadata.set(key, value); if (key === 'collapsed') { const jupyter = ((_a = this.getMetadata('jupyter')) !== null && _a !== void 0 ? _a : {}); if (jupyter.outputs_hidden !== value) { this.setMetadata('jupyter', { ...jupyter, outputs_hidden: value }); } } else if (key === 'jupyter') { const isHidden = value['outputs_hidden']; if (typeof isHidden !== 'undefined') { if (this.getMetadata('collapsed') !== isHidden) { this.setMetadata('collapsed', isHidden); } } else { this.deleteMetadata('collapsed'); } } }, false); } else { const clone = JSONExt.deepCopy(metadata); if (clone.collapsed != null) { clone.jupyter = clone.jupyter || {}; clone.jupyter.outputs_hidden = clone.collapsed; } else if (((_b = clone === null || clone === void 0 ? void 0 : clone.jupyter) === null || _b === void 0 ? void 0 : _b.outputs_hidden) != null) { clone.collapsed = clone.jupyter.outputs_hidden; } if (!JSONExt.deepEqual(clone, this.getMetadata())) { this.transact(() => { for (const [key, value] of Object.entries(clone)) { this._ymetadata.set(key, value); } }, false); } } } /** * Serialize the model to JSON. */ toJSON() { return { id: this.getId(), cell_type: this.cell_type, source: this.getSource(), metadata: this.getMetadata() }; } /** * Perform a transaction. While the function f is called, all changes to the shared * document are bundled into a single event. * * @param f Transaction to execute * @param undoable Whether to track the change in the action history or not (default `true`) */ transact(f, undoable = true, origin = null) { !this.notebook || this.notebook.disableDocumentWideUndoRedo ? this.ymodel.doc == null ? f() : this.ymodel.doc.transact(f, undoable ? this : origin) : this.notebook.transact(f, undoable); } /** * Extract changes from YJS events * * @param events YJS events * @returns Cell changes */ getChanges(events) { const changes = {}; const sourceEvent = events.find(event => event.target === this.ymodel.get('source')); if (sourceEvent) { changes.sourceChange = sourceEvent.changes.delta; } const metadataEvents = events.find(event => event.target === this._ymetadata); if (metadataEvents) { changes.metadataChange = metadataEvents.changes.keys; metadataEvents.changes.keys.forEach((change, key) => { switch (change.action) { case 'add': this._metadataChanged.emit({ key, newValue: this._ymetadata.get(key), type: 'add' }); break; case 'delete': this._metadataChanged.emit({ key, oldValue: change.oldValue, type: 'remove' }); break; case 'update': { const newValue = this._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; } }); } const modelEvent = events.find(event => event.target === this.ymodel); // The model allows us to replace the complete source with a new string. We express this in the Delta format // as a replace of the complete string. const ysource = this.ymodel.get('source'); if (modelEvent && modelEvent.keysChanged.has('source')) { changes.sourceChange = [ { delete: this._prevSourceLength }, { insert: ysource.toString() } ]; } this._prevSourceLength = ysource.length; return changes; } } /** * Shareable code cell. */ export class YCodeCell extends YBaseCell { /** * Create a new YCodeCell that works standalone. It cannot be * inserted into a YNotebook because the Yjs model is already * attached to an anonymous Y.Doc instance. */ static create(id) { return super.create(id); } /** * Code cell constructor * * ### Notes * Don't use the constructor directly - prefer using ``YNotebook.insertCell`` * * The ``ysource`` is needed because ``ymodel.get('source')`` will * not return the real source if the model is not yet attached to * a document. Requesting it explicitly allows to introspect a non-empty * source before the cell is attached to the document. * * @param ymodel Cell map * @param ysource Cell source * @param youtputs Code cell outputs * @param options \{ notebook?: The notebook the cell is attached to \} * @param ymetadata Cell metadata */ constructor(ymodel, ysource, youtputs, options = {}, ymetadata) { super(ymodel, ysource, options, ymetadata); this._youtputs = youtputs; } /** * The type of the cell. */ get cell_type() { return 'code'; } /** * The code cell's prompt number. Will be null if the cell has not been run. */ get execution_count() { return this.ymodel.get('execution_count') || null; } set execution_count(count) { // Do not use `this.execution_count`. When initializing the // cell, we need to set execution_count to `null` if we compare // using `this.execution_count` it will return `null` and we will // never initialize it if (this.ymodel.get('execution_count') !== count) { this.transact(() => { this.ymodel.set('execution_count', count); }, false); } } /** * The code cell's execution state. */ get executionState() { var _a; return (_a = this.ymodel.get('execution_state')) !== null && _a !== void 0 ? _a : 'idle'; } set executionState(state) { if (this.ymodel.get('execution_state') !== state) { this.transact(() => { this.ymodel.set('execution_state', state); }, false); } } /** * Cell outputs. */ get outputs() { return this.getOutputs(); } set outputs(v) { this.setOutputs(v); } get youtputs() { return this._youtputs; } /** * Execution, display, or stream outputs. */ getOutputs() { return JSONExt.deepCopy(this._youtputs.toJSON()); } createOutputs(outputs) { const newOutputs = []; for (const output of JSONExt.deepCopy(outputs)) { let _newOutput1; if (output.output_type === 'stream') { // Set the text field as a Y.Text const { text, ...outputWithoutText } = output; _newOutput1 = outputWithoutText; const newText = new Y.Text(); let _text = text instanceof Array ? text.join() : text; newText.insert(0, _text); _newOutput1['text'] = newText; } else { _newOutput1 = output; } const _newOutput2 = []; for (const [key, value] of Object.entries(_newOutput1)) { _newOutput2.push([key, value]); } const newOutput = new Y.Map(_newOutput2); newOutputs.push(newOutput); } return newOutputs; } /** * Replace all outputs. */ setOutputs(outputs) { this.transact(() => { this._youtputs.delete(0, this._youtputs.length); const newOutputs = this.createOutputs(outputs); this._youtputs.insert(0, newOutputs); }, false); } /** * Remove text from a stream output. */ removeStreamOutput(index, start, origin = null) { this.transact(() => { const output = this._youtputs.get(index); const prevText = output.get('text'); const length = prevText.length - start; prevText.delete(start, length); }, false, origin); } /** * Append text to a stream output. */ appendStreamOutput(index, text, origin = null) { this.transact(() => { const output = this._youtputs.get(index); const prevText = output.get('text'); prevText.insert(prevText.length, text); }, false, origin); } /** * Replace content from `start' to `end` with `outputs`. * * @param start: The start index of the range to replace (inclusive). * * @param end: The end index of the range to replace (exclusive). * * @param outputs: New outputs (optional). */ updateOutputs(start, end, outputs = [], origin = null) { const fin = end < this._youtputs.length ? end - start : this._youtputs.length - start; this.transact(() => { this._youtputs.delete(start, fin); const newOutputs = this.createOutputs(outputs); this._youtputs.insert(start, newOutputs); }, false, origin); } /** * Serialize the model to JSON. */ toJSON() { return { ...super.toJSON(), outputs: this.getOutputs(), execution_count: this.execution_count }; } /** * Extract changes from YJS events * * @param events YJS events * @returns Cell changes */ getChanges(events) { const changes = super.getChanges(events); const streamOutputEvent = events.find( // Changes to the 'text' of a cell's stream output can be accessed like so: // ycell['outputs'][output_idx]['text'] // This translates to an event path of: ['outputs', output_idx, 'text] event => event.path.length === 3 && event.path[0] === 'outputs' && event.path[2] === 'text'); if (streamOutputEvent) { changes.streamOutputChange = streamOutputEvent.changes.delta; } const outputEvent = events.find(event => event.target === this.ymodel.get('outputs')); if (outputEvent) { changes.outputsChange = outputEvent.changes.delta; } const modelEvent = events.find(event => event.target === this.ymodel); if (modelEvent && modelEvent.keysChanged.has('execution_count')) { const change = modelEvent.changes.keys.get('execution_count'); changes.executionCountChange = { oldValue: change.oldValue, newValue: this.ymodel.get('execution_count') }; } if (modelEvent && modelEvent.keysChanged.has('execution_state')) { const change = modelEvent.changes.keys.get('execution_state'); changes.executionStateChange = { oldValue: change.oldValue, newValue: this.ymodel.get('execution_state') }; } return changes; } } class YAttachmentCell extends YBaseCell { /** * Cell attachments */ get attachments() { return this.getAttachments(); } set attachments(v) { this.setAttachments(v); } /** * Gets the cell attachments. * * @returns The cell attachments. */ getAttachments() { return this.ymodel.get('attachments'); } /** * Sets the cell attachments * * @param attachments: The cell attachments. */ setAttachments(attachments) { this.transact(() => { if (attachments == null) { this.ymodel.delete('attachments'); } else { this.ymodel.set('attachments', attachments); } }, false); } /** * Extract changes from YJS events * * @param events YJS events * @returns Cell changes */ getChanges(events) { const changes = super.getChanges(events); const modelEvent = events.find(event => event.target === this.ymodel); if (modelEvent && modelEvent.keysChanged.has('attachments')) { const change = modelEvent.changes.keys.get('attachments'); changes.attachmentsChange = { oldValue: change.oldValue, newValue: this.ymodel.get('attachments') }; } return changes; } } /** * Shareable raw cell. */ export class YRawCell extends YAttachmentCell { /** * Create a new YRawCell that works standalone. It cannot be * inserted into a YNotebook because the Yjs model is already * attached to an anonymous Y.Doc instance. */ static create(id) { return super.create(id); } /** * String identifying the type of cell. */ get cell_type() { return 'raw'; } /** * Serialize the model to JSON. */ toJSON() { return { id: this.getId(), cell_type: 'raw', source: this.getSource(), metadata: this.getMetadata(), attachments: this.getAttachments() }; } } /** * Shareable markdown cell. */ export class YMarkdownCell extends YAttachmentCell { /** * Create a new YMarkdownCell that works standalone. It cannot be * inserted into a YNotebook because the Yjs model is already * attached to an anonymous Y.Doc instance. */ static create(id) { return super.create(id); } /** * String identifying the type of cell. */ get cell_type() { return 'markdown'; } /** * Serialize the model to JSON. */ toJSON() { return { id: this.getId(), cell_type: 'markdown', source: this.getSource(), metadata: this.getMetadata(), attachments: this.getAttachments() }; } } //# sourceMappingURL=ycell.js.map