@jupyter/ydoc
Version:
Jupyter document structures for collaborative editing using YJS
513 lines • 18.4 kB
JavaScript
/* -----------------------------------------------------------------------------
| 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