diff --git a/schema/plugin.json b/schema/plugin.json index e37b353f..3a75a089 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -3,14 +3,65 @@ "title": "jupytereverywhere", "description": "jupytereverywhere settings.", "type": "object", - "properties": {}, "additionalProperties": false, "jupyter.lab.toolbars": { - "Notebook": [ + "ViewOnlyNotebook": [ { "name": "save", + "disabled": true + }, + { + "name": "cut", + "command": "notebook:cut-cell", + "disabled": true + }, + { + "name": "copy", + "command": "notebook:copy-cell", + "disabled": true + }, + { + "name": "paste", + "command": "notebook:paste-cell-below", + "disabled": true + }, + { + "name": "run", + "caption": "Run cell", + "disabled": true + }, + { + "name": "interrupt", + "caption": "Interrupt notebook", + "disabled": true + }, + { + "name": "restart", + "caption": "Restart notebook", + "disabled": true + }, + { + "name": "restart-and-run", + "caption": "Restart and run all cells", + "disabled": true + }, + { + "name": "cellType", "command": "", - "disabled": true, + "disabled": true + }, + { + "name": "share", + "rank": 35 + }, + { + "name": "downloadDropdown", + "rank": 36 + } + ], + "Notebook": [ + { + "name": "save", "rank": 10 }, { @@ -106,5 +157,13 @@ "disabled": true } ] + }, + "jupyter.lab.transform": true, + "properties": { + "toolbar": { + "title": "View-only notebook panel toolbar items", + "type": "array", + "default": [] + } } } diff --git a/src/index.ts b/src/index.ts index 77c2922e..82d0d2c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { Commands } from './commands'; import { competitions } from './pages/competitions'; import { notebookPlugin } from './pages/notebook'; import { generateDefaultNotebookName } from './notebook-name'; +import { viewOnlyNotebookFactoryPlugin } from './view-only'; /** * Generate a shareable URL for the currently active notebook. @@ -236,4 +237,11 @@ const plugin: JupyterFrontEndPlugin = { } }; -export default [plugin, notebookPlugin, files, competitions, customSidebar]; +export default [ + viewOnlyNotebookFactoryPlugin, + plugin, + notebookPlugin, + files, + competitions, + customSidebar +]; diff --git a/src/pages/notebook.tsx b/src/pages/notebook.tsx index 4cb32d3b..90a1caef 100644 --- a/src/pages/notebook.tsx +++ b/src/pages/notebook.tsx @@ -1,24 +1,26 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; import { INotebookTracker } from '@jupyterlab/notebook'; +import { INotebookContent } from '@jupyterlab/nbformat'; import { SidebarIcon } from '../ui-components/SidebarIcon'; import { EverywhereIcons } from '../icons'; import { ToolbarButton, IToolbarWidgetRegistry } from '@jupyterlab/apputils'; import { DownloadDropdownButton } from '../ui-components/DownloadDropdownButton'; import { Commands } from '../commands'; import { SharingService } from '../sharing-service'; -import { INotebookContent } from '@jupyterlab/nbformat'; +import { VIEW_ONLY_NOTEBOOK_FACTORY, IViewOnlyNotebookTracker } from '../view-only'; export const notebookPlugin: JupyterFrontEndPlugin = { id: 'jupytereverywhere:notebook', autoStart: true, - requires: [INotebookTracker, IToolbarWidgetRegistry], + requires: [INotebookTracker, IViewOnlyNotebookTracker, IToolbarWidgetRegistry], activate: ( app: JupyterFrontEnd, tracker: INotebookTracker, + readonlyTracker: IViewOnlyNotebookTracker, toolbarRegistry: IToolbarWidgetRegistry ) => { - const { commands, shell } = app; - const contents = app.serviceManager.contents; + const { commands, shell, serviceManager } = app; + const { contents } = serviceManager; const params = new URLSearchParams(window.location.search); let notebookId = params.get('notebook'); @@ -41,12 +43,15 @@ export const notebookPlugin: JupyterFrontEndPlugin = { console.log('Retrieving notebook from API...'); const notebookResponse = await sharingService.retrieve(id); - console.log('API Response received:', notebookResponse); // debug + console.log('API Response received:', notebookResponse); - const content: INotebookContent = notebookResponse.content; + const { content }: { content: INotebookContent } = notebookResponse; - // We make all cells read-only by setting editable: false - // by iterating over each cell in the notebook content. + // We make all cells read-only by setting editable: false. + // This is still required with a custom widget factory as + // it is not trivial to coerce the cells to respect the `readOnly` + // property otherwise (Mike tried swapping `Notebook.ContentFactory` + // and it does not work without further hacks). if (content.cells) { content.cells.forEach(cell => { cell.metadata = { @@ -56,27 +61,31 @@ export const notebookPlugin: JupyterFrontEndPlugin = { }); } + const { id: responseId, readable_id, domain_id } = notebookResponse; content.metadata = { ...content.metadata, isSharedNotebook: true, - sharedId: notebookResponse.id, - readableId: notebookResponse.readable_id, - domainId: notebookResponse.domain_id + sharedId: responseId, + readableId: readable_id, + domainId: domain_id }; - // Generate a meaningful filename for the shared notebook - const filename = `Shared_${notebookResponse.readable_id || notebookResponse.id}.ipynb`; + const filename = `Shared_${readable_id || responseId}.ipynb`; await contents.save(filename, { content, format: 'json', type: 'notebook', + // Even though we have a custom view-only factory, we still + // want to indicate that notebook is read-only to avoid + // error on Ctrl + S and instead get a nice notification that + // the notebook cannot be saved unless using save-as. writable: false }); await commands.execute('docmanager:open', { path: filename, - factory: 'Notebook' + factory: VIEW_ONLY_NOTEBOOK_FACTORY }); console.log(`Successfully loaded shared notebook: ${filename}`); @@ -125,8 +134,11 @@ export const notebookPlugin: JupyterFrontEndPlugin = { label: 'Notebook', icon: EverywhereIcons.notebook, execute: () => { + if (readonlyTracker.currentWidget) { + return shell.activateById(readonlyTracker.currentWidget.id); + } if (tracker.currentWidget) { - shell.activateById(tracker.currentWidget.id); + return shell.activateById(tracker.currentWidget.id); } } }); @@ -135,24 +147,26 @@ export const notebookPlugin: JupyterFrontEndPlugin = { app.shell.activateById(sidebarItem.id); app.restored.then(() => app.shell.activateById(sidebarItem.id)); - toolbarRegistry.addFactory( - 'Notebook', - 'downloadDropdown', - () => new DownloadDropdownButton(commands) - ); - - toolbarRegistry.addFactory( - 'Notebook', - 'share', - () => - new ToolbarButton({ - label: 'Share', - icon: EverywhereIcons.link, - tooltip: 'Share this notebook', - onClick: () => { - void commands.execute(Commands.shareNotebookCommand); - } - }) - ); + for (const toolbarName of ['Notebook', 'ViewOnlyNotebook']) { + toolbarRegistry.addFactory( + toolbarName, + 'downloadDropdown', + () => new DownloadDropdownButton(commands) + ); + + toolbarRegistry.addFactory( + toolbarName, + 'share', + () => + new ToolbarButton({ + label: 'Share', + icon: EverywhereIcons.link, + tooltip: 'Share this notebook', + onClick: () => { + void commands.execute(Commands.shareNotebookCommand); + } + }) + ); + } } }; diff --git a/src/sidebar.ts b/src/sidebar.ts index 04b9a6d5..ec6e18e2 100644 --- a/src/sidebar.ts +++ b/src/sidebar.ts @@ -24,7 +24,6 @@ export const customSidebar: JupyterFrontEndPlugin = { const newWidget = args.currentTitle ? leftHandler._findWidgetByTitle(args.currentTitle) : null; - console.log(newWidget); if (newWidget && newWidget instanceof SidebarIcon) { const cancel = newWidget.execute(); if (cancel) { diff --git a/src/view-only.ts b/src/view-only.ts new file mode 100644 index 00000000..8bab6a7d --- /dev/null +++ b/src/view-only.ts @@ -0,0 +1,210 @@ +import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { IEditorMimeTypeService } from '@jupyterlab/codeeditor'; +import { WidgetTracker, IWidgetTracker } from '@jupyterlab/apputils'; +import { ReactiveToolbar, Toolbar } from '@jupyterlab/ui-components'; +import { IEditorServices } from '@jupyterlab/codeeditor'; +import { ABCWidgetFactory, DocumentRegistry, DocumentWidget } from '@jupyterlab/docregistry'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; +import { ITranslator } from '@jupyterlab/translation'; +import { INotebookModel, Notebook, StaticNotebook } from '@jupyterlab/notebook'; +import { createToolbarFactory, IToolbarWidgetRegistry } from '@jupyterlab/apputils'; +import { Widget } from '@lumino/widgets'; +import { Token } from '@lumino/coreutils'; +import { MarkdownCell } from '@jupyterlab/cells'; + +export const VIEW_ONLY_NOTEBOOK_FACTORY = 'ViewOnlyNotebook'; + +const NOTEBOOK_PANEL_CLASS = 'jp-NotebookPanel'; + +const NOTEBOOK_PANEL_TOOLBAR_CLASS = 'jp-NotebookPanel-toolbar'; + +const NOTEBOOK_PANEL_NOTEBOOK_CLASS = 'jp-NotebookPanel-notebook'; + +export const IViewOnlyNotebookTracker = new Token( + 'jupytereverywhere:view-only-notebook:IViewOnlyNotebookTracker' +); + +export interface IViewOnlyNotebookTracker extends IWidgetTracker {} + +/** + * Creates a "View Only" header widget for view-only notebooks. + */ +class ViewOnlyHeader extends Widget { + constructor() { + super(); + this.addClass('je-ViewOnlyHeader'); + const contentNode = document.createElement('div'); + contentNode.className = 'je-ViewOnlyHeader-content'; + contentNode.textContent = 'View Only'; + this.node.appendChild(contentNode); + } +} + +namespace FilteredToolbar { + export interface IOptions extends Toolbar.IOptions { + itemsToFilterOut: Set; + } +} + +class FilteredToolbar extends ReactiveToolbar { + constructor(options: FilteredToolbar.IOptions) { + super(options); + this._itemsToFilterOut = options.itemsToFilterOut; + } + insertItem(index: number, name: string, widget: Widget): boolean { + if (this._itemsToFilterOut?.has(name)) { + return false; + } + return super.insertItem(index, name, widget); + } + // This can be undefined during the super() call in constructor + private _itemsToFilterOut: Set | undefined; +} + +class ViewOnlyNotebook extends StaticNotebook { + // Add any customization for view-only notebook here if needed +} + +class ViewOnlyNotebookPanel extends DocumentWidget { + /** + * Construct a new view-only notebook panel. + */ + constructor(options: DocumentWidget.IOptions) { + super({ + ...options, + toolbar: new FilteredToolbar({ + itemsToFilterOut: new Set(['read-only-indicator']) + }) + }); + + this.addClass(NOTEBOOK_PANEL_CLASS); + this.toolbar.addClass(NOTEBOOK_PANEL_TOOLBAR_CLASS); + this.content.addClass(NOTEBOOK_PANEL_NOTEBOOK_CLASS); + + this.content.model = this.context.model; + const headerWidget = new ViewOnlyHeader(); + this.contentHeader.insertWidget(0, headerWidget); + this.contentHeader.addClass('je-ViewOnlyHeader-wrapper'); + } +} + +namespace ViewOnlyNotebookWidgetFactory { + export interface IOptions extends DocumentRegistry.IWidgetFactoryOptions { + rendermime: IRenderMimeRegistry; + contentFactory: Notebook.IContentFactory; + mimeTypeService: IEditorMimeTypeService; + editorConfig?: StaticNotebook.IEditorConfig; + notebookConfig?: StaticNotebook.INotebookConfig; + translator?: ITranslator; + } +} + +class ViewOnlyNotebookWidgetFactory extends ABCWidgetFactory< + ViewOnlyNotebookPanel, + INotebookModel +> { + /** + * Construct a new notebook widget factory. + * + * @param options - The options used to construct the factory. + */ + constructor(private _options: ViewOnlyNotebookWidgetFactory.IOptions) { + super(_options); + } + + /** + * Create a new widget. + */ + protected createNewWidget( + context: DocumentRegistry.IContext, + source?: ViewOnlyNotebookPanel + ): ViewOnlyNotebookPanel { + const translator = (context as any).translator; + const { contentFactory, mimeTypeService, rendermime } = this._options; + const nbOptions = { + rendermime: source + ? source.content.rendermime + : rendermime.clone({ resolver: context.urlResolver }), + contentFactory, + mimeTypeService, + editorConfig: source + ? source.content.editorConfig + : this._options.editorConfig || StaticNotebook.defaultEditorConfig, + notebookConfig: source + ? source.content.notebookConfig + : this._options.notebookConfig || StaticNotebook.defaultNotebookConfig, + translator + }; + const content = new ViewOnlyNotebook(nbOptions); + + return new ViewOnlyNotebookPanel({ context, content }); + } +} + +class ViewOnlyContentFactory extends Notebook.ContentFactory { + createMarkdownCell(options: MarkdownCell.IOptions): MarkdownCell { + const cell = super.createMarkdownCell(options); + cell.showEditorForReadOnly = false; + return cell; + } +} + +export const viewOnlyNotebookFactoryPlugin: JupyterFrontEndPlugin = { + id: 'jupytereverywhere:view-only-notebook', + requires: [ + IEditorServices, + IRenderMimeRegistry, + IToolbarWidgetRegistry, + ISettingRegistry, + ITranslator + ], + provides: IViewOnlyNotebookTracker, + autoStart: true, + activate: ( + app: JupyterFrontEnd, + editorServices: IEditorServices, + rendermime: IRenderMimeRegistry, + toolbarRegistry: IToolbarWidgetRegistry, + settingRegistry: ISettingRegistry, + translator: ITranslator + ) => { + // This needs to have a `toolbar` property with an array + const PANEL_SETTINGS = 'jupytereverywhere:plugin'; + + const toolbarFactory = createToolbarFactory( + toolbarRegistry, + settingRegistry, + VIEW_ONLY_NOTEBOOK_FACTORY, + PANEL_SETTINGS, + translator + ); + + const trans = translator.load('jupyterlab'); + const editorFactory = editorServices.factoryService.newInlineEditor; + + const factory = new ViewOnlyNotebookWidgetFactory({ + name: VIEW_ONLY_NOTEBOOK_FACTORY, + label: trans.__('View-only Notebook'), + fileTypes: ['notebook'], + modelName: 'notebook', + preferKernel: false, + canStartKernel: false, + rendermime, + contentFactory: new ViewOnlyContentFactory({ editorFactory }), + editorConfig: StaticNotebook.defaultEditorConfig, + notebookConfig: StaticNotebook.defaultNotebookConfig, + mimeTypeService: editorServices.mimeTypeService, + toolbarFactory, + translator + }); + const tracker = new WidgetTracker({ + namespace: 'view-only-notebook' + }); + factory.widgetCreated.connect((sender, widget) => { + void tracker.add(widget); + }); + app.docRegistry.addWidgetFactory(factory); + return tracker; + } +}; diff --git a/style/base.css b/style/base.css index e8f844c2..f97a3d0b 100644 --- a/style/base.css +++ b/style/base.css @@ -62,16 +62,7 @@ font-weight: 600; } -#jp-main-dock-panel[data-mode='single-document'] { - padding: 25px !important; - background: #d8b8dc; -} - -#jp-main-dock-panel[data-mode='single-document'] .jp-MainAreaWidget { - border-radius: var(--je-round-corners); - background: transparent; -} - +/* Main area widget base styles */ .jp-MainAreaWidget > .jp-Toolbar { border-radius: var(--je-round-corners); } @@ -81,3 +72,41 @@ margin-top: 10px; background: white; } + +/* View Only header */ +.je-ViewOnlyHeader { + min-height: 40px; + display: flex; + align-items: center; + background: var(--je-slate-blue); +} + +.je-ViewOnlyHeader-content { + color: white; + font-family: var(--je-font-family); + font-size: 14px; + font-weight: 600; + text-align: center; + padding: 8px 16px; + width: 100%; +} + +.jp-MainAreaWidget > .je-ViewOnlyHeader-wrapper { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.je-ViewOnlyHeader-wrapper + .jp-Notebook { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +#jp-main-dock-panel[data-mode='single-document'] { + padding: 25px !important; + background: #d8b8dc; +} + +#jp-main-dock-panel[data-mode='single-document'] .jp-MainAreaWidget { + border-radius: var(--je-round-corners); + background: transparent; +} diff --git a/ui-tests/tests/jupytereverywhere.spec.ts-snapshots/application-shell-chromium-linux.png b/ui-tests/tests/jupytereverywhere.spec.ts-snapshots/application-shell-chromium-linux.png index ec0925b6..f0b281f1 100644 Binary files a/ui-tests/tests/jupytereverywhere.spec.ts-snapshots/application-shell-chromium-linux.png and b/ui-tests/tests/jupytereverywhere.spec.ts-snapshots/application-shell-chromium-linux.png differ diff --git a/ui-tests/tests/jupytereverywhere.spec.ts-snapshots/read-only-notebook-chromium-linux.png b/ui-tests/tests/jupytereverywhere.spec.ts-snapshots/read-only-notebook-chromium-linux.png index 2827eaa2..deaad632 100644 Binary files a/ui-tests/tests/jupytereverywhere.spec.ts-snapshots/read-only-notebook-chromium-linux.png and b/ui-tests/tests/jupytereverywhere.spec.ts-snapshots/read-only-notebook-chromium-linux.png differ