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