import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; import { INotebookTracker } from '@jupyterlab/notebook'; import { WidgetTracker, showDialog, Dialog, InputDialog } from '@jupyterlab/apputils'; import { CodeCell } from '@jupyterlab/cells'; import { Dashboard, DashboardDocumentFactory, DashboardTracker, IDashboardTracker } from './dashboard'; import { DashboardWidget } from './widget'; import { IDocumentManager } from '@jupyterlab/docmanager'; import { IMainMenu } from '@jupyterlab/mainmenu'; import { Widget, Menu } from '@lumino/widgets'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { ILauncher } from '@jupyterlab/launcher'; import { DashboardIcons } from './icons'; import { DashboardModel, DashboardModelFactory } from './model'; import { undoIcon, redoIcon, copyIcon, cutIcon, pasteIcon, runIcon, saveIcon } from '@jupyterlab/ui-components'; import { CommandIDs } from './commands'; import { ReadonlyJSONObject } from '@lumino/coreutils'; import { DashboardLayout } from './layout'; import { Widgetstore, WidgetInfo } from './widgetstore'; import { getMetadata } from './utils'; const extension: JupyterFrontEndPlugin<IDashboardTracker> = { id: 'jupyterlab-interactive-dashboard-editor', autoStart: true, requires: [INotebookTracker, IMainMenu, IDocumentManager, ILauncher], provides: IDashboardTracker, activate: ( app: JupyterFrontEnd, notebookTracker: INotebookTracker, mainMenu: IMainMenu, docManager: IDocumentManager, launcher: ILauncher ): IDashboardTracker => { // Tracker for Dashboard const dashboardTracker = new DashboardTracker({ namespace: 'dashboards' }); //Tracker for DashboardWidgets const outputTracker = new WidgetTracker<DashboardWidget>({ namespace: 'dashboard-outputs' }); // Clipboard for copy/pasting outputs. const clipboard = new Set<Widgetstore.WidgetInfo>(); // Define dashboard file type. const dashboardFiletype: Partial<DocumentRegistry.IFileType> = { name: 'dashboard', displayName: 'Dashboard', contentType: 'file', extensions: ['.dashboard', '.dash'], fileFormat: 'text', icon: DashboardIcons.tealDashboard, iconLabel: 'Dashboard', mimeTypes: ['application/json'] }; // Add dashboard file type to the doc registry. app.docRegistry.addFileType(dashboardFiletype); addCommands( app, dashboardTracker, outputTracker, clipboard, docManager, notebookTracker ); // Create a new model factory. const modelFactory = new DashboardModelFactory({ notebookTracker }); // Create a new widget factory. const widgetFactory = new DashboardDocumentFactory({ name: 'dashboard', modelName: 'dashboard', fileTypes: ['dashboard'], defaultFor: ['dashboard'], commandRegistry: app.commands, outputTracker }); app.docRegistry.addModelFactory(modelFactory); app.docRegistry.addWidgetFactory(widgetFactory); // Add newly created dashboards to the tracker, set their icon and label, // and set the default width, height, and scrollMode. widgetFactory.widgetCreated.connect((_sender, widget) => { void dashboardTracker.add(widget.content); widget.title.icon = dashboardFiletype.icon; widget.title.iconClass = dashboardFiletype.iconClass || ''; widget.title.iconLabel = dashboardFiletype.iconLabel || ''; const model = widget.content.model; // TODO: Make scrollMode changable in JL. Default 'infinite' for now. model.scrollMode = 'infinite'; model.width = Dashboard.DEFAULT_WIDTH; model.height = Dashboard.DEFAULT_HEIGHT; }); // Add commands to context menus. app.contextMenu.addItem({ command: CommandIDs.save, selector: '.pr-JupyterDashboard', rank: 3 }); app.contextMenu.addItem({ command: CommandIDs.undo, selector: '.pr-JupyterDashboard', rank: 1 }); app.contextMenu.addItem({ command: CommandIDs.redo, selector: '.pr-JupyterDashboard', rank: 2 }); app.contextMenu.addItem({ command: CommandIDs.cut, selector: '.pr-JupyterDashboard', rank: 3 }); app.contextMenu.addItem({ command: CommandIDs.copy, selector: '.pr-JupyterDashboard', rank: 4 }); app.contextMenu.addItem({ command: CommandIDs.paste, selector: '.pr-JupyterDashboard', rank: 5 }); const experimentalMenu = new Menu({ commands: app.commands }); experimentalMenu.title.label = 'Experimental'; experimentalMenu.addItem({ command: CommandIDs.saveToMetadata }); experimentalMenu.addItem({ command: CommandIDs.toggleInfiniteScroll }); experimentalMenu.addItem({ command: CommandIDs.trimDashboard }); app.contextMenu.addItem({ type: 'submenu', submenu: experimentalMenu, selector: '.pr-JupyterDashboard', rank: 6 }); app.contextMenu.addItem({ command: CommandIDs.deleteOutput, selector: '.pr-EditableWidget', rank: 0 }); app.contextMenu.addItem({ command: CommandIDs.toggleFitContent, selector: '.pr-EditableWidget', rank: 1 }); app.contextMenu.addItem({ command: CommandIDs.toggleWidgetMode, selector: '.pr-EditableWidget', rank: 2 }); app.contextMenu.addItem({ type: 'separator', selector: '.pr-EditableWidget', rank: 3 }); app.contextMenu.addItem({ command: CommandIDs.openFromMetadata, selector: '.jp-Notebook', rank: 16 }); // Add commands to key bindings app.commands.addKeyBinding({ command: CommandIDs.deleteOutput, args: {}, keys: ['Backspace'], selector: '.pr-EditableWidget' }); app.commands.addKeyBinding({ command: CommandIDs.undo, args: {}, keys: ['Z'], selector: '.pr-JupyterDashboard' }); app.commands.addKeyBinding({ command: CommandIDs.redo, args: {}, keys: ['Shift Z'], selector: '.pr-JupyterDashboard' }); app.commands.addKeyBinding({ command: CommandIDs.cut, args: {}, keys: ['Accel X'], selector: '.pr-JupyterDashboard' }); app.commands.addKeyBinding({ command: CommandIDs.copy, args: {}, keys: ['Accel C'], selector: '.pr-JupyterDashboard' }); app.commands.addKeyBinding({ command: CommandIDs.paste, args: {}, keys: ['Accel V'], selector: '.pr-JupyterDashboard' }); app.commands.addKeyBinding({ command: CommandIDs.toggleFitContent, args: {}, keys: ['K'], selector: '.pr-EditableWidget' }); // Add commands to edit menu. mainMenu.fileMenu.addGroup([ { command: CommandIDs.setDimensions }, { command: CommandIDs.setTileSize } ]); mainMenu.fileMenu.newMenu.addGroup([ { command: CommandIDs.createNew } ]); launcher.add({ command: CommandIDs.createNew, category: 'Other', rank: 1 }); return dashboardTracker; } }; /** * Add commands to the main JupyterLab command registry. * * @param app - the JupyterLab instance. * * @param dashboardTracker - a tracker for dashboards. * * @param outputTracker - a tracker for dashboard outputs. * * @param clipboard - a set used to keep track of widgets for copy/pasting. * * @param docManager - a document manager used to create/rename files. * * @param notebookTracker - a tracker for notebooks. */ function addCommands( app: JupyterFrontEnd, dashboardTracker: WidgetTracker<Dashboard>, outputTracker: WidgetTracker<DashboardWidget>, clipboard: Set<Widgetstore.WidgetInfo>, docManager: IDocumentManager, notebookTracker: INotebookTracker ): void { const { commands } = app; /** * Whether there is an active dashboard. */ function hasDashboard(): boolean { return dashboardTracker.currentWidget !== null; } /** * Whether there is a dashboard output. */ function hasOutput(): boolean { return outputTracker.currentWidget !== null; } function inToolbar(args: ReadonlyJSONObject): boolean { return args.toolbar as boolean; } /** * Deletes a selected DashboardWidget. */ commands.addCommand(CommandIDs.deleteOutput, { label: 'Delete Output', execute: args => { const widget = outputTracker.currentWidget; const dashboard = dashboardTracker.currentWidget; dashboard.deleteWidget(widget); } }); /** * Undo the last change to a dashboard. */ commands.addCommand(CommandIDs.undo, { label: args => (inToolbar(args) ? '' : 'Undo'), icon: undoIcon, execute: args => { dashboardTracker.currentWidget.undo(); }, isEnabled: args => inToolbar(args) || (dashboardTracker.currentWidget && dashboardTracker.currentWidget.model.widgetstore.hasUndo()) }); /** * Redo the last undo to a dashboard. */ commands.addCommand(CommandIDs.redo, { label: args => (inToolbar(args) ? '' : 'Redo'), icon: redoIcon, execute: args => { dashboardTracker.currentWidget.redo(); }, isEnabled: args => inToolbar(args) || (dashboardTracker.currentWidget && dashboardTracker.currentWidget.model.widgetstore.hasRedo()) }); commands.addCommand(CommandIDs.toggleFitContent, { label: args => 'Fit To Content', execute: args => { const widget = outputTracker.currentWidget; widget.fitToContent = !widget.fitToContent; if (widget.fitToContent) { widget.fitContent(); } }, isVisible: args => outputTracker.currentWidget.mode === 'free-edit', isToggled: args => outputTracker.currentWidget.fitToContent }); commands.addCommand(CommandIDs.toggleMode, { icon: args => { const mode = dashboardTracker.currentWidget?.model.mode || 'present'; if (mode === 'present') { return DashboardIcons.edit; } else { return DashboardIcons.view; } }, label: args => { if (inToolbar(args)) { return ''; } const mode = dashboardTracker.currentWidget?.model.mode || 'present'; if (mode === 'present') { return 'Switch To Edit Mode'; } else { return 'Switch To Presentation Mode'; } }, execute: args => { const dashboard = dashboardTracker.currentWidget; if (dashboard.model.mode === 'present') { dashboard.model.mode = 'free-edit'; } else { dashboard.model.mode = 'present'; } } }); commands.addCommand(CommandIDs.runOutput, { label: args => (inToolbar(args) ? '' : 'Run Output'), icon: runIcon, execute: args => { const widget = outputTracker.currentWidget; const sessionContext = widget.notebook.sessionContext; CodeCell.execute(widget.cell as CodeCell, sessionContext); } }); commands.addCommand(CommandIDs.setDimensions, { label: 'Set Dashboard Dimensions', execute: async args => { const model = dashboardTracker.currentWidget.model; const width = model.width ? model.width : Dashboard.DEFAULT_WIDTH; const height = model.height ? model.height : Dashboard.DEFAULT_HEIGHT; await showDialog({ title: 'Enter Dimensions', body: new Private.ResizeHandler(width, height), focusNodeSelector: 'input', buttons: [Dialog.cancelButton(), Dialog.okButton()] }).then(result => { const value = result.value; let newWidth = value[0]; let newHeight = value[1]; if (value === null && model.width && model.height) { return; } if (!newWidth) { if (!model.width) { newWidth = Dashboard.DEFAULT_WIDTH; } else { newWidth = model.width; } } if (!newHeight) { if (!model.height) { newHeight = Dashboard.DEFAULT_HEIGHT; } else { newHeight = model.height; } } model.width = newWidth; model.height = newHeight; }); }, isEnabled: hasDashboard }); commands.addCommand(CommandIDs.setTileSize, { label: 'Set Grid Dimensions', execute: async args => { const newSize = await InputDialog.getNumber({ title: 'Enter Grid Size' }); if (newSize.value) { const layout = dashboardTracker.currentWidget.layout as DashboardLayout; layout.setTileSize(newSize.value); } }, isEnabled: hasDashboard }); commands.addCommand(CommandIDs.copy, { label: args => (inToolbar(args) ? '' : 'Copy'), icon: copyIcon, execute: args => { const info = outputTracker.currentWidget.info; clipboard.clear(); clipboard.add(info); }, isEnabled: args => inToolbar(args) || hasOutput() }); commands.addCommand(CommandIDs.cut, { label: args => (inToolbar(args) ? '' : 'Cut'), icon: cutIcon, execute: args => { const widget = outputTracker.currentWidget; const info = widget.info; const dashboard = dashboardTracker.currentWidget; clipboard.clear(); clipboard.add(info); dashboard.deleteWidget(widget); }, isEnabled: args => inToolbar(args) || hasOutput() }); commands.addCommand(CommandIDs.paste, { label: args => (inToolbar(args) ? '' : 'Paste'), icon: pasteIcon, execute: args => { const id = args.dashboardId; let dashboard: Dashboard; if (id) { dashboard = dashboardTracker.find(widget => widget.id === id); } else { dashboard = dashboardTracker.currentWidget; } const widgetstore = dashboard.model.widgetstore; clipboard.forEach(info => { const widgetId = DashboardWidget.createDashboardWidgetId(); const pos = info.pos; pos.left = Math.max(pos.left - 10, 0); pos.top = Math.max(pos.top - 10, 0); const newWidget = widgetstore.createWidget({ ...info, widgetId, pos }); dashboard.addWidget(newWidget, pos); }); }, isEnabled: args => inToolbar(args) || (hasOutput() && clipboard.size !== 0) }); commands.addCommand(CommandIDs.saveToMetadata, { label: 'Save Dashboard To Notebook Metadata', execute: args => { const dashboard = dashboardTracker.currentWidget; dashboard.saveToNotebookMetadata(); } }); commands.addCommand(CommandIDs.createNew, { label: 'Dashboard', icon: DashboardIcons.tealDashboard, execute: async args => { // A new file is created and opened separately to override the default // opening behavior when there's a notebook and open the dashboard in a // split pane instead of a tab. const notebook = notebookTracker.currentWidget; const newModel = await docManager.newUntitled({ ext: 'dash', path: '/', type: 'file' }); const path = newModel.path; if (notebook) { docManager.openOrReveal(`/${path}`, undefined, undefined, { mode: 'split-left', ref: notebook.id }); } else { docManager.openOrReveal(`/${path}`); } } }); // TODO: Make this optionally saveAs (based on filename?) commands.addCommand(CommandIDs.save, { label: args => (inToolbar(args) ? '' : 'Save'), icon: saveIcon, execute: args => { const dashboard = dashboardTracker.currentWidget; dashboard.context.save(); }, isEnabled: args => inToolbar(args) || hasDashboard() }); commands.addCommand(CommandIDs.openFromMetadata, { label: 'Open Metadata Dashboard', execute: args => { const notebook = notebookTracker.currentWidget; const notebookMetadata = getMetadata(notebook); const notebookId = notebookMetadata.id; const cells = notebook.content.widgets; const widgetstore = new Widgetstore({ id: 0, notebookTracker }); widgetstore.startBatch(); for (const cell of cells) { const metadata = getMetadata(cell); if (metadata !== undefined && !metadata.hidden) { const widgetInfo: WidgetInfo = { widgetId: DashboardWidget.createDashboardWidgetId(), notebookId, cellId: metadata.id, pos: metadata.pos }; widgetstore.addWidget(widgetInfo); } } widgetstore.endBatch(); const model = new DashboardModel({ widgetstore, notebookTracker }); const dashboard = new Dashboard({ outputTracker, model }); dashboard.updateLayoutFromWidgetstore(); dashboard.model.mode = 'present'; notebook.context.addSibling(dashboard, { mode: 'split-left' }); }, isEnabled: args => { const notebook = notebookTracker.currentWidget; const metadata = getMetadata(notebook); if (metadata !== undefined && metadata.hasDashboard !== undefined) { return metadata.hasDashboard; } return false; } }); commands.addCommand(CommandIDs.toggleWidgetMode, { label: 'Snap to Grid', isToggled: args => { const widget = outputTracker.currentWidget; return widget.mode === 'grid-edit'; }, execute: args => { const widget = outputTracker.currentWidget; if (widget.mode === 'grid-edit') { widget.mode = 'free-edit'; } else if (widget.mode === 'free-edit') { widget.mode = 'grid-edit'; } } }); commands.addCommand(CommandIDs.toggleInfiniteScroll, { label: 'Infinite Scroll', isToggled: args => dashboardTracker.currentWidget?.model.scrollMode === 'infinite', execute: args => { const dashboard = dashboardTracker.currentWidget; if (dashboard.model.scrollMode === 'infinite') { dashboard.model.scrollMode = 'constrained'; } else { dashboard.model.scrollMode = 'infinite'; } } }); commands.addCommand(CommandIDs.trimDashboard, { label: 'Trim Dashboard', execute: args => { const dashboard = dashboardTracker.currentWidget; (dashboard.layout as DashboardLayout).trimDashboard(); } }); } /** * A namespace for private functionality. */ namespace Private { /** * A dialog with two boxes for setting a dashboard's width and height. */ export class ResizeHandler extends Widget { constructor(oldWidth: number, oldHeight: number) { const node = document.createElement('div'); const name = document.createElement('label'); name.textContent = 'Enter New Width/Height'; const width = document.createElement('input'); const height = document.createElement('input'); width.type = 'number'; height.type = 'number'; width.min = '0'; width.max = '10000'; height.min = '0'; height.max = '10000'; width.required = true; height.required = true; width.placeholder = `Width (${oldWidth})`; height.placeholder = `Height (${oldHeight})`; node.appendChild(name); node.appendChild(width); node.appendChild(height); super({ node }); } getValue(): number[] { const inputs = this.node.getElementsByTagName('input'); const widthInput = inputs[0]; const heightInput = inputs[1]; return [+widthInput.value, +heightInput.value]; } } } export default extension;