diff --git a/src/index.ts b/src/index.ts index 897025b..334738b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,8 @@ export * from './notebookrenderer'; export * from './notebookrenderer/types'; import { notebookRenderer, yWidgetManager } from './notebookrenderer'; +import { yOutputHandler } from './youtputhandler'; +import { yInputWidget } from './yinputwidget'; +import { yStdoutOutputWidget } from './youtputwidget'; -export default [notebookRenderer, yWidgetManager]; +export default [notebookRenderer, yWidgetManager, yOutputHandler, yStdoutOutputWidget, yInputWidget]; diff --git a/src/model.ts b/src/model.ts index 0666b19..953a74d 100644 --- a/src/model.ts +++ b/src/model.ts @@ -6,10 +6,11 @@ import * as Y from 'yjs'; import { IJupyterYDoc, IJupyterYModel } from './types'; export class JupyterYModel implements IJupyterYModel { - constructor(commMetadata: {[key: string]: any}) { - this._yModelName = commMetadata.ymodel_name; - const ydoc = this.ydocFactory(commMetadata); - this._sharedModel = new JupyterYDoc(commMetadata, ydoc); + constructor(options: {[key: string]: any}) { + this._yModelName = options.ymodel_name; + const ydoc = this.ydocFactory(options); + this._sharedModel = new JupyterYDoc(options, ydoc); + this.roomId = options.room_id; } get yModelName(): string { @@ -32,7 +33,7 @@ export class JupyterYModel implements IJupyterYModel { return this._isDisposed; } - ydocFactory(commMetadata: {[key: string]: any}): Y.Doc { + ydocFactory(options: {[key: string]: any}): Y.Doc { return new Y.Doc(); } @@ -56,24 +57,24 @@ export class JupyterYModel implements IJupyterYModel { private _yModelName: string; private _sharedModel: IJupyterYDoc; - private _isDisposed = false; - private _disposed = new Signal(this); + + roomId?: string; } export class JupyterYDoc implements IJupyterYDoc { - constructor(commMetadata: {[key: string]: any}, ydoc: Y.Doc) { - this._commMetadata = commMetadata; + constructor(options: {[key: string]: any}, ydoc: Y.Doc) { + this._options = options; this._ydoc = ydoc; - if (commMetadata.create_ydoc) { + if (options.create_ydoc) { this._attrs = this._ydoc.getMap('_attrs'); this._attrs.observe(this._attrsObserver); } } - get commMetadata(): {[key: string]: any} { - return this._commMetadata; + get options(): {[key: string]: any} { + return this._options; } get ydoc(): Y.Doc { @@ -130,5 +131,5 @@ export class JupyterYDoc implements IJupyterYDoc { private _disposed = new Signal(this); private _ydoc: Y.Doc; - private _commMetadata: {[key: string]: any}; + private _options: {[key: string]: any}; } diff --git a/src/notebookrenderer/index.ts b/src/notebookrenderer/index.ts index a7203a4..c553884 100644 --- a/src/notebookrenderer/index.ts +++ b/src/notebookrenderer/index.ts @@ -34,7 +34,7 @@ export const notebookRenderer: JupyterFrontEndPlugin = { nbTracker.currentWidget?.sessionContext.session?.kernel?.id; const mimeType = options.mimeType; const modelFactory = new NotebookRendererModel({ - kernelId, + kernelOrNotebookId: kernelId, widgetManager: wmManager }); return new JupyterYWidget({ mimeType, modelFactory }); diff --git a/src/notebookrenderer/model.ts b/src/notebookrenderer/model.ts index 4617734..9495904 100644 --- a/src/notebookrenderer/model.ts +++ b/src/notebookrenderer/model.ts @@ -6,7 +6,7 @@ import { IJupyterYWidgetManager } from './types'; export class NotebookRendererModel implements IDisposable { constructor(options: NotebookRendererModel.IOptions) { this._widgetManager = options.widgetManager; - this._kernelId = options.kernelId; + this._kernelOrNotebookId = options.kernelOrNotebookId; } get isDisposed(): boolean { @@ -20,15 +20,15 @@ export class NotebookRendererModel implements IDisposable { this._isDisposed = true; } - getYModel(commId: string): IJupyterYModel | undefined { - if (this._kernelId) { - return this._widgetManager.getWidgetModel(this._kernelId, commId); + getYModel(commOrRoomId: string): IJupyterYModel | undefined { + if (this._kernelOrNotebookId) { + return this._widgetManager.getWidgetModel(this._kernelOrNotebookId, commOrRoomId); } } - createYWidget(commId: string, node: HTMLElement): void { - if (this._kernelId) { - const yModel = this._widgetManager.getWidgetModel(this._kernelId, commId); + createYWidget(commOrRoomId: string, node: HTMLElement): void { + if (this._kernelOrNotebookId) { + const yModel = this._widgetManager.getWidgetModel(this._kernelOrNotebookId, commOrRoomId); if (yModel) { const widgetFactory = this._widgetManager.getWidgetFactory( yModel.yModelName @@ -39,13 +39,13 @@ export class NotebookRendererModel implements IDisposable { } private _isDisposed = false; - private _kernelId?: string; + private _kernelOrNotebookId?: string; private _widgetManager: IJupyterYWidgetManager; } export namespace NotebookRendererModel { export interface IOptions { - kernelId?: string; + kernelOrNotebookId?: string; widgetManager: IJupyterYWidgetManager; } } diff --git a/src/notebookrenderer/types.ts b/src/notebookrenderer/types.ts index 86e6321..2ea1aef 100644 --- a/src/notebookrenderer/types.ts +++ b/src/notebookrenderer/types.ts @@ -4,6 +4,7 @@ import { IJupyterYModel } from '../types'; export interface IJupyterYWidgetModelRegistry { getModel(id: string): IJupyterYModel | undefined; + setModel(id: string, model: IJupyterYModel): void; } export interface IJupyterYModelFactory { @@ -15,6 +16,7 @@ export interface IJupyterYWidgetFactory { } export interface IJupyterYWidgetManager { + registerNotebook(notebookId: string): IJupyterYWidgetModelRegistry; registerKernel(kernel: Kernel.IKernelConnection): void; registerWidget( name: string, @@ -23,6 +25,7 @@ export interface IJupyterYWidgetManager { ): void; getWidgetModel(kernelId: string, commId: string): IJupyterYModel | undefined; getWidgetFactory(modelName: string): any | undefined; + yModelFactories: Map; } export const IJupyterYWidgetManager = new Token( diff --git a/src/notebookrenderer/view.ts b/src/notebookrenderer/view.ts index ef4c37e..0432381 100644 --- a/src/notebookrenderer/view.ts +++ b/src/notebookrenderer/view.ts @@ -28,6 +28,7 @@ export class JupyterYWidget extends Widget implements IRenderMime.IRenderer { this._yModel?.dispose(); super.dispose(); } + async renderModel(mimeModel: IRenderMime.IMimeModel): Promise { const modelId = mimeModel.data[this._mimeType]!['model_id']; @@ -38,6 +39,14 @@ export class JupyterYWidget extends Widget implements IRenderMime.IRenderer { this._modelFactory.createYWidget(modelId, this.node); } + render(roomId: string): void { + this._yModel = this._modelFactory.getYModel(roomId); + if (!this._yModel) { + return; + } + this._modelFactory.createYWidget(roomId, this.node); + } + private _modelFactory: NotebookRendererModel; private _mimeType: string; private _yModel?: IJupyterYModel; diff --git a/src/notebookrenderer/widgetManager.ts b/src/notebookrenderer/widgetManager.ts index e36601b..117f8b3 100644 --- a/src/notebookrenderer/widgetManager.ts +++ b/src/notebookrenderer/widgetManager.ts @@ -9,8 +9,16 @@ import { YCommProvider } from './yCommProvider'; import { IJupyterYModel } from '../types'; export class JupyterYWidgetManager implements IJupyterYWidgetManager { + + registerNotebook(notebookId: string): IJupyterYWidgetModelRegistry { + const yModelFactories = this.yModelFactories; + const wm = new WidgetModelRegistry({ yModelFactories }); + this._registry.set(notebookId, wm); + return wm; + } + registerKernel(kernel: Kernel.IKernelConnection): void { - const yModelFactories = this._yModelFactories; + const yModelFactories = this.yModelFactories; const wm = new WidgetModelRegistry({ kernel, yModelFactories }); this._registry.set(kernel.id, wm); } @@ -26,37 +34,43 @@ export class JupyterYWidgetManager implements IJupyterYWidgetManager { yModelFactory: IJupyterYModelFactory, yWidgetFactory: IJupyterYWidgetFactory ): void { - this._yModelFactories.set(name, yModelFactory); + this.yModelFactories.set(name, yModelFactory); this._yWidgetFactories.set(name, yWidgetFactory); } - getWidgetModel(kernelId: string, commId: string): IJupyterYModel | undefined { - return this._registry.get(kernelId)?.getModel(commId); + getWidgetModel(kernelOrNotebookId: string, commOrRoomId: string): IJupyterYModel | undefined { + return this._registry.get(kernelOrNotebookId)?.getModel(commOrRoomId); } getWidgetFactory(modelName: string) { return this._yWidgetFactories.get(modelName); } + yModelFactories = new Map(); private _registry = new Map(); - private _yModelFactories = new Map(); private _yWidgetFactories = new Map(); } export class WidgetModelRegistry implements IJupyterYWidgetModelRegistry { constructor(options: { - kernel: Kernel.IKernelConnection; + kernel?: Kernel.IKernelConnection; yModelFactories: any; }) { const { kernel, yModelFactories } = options; this._yModelFactories = yModelFactories; - kernel.registerCommTarget('ywidget', this._handle_comm_open); + if (kernel !== undefined) { + kernel.registerCommTarget('ywidget', this._handle_comm_open); + } } getModel(id: string): IJupyterYModel | undefined { return this._yModels.get(id); } + setModel(id: string, model: IJupyterYModel): void { + this._yModels.set(id, model); + } + /** * Handle when a comm is opened. */ diff --git a/src/types.ts b/src/types.ts index 449007a..15d63b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,7 +18,7 @@ export interface IJupyterYDoc extends IDisposable { attrsChanged: ISignal; ydoc: Y.Doc; - commMetadata: {[key: string]: any}; + options: {[key: string]: any}; disposed: ISignal; } @@ -26,6 +26,7 @@ export interface IJupyterYModel extends IDisposable { yModelName: string; isDisposed: boolean; sharedModel: IJupyterYDoc; + roomId?: string; sharedAttrsChanged: ISignal; diff --git a/src/yinputwidget/index.ts b/src/yinputwidget/index.ts new file mode 100644 index 0000000..2464e02 --- /dev/null +++ b/src/yinputwidget/index.ts @@ -0,0 +1,67 @@ +import { IJupyterYModel } from '../types'; +import { JupyterYModel } from '../model'; +import { IJupyterYWidgetManager } from '../notebookrenderer/types'; +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { ybinding } from '@jupyterlab/codemirror'; +import { StateCommand } from '@codemirror/state'; +import { EditorView, KeyBinding, keymap } from '@codemirror/view'; +import { WebsocketProvider } from 'y-websocket'; + +class InputWidget { + constructor(yModel: IJupyterYModel, node: HTMLElement) { + this.yModel = yModel; + this.node = node; + + const wsProvider = new WebsocketProvider( + 'ws://127.0.0.1:8000', `api/collaboration/room/${yModel.roomId}`, + yModel.sharedModel.ydoc + ); + + wsProvider.on('sync', (isSynced) => { + const prompt: string = this.yModel.sharedModel.getAttr('prompt'); + const password: boolean = this.yModel.sharedModel.getAttr('password'); + const promptNode = document.createElement('pre'); + promptNode.textContent = prompt; + const input1 = document.createElement('div'); + input1.style.border = 'thin solid'; + const input2 = document.createElement('div'); + if (password === true) { + (input2.style as any).webkitTextSecurity = 'disc'; + } + input1.appendChild(input2); + this.node.appendChild(promptNode); + promptNode.appendChild(input1); + + const stdin = this.yModel.sharedModel.getAttr('value'); + const ybind = ybinding({ ytext: stdin }); + const submit: StateCommand = ({ state, dispatch }) => { + this.yModel.sharedModel.setAttr('submitted', true); + return true; + }; + const submitWithEnter: KeyBinding = { + key: 'Enter', + run: submit + }; + new EditorView({ + doc: stdin.toString(), + extensions: [keymap.of([submitWithEnter]), ybind], + parent: input2 + }); + }); + } + + yModel: IJupyterYModel; + node: HTMLElement; +} + +export const yInputWidget: JupyterFrontEndPlugin = { + id: 'jupyterywidget:yInputWidget', + autoStart: true, + requires: [IJupyterYWidgetManager], + activate: (app: JupyterFrontEnd, wm: IJupyterYWidgetManager): void => { + wm.registerWidget('Input', JupyterYModel, InputWidget); + } +}; diff --git a/src/youtputhandler/index.ts b/src/youtputhandler/index.ts new file mode 100644 index 0000000..78ae6c5 --- /dev/null +++ b/src/youtputhandler/index.ts @@ -0,0 +1,47 @@ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin, +} from '@jupyterlab/application'; +import { + INotebookTracker, + INotebookModel, + NotebookPanel, +} from '@jupyterlab/notebook'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; +import YWidget, { PLUGIN_NAME } from './yWidget'; +import { IJupyterYWidgetManager } from '../notebookrenderer/types'; + +class yWidgetExtension implements DocumentRegistry.WidgetExtension { + constructor(tracker: INotebookTracker, wmManager: IJupyterYWidgetManager) { + this._tracker = tracker; + this._wmManager = wmManager; + } + + createNew( + panel: NotebookPanel, + context: DocumentRegistry.IContext + ) { + return new YWidget(panel, this._tracker, this._wmManager); + } + + private _tracker: INotebookTracker; + private _wmManager: IJupyterYWidgetManager; +} + +export const yOutputHandler: JupyterFrontEndPlugin = { + id: PLUGIN_NAME, + autoStart: true, + requires: [INotebookTracker, ISettingRegistry, IJupyterYWidgetManager], + activate: async ( + app: JupyterFrontEnd, + tracker: INotebookTracker, + settingRegistry: ISettingRegistry, + wmManager: IJupyterYWidgetManager + ) => { + app.docRegistry.addWidgetExtension( + 'Notebook', + new yWidgetExtension(tracker, wmManager) + ); + }, +}; diff --git a/src/youtputhandler/yWidget.ts b/src/youtputhandler/yWidget.ts new file mode 100644 index 0000000..c59d849 --- /dev/null +++ b/src/youtputhandler/yWidget.ts @@ -0,0 +1,125 @@ +import { + NotebookPanel, + INotebookTracker, + type CellList, +} from '@jupyterlab/notebook'; +import { IObservableList } from '@jupyterlab/observables'; +import { Cell, CodeCell, ICellModel } from '@jupyterlab/cells'; +import { YCodeCell } from '@jupyter/ydoc'; +import * as Y from 'yjs'; +import { UUID } from '@lumino/coreutils'; +import { Panel, Widget } from '@lumino/widgets'; +import { OutputPrompt } from '@jupyterlab/outputarea'; + +import { JupyterYWidget } from '../notebookrenderer/view'; +import { NotebookRendererModel } from '../notebookrenderer/model'; +import { IJupyterYWidgetManager, IJupyterYWidgetModelRegistry } from '../notebookrenderer/types'; +import { IJupyterYModel } from '../types'; + +const OUTPUT_AREA_ITEM_CLASS = 'jp-OutputArea-child'; +const OUTPUT_AREA_STDIN_ITEM_CLASS = 'jp-OutputArea-stdin-item'; +const OUTPUT_AREA_PROMPT_CLASS = 'jp-OutputArea-prompt'; +const OUTPUT_AREA_OUTPUT_CLASS = 'jp-OutputArea-output'; + +export const PLUGIN_NAME = 'jupyterywidget:yOutputHandler'; + +export default class YWidget extends Widget { + constructor( + panel: NotebookPanel, + tracker: INotebookTracker, + wmManager: IJupyterYWidgetManager + ) { + super(); + this._panel = panel; + this._notebookId = UUID.uuid4(); + this._wmManager = wmManager; + this._wm = wmManager.registerNotebook(this._notebookId); + const cells = panel.context.model.cells; + cells.changed.connect(this.updateConnectedCell, this); + this._modelFactory = new NotebookRendererModel({ + kernelOrNotebookId: this._notebookId, + widgetManager: this._wmManager + }); + } + + updateConnectedCell( + sender: CellList, + changed: IObservableList.IChangedArgs + ): void { + changed.newValues.forEach(this._observeYOutput.bind(this)); + } + + _observeYOutput(cellModel: ICellModel) { + const codeCell = this._getCodeCell(cellModel); + cellModel.sharedModel.changed.connect((sender: any, args: any) => { this.handleYOutput(codeCell, args); }); + const youtputs = (cellModel.sharedModel as YCodeCell).ymodel.get('outputs'); + for (const youtput of youtputs) { + if (youtput instanceof Y.Map && youtput.get('output_type') === 'ywidget') { + const roomId = youtput.get('room_id'); + const ymodel_name = youtput.get('model_name'); + this.createYWidget(codeCell, roomId, ymodel_name); + } + } + } + + handleYOutput(sender: any, args: any): void { + if ( + args.outputsChange !== undefined && + args.outputsChange[0].insert !== undefined + ) { + const youtput = args.outputsChange[0].insert[0]; + const output_type = youtput.get('output_type'); + if (output_type === 'ywidget') { + const roomId = youtput.get('room_id'); + const ymodel_name = youtput.get('model_name'); + this.createYWidget(sender, roomId, ymodel_name); + } + } + } + + createYWidget( + cell: any, + roomId: string, + ymodel_name: string + ): void { + let yModel: IJupyterYModel; + + if (ymodel_name !== '') { + const yModelFactory = this._wmManager.yModelFactories.get(ymodel_name)!; + yModel = new yModelFactory({ymodel_name, 'create_ydoc': true, 'room_id': roomId}); + this._wm.setModel(roomId, yModel); + } + else { + yModel = this._wm.getModel(roomId)!; + } + + const widget = new JupyterYWidget({ mimeType: '', modelFactory: this._modelFactory }); + + const panel = new Panel(); + panel.addClass(OUTPUT_AREA_ITEM_CLASS); + panel.addClass(OUTPUT_AREA_STDIN_ITEM_CLASS); + const outputPrompt = new OutputPrompt(); + outputPrompt.addClass(OUTPUT_AREA_PROMPT_CLASS); + panel.addWidget(outputPrompt); + widget.addClass(OUTPUT_AREA_OUTPUT_CLASS); + panel.addWidget(widget); + cell.outputArea.layout.addWidget(panel); + widget.render(roomId); + } + + _getCodeCell(cellModel: ICellModel): CodeCell | null { + if (cellModel.type === 'code') { + const cell = this._panel.content.widgets.find( + (widget: Cell) => widget.model === cellModel + ); + return cell as CodeCell; + } + return null; + } + + private _panel: NotebookPanel; + private _notebookId: string; + private _wmManager: IJupyterYWidgetManager; + private _wm: IJupyterYWidgetModelRegistry; + private _modelFactory: NotebookRendererModel; +} diff --git a/src/youtputwidget/index.ts b/src/youtputwidget/index.ts new file mode 100644 index 0000000..68d4ce4 --- /dev/null +++ b/src/youtputwidget/index.ts @@ -0,0 +1,43 @@ +import { IJupyterYModel } from '../types'; +import { JupyterYModel } from '../model'; +import { IJupyterYWidgetManager } from '../notebookrenderer/types'; +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import * as Y from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; + +class StdoutOutputWidget { + constructor(yModel: IJupyterYModel, node: HTMLElement) { + this.yModel = yModel; + this.node = node; + + const wsProvider = new WebsocketProvider( + 'ws://127.0.0.1:8000', `api/collaboration/room/${yModel.roomId}`, + yModel.sharedModel.ydoc + ); + + wsProvider.on('sync', (isSynced) => { + const text: Y.Text = this.yModel.sharedModel.getAttr('text'); + const pre = document.createElement('pre'); + pre.innerText = text.toString(); + this.node.appendChild(pre); + text.observe((event: any) => { + pre.innerText = event.target.toString(); + }); + }); + } + + yModel: IJupyterYModel; + node: HTMLElement; +} + +export const yStdoutOutputWidget: JupyterFrontEndPlugin = { + id: 'jupyterywidget:yStdoutOutputWidget', + autoStart: true, + requires: [IJupyterYWidgetManager], + activate: (app: JupyterFrontEnd, wm: IJupyterYWidgetManager): void => { + wm.registerWidget('StdoutOutput', JupyterYModel, StdoutOutputWidget); + } +};