diff --git a/docs/source/extension/extension_points.rst b/docs/source/extension/extension_points.rst index 49a8b6a96d57..9edf21e19b4a 100644 --- a/docs/source/extension/extension_points.rst +++ b/docs/source/extension/extension_points.rst @@ -55,6 +55,8 @@ might want to use the services in your extensions. - ``@jupyterlab/apputils:ISplashScreen``: A service for the splash screen for the application. Use this if you want to show the splash screen for your own purposes. - ``@jupyterlab/apputils:IThemeManager``: A service for the theme manager for the application. This is used primarily in theme extensions to register new themes. +- ``@jupyterlab/apputils:IToolbarWidgetRegistry``: A registry for toolbar widgets. Require this + if you want to build the toolbar dynamically from a data definition (stored in settings for example). - ``@jupyterlab/apputils:IWindowResolver``: A service for a window resolver for the application. JupyterLab workspaces are given a name, which are determined using the window resolver. Require this if you want to use the name of the current workspace. @@ -256,13 +258,13 @@ Item must follow this definition: .. literalinclude:: ../snippets/packages/settingregistry/src/plugin-schema.json :language: json - :lines: 21-39 + :lines: 37-55 where ``menuItem`` definition is: .. literalinclude:: ../snippets/packages/settingregistry/src/plugin-schema.json :language: json - :lines: 129-167 + :lines: 158-196 The same example using the API is shown below. See the Lumino `docs @@ -575,13 +577,13 @@ A menu must respect the following schema: .. literalinclude:: ../snippets/packages/settingregistry/src/plugin-schema.json :language: json - :lines: 72-125 + :lines: 101-157 And an item must follow: .. literalinclude:: ../snippets/packages/settingregistry/src/plugin-schema.json :language: json - :lines: 129-167 + :lines: 158-196 Menus added to the settings system will be editable by users using the ``mainmenu-extension`` settings. In particular, they can be disabled at the item or the menu level by setting the @@ -722,6 +724,143 @@ When the ``labStatus`` busy state changes, we update the text content of the item: statusWidget }); +.. _toolbar-registry: + + +Toolbar Registry +---------------- + +JupyterLab provides an infrastructure to define and customize toolbar widgets of ``DocumentWidget`` s +from the settings, which is similar to that defining the context menu and the main menu +bar. A typical example is the notebook toolbar as in the snippet below: + +.. code:: typescript + + function activatePlugin( + app: JupyterFrontEnd, + // ... + toolbarRegistry: IToolbarWidgetRegistry | null, + settingRegistry: ISettingRegistry | null + ): NotebookWidgetFactory.IFactory { + const { commands } = app; + let toolbarFactory: + | ((widget: NotebookPanel) => DocumentRegistry.IToolbarItem[]) + | undefined; + + // Register notebook toolbar specific widgets + if (toolbarRegistry) { + toolbarRegistry.registerFactory(FACTORY, 'cellType', panel => + ToolbarItems.createCellTypeItem(panel, translator) + ); + + toolbarRegistry.registerFactory( + FACTORY, + 'kernelStatus', + panel => Toolbar.createKernelStatusItem(panel.sessionContext, translator) + ); + // etc... + + if (settingRegistry) { + // Create the factory + toolbarFactory = createToolbarFactory( + toolbarRegistry, + settingRegistry, + // Factory name + FACTORY, + // Setting id in which the toolbar items are defined + '@jupyterlab/notebook-extension:panel', + translator + ); + } + } + + const factory = new NotebookWidgetFactory({ + name: FACTORY, + fileTypes: ['notebook'], + modelName: 'notebook', + defaultFor: ['notebook'], + // ... + toolbarFactory, + translator: translator + }); + app.docRegistry.addWidgetFactory(factory); + +The registry ``registerFactory`` method allows an extension to provide special widget for a unique pair +(factory name, toolbar item name). Then the helper ``createToolbarFactory`` can be used to extract the +toolbar definition from the settings and build the factory to pass to the widget factory. + +The default toolbar items can be defined across multiple extensions by providing an entry in the ``"jupyter.lab.toolbars"`` +mapping. For example for the notebook panel: + +.. code:: js + + "jupyter.lab.toolbars": { + "Notebook": [ // Factory name + // Item with non-default widget - it must be registered within an extension + { + "name": "save", // Unique toolbar item name + "rank": 10 // Item rank + }, + // Item with default button widget triggering a command + { "name": "insert", "command": "notebook:insert-cell-below", "rank": 20 }, + { "name": "cut", "command": "notebook:cut-cell", "rank": 21 }, + { "name": "copy", "command": "notebook:copy-cell", "rank": 22 }, + { "name": "paste", "command": "notebook:paste-cell-below", "rank": 23 }, + { "name": "run", "command": "runmenu:run", "rank": 30 }, + { "name": "interrupt", "command": "kernelmenu:interrupt", "rank": 31 }, + { "name": "restart", "command": "kernelmenu:restart", "rank": 32 }, + { + "name": "restart-and-run", + "command": "runmenu:restart-and-run-all", + "rank": 33 // The default rank is 50 + }, + { "name": "cellType", "rank": 40 }, + // Horizontal spacer widget + { "name": "spacer", "type": "spacer", "rank": 100 }, + { "name": "kernelName", "rank": 1000 }, + { "name": "kernelStatus", "rank": 1001 } + ] + }, + "jupyter.lab.transform": true, + "properties": { + "toolbar": { + "title": "Notebook panel toolbar items", + "items": { + "$ref": "#/definitions/toolbarItem" + }, + "type": "array", + "default": [] + } + } + + +The settings registry will merge those definitions from settings schema with any +user-provided overrides (customizations) transparently and save them under the +``toolbar`` property in the final settings object. The ``toolbar`` list will be used to +create the toolbar. Both the source settings schema and the final settings object +are identified by the plugin ID passed to ``createToolbarFactory``. The user can +customize the toolbar by adding new items or overriding existing ones (like +providing a different rank or adding ``"disabled": true`` to remove the item). + +.. note:: + + You need to set ``jupyter.lab.transform`` to ``true`` in the plugin id that will gather all items. + + +The current widget factories supporting the toolbar customization are: + +- ``Notebook``: Notebook panel toolbar +- ``Editor``: Text editor toolbar +- ``HTML Viewer``: HTML Viewer toolbar +- ``CSVTable``: CSV (Comma Separated Value) Viewer toolbar +- ``TSVTable``: TSV (Tabulation Separated Value) Viewer toolbar + +Add the toolbar item must follow this definition: + +.. literalinclude:: ../snippets/packages/settingregistry/src/plugin-schema.json + :language: json + :lines: 207-252 + .. _widget-tracker: Widget Tracker diff --git a/docs/source/extension/notebook.rst b/docs/source/extension/notebook.rst index 5ea265a00224..7c60fe394ef0 100644 --- a/docs/source/extension/notebook.rst +++ b/docs/source/extension/notebook.rst @@ -147,11 +147,30 @@ How to extend the Notebook plugin We'll walk through two notebook extensions: - adding a button to the toolbar +- adding a widget to the notebook header - adding an ipywidgets extension Adding a button to the toolbar ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Since JupyterLab 3.2, adding toolbar item can be done using a :ref:`toolbar-registry` and settings. In particular +for the notebook, if the button is linked to a new command, you can add a button in the toolbar using the +following JSON snippet in your extension settings file: + +.. code:: js + + "jupyter.lab.toolbars": { + "Notebook": [ // Widget factory name for which you want to add a toolbar item. + // Item with default button widget triggering a command + { "name": "run", "command": "runmenu:run" } + ] + } + +You may add a ``rank`` attribute to modify the item position (the default value is 50). + +Adding a widget to the notebook header +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + Start from the cookie cutter extension template. :: @@ -165,90 +184,94 @@ released npm packages, not the development versions. :: - npm install --save @jupyterlab/notebook @jupyterlab/application @jupyterlab/apputils @jupyterlab/docregistry @lumino/disposable --legacy-peer-deps + jlpm add -D @jupyterlab/notebook @jupyterlab/application @jupyterlab/ui-components @jupyterlab/docregistry @lumino/disposable @lumino/widgets --legacy-peer-deps Copy the following to ``src/index.ts``: .. code:: typescript - import { - IDisposable, DisposableDelegate - } from '@lumino/disposable'; + import { IDisposable, DisposableDelegate } from '@lumino/disposable'; - import { - JupyterFrontEnd, JupyterFrontEndPlugin - } from '@jupyterlab/application'; + import { Widget } from '@lumino/widgets'; import { - ToolbarButton - } from '@jupyterlab/apputils'; + JupyterFrontEnd, + JupyterFrontEndPlugin + } from '@jupyterlab/application'; import { DocumentRegistry } from '@jupyterlab/docregistry'; - import { - NotebookActions, NotebookPanel, INotebookModel - } from '@jupyterlab/notebook'; - + import { NotebookPanel, INotebookModel } from '@jupyterlab/notebook'; /** - * The plugin registration information. - */ + * The plugin registration information. + */ const plugin: JupyterFrontEndPlugin = { activate, - id: 'my-extension-name:buttonPlugin', + id: 'my-extension-name:widgetPlugin', autoStart: true }; - /** - * A notebook widget extension that adds a button to the toolbar. - */ - export - class ButtonExtension implements DocumentRegistry.IWidgetExtension { + * A notebook widget extension that adds a widget in the notebook header (widget below the toolbar). + */ + export class WidgetExtension + implements DocumentRegistry.IWidgetExtension + { /** - * Create a new extension object. - */ - createNew(panel: NotebookPanel, context: DocumentRegistry.IContext): IDisposable { - let callback = () => { - NotebookActions.runAll(panel.content, context.sessionContext); - }; - let button = new ToolbarButton({ - className: 'myButton', - iconClass: 'fa fa-fast-forward', - onClick: callback, - tooltip: 'Run All' - }); - - panel.toolbar.insertItem(0, 'runAll', button); + * Create a new extension object. + */ + createNew( + panel: NotebookPanel, + context: DocumentRegistry.IContext + ): IDisposable { + const widget = new Widget({ node: Private.createNode() }); + widget.addClass('jp-myextension-myheader'); + + panel.contentHeader.insertWidget(0, widget); return new DisposableDelegate(() => { - button.dispose(); + widget.dispose(); }); } } /** - * Activate the extension. - */ - function activate(app: JupyterFrontEnd) { - app.docRegistry.addWidgetExtension('Notebook', new ButtonExtension()); - }; - + * Activate the extension. + */ + function activate(app: JupyterFrontEnd): void { + app.docRegistry.addWidgetExtension('Notebook', new WidgetExtension()); + } /** - * Export the plugin as default. - */ + * Export the plugin as default. + */ export default plugin; + /** + * Private helpers + */ + namespace Private { + /** + * Generate the widget node + */ + export function createNode(): HTMLElement { + const span = document.createElement('span'); + span.textContent = 'My custom header'; + return span; + } + } + And the following to ``style/base.css``: .. code:: css - .myButton.jp-Button.minimal .jp-Icon { - color: black; - } + .jp-myextension-myheader { + min-height: 20px; + background-color: lightsalmon; + } Run the following commands: @@ -256,11 +279,12 @@ Run the following commands: :: pip install -e . - pip install jupyter_packaging + pip install jupyter-packaging jupyter labextension develop . --overwrite jupyter lab -Open a notebook and observe the new "Run All" button. +Open a notebook and observe the new "Header" widget. + The *ipywidgets* third party extension ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/packages/application-extension/src/index.tsx b/packages/application-extension/src/index.tsx index da5fcaa3a72c..8ab5b148f8d4 100644 --- a/packages/application-extension/src/index.tsx +++ b/packages/application-extension/src/index.tsx @@ -1031,9 +1031,11 @@ namespace Private { const defaults = canonical.properties?.contextMenu?.default ?? []; const user = { + ...plugin.data.user, contextMenu: plugin.data.user.contextMenu ?? [] }; const composite = { + ...plugin.data.composite, contextMenu: SettingRegistry.reconcileItems( defaults as ISettingRegistry.IContextMenuItem[], user.contextMenu as ISettingRegistry.IContextMenuItem[], diff --git a/packages/apputils-extension/src/index.ts b/packages/apputils-extension/src/index.ts index 92b9bdfad825..fe5cee38f259 100644 --- a/packages/apputils-extension/src/index.ts +++ b/packages/apputils-extension/src/index.ts @@ -37,6 +37,7 @@ import { Debouncer, Throttler } from '@lumino/polling'; import { Palette } from './palette'; import { settingsPlugin } from './settingsplugin'; import { themesPaletteMenuPlugin, themesPlugin } from './themesplugins'; +import { toolbarRegistry } from './toolbarregistryplugin'; import { workspacesPlugin } from './workspacesplugin'; /** @@ -629,6 +630,7 @@ const plugins: JupyterFrontEndPlugin[] = [ themesPlugin, themesPaletteMenuPlugin, toggleHeader, + toolbarRegistry, utilityCommands, workspacesPlugin ]; diff --git a/packages/apputils-extension/src/toolbarregistryplugin.ts b/packages/apputils-extension/src/toolbarregistryplugin.ts new file mode 100644 index 000000000000..7a47800bfcd1 --- /dev/null +++ b/packages/apputils-extension/src/toolbarregistryplugin.ts @@ -0,0 +1,24 @@ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { + createDefaultFactory, + IToolbarWidgetRegistry, + ToolbarWidgetRegistry +} from '@jupyterlab/apputils'; + +/** + * The default toolbar registry. + */ +export const toolbarRegistry: JupyterFrontEndPlugin = { + id: '@jupyterlab/apputils-extension:toolbar-registry', + autoStart: true, + provides: IToolbarWidgetRegistry, + activate: (app: JupyterFrontEnd) => { + const registry = new ToolbarWidgetRegistry({ + defaultFactory: createDefaultFactory(app.commands) + }); + return registry; + } +}; diff --git a/packages/apputils/package.json b/packages/apputils/package.json index bf4772e0d3ca..4ab8e5320ea6 100644 --- a/packages/apputils/package.json +++ b/packages/apputils/package.json @@ -28,9 +28,7 @@ "lib": "lib/" }, "files": [ - "lib/*.d.ts", - "lib/*.js.map", - "lib/*.js", + "lib/**/*.{d.ts,js,js.map}", "style/*.css", "style/index.js" ], @@ -47,6 +45,7 @@ }, "dependencies": { "@jupyterlab/coreutils": "^5.3.0-alpha.16", + "@jupyterlab/observables": "^4.3.0-alpha.16", "@jupyterlab/services": "^6.3.0-alpha.16", "@jupyterlab/settingregistry": "^3.3.0-alpha.16", "@jupyterlab/statedb": "^3.3.0-alpha.16", diff --git a/packages/apputils/src/menufactory.ts b/packages/apputils/src/menufactory.ts index c0431b071a84..a4507f1ec9da 100644 --- a/packages/apputils/src/menufactory.ts +++ b/packages/apputils/src/menufactory.ts @@ -165,7 +165,7 @@ export namespace MenuFactory { const existingItem = menu?.items.find( (i, idx) => i.type === entry.type && - i.command === entry.command && + i.command === (entry.command ?? '') && i.submenu?.id === entry.submenu?.id ); diff --git a/packages/apputils/src/tokens.ts b/packages/apputils/src/tokens.ts index c092f366ca8e..1b95ff2f4ec2 100644 --- a/packages/apputils/src/tokens.ts +++ b/packages/apputils/src/tokens.ts @@ -2,9 +2,11 @@ // Distributed under the terms of the Modified BSD License. import { IChangedArgs } from '@jupyterlab/coreutils'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { Token } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; import { ISignal } from '@lumino/signaling'; +import { Widget } from '@lumino/widgets'; import { ISessionContext } from './sessioncontext'; /** @@ -178,3 +180,96 @@ export namespace ISanitizer { allowedStyles?: { [key: string]: { [key: string]: RegExp[] } }; } } + +/** + * The namespace for `IToolbarWidgetRegistry` related interfaces + */ +export namespace ToolbarRegistry { + /** + * Interface of item to be inserted in a toolbar + */ + export interface IToolbarItem { + /** + * Unique item name + */ + name: string; + /** + * Toolbar widget + */ + widget: Widget; + } + + /** + * Interface describing a toolbar item widget + */ + export interface IWidget extends ISettingRegistry.IToolbarItem {} + + /** + * Options to set up the toolbar widget registry + */ + export interface IOptions { + /** + * Default toolbar widget factory + * + * The factory is receiving 3 arguments: + * @param widgetFactory The widget factory name that creates the toolbar + * @param widget The newly widget containing the toolbar + * @param toolbarItem The toolbar item definition + * @returns The widget to be inserted in the toolbar. + */ + defaultFactory: ( + widgetFactory: string, + widget: Widget, + toolbarItem: IWidget + ) => Widget; + } +} + +/** + * Toolbar widget registry interface + */ +export interface IToolbarWidgetRegistry { + /** + * Default toolbar item factory + */ + defaultFactory: ( + widgetFactory: string, + widget: Widget, + toolbarItem: ToolbarRegistry.IWidget + ) => Widget; + + /** + * Create a toolbar item widget + * + * @param widgetFactory The widget factory name that creates the toolbar + * @param widget The newly widget containing the toolbar + * @param toolbarItem The toolbar item definition + * @returns The widget to be inserted in the toolbar. + */ + createWidget( + widgetFactory: string, + widget: Widget, + toolbarItem: ToolbarRegistry.IWidget + ): Widget; + + /** + * Register a new toolbar item factory + * + * @param widgetFactory The widget factory name that creates the toolbar + * @param toolbarItemName The unique toolbar item + * @param factory The factory function that receives the widget containing the toolbar and returns the toolbar widget. + * @returns The previously defined factory + */ + registerFactory( + widgetFactory: string, + toolbarItemName: string, + factory: (main: T) => Widget + ): ((main: T) => Widget) | undefined; +} + +/** + * The toolbar registry token. + */ +export const IToolbarWidgetRegistry = new Token( + '@jupyterlab/apputils:IToolbarWidgetRegistry' +); diff --git a/packages/apputils/src/toolbar/factory.ts b/packages/apputils/src/toolbar/factory.ts new file mode 100644 index 000000000000..2c1946937050 --- /dev/null +++ b/packages/apputils/src/toolbar/factory.ts @@ -0,0 +1,292 @@ +import { IObservableList, ObservableList } from '@jupyterlab/observables'; +import { ISettingRegistry, SettingRegistry } from '@jupyterlab/settingregistry'; +import { ITranslator, TranslationBundle } from '@jupyterlab/translation'; +import { findIndex, toArray } from '@lumino/algorithm'; +import { JSONExt, PartialJSONObject } from '@lumino/coreutils'; +import { Widget } from '@lumino/widgets'; +import { Dialog, showDialog } from '../dialog'; +import { IToolbarWidgetRegistry, ToolbarRegistry } from '../tokens'; + +/** + * Default toolbar item rank + * + * #### Notes + * This will place item just before the white spacer item in the notebook toolbar. + */ +const DEFAULT_TOOLBAR_ITEM_RANK = 50; + +/** + * Display warning when the toolbar definition have been modified. + * + * @param trans Translation bundle + */ +async function displayInformation(trans: TranslationBundle): Promise { + const result = await showDialog({ + title: trans.__('Information'), + body: trans.__( + 'Toolbar customization has changed. You will need to reload JupyterLab to see the changes.' + ), + buttons: [ + Dialog.cancelButton(), + Dialog.okButton({ label: trans.__('Reload') }) + ] + }); + + if (result.button.accept) { + location.reload(); + } +} + +/** + * Accumulate the toolbar definition from all settings and set the default value from it. + * + * @param registry Application settings registry + * @param factoryName Widget factory name that needs a toolbar + * @param pluginId Settings plugin id + * @param translator Translator object + * @param propertyId Property holding the toolbar definition in the settings; default 'toolbar' + * @returns List of toolbar items + */ +async function getToolbarItems( + registry: ISettingRegistry, + factoryName: string, + pluginId: string, + translator: ITranslator, + propertyId: string = 'toolbar' +): Promise> { + const trans = translator.load('jupyterlab'); + let canonical: ISettingRegistry.ISchema | null; + let loaded: { [name: string]: ISettingRegistry.IToolbarItem[] } = {}; + + /** + * Populate the plugin's schema defaults. + * + * We keep track of disabled entries in case the plugin is loaded + * after the toolbar initialization. + */ + function populate(schema: ISettingRegistry.ISchema) { + loaded = {}; + schema.properties![propertyId].default = Object.keys(registry.plugins) + .map(plugin => { + const items = + (registry.plugins[plugin]!.schema['jupyter.lab.toolbars'] ?? {})[ + factoryName + ] ?? []; + loaded[plugin] = items; + return items; + }) + .concat([ + (schema['jupyter.lab.toolbars'] ?? {})[factoryName] ?? [], + schema.properties![propertyId].default as any[] + ]) + .reduceRight( + ( + acc: ISettingRegistry.IToolbarItem[], + val: ISettingRegistry.IToolbarItem[] + ) => SettingRegistry.reconcileToolbarItems(acc, val, true), + [] + )! // flatten one level + .sort( + (a, b) => + (a.rank ?? DEFAULT_TOOLBAR_ITEM_RANK) - + (b.rank ?? DEFAULT_TOOLBAR_ITEM_RANK) + ); + } + + // Transform the plugin object to return different schema than the default. + registry.transform(pluginId, { + compose: plugin => { + // Only override the canonical schema the first time. + if (!canonical) { + canonical = JSONExt.deepCopy(plugin.schema); + populate(canonical); + } + + const defaults = + ((canonical.properties ?? {})[propertyId] ?? {}).default ?? []; + // Initialize the settings + const user: PartialJSONObject = plugin.data.user; + const composite: PartialJSONObject = plugin.data.composite; + // Overrides the value with using the aggregated default for the toolbar property + user[propertyId] = + (plugin.data.user[propertyId] as ISettingRegistry.IToolbarItem[]) ?? []; + composite[propertyId] = + SettingRegistry.reconcileToolbarItems( + defaults as ISettingRegistry.IToolbarItem[], + user[propertyId] as ISettingRegistry.IToolbarItem[], + false + ) ?? []; + + plugin.data = { composite, user }; + + return plugin; + }, + fetch: plugin => { + // Only override the canonical schema the first time. + if (!canonical) { + canonical = JSONExt.deepCopy(plugin.schema); + populate(canonical); + } + + return { + data: plugin.data, + id: plugin.id, + raw: plugin.raw, + schema: canonical, + version: plugin.version + }; + } + }); + + // Repopulate the canonical variable after the setting registry has + // preloaded all initial plugins. + canonical = null; + + const settings = await registry.load(pluginId); + + const toolbarItems: IObservableList = new ObservableList( + { + values: JSONExt.deepCopy(settings.composite[propertyId] as any) ?? [], + itemCmp: (a, b) => JSONExt.deepEqual(a, b) + } + ); + + // React to customization by the user + settings.changed.connect(() => { + // As extension may change the toolbar through API, + // prompt the user to reload if the toolbar definition has been updated. + const newItems = (settings.composite[propertyId] as any) ?? []; + if (!JSONExt.deepEqual(toArray(toolbarItems.iter()), newItems)) { + void displayInformation(trans); + } + }); + + // React to plugin changes + registry.pluginChanged.connect(async (sender, plugin) => { + // As the plugin storing the toolbar definition is transformed using + // the above definition, if it changes, this means that a request to + // reloaded was triggered. Hence the toolbar definitions from the other + // plugins has been automatically reset during the transform step. + if (plugin !== pluginId) { + // If a plugin changed its toolbar items + const oldItems = loaded[plugin] ?? []; + const newItems = + (registry.plugins[plugin]!.schema['jupyter.lab.toolbars'] ?? {})[ + factoryName + ] ?? []; + if (!JSONExt.deepEqual(oldItems, newItems)) { + if (loaded[plugin]) { + // The plugin has changed, request the user to reload the UI + await displayInformation(trans); + } else { + // The plugin was not yet loaded => update the toolbar items list + loaded[plugin] = JSONExt.deepCopy(newItems); + const newList = + SettingRegistry.reconcileToolbarItems( + toArray(toolbarItems), + newItems, + false + ) ?? []; + + // Existing items cannot be removed. + newList?.forEach(item => { + const index = findIndex( + toolbarItems, + value => item.name === value.name + ); + if (index < 0) { + toolbarItems.push(item); + } else { + toolbarItems.set(index, item); + } + }); + } + } + } + }); + + return toolbarItems; +} + +/** + * Create the toolbar factory for a given container widget based + * on a data description stored in settings + * + * @param toolbarRegistry Toolbar widgets registry + * @param settingsRegistry Settings registry + * @param factoryName Toolbar container factory name + * @param pluginId Settings plugin id + * @param translator Translator + * @param propertyId Toolbar definition key in the settings plugin + * @returns List of toolbar widgets + */ +export function createToolbarFactory( + toolbarRegistry: IToolbarWidgetRegistry, + settingsRegistry: ISettingRegistry, + factoryName: string, + pluginId: string, + translator: ITranslator, + propertyId: string = 'toolbar' +): (widget: Widget) => ToolbarRegistry.IToolbarItem[] { + const items: ToolbarRegistry.IWidget[] = []; + let rawItems: IObservableList; + + const transfer = ( + list: IObservableList, + change: IObservableList.IChangedArgs + ) => { + switch (change.type) { + case 'move': + break; + case 'add': + case 'remove': + case 'set': + items.length = 0; + items.push( + ...toArray(list) + .filter(item => !item.disabled) + .sort( + (a, b) => + (a.rank ?? DEFAULT_TOOLBAR_ITEM_RANK) - + (b.rank ?? DEFAULT_TOOLBAR_ITEM_RANK) + ) + ); + break; + } + }; + + // Get toolbar definition from the settings + getToolbarItems( + settingsRegistry, + factoryName, + pluginId, + translator, + propertyId + ) + .then(candidates => { + rawItems = candidates; + rawItems.changed.connect(transfer); + // Force initialization of items + transfer(rawItems, { + type: 'add', + newIndex: 0, + newValues: [], + oldIndex: 0, + oldValues: [] + }); + }) + .catch(reason => { + console.error( + `Failed to load toolbar items for factory ${factoryName} from ${pluginId}`, + reason + ); + }); + + return (widget: Widget) => + items.map(item => { + return { + name: item.name, + widget: toolbarRegistry.createWidget(factoryName, widget, item) + }; + }); +} diff --git a/packages/apputils/src/toolbar/index.ts b/packages/apputils/src/toolbar/index.ts new file mode 100644 index 000000000000..267d80ea5334 --- /dev/null +++ b/packages/apputils/src/toolbar/index.ts @@ -0,0 +1,3 @@ +export * from './factory'; +export * from './registry'; +export * from './widget'; diff --git a/packages/apputils/src/toolbar/registry.ts b/packages/apputils/src/toolbar/registry.ts new file mode 100644 index 000000000000..cc4f1e6a7dbf --- /dev/null +++ b/packages/apputils/src/toolbar/registry.ts @@ -0,0 +1,134 @@ +import { LabIcon } from '@jupyterlab/ui-components'; +import { CommandRegistry } from '@lumino/commands'; +import { Widget } from '@lumino/widgets'; +import { IToolbarWidgetRegistry, ToolbarRegistry } from '../tokens'; +import { CommandToolbarButton, Toolbar } from './widget'; + +/** + * Concrete implementation of IToolbarWidgetRegistry interface + */ +export class ToolbarWidgetRegistry implements IToolbarWidgetRegistry { + constructor(options: ToolbarRegistry.IOptions) { + this._defaultFactory = options.defaultFactory; + } + + /** + * Default toolbar item factory + */ + get defaultFactory(): ( + widgetFactory: string, + widget: Widget, + toolbarItem: ToolbarRegistry.IWidget + ) => Widget { + return this._defaultFactory; + } + set defaultFactory( + factory: ( + widgetFactory: string, + widget: Widget, + toolbarItem: ToolbarRegistry.IWidget + ) => Widget + ) { + this._defaultFactory = factory; + } + + /** + * Create a toolbar item widget + * + * @param widgetFactory The widget factory name that creates the toolbar + * @param widget The newly widget containing the toolbar + * @param toolbarItem The toolbar item definition + * @returns The widget to be inserted in the toolbar. + */ + createWidget( + widgetFactory: string, + widget: Widget, + toolbarItem: ToolbarRegistry.IWidget + ): Widget { + const factory = this._widgets.get(widgetFactory)?.get(toolbarItem.name); + return factory + ? factory(widget) + : this._defaultFactory(widgetFactory, widget, toolbarItem); + } + + /** + * Register a new toolbar item factory + * + * @param widgetFactory The widget factory name that creates the toolbar + * @param toolbarItemName The unique toolbar item + * @param factory The factory function that receives the widget containing the toolbar and returns the toolbar widget. + * @returns The previously defined factory + */ + registerFactory( + widgetFactory: string, + toolbarItemName: string, + factory: (main: T) => Widget + ): ((main: T) => Widget) | undefined { + let namespace = this._widgets.get(widgetFactory); + const oldFactory = namespace?.get(toolbarItemName); + if (!namespace) { + namespace = new Map Widget>(); + this._widgets.set(widgetFactory, namespace); + } + namespace.set(toolbarItemName, factory); + return oldFactory; + } + + protected _defaultFactory: ( + widgetFactory: string, + widget: Widget, + toolbarItem: ToolbarRegistry.IWidget + ) => Widget; + protected _widgets: Map< + string, + Map Widget> + > = new Map Widget>>(); +} + +/** + * Create the default toolbar item widget factory + * + * @param commands Application commands registry + * @returns Default factory + */ +export function createDefaultFactory( + commands: CommandRegistry +): ( + widgetFactory: string, + widget: Widget, + toolbarItem: ToolbarRegistry.IWidget +) => Widget { + return ( + widgetFactory: string, + widget: Widget, + toolbarItem: ToolbarRegistry.IWidget + ) => { + switch (toolbarItem.type ?? 'command') { + case 'command': { + const { + command: tId, + args: tArgs, + label: tLabel, + icon: tIcon + } = toolbarItem; + const id = tId ?? ''; + const args = { toolbar: true, ...tArgs }; + const icon = tIcon ? LabIcon.resolve({ icon: tIcon }) : undefined; + // If there is an icon, undefined label will results in no label + // otherwise the label will be set using the setting or the command label + const label = icon ?? commands.icon(id, args) ? tLabel ?? '' : tLabel; + return new CommandToolbarButton({ + commands, + id, + args, + icon, + label + }); + } + case 'spacer': + return Toolbar.createSpacerItem(); + default: + return new Widget(); + } + }; +} diff --git a/packages/apputils/src/toolbar.tsx b/packages/apputils/src/toolbar/widget.tsx similarity index 96% rename from packages/apputils/src/toolbar.tsx rename to packages/apputils/src/toolbar/widget.tsx index 91e243401e76..122fd922df07 100644 --- a/packages/apputils/src/toolbar.tsx +++ b/packages/apputils/src/toolbar/widget.tsx @@ -24,8 +24,8 @@ import { Message, MessageLoop } from '@lumino/messaging'; import { AttachedProperty } from '@lumino/properties'; import { PanelLayout, Widget } from '@lumino/widgets'; import * as React from 'react'; -import { ISessionContext, sessionContextDialogs } from './sessioncontext'; -import { ReactWidget, UseSignal } from './vdom'; +import { ISessionContext, sessionContextDialogs } from '../sessioncontext'; +import { ReactWidget, UseSignal } from '../vdom'; import { Throttler } from '@lumino/polling'; /** @@ -335,7 +335,10 @@ export class Toolbar extends Widget { /** * Handle a DOM click event. */ - protected handleClick(event: Event) { + protected handleClick(event: Event): void { + // Stop propagating the click outside the toolbar + event.stopPropagation(); + // Clicking a label focuses the corresponding control // that is linked with `for` attribute, so let it be. if (event.target instanceof HTMLLabelElement) { @@ -568,6 +571,9 @@ export class ReactiveToolbar extends Toolbar { export namespace Toolbar { /** * Create an interrupt toolbar item. + * + * @deprecated since version v3.2 + * This is dead code now. */ export function createInterruptButton( sessionContext: ISessionContext, @@ -586,6 +592,9 @@ export namespace Toolbar { /** * Create a restart toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. */ export function createRestartButton( sessionContext: ISessionContext, @@ -697,7 +706,9 @@ export namespace ToolbarButtonComponent { * * @param props - The props for ToolbarButtonComponent. */ -export function ToolbarButtonComponent(props: ToolbarButtonComponent.IProps) { +export function ToolbarButtonComponent( + props: ToolbarButtonComponent.IProps +): JSX.Element { // In some browsers, a button click event moves the focus from the main // content to the button (see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus). // We avoid a click event by calling preventDefault in mousedown, and @@ -876,9 +887,26 @@ export namespace CommandToolbarButtonComponent { * Interface for CommandToolbarButtonComponent props. */ export interface IProps { + /** + * Application commands registry + */ commands: CommandRegistry; + /** + * Command unique id + */ id: string; + /** + * Command arguments + */ args?: ReadonlyJSONObject; + /** + * Overrides command icon + */ + icon?: LabIcon; + /** + * Overrides command label + */ + label?: string; } } @@ -925,7 +953,7 @@ export class CommandToolbarButton extends ReactWidget { super(); addCommandToolbarButtonClass(this); } - render() { + render(): JSX.Element { return ; } } @@ -1112,7 +1140,7 @@ namespace Private { const iconLabel = commands.iconLabel(id, args); // DEPRECATED: remove _icon when lumino 2.0 is adopted // if icon is aliasing iconClass, don't use it - const _icon = commands.icon(id, args); + const _icon = options.icon ?? commands.icon(id, args); const icon = _icon === iconClass ? undefined : _icon; const label = commands.label(id, args); @@ -1124,7 +1152,9 @@ namespace Private { if (!commands.isVisible(id, args)) { className += ' lm-mod-hidden'; } - let tooltip = commands.caption(id, args) || label || iconLabel; + + let tooltip = + commands.caption(id, args) || options.label || label || iconLabel; // Shows hot keys in tooltips const binding = commands.keyBindings.find(b => b.command === id); if (binding) { @@ -1136,7 +1166,15 @@ namespace Private { }; const enabled = commands.isEnabled(id, args); - return { className, icon, iconClass, tooltip, onClick, enabled, label }; + return { + className, + icon, + iconClass, + tooltip, + onClick, + enabled, + label: options.label ?? label + }; } /** diff --git a/packages/apputils/test/toolbar.spec.ts b/packages/apputils/test/toolbar.spec.ts index c4658894f80d..fe2c388bbc85 100644 --- a/packages/apputils/test/toolbar.spec.ts +++ b/packages/apputils/test/toolbar.spec.ts @@ -18,7 +18,12 @@ import { CommandRegistry } from '@lumino/commands'; import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; import { PanelLayout, Widget } from '@lumino/widgets'; import { simulate } from 'simulate-event'; -import { bugDotIcon, bugIcon } from '@jupyterlab/ui-components'; +import { + blankIcon, + bugDotIcon, + bugIcon, + jupyterIcon +} from '@jupyterlab/ui-components'; const server = new JupyterServer(); @@ -31,18 +36,110 @@ afterAll(async () => { }); describe('@jupyterlab/apputils', () => { - let widget: Toolbar; + describe('CommandToolbarButton', () => { + let commands: CommandRegistry; + const id = 'test-command'; + const options: CommandRegistry.ICommandOptions = { + execute: jest.fn() + }; - beforeEach(async () => { - jest.setTimeout(20000); - widget = new Toolbar(); - }); + beforeEach(() => { + commands = new CommandRegistry(); + }); + + it('should render a command', async () => { + commands.addCommand(id, options); + const button = new CommandToolbarButton({ + commands, + id + }); + + Widget.attach(button, document.body); + await framePromise(); + + expect(button.hasClass('jp-CommandToolbarButton')).toBe(true); + simulate(button.node.firstElementChild!, 'mousedown'); + expect(options.execute).toBeCalledTimes(1); + }); + + it('should render the label command', async () => { + const label = 'This is a test label'; + commands.addCommand(id, { ...options, label }); + const button = new CommandToolbarButton({ + commands, + id + }); + + Widget.attach(button, document.body); + await framePromise(); + + expect(button.node.textContent).toMatch(label); + }); + + it('should render the customized label command', async () => { + const label = 'This is a test label'; + const buttonLabel = 'This is the button label'; + commands.addCommand(id, { ...options, label }); + const button = new CommandToolbarButton({ + commands, + id, + label: buttonLabel + }); + + Widget.attach(button, document.body); + await framePromise(); - afterEach(async () => { - widget.dispose(); + expect(button.node.textContent).toMatch(buttonLabel); + expect(button.node.textContent).not.toMatch(label); + }); + + it('should render the icon command', async () => { + const icon = jupyterIcon; + commands.addCommand(id, { ...options, icon }); + const button = new CommandToolbarButton({ + commands, + id + }); + + Widget.attach(button, document.body); + await framePromise(); + + expect(button.node.getElementsByTagName('svg')[0].dataset.icon).toMatch( + icon.name + ); + }); + + it('should render the customized icon command', async () => { + const icon = jupyterIcon; + const buttonIcon = blankIcon; + commands.addCommand(id, { ...options, icon }); + const button = new CommandToolbarButton({ + commands, + id, + icon: buttonIcon + }); + + Widget.attach(button, document.body); + await framePromise(); + + const iconSVG = button.node.getElementsByTagName('svg')[0]; + expect(iconSVG.dataset.icon).toMatch(buttonIcon.name); + expect(iconSVG.dataset.icon).not.toMatch(icon.name); + }); }); describe('Toolbar', () => { + let widget: Toolbar; + + beforeEach(async () => { + jest.setTimeout(20000); + widget = new Toolbar(); + }); + + afterEach(async () => { + widget.dispose(); + }); + describe('#constructor()', () => { it('should construct a new toolbar widget', () => { const widget = new Toolbar(); diff --git a/packages/apputils/tsconfig.json b/packages/apputils/tsconfig.json index 4beda3db4a88..386ee5868935 100644 --- a/packages/apputils/tsconfig.json +++ b/packages/apputils/tsconfig.json @@ -4,11 +4,14 @@ "outDir": "lib", "rootDir": "src" }, - "include": ["src/*"], + "include": ["src/**/*"], "references": [ { "path": "../coreutils" }, + { + "path": "../observables" + }, { "path": "../services" }, diff --git a/packages/apputils/tsconfig.test.json b/packages/apputils/tsconfig.test.json index 3074a70a8161..0906c34437d5 100644 --- a/packages/apputils/tsconfig.test.json +++ b/packages/apputils/tsconfig.test.json @@ -5,6 +5,9 @@ { "path": "../coreutils" }, + { + "path": "../observables" + }, { "path": "../services" }, @@ -29,6 +32,9 @@ { "path": "../coreutils" }, + { + "path": "../observables" + }, { "path": "../services" }, diff --git a/packages/csvviewer-extension/package.json b/packages/csvviewer-extension/package.json index 1e130622ee59..e76f0417b0f9 100644 --- a/packages/csvviewer-extension/package.json +++ b/packages/csvviewer-extension/package.json @@ -26,6 +26,7 @@ "lib/*.d.ts", "lib/*.js.map", "lib/*.js", + "schema/*.json", "style/**/*.css", "style/index.js" ], @@ -42,6 +43,7 @@ "@jupyterlab/docregistry": "^3.3.0-alpha.16", "@jupyterlab/documentsearch": "^3.3.0-alpha.16", "@jupyterlab/mainmenu": "^3.3.0-alpha.16", + "@jupyterlab/settingregistry": "^3.3.0-alpha.16", "@jupyterlab/translation": "^3.3.0-alpha.16", "@lumino/datagrid": "^0.20.0", "@lumino/signaling": "^1.4.3", @@ -56,7 +58,8 @@ "access": "public" }, "jupyterlab": { - "extension": true + "extension": true, + "schemaDir": "schema" }, "styleModule": "style/index.js" } diff --git a/packages/csvviewer-extension/schema/csv.json b/packages/csvviewer-extension/schema/csv.json new file mode 100644 index 000000000000..b3636b46c70c --- /dev/null +++ b/packages/csvviewer-extension/schema/csv.json @@ -0,0 +1,69 @@ +{ + "title": "CSV Viewer", + "description": "CSV Viewer settings.", + "jupyter.lab.toolbars": { + "CSVTable": [{ "name": "delimiter", "rank": 10 }] + }, + "jupyter.lab.transform": true, + "properties": { + "toolbar": { + "title": "CSV viewer toolbar items", + "description": "Note: To disable a toolbar item,\ncopy it to User Preferences and add the\n\"disabled\" key. The following example will disable the delimiter selector item:\n{\n \"toolbar\": [\n {\n \"name\": \"delimiter\",\n \"disabled\": true\n }\n ]\n}\n\nToolbar description:", + "items": { + "$ref": "#/definitions/toolbarItem" + }, + "type": "array", + "default": [] + } + }, + "additionalProperties": false, + "type": "object", + "definitions": { + "toolbarItem": { + "properties": { + "name": { + "title": "Unique name", + "type": "string" + }, + "args": { + "title": "Command arguments", + "type": "object" + }, + "command": { + "title": "Command id", + "type": "string", + "default": "" + }, + "disabled": { + "title": "Whether the item is ignored or not", + "type": "boolean", + "default": false + }, + "icon": { + "title": "Item icon id", + "description": "If defined, it will override the command icon", + "type": "string" + }, + "label": { + "title": "Item label", + "description": "If defined, it will override the command label", + "type": "string" + }, + "type": { + "title": "Item type", + "type": "string", + "enum": ["command", "spacer"] + }, + "rank": { + "title": "Item rank", + "type": "number", + "minimum": 0, + "default": 50 + } + }, + "required": ["name"], + "additionalProperties": false, + "type": "object" + } + } +} diff --git a/packages/csvviewer-extension/schema/tsv.json b/packages/csvviewer-extension/schema/tsv.json new file mode 100644 index 000000000000..6217ebaeb9e8 --- /dev/null +++ b/packages/csvviewer-extension/schema/tsv.json @@ -0,0 +1,69 @@ +{ + "title": "TSV Viewer", + "description": "TSV Viewer settings.", + "jupyter.lab.toolbars": { + "TSVTable": [{ "name": "delimiter", "rank": 10 }] + }, + "jupyter.lab.transform": true, + "properties": { + "toolbar": { + "title": "TSV viewer toolbar items", + "description": "Note: To disable a toolbar item,\ncopy it to User Preferences and add the\n\"disabled\" key. The following example will disable the delimiter selector item:\n{\n \"toolbar\": [\n {\n \"name\": \"delimiter\",\n \"disabled\": true\n }\n ]\n}\n\nToolbar description:", + "items": { + "$ref": "#/definitions/toolbarItem" + }, + "type": "array", + "default": [] + } + }, + "additionalProperties": false, + "type": "object", + "definitions": { + "toolbarItem": { + "properties": { + "name": { + "title": "Unique name", + "type": "string" + }, + "args": { + "title": "Command arguments", + "type": "object" + }, + "command": { + "title": "Command id", + "type": "string", + "default": "" + }, + "disabled": { + "title": "Whether the item is ignored or not", + "type": "boolean", + "default": false + }, + "icon": { + "title": "Item icon id", + "description": "If defined, it will override the command icon", + "type": "string" + }, + "label": { + "title": "Item label", + "description": "If defined, it will override the command label", + "type": "string" + }, + "type": { + "title": "Item type", + "type": "string", + "enum": ["command", "spacer"] + }, + "rank": { + "title": "Item rank", + "type": "number", + "minimum": 0, + "default": 50 + } + }, + "required": ["name"], + "additionalProperties": false, + "type": "object" + } + } +} diff --git a/packages/csvviewer-extension/src/index.ts b/packages/csvviewer-extension/src/index.ts index 405fe3fc8e50..90b7f508a959 100644 --- a/packages/csvviewer-extension/src/index.ts +++ b/packages/csvviewer-extension/src/index.ts @@ -11,19 +11,23 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { + createToolbarFactory, InputDialog, IThemeManager, + IToolbarWidgetRegistry, WidgetTracker } from '@jupyterlab/apputils'; import { + CSVDelimiter, CSVViewer, CSVViewerFactory, TextRenderConfig, TSVViewerFactory } from '@jupyterlab/csvviewer'; -import { IDocumentWidget } from '@jupyterlab/docregistry'; +import { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry'; import { ISearchProviderRegistry } from '@jupyterlab/documentsearch'; import { IEditMenu, IMainMenu } from '@jupyterlab/mainmenu'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator } from '@jupyterlab/translation'; import { DataGrid } from '@lumino/datagrid'; import { CSVSearchProvider } from './searchprovider'; @@ -45,7 +49,9 @@ const csv: JupyterFrontEndPlugin = { ILayoutRestorer, IThemeManager, IMainMenu, - ISearchProviderRegistry + ISearchProviderRegistry, + ISettingRegistry, + IToolbarWidgetRegistry ], autoStart: true }; @@ -61,7 +67,9 @@ const tsv: JupyterFrontEndPlugin = { ILayoutRestorer, IThemeManager, IMainMenu, - ISearchProviderRegistry + ISearchProviderRegistry, + ISettingRegistry, + IToolbarWidgetRegistry ], autoStart: true }; @@ -100,13 +108,42 @@ function activateCsv( restorer: ILayoutRestorer | null, themeManager: IThemeManager | null, mainMenu: IMainMenu | null, - searchregistry: ISearchProviderRegistry | null + searchregistry: ISearchProviderRegistry | null, + settingRegistry: ISettingRegistry | null, + toolbarRegistry: IToolbarWidgetRegistry | null ): void { + let toolbarFactory: + | ((widget: IDocumentWidget) => DocumentRegistry.IToolbarItem[]) + | undefined; + + if (toolbarRegistry) { + toolbarRegistry.registerFactory>( + FACTORY_CSV, + 'delimiter', + widget => + new CSVDelimiter({ + widget: widget.content, + translator + }) + ); + + if (settingRegistry) { + toolbarFactory = createToolbarFactory( + toolbarRegistry, + settingRegistry, + FACTORY_CSV, + csv.id, + translator + ); + } + } + const factory = new CSVViewerFactory({ name: FACTORY_CSV, fileTypes: ['csv'], defaultFor: ['csv'], readOnly: true, + toolbarFactory, translator }); const tracker = new WidgetTracker>({ @@ -182,13 +219,42 @@ function activateTsv( restorer: ILayoutRestorer | null, themeManager: IThemeManager | null, mainMenu: IMainMenu | null, - searchregistry: ISearchProviderRegistry | null + searchregistry: ISearchProviderRegistry | null, + settingRegistry: ISettingRegistry | null, + toolbarRegistry: IToolbarWidgetRegistry | null ): void { + let toolbarFactory: + | ((widget: IDocumentWidget) => DocumentRegistry.IToolbarItem[]) + | undefined; + + if (toolbarRegistry) { + toolbarRegistry.registerFactory>( + FACTORY_TSV, + 'delimiter', + widget => + new CSVDelimiter({ + widget: widget.content, + translator + }) + ); + + if (settingRegistry) { + toolbarFactory = createToolbarFactory( + toolbarRegistry, + settingRegistry, + FACTORY_TSV, + tsv.id, + translator + ); + } + } + const factory = new TSVViewerFactory({ name: FACTORY_TSV, fileTypes: ['tsv'], defaultFor: ['tsv'], readOnly: true, + toolbarFactory, translator }); const tracker = new WidgetTracker>({ diff --git a/packages/csvviewer-extension/tsconfig.json b/packages/csvviewer-extension/tsconfig.json index b6b130ee79b5..3ab5c0b1730e 100644 --- a/packages/csvviewer-extension/tsconfig.json +++ b/packages/csvviewer-extension/tsconfig.json @@ -24,6 +24,9 @@ { "path": "../mainmenu" }, + { + "path": "../settingregistry" + }, { "path": "../translation" } diff --git a/packages/csvviewer/src/toolbar.ts b/packages/csvviewer/src/toolbar.ts index 48d676a12827..e5757a5676a7 100644 --- a/packages/csvviewer/src/toolbar.ts +++ b/packages/csvviewer/src/toolbar.ts @@ -7,6 +7,7 @@ import { each } from '@lumino/algorithm'; import { Message } from '@lumino/messaging'; import { ISignal, Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; +import { CSVViewer } from './widget'; /** * The class name added to a csv toolbar widget. @@ -28,12 +29,18 @@ export class CSVDelimiter extends Widget { * Construct a new csv table widget. */ constructor(options: CSVToolbar.IOptions) { - super({ node: Private.createNode(options.selected, options.translator) }); + super({ + node: Private.createNode(options.widget.delimiter, options.translator) + }); + this._widget = options.widget; this.addClass(CSV_DELIMITER_CLASS); } /** * A signal emitted when the delimiter selection has changed. + * + * @deprecated since v3.2 + * This is dead code now. */ get delimiterChanged(): ISignal { return this._delimiterChanged; @@ -60,6 +67,7 @@ export class CSVDelimiter extends Widget { switch (event.type) { case 'change': this._delimiterChanged.emit(this.selectNode.value); + this._widget.delimiter = this.selectNode.value; break; default: break; @@ -81,6 +89,7 @@ export class CSVDelimiter extends Widget { } private _delimiterChanged = new Signal(this); + protected _widget: CSVViewer; } /** @@ -92,9 +101,9 @@ export namespace CSVToolbar { */ export interface IOptions { /** - * The initially selected delimiter. + * Document widget for this toolbar */ - selected: string; + widget: CSVViewer; /** * The application language translator. diff --git a/packages/csvviewer/src/widget.ts b/packages/csvviewer/src/widget.ts index 6ebd5ccdebb2..7575a66791f3 100644 --- a/packages/csvviewer/src/widget.ts +++ b/packages/csvviewer/src/widget.ts @@ -8,7 +8,6 @@ import { DocumentWidget, IDocumentWidget } from '@jupyterlab/docregistry'; -import { ITranslator } from '@jupyterlab/translation'; import { PromiseDelegate } from '@lumino/coreutils'; import { BasicKeyHandler, @@ -106,7 +105,7 @@ export class GridSearchService { /** * Clear the search. */ - clear() { + clear(): void { this._query = null; this._row = 0; this._column = -1; @@ -286,7 +285,7 @@ export class CSVViewer extends Widget { /** * A promise that resolves when the csv viewer is ready to be revealed. */ - get revealed() { + get revealed(): Promise { return this._revealed.promise; } @@ -342,7 +341,7 @@ export class CSVViewer extends Widget { /** * Go to line */ - goToLine(lineNumber: number) { + goToLine(lineNumber: number): void { this._grid.scrollToRow(lineNumber); } @@ -434,13 +433,6 @@ export class CSVDocumentWidget extends DocumentWidget { if (delimiter) { content.delimiter = delimiter; } - const csvDelimiter = new CSVDelimiter({ selected: content.delimiter }); - this.toolbar.addItem('delimiter', csvDelimiter); - csvDelimiter.delimiterChanged.connect( - (sender: CSVDelimiter, delimiter: string) => { - content!.delimiter = delimiter; - } - ); } /** @@ -476,19 +468,17 @@ export namespace CSVDocumentWidget { export interface IOptions extends DocumentWidget.IOptionsOptionalContent { - delimiter?: string; - /** - * The application language translator. + * Data delimiter character */ - translator?: ITranslator; + delimiter?: string; } } namespace Private { export function createContent( context: DocumentRegistry.IContext - ) { + ): CSVViewer { return new CSVViewer({ context }); } } @@ -508,14 +498,29 @@ export class CSVViewerFactory extends ABCWidgetFactory< const translator = this.translator; return new CSVDocumentWidget({ context, translator }); } + + /** + * Default factory for toolbar items to be added after the widget is created. + */ + protected defaultToolbarFactory( + widget: IDocumentWidget + ): DocumentRegistry.IToolbarItem[] { + return [ + { + name: 'delimiter', + widget: new CSVDelimiter({ + widget: widget.content, + translator: this.translator + }) + } + ]; + } } /** * A widget factory for TSV widgets. */ -export class TSVViewerFactory extends ABCWidgetFactory< - IDocumentWidget -> { +export class TSVViewerFactory extends CSVViewerFactory { /** * Create a new widget given a context. */ diff --git a/packages/csvviewer/test/toolbar.spec.ts b/packages/csvviewer/test/toolbar.spec.ts index a5e2200765a4..410b948946cd 100644 --- a/packages/csvviewer/test/toolbar.spec.ts +++ b/packages/csvviewer/test/toolbar.spec.ts @@ -3,15 +3,26 @@ import { Widget } from '@lumino/widgets'; import { simulate } from 'simulate-event'; -import { CSVDelimiter } from '../src'; +import { CSVDelimiter, CSVViewer } from '../src'; const DELIMITERS = [',', ';', '\t']; describe('csvviewer/toolbar', () => { + let delimiter = DELIMITERS[0]; + const mockViewer: jest.Mock = jest.fn().mockImplementation(() => { + return { + delimiter + }; + }); + + beforeEach(() => { + delimiter = DELIMITERS[0]; + }); + describe('CSVDelimiter', () => { describe('#constructor()', () => { it('should instantiate a `CSVDelimiter` toolbar widget', () => { - const widget = new CSVDelimiter({ selected: ',' }); + const widget = new CSVDelimiter({ widget: mockViewer() }); expect(widget).toBeInstanceOf(CSVDelimiter); expect(Array.from(widget.node.classList)).toEqual( expect.arrayContaining(['jp-CSVDelimiter']) @@ -20,8 +31,8 @@ describe('csvviewer/toolbar', () => { }); it('should allow pre-selecting the delimiter', () => { - const wanted = DELIMITERS[DELIMITERS.length - 1]; - const widget = new CSVDelimiter({ selected: wanted }); + const wanted = (delimiter = DELIMITERS[DELIMITERS.length - 1]); + const widget = new CSVDelimiter({ widget: mockViewer() }); expect(widget.selectNode.value).toBe(wanted); widget.dispose(); }); @@ -29,24 +40,36 @@ describe('csvviewer/toolbar', () => { describe('#delimiterChanged', () => { it('should emit a value when the dropdown value changes', () => { - const widget = new CSVDelimiter({ selected: ',' }); - let delimiter = ''; + const widget = new CSVDelimiter({ widget: mockViewer() }); + let delimiterTest = ''; const index = DELIMITERS.length - 1; const wanted = DELIMITERS[index]; widget.delimiterChanged.connect((s, value) => { - delimiter = value; + delimiterTest = value; }); Widget.attach(widget, document.body); widget.selectNode.selectedIndex = index; simulate(widget.selectNode, 'change'); - expect(delimiter).toBe(wanted); + expect(delimiterTest).toBe(wanted); + widget.dispose(); + }); + }); + + describe('#handleEvent', () => { + it('should change the delimiter', () => { + const viewer = mockViewer(); + const widget = new CSVDelimiter({ widget: viewer }); + const wanted = DELIMITERS[1]; + widget.selectNode.value = wanted; + widget.handleEvent({ type: 'change' } as any); + expect(viewer.delimiter).toBe(wanted); widget.dispose(); }); }); describe('#selectNode', () => { it('should return the delimiter dropdown select tag', () => { - const widget = new CSVDelimiter({ selected: ',' }); + const widget = new CSVDelimiter({ widget: mockViewer() }); expect(widget.selectNode.tagName.toLowerCase()).toBe('select'); widget.dispose(); }); @@ -54,14 +77,14 @@ describe('csvviewer/toolbar', () => { describe('#dispose()', () => { it('should dispose of the resources held by the widget', () => { - const widget = new CSVDelimiter({ selected: ',' }); + const widget = new CSVDelimiter({ widget: mockViewer() }); expect(widget.isDisposed).toBe(false); widget.dispose(); expect(widget.isDisposed).toBe(true); }); it('should be safe to call multiple times', () => { - const widget = new CSVDelimiter({ selected: ',' }); + const widget = new CSVDelimiter({ widget: mockViewer() }); expect(widget.isDisposed).toBe(false); widget.dispose(); widget.dispose(); diff --git a/packages/docmanager-extension/package.json b/packages/docmanager-extension/package.json index c3d0539d9b4d..0bbe4882f297 100644 --- a/packages/docmanager-extension/package.json +++ b/packages/docmanager-extension/package.json @@ -47,10 +47,14 @@ "@jupyterlab/settingregistry": "^3.3.0-alpha.16", "@jupyterlab/statusbar": "^3.3.0-alpha.16", "@jupyterlab/translation": "^3.3.0-alpha.16", + "@jupyterlab/ui-components": "^3.3.0-alpha.15", "@lumino/algorithm": "^1.3.3", + "@lumino/commands": "^1.12.0", "@lumino/coreutils": "^1.5.3", "@lumino/disposable": "^1.4.3", - "@lumino/widgets": "^1.19.0" + "@lumino/signaling": "^1.4.3", + "@lumino/widgets": "^1.19.0", + "react": "^17.0.1" }, "devDependencies": { "rimraf": "~3.0.0", diff --git a/packages/docmanager-extension/src/index.ts b/packages/docmanager-extension/src/index.tsx similarity index 96% rename from packages/docmanager-extension/src/index.ts rename to packages/docmanager-extension/src/index.tsx index 9e1e6c6397d3..fac8aadb2b63 100644 --- a/packages/docmanager-extension/src/index.ts +++ b/packages/docmanager-extension/src/index.tsx @@ -12,11 +12,15 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { + addCommandToolbarButtonClass, + CommandToolbarButtonComponent, Dialog, ICommandPalette, ISessionContextDialogs, + ReactWidget, showDialog, - showErrorMessage + showErrorMessage, + UseSignal } from '@jupyterlab/apputils'; import { IChangedArgs, Time } from '@jupyterlab/coreutils'; import { @@ -32,10 +36,14 @@ import { Contents, Kernel } from '@jupyterlab/services'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { IStatusBar } from '@jupyterlab/statusbar'; import { ITranslator, TranslationBundle } from '@jupyterlab/translation'; +import { saveIcon } from '@jupyterlab/ui-components'; import { each, map, some, toArray } from '@lumino/algorithm'; +import { CommandRegistry } from '@lumino/commands'; import { JSONExt } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; +import { ISignal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; +import * as React from 'react'; /** * The command IDs used by the document manager plugin. @@ -435,6 +443,35 @@ const plugins: JupyterFrontEndPlugin[] = [ ]; export default plugins; +/** + * Toolbar item factory + */ +export namespace ToolbarItems { + /** + * Create save button toolbar item. + * + */ + export function createSaveButton( + commands: CommandRegistry, + fileChanged: ISignal + ): Widget { + return addCommandToolbarButtonClass( + ReactWidget.create( + + {() => ( + + )} + + ) + ); + } +} + /* Widget to display the revert to checkpoint confirmation. */ class RevertConfirmWidget extends Widget { /** @@ -656,11 +693,13 @@ function addCommands( commands.addCommand(CommandIDs.save, { label: () => trans.__('Save %1', fileType(shell.currentWidget, docManager)), caption: trans.__('Save and create checkpoint'), + icon: args => (args.toolbar ? saveIcon : ''), isEnabled: isWritable, execute: () => { // Checks that shell.currentWidget is valid: if (isEnabled()) { - const context = docManager.contextForWidget(shell.currentWidget!); + const widget = shell.currentWidget; + const context = docManager.contextForWidget(widget!); if (!context) { return showDialog({ title: trans.__('Cannot Save'), @@ -678,7 +717,11 @@ function addCommands( return context .save() - .then(() => context!.createCheckpoint()) + .then(() => { + if (!widget?.isDisposed) { + return context!.createCheckpoint(); + } + }) .catch(err => { // If the save was canceled by user-action, do nothing. // FIXME-TRANS: Is this using the text on the button or? diff --git a/packages/docmanager-extension/style/index.css b/packages/docmanager-extension/style/index.css index ae93b9d67fe5..1c18f6a0218a 100644 --- a/packages/docmanager-extension/style/index.css +++ b/packages/docmanager-extension/style/index.css @@ -5,6 +5,7 @@ /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ @import url('~@lumino/widgets/style/index.css'); +@import url('~@jupyterlab/ui-components/style/index.css'); @import url('~@jupyterlab/apputils/style/index.css'); @import url('~@jupyterlab/statusbar/style/index.css'); @import url('~@jupyterlab/docregistry/style/index.css'); diff --git a/packages/docmanager-extension/style/index.js b/packages/docmanager-extension/style/index.js index 4d79825a3ed9..d1aea369dc99 100644 --- a/packages/docmanager-extension/style/index.js +++ b/packages/docmanager-extension/style/index.js @@ -5,6 +5,7 @@ /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ import '@lumino/widgets/style/index.js'; +import '@jupyterlab/ui-components/style/index.js'; import '@jupyterlab/apputils/style/index.js'; import '@jupyterlab/statusbar/style/index.js'; import '@jupyterlab/docregistry/style/index.js'; diff --git a/packages/docmanager-extension/tsconfig.json b/packages/docmanager-extension/tsconfig.json index 1a31f1d341b1..dc6f630de7b2 100644 --- a/packages/docmanager-extension/tsconfig.json +++ b/packages/docmanager-extension/tsconfig.json @@ -35,6 +35,9 @@ }, { "path": "../translation" + }, + { + "path": "../ui-components" } ] } diff --git a/packages/docregistry/src/default.ts b/packages/docregistry/src/default.ts index 3480a8203ba1..a5f902aa640e 100644 --- a/packages/docregistry/src/default.ts +++ b/packages/docregistry/src/default.ts @@ -586,6 +586,10 @@ export namespace DocumentWidget { U extends DocumentRegistry.IModel = DocumentRegistry.IModel > extends MainAreaWidget.IOptionsOptionalContent { context: DocumentRegistry.IContext; + + /** + * The application language translator. + */ translator?: ITranslator; } } diff --git a/packages/docregistry/src/registry.ts b/packages/docregistry/src/registry.ts index 95f5607eb3f7..b13a2b86daa2 100644 --- a/packages/docregistry/src/registry.ts +++ b/packages/docregistry/src/registry.ts @@ -1,7 +1,11 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { ISessionContext, Toolbar } from '@jupyterlab/apputils'; +import { + ISessionContext, + Toolbar, + ToolbarRegistry +} from '@jupyterlab/apputils'; import { CodeEditor } from '@jupyterlab/codeeditor'; import { IChangedArgs as IChangedArgsGeneric, @@ -712,10 +716,8 @@ export namespace DocumentRegistry { /** * The item to be added to document toolbar. */ - export interface IToolbarItem { - name: string; - widget: Widget; - } + export interface IToolbarItem extends ToolbarRegistry.IToolbarItem {} + /** * The options used to create a document registry. */ diff --git a/packages/fileeditor-extension/schema/plugin.json b/packages/fileeditor-extension/schema/plugin.json index 976454a4c429..2854fba46897 100644 --- a/packages/fileeditor-extension/schema/plugin.json +++ b/packages/fileeditor-extension/schema/plugin.json @@ -1,4 +1,6 @@ { + "title": "Text Editor", + "description": "Text editor settings.", "jupyter.lab.setting-icon": "ui-components:text-editor", "jupyter.lab.setting-icon-label": "Editor", "jupyter.lab.menus": { @@ -140,8 +142,44 @@ } ] }, - "title": "Text Editor", - "description": "Text editor settings.", + "jupyter.lab.toolbars": { + "Editor": [] + }, + "jupyter.lab.transform": true, + "properties": { + "editorConfig": { + "title": "Editor Configuration", + "description": "The configuration for all text editors.\nIf `fontFamily`, `fontSize` or `lineHeight` are `null`,\nvalues from current theme are used.", + "$ref": "#/definitions/editorConfig", + "default": { + "autoClosingBrackets": false, + "codeFolding": false, + "cursorBlinkRate": 530, + "fontFamily": null, + "fontSize": null, + "insertSpaces": true, + "lineHeight": null, + "lineNumbers": true, + "lineWrap": "on", + "matchBrackets": true, + "readOnly": false, + "rulers": [], + "tabSize": 4, + "wordWrapColumn": 80 + } + }, + "toolbar": { + "title": "Text editor toolbar items", + "description": "Note: To disable a toolbar item,\ncopy it to User Preferences and add the\n\"disabled\" key. Toolbar description:", + "items": { + "$ref": "#/definitions/toolbarItem" + }, + "type": "array", + "default": [] + } + }, + "additionalProperties": false, + "type": "object", "definitions": { "editorConfig": { "properties": { @@ -205,9 +243,6 @@ "type": "boolean", "default": false }, - "showTrailingSpace": { - "type": "boolean" - }, "tabSize": { "type": "number", "default": 4 @@ -219,32 +254,52 @@ }, "additionalProperties": false, "type": "object" + }, + "toolbarItem": { + "properties": { + "name": { + "title": "Unique name", + "type": "string" + }, + "args": { + "title": "Command arguments", + "type": "object" + }, + "command": { + "title": "Command id", + "type": "string", + "default": "" + }, + "disabled": { + "title": "Whether the item is ignored or not", + "type": "boolean", + "default": false + }, + "icon": { + "title": "Item icon id", + "description": "If defined, it will override the command icon", + "type": "string" + }, + "label": { + "title": "Item label", + "description": "If defined, it will override the command label", + "type": "string" + }, + "type": { + "title": "Item type", + "type": "string", + "enum": ["command", "spacer"] + }, + "rank": { + "title": "Item rank", + "type": "number", + "minimum": 0, + "default": 50 + } + }, + "required": ["name"], + "additionalProperties": false, + "type": "object" } - }, - "properties": { - "editorConfig": { - "title": "Editor Configuration", - "description": "The configuration for all text editors.\nIf `fontFamily`, `fontSize` or `lineHeight` are `null`,\nvalues from current theme are used.", - "$ref": "#/definitions/editorConfig", - "default": { - "autoClosingBrackets": false, - "codeFolding": false, - "cursorBlinkRate": 530, - "fontFamily": null, - "fontSize": null, - "insertSpaces": true, - "lineHeight": null, - "lineNumbers": true, - "lineWrap": "on", - "matchBrackets": true, - "readOnly": false, - "rulers": [], - "showTrailingSpace": false, - "tabSize": 4, - "wordWrapColumn": 80 - } - } - }, - "additionalProperties": false, - "type": "object" + } } diff --git a/packages/fileeditor-extension/src/commands.ts b/packages/fileeditor-extension/src/commands.ts index 785d289a9b69..95779f5cdc9a 100644 --- a/packages/fileeditor-extension/src/commands.ts +++ b/packages/fileeditor-extension/src/commands.ts @@ -1207,7 +1207,7 @@ export namespace Commands { menu.fileMenu.newMenu.addItem({ command: CommandIDs.createNew, args: ext, - rank: 30 + rank: 31 }); } } diff --git a/packages/fileeditor-extension/src/index.ts b/packages/fileeditor-extension/src/index.ts index fc3617c18646..10108f0d09f4 100644 --- a/packages/fileeditor-extension/src/index.ts +++ b/packages/fileeditor-extension/src/index.ts @@ -11,13 +11,15 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { + createToolbarFactory, ICommandPalette, ISessionContextDialogs, + IToolbarWidgetRegistry, WidgetTracker } from '@jupyterlab/apputils'; import { CodeEditor, IEditorServices } from '@jupyterlab/codeeditor'; import { IConsoleTracker } from '@jupyterlab/console'; -import { IDocumentWidget } from '@jupyterlab/docregistry'; +import { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry'; import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; import { FileEditor, @@ -54,7 +56,8 @@ const plugin: JupyterFrontEndPlugin = { ILauncher, IMainMenu, ILayoutRestorer, - ISessionContextDialogs + ISessionContextDialogs, + IToolbarWidgetRegistry ], provides: IEditorTracker, autoStart: true @@ -155,17 +158,34 @@ function activate( launcher: ILauncher | null, menu: IMainMenu | null, restorer: ILayoutRestorer | null, - sessionDialogs: ISessionContextDialogs | null + sessionDialogs: ISessionContextDialogs | null, + toolbarRegistry: IToolbarWidgetRegistry | null ): IEditorTracker { const id = plugin.id; const trans = translator.load('jupyterlab'); const namespace = 'editor'; + let toolbarFactory: + | ((widget: IDocumentWidget) => DocumentRegistry.IToolbarItem[]) + | undefined; + + if (toolbarRegistry) { + toolbarFactory = createToolbarFactory( + toolbarRegistry, + settingRegistry, + FACTORY, + id, + translator + ); + } + const factory = new FileEditorFactory({ editorServices, factoryOptions: { name: FACTORY, fileTypes: ['markdown', '*'], // Explicitly add the markdown fileType so - defaultFor: ['markdown', '*'] // it outranks the defaultRendered viewer. + defaultFor: ['markdown', '*'], // it outranks the defaultRendered viewer. + toolbarFactory, + translator } }); const { commands, restored, shell } = app; diff --git a/packages/htmlviewer-extension/package.json b/packages/htmlviewer-extension/package.json index 3bcf7eee3165..626849bfd5be 100644 --- a/packages/htmlviewer-extension/package.json +++ b/packages/htmlviewer-extension/package.json @@ -24,6 +24,7 @@ "style": "style/index.css", "files": [ "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", + "schema/*.json", "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}", "style/index.js" ], @@ -37,6 +38,7 @@ "@jupyterlab/apputils": "^3.3.0-alpha.16", "@jupyterlab/docregistry": "^3.3.0-alpha.16", "@jupyterlab/htmlviewer": "^3.3.0-alpha.16", + "@jupyterlab/settingregistry": "^3.3.0-alpha.16", "@jupyterlab/translation": "^3.3.0-alpha.16", "@jupyterlab/ui-components": "^3.3.0-alpha.15" }, @@ -48,7 +50,8 @@ "access": "public" }, "jupyterlab": { - "extension": true + "extension": true, + "schemaDir": "schema" }, "styleModule": "style/index.js" } diff --git a/packages/htmlviewer-extension/schema/plugin.json b/packages/htmlviewer-extension/schema/plugin.json new file mode 100644 index 000000000000..3c22f62921c8 --- /dev/null +++ b/packages/htmlviewer-extension/schema/plugin.json @@ -0,0 +1,72 @@ +{ + "title": "HTML Viewer", + "description": "HTML Viewer settings.", + "jupyter.lab.toolbars": { + "HTML Viewer": [ + { "name": "refresh", "rank": 10 }, + { "name": "trust", "rank": 20 } + ] + }, + "jupyter.lab.transform": true, + "properties": { + "toolbar": { + "title": "HTML viewer toolbar items", + "description": "Note: To disable a toolbar item,\ncopy it to User Preferences and add the\n\"disabled\" key. The following example will disable the refresh item:\n{\n \"toolbar\": [\n {\n \"name\": \"refresh\",\n \"disabled\": true\n }\n ]\n}\n\nToolbar description:", + "items": { + "$ref": "#/definitions/toolbarItem" + }, + "type": "array", + "default": [] + } + }, + "additionalProperties": false, + "type": "object", + "definitions": { + "toolbarItem": { + "properties": { + "name": { + "title": "Unique name", + "type": "string" + }, + "args": { + "title": "Command arguments", + "type": "object" + }, + "command": { + "title": "Command id", + "type": "string", + "default": "" + }, + "disabled": { + "title": "Whether the item is ignored or not", + "type": "boolean", + "default": false + }, + "icon": { + "title": "Item icon id", + "description": "If defined, it will override the command icon", + "type": "string" + }, + "label": { + "title": "Item label", + "description": "If defined, it will override the command label", + "type": "string" + }, + "type": { + "title": "Item type", + "type": "string", + "enum": ["command", "spacer"] + }, + "rank": { + "title": "Item rank", + "type": "number", + "minimum": 0, + "default": 50 + } + }, + "required": ["name"], + "additionalProperties": false, + "type": "object" + } + } +} diff --git a/packages/htmlviewer-extension/src/index.tsx b/packages/htmlviewer-extension/src/index.tsx index 25a012c53567..29a0d9c299dc 100644 --- a/packages/htmlviewer-extension/src/index.tsx +++ b/packages/htmlviewer-extension/src/index.tsx @@ -12,16 +12,28 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { ICommandPalette, WidgetTracker } from '@jupyterlab/apputils'; +import { + createToolbarFactory, + ICommandPalette, + IToolbarWidgetRegistry, + WidgetTracker +} from '@jupyterlab/apputils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { HTMLViewer, HTMLViewerFactory, - IHTMLViewerTracker + IHTMLViewerTracker, + ToolbarItems } from '@jupyterlab/htmlviewer'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator } from '@jupyterlab/translation'; import { html5Icon } from '@jupyterlab/ui-components'; +/** + * Factory name + */ +const FACTORY = 'HTML Viewer'; + /** * Command IDs used by the plugin. */ @@ -37,7 +49,12 @@ const htmlPlugin: JupyterFrontEndPlugin = { id: '@jupyterlab/htmlviewer-extension:plugin', provides: IHTMLViewerTracker, requires: [ITranslator], - optional: [ICommandPalette, ILayoutRestorer], + optional: [ + ICommandPalette, + ILayoutRestorer, + ISettingRegistry, + IToolbarWidgetRegistry + ], autoStart: true }; @@ -48,10 +65,35 @@ function activateHTMLViewer( app: JupyterFrontEnd, translator: ITranslator, palette: ICommandPalette | null, - restorer: ILayoutRestorer | null + restorer: ILayoutRestorer | null, + settingRegistry: ISettingRegistry | null, + toolbarRegistry: IToolbarWidgetRegistry | null ): IHTMLViewerTracker { - // Add an HTML file type to the docregistry. + let toolbarFactory: + | ((widget: HTMLViewer) => DocumentRegistry.IToolbarItem[]) + | undefined; const trans = translator.load('jupyterlab'); + + if (toolbarRegistry) { + toolbarRegistry.registerFactory(FACTORY, 'refresh', widget => + ToolbarItems.createRefreshButton(widget, translator) + ); + toolbarRegistry.registerFactory(FACTORY, 'trust', widget => + ToolbarItems.createTrustButton(widget, translator) + ); + + if (settingRegistry) { + toolbarFactory = createToolbarFactory( + toolbarRegistry, + settingRegistry, + FACTORY, + htmlPlugin.id, + translator + ); + } + } + + // Add an HTML file type to the docregistry. const ft: DocumentRegistry.IFileType = { name: 'html', contentType: 'file', @@ -65,10 +107,12 @@ function activateHTMLViewer( // Create a new viewer factory. const factory = new HTMLViewerFactory({ - name: trans.__('HTML Viewer'), + name: FACTORY, fileTypes: ['html'], defaultFor: ['html'], - readOnly: true + readOnly: true, + toolbarFactory, + translator }); // Create a widget tracker for HTML documents. @@ -108,6 +152,10 @@ function activateHTMLViewer( // allowing script executions in its context. app.commands.addCommand(CommandIDs.trustHTML, { label: trans.__('Trust HTML File'), + caption: trans.__(`Whether the HTML file is trusted. + Trusting the file allows scripts to run in it, + which may result in security risks. + Only enable for files you trust.`), isEnabled: () => !!tracker.currentWidget, isToggled: () => { const current = tracker.currentWidget; diff --git a/packages/htmlviewer-extension/tsconfig.json b/packages/htmlviewer-extension/tsconfig.json index dcdf5f091711..b84b72b78c56 100644 --- a/packages/htmlviewer-extension/tsconfig.json +++ b/packages/htmlviewer-extension/tsconfig.json @@ -18,6 +18,9 @@ { "path": "../htmlviewer" }, + { + "path": "../settingregistry" + }, { "path": "../translation" }, diff --git a/packages/htmlviewer/package.json b/packages/htmlviewer/package.json index 840acf6e7432..5dde9ec5ed3b 100644 --- a/packages/htmlviewer/package.json +++ b/packages/htmlviewer/package.json @@ -39,6 +39,7 @@ "@jupyterlab/ui-components": "^3.3.0-alpha.15", "@lumino/coreutils": "^1.5.3", "@lumino/signaling": "^1.4.3", + "@lumino/widgets": "^1.19.0", "react": "^17.0.1" }, "devDependencies": { diff --git a/packages/htmlviewer/src/index.tsx b/packages/htmlviewer/src/index.tsx index 5d827e73773a..fed8fca83320 100644 --- a/packages/htmlviewer/src/index.tsx +++ b/packages/htmlviewer/src/index.tsx @@ -26,6 +26,7 @@ import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { refreshIcon } from '@jupyterlab/ui-components'; import { Token } from '@lumino/coreutils'; import { ISignal, Signal } from '@lumino/signaling'; +import { Widget } from '@lumino/widgets'; import * as React from 'react'; /** @@ -74,7 +75,6 @@ export class HTMLViewer content: new IFrame({ sandbox: ['allow-same-origin'] }) }); this.translator = options.translator || nullTranslator; - const trans = this.translator.load('jupyterlab'); this.content.addClass(CSS_CLASS); void this.context.ready.then(() => { @@ -86,31 +86,6 @@ export class HTMLViewer }); this._monitor.activityStopped.connect(this.update, this); }); - - // Make a refresh button for the toolbar. - this.toolbar.addItem( - 'refresh', - new ToolbarButton({ - icon: refreshIcon, - onClick: async () => { - if (!this.context.model.dirty) { - await this.context.revert(); - this.update(); - } - }, - tooltip: trans.__('Rerender HTML Document') - }) - ); - // Make a trust button for the toolbar. - this.toolbar.addItem( - 'trust', - ReactWidget.create( - - ) - ); } /** @@ -234,6 +209,73 @@ export class HTMLViewerFactory extends ABCWidgetFactory { protected createNewWidget(context: DocumentRegistry.Context): HTMLViewer { return new HTMLViewer({ context }); } + + /** + * Default factory for toolbar items to be added after the widget is created. + */ + protected defaultToolbarFactory( + widget: HTMLViewer + ): DocumentRegistry.IToolbarItem[] { + return [ + // Make a refresh button for the toolbar. + { + name: 'refresh', + widget: ToolbarItems.createRefreshButton(widget, this.translator) + }, + // Make a trust button for the toolbar. + { + name: 'trust', + widget: ToolbarItems.createTrustButton(widget, this.translator) + } + ]; + } +} + +/** + * A namespace for toolbar items generator + */ +export namespace ToolbarItems { + /** + * Create the refresh button + * + * @param widget HTML viewer widget + * @param translator Application translator object + * @returns Toolbar item button + */ + export function createRefreshButton( + widget: HTMLViewer, + translator?: ITranslator + ): Widget { + const trans = (translator ?? nullTranslator).load('jupyterlab'); + return new ToolbarButton({ + icon: refreshIcon, + onClick: async () => { + if (!widget.context.model.dirty) { + await widget.context.revert(); + widget.update(); + } + }, + tooltip: trans.__('Rerender HTML Document') + }); + } + /** + * Create the trust button + * + * @param document HTML viewer widget + * @param translator Application translator object + * @returns Toolbar item button + */ + export function createTrustButton( + document: HTMLViewer, + translator: ITranslator + ): Widget { + return ReactWidget.create( + + ); + } } /** @@ -272,7 +314,9 @@ namespace Private { * * This wraps the ToolbarButtonComponent and watches for trust changes. */ - export function TrustButtonComponent(props: TrustButtonComponent.IProps) { + export function TrustButtonComponent( + props: TrustButtonComponent.IProps + ): JSX.Element { const translator = props.translator || nullTranslator; const trans = translator.load('jupyterlab'); return ( @@ -280,7 +324,7 @@ namespace Private { signal={props.htmlDocument.trustedChanged} initialSender={props.htmlDocument} > - {session => ( + {() => ( diff --git a/packages/htmlviewer/style/index.css b/packages/htmlviewer/style/index.css index c845303e874b..98d5e9c91227 100644 --- a/packages/htmlviewer/style/index.css +++ b/packages/htmlviewer/style/index.css @@ -4,6 +4,7 @@ |----------------------------------------------------------------------------*/ /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ +@import url('~@lumino/widgets/style/index.css'); @import url('~@jupyterlab/ui-components/style/index.css'); @import url('~@jupyterlab/apputils/style/index.css'); @import url('~@jupyterlab/docregistry/style/index.css'); diff --git a/packages/htmlviewer/style/index.js b/packages/htmlviewer/style/index.js index b3e161e5ff8c..698de7a5d912 100644 --- a/packages/htmlviewer/style/index.js +++ b/packages/htmlviewer/style/index.js @@ -4,6 +4,7 @@ |----------------------------------------------------------------------------*/ /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ +import '@lumino/widgets/style/index.js'; import '@jupyterlab/ui-components/style/index.js'; import '@jupyterlab/apputils/style/index.js'; import '@jupyterlab/docregistry/style/index.js'; diff --git a/packages/mainmenu-extension/package.json b/packages/mainmenu-extension/package.json index cf64f073e6a1..8c5c5a9ffc81 100644 --- a/packages/mainmenu-extension/package.json +++ b/packages/mainmenu-extension/package.json @@ -44,6 +44,7 @@ "@jupyterlab/services": "^6.3.0-alpha.16", "@jupyterlab/settingregistry": "^3.3.0-alpha.16", "@jupyterlab/translation": "^3.3.0-alpha.16", + "@jupyterlab/ui-components": "^3.3.0-alpha.15", "@lumino/algorithm": "^1.3.3", "@lumino/coreutils": "^1.5.3", "@lumino/disposable": "^1.4.3", diff --git a/packages/mainmenu-extension/src/index.ts b/packages/mainmenu-extension/src/index.ts index 5da26a94812d..8fd82df898bf 100644 --- a/packages/mainmenu-extension/src/index.ts +++ b/packages/mainmenu-extension/src/index.ts @@ -33,6 +33,12 @@ import { import { ServerConnection } from '@jupyterlab/services'; import { ISettingRegistry, SettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator, TranslationBundle } from '@jupyterlab/translation'; +import { + fastForwardIcon, + refreshIcon, + runIcon, + stopIcon +} from '@jupyterlab/ui-components'; import { each, find } from '@lumino/algorithm'; import { JSONExt } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; @@ -463,6 +469,8 @@ export function createKernelMenu( commands.addCommand(CommandIDs.interruptKernel, { label: trans.__('Interrupt Kernel'), + caption: trans.__('Interrupt the kernel'), + icon: args => (args.toolbar ? stopIcon : undefined), isEnabled: Private.delegateEnabled( app, menu.kernelUsers, @@ -483,6 +491,8 @@ export function createKernelMenu( commands.addCommand(CommandIDs.restartKernel, { label: trans.__('Restart Kernel…'), + caption: trans.__('Restart the kernel'), + icon: args => (args.toolbar ? refreshIcon : undefined), isEnabled: Private.delegateEnabled(app, menu.kernelUsers, 'restartKernel'), execute: Private.delegateExecute(app, menu.kernelUsers, 'restartKernel') }); @@ -634,6 +644,16 @@ export function createRunMenu( const enabled = Private.delegateEnabled(app, menu.codeRunners, 'run')(); return enabled ? localizedLabel : trans.__('Run Selected'); }, + caption: () => { + const localizedCaption = Private.delegateLabel( + app, + menu.codeRunners, + 'runCaption' + ); + const enabled = Private.delegateEnabled(app, menu.codeRunners, 'run')(); + return enabled ? localizedCaption : trans.__('Run Selected'); + }, + icon: args => (args.toolbar ? runIcon : undefined), isEnabled: Private.delegateEnabled(app, menu.codeRunners, 'run'), execute: Private.delegateExecute(app, menu.codeRunners, 'run') }); @@ -655,6 +675,22 @@ export function createRunMenu( } return localizedLabel; }, + caption: () => { + let localizedCaption = trans.__('Run All'); + const enabled = Private.delegateEnabled( + app, + menu.codeRunners, + 'runAll' + )(); + if (enabled) { + localizedCaption = Private.delegateLabel( + app, + menu.codeRunners, + 'runAllCaption' + ); + } + return localizedCaption; + }, isEnabled: Private.delegateEnabled(app, menu.codeRunners, 'runAll'), execute: Private.delegateExecute(app, menu.codeRunners, 'runAll') }); @@ -675,6 +711,23 @@ export function createRunMenu( } return localizedLabel; }, + caption: () => { + let localizedCaption = trans.__('Restart Kernel and Run All'); + const enabled = Private.delegateEnabled( + app, + menu.codeRunners, + 'restartAndRunAll' + )(); + if (enabled) { + localizedCaption = Private.delegateLabel( + app, + menu.codeRunners, + 'restartAndRunAllLabel' + ); + } + return localizedCaption; + }, + icon: args => (args.toolbar ? fastForwardIcon : undefined), isEnabled: Private.delegateEnabled( app, menu.codeRunners, @@ -945,9 +998,11 @@ namespace Private { const defaults = canonical.properties?.menus?.default ?? []; const user = { + ...plugin.data.user, menus: plugin.data.user.menus ?? [] }; const composite = { + ...plugin.data.composite, menus: SettingRegistry.reconcileMenus( defaults as ISettingRegistry.IMenu[], user.menus as ISettingRegistry.IMenu[] diff --git a/packages/mainmenu-extension/style/index.css b/packages/mainmenu-extension/style/index.css index 95257e61ca51..b7a4e62ec72d 100644 --- a/packages/mainmenu-extension/style/index.css +++ b/packages/mainmenu-extension/style/index.css @@ -5,6 +5,7 @@ /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ @import url('~@lumino/widgets/style/index.css'); +@import url('~@jupyterlab/ui-components/style/index.css'); @import url('~@jupyterlab/apputils/style/index.css'); @import url('~@jupyterlab/application/style/index.css'); @import url('~@jupyterlab/mainmenu/style/index.css'); diff --git a/packages/mainmenu-extension/style/index.js b/packages/mainmenu-extension/style/index.js index b5571d42355b..244bbd06b515 100644 --- a/packages/mainmenu-extension/style/index.js +++ b/packages/mainmenu-extension/style/index.js @@ -5,6 +5,7 @@ /* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ import '@lumino/widgets/style/index.js'; +import '@jupyterlab/ui-components/style/index.js'; import '@jupyterlab/apputils/style/index.js'; import '@jupyterlab/application/style/index.js'; import '@jupyterlab/mainmenu/style/index.js'; diff --git a/packages/mainmenu-extension/tsconfig.json b/packages/mainmenu-extension/tsconfig.json index 9c27850dc1a6..3e1ce91d3447 100644 --- a/packages/mainmenu-extension/tsconfig.json +++ b/packages/mainmenu-extension/tsconfig.json @@ -26,6 +26,9 @@ }, { "path": "../translation" + }, + { + "path": "../ui-components" } ] } diff --git a/packages/mainmenu/src/run.ts b/packages/mainmenu/src/run.ts index ece48321c270..d540e510878d 100644 --- a/packages/mainmenu/src/run.ts +++ b/packages/mainmenu/src/run.ts @@ -56,6 +56,14 @@ export namespace IRunMenu { * registered with the Run menu. */ export interface ICodeRunner extends IMenuExtender { + /** + * Return the caption associated to the `run` function. + * + * This function receives the number of items `n` to be able to provided + * correct pluralized forms of translations. + */ + runCaption?: (n: number) => string; + /** * Return the label associated to the `run` function. * @@ -65,7 +73,15 @@ export namespace IRunMenu { runLabel?: (n: number) => string; /** - * Return the label associated to the `runAllLabel` function. + * Return the caption associated to the `runAll` function. + * + * This function receives the number of items `n` to be able to provided + * correct pluralized forms of translations. + */ + runAllCaption?: (n: number) => string; + + /** + * Return the label associated to the `runAll` function. * * This function receives the number of items `n` to be able to provided * correct pluralized forms of translations. @@ -73,7 +89,15 @@ export namespace IRunMenu { runAllLabel?: (n: number) => string; /** - * Return the label associated to the `restartAndRunAllLabel` function. + * Return the caption associated to the `restartAndRunAll` function. + * + * This function receives the number of items `n` to be able to provided + * correct pluralized forms of translations. + */ + restartAndRunAllCaption?: (n: number) => string; + + /** + * Return the label associated to the `restartAndRunAll` function. * * This function receives the number of items `n` to be able to provided * correct pluralized forms of translations. diff --git a/packages/notebook-extension/package.json b/packages/notebook-extension/package.json index 04498fd95049..f46478fc1d35 100644 --- a/packages/notebook-extension/package.json +++ b/packages/notebook-extension/package.json @@ -42,6 +42,8 @@ "@jupyterlab/codeeditor": "^3.3.0-alpha.16", "@jupyterlab/coreutils": "^5.3.0-alpha.16", "@jupyterlab/docmanager": "^3.3.0-alpha.16", + "@jupyterlab/docmanager-extension": "^3.3.0-alpha.16", + "@jupyterlab/docregistry": "^3.3.0-alpha.16", "@jupyterlab/filebrowser": "^3.3.0-alpha.16", "@jupyterlab/launcher": "^3.3.0-alpha.16", "@jupyterlab/logconsole": "^3.3.0-alpha.16", diff --git a/packages/notebook-extension/schema/panel.json b/packages/notebook-extension/schema/panel.json new file mode 100644 index 000000000000..fae3db3ce825 --- /dev/null +++ b/packages/notebook-extension/schema/panel.json @@ -0,0 +1,87 @@ +{ + "title": "Notebook Panel", + "description": "Notebook Panel settings.", + "jupyter.lab.toolbars": { + "Notebook": [ + { "name": "save", "rank": 10 }, + { "name": "insert", "command": "notebook:insert-cell-below", "rank": 20 }, + { "name": "cut", "command": "notebook:cut-cell", "rank": 21 }, + { "name": "copy", "command": "notebook:copy-cell", "rank": 22 }, + { "name": "paste", "command": "notebook:paste-cell-below", "rank": 23 }, + { "name": "run", "command": "runmenu:run", "rank": 30 }, + { "name": "interrupt", "command": "kernelmenu:interrupt", "rank": 31 }, + { "name": "restart", "command": "kernelmenu:restart", "rank": 32 }, + { + "name": "restart-and-run", + "command": "runmenu:restart-and-run-all", + "rank": 33 + }, + { "name": "cellType", "rank": 40 }, + { "name": "spacer", "type": "spacer", "rank": 100 }, + { "name": "kernelName", "rank": 1000 }, + { "name": "kernelStatus", "rank": 1001 } + ] + }, + "jupyter.lab.transform": true, + "properties": { + "toolbar": { + "title": "Notebook panel toolbar items", + "description": "Note: To disable a toolbar item,\ncopy it to User Preferences and add the\n\"disabled\" key. The following example will disable the Interrupt button item:\n{\n \"toolbar\": [\n {\n \"name\": \"interrupt\",\n \"disabled\": true\n }\n ]\n}\n\nToolbar description:", + "items": { + "$ref": "#/definitions/toolbarItem" + }, + "type": "array", + "default": [] + } + }, + "additionalProperties": false, + "type": "object", + "definitions": { + "toolbarItem": { + "properties": { + "name": { + "title": "Unique name", + "type": "string" + }, + "args": { + "title": "Command arguments", + "type": "object" + }, + "command": { + "title": "Command id", + "type": "string", + "default": "" + }, + "disabled": { + "title": "Whether the item is ignored or not", + "type": "boolean", + "default": false + }, + "icon": { + "title": "Item icon id", + "description": "If defined, it will override the command icon", + "type": "string" + }, + "label": { + "title": "Item label", + "description": "If defined, it will override the command label", + "type": "string" + }, + "type": { + "title": "Item type", + "type": "string", + "enum": ["command", "spacer"] + }, + "rank": { + "title": "Item rank", + "type": "number", + "minimum": 0, + "default": 50 + } + }, + "required": ["name"], + "additionalProperties": false, + "type": "object" + } + } +} diff --git a/packages/notebook-extension/src/index.ts b/packages/notebook-extension/src/index.ts index 0a421d5add7e..31b2625d4f2f 100644 --- a/packages/notebook-extension/src/index.ts +++ b/packages/notebook-extension/src/index.ts @@ -10,30 +10,27 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; - import { + createToolbarFactory, Dialog, ICommandPalette, InputDialog, ISessionContextDialogs, + IToolbarWidgetRegistry, MainAreaWidget, sessionContextDialogs, showDialog, + Toolbar, WidgetTracker } from '@jupyterlab/apputils'; - import { Cell, CodeCell, ICellModel, MarkdownCell } from '@jupyterlab/cells'; - import { IEditorServices } from '@jupyterlab/codeeditor'; - import { PageConfig } from '@jupyterlab/coreutils'; - import { IDocumentManager } from '@jupyterlab/docmanager'; - +import { ToolbarItems as DocToolbarItems } from '@jupyterlab/docmanager-extension'; +import { DocumentRegistry } from '@jupyterlab/docregistry'; import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; - import { ILauncher } from '@jupyterlab/launcher'; - import { IEditMenu, IFileMenu, @@ -43,9 +40,7 @@ import { IRunMenu, IViewMenu } from '@jupyterlab/mainmenu'; - import * as nbformat from '@jupyterlab/nbformat'; - import { CommandEditStatus, INotebookTools, @@ -59,31 +54,29 @@ import { NotebookTracker, NotebookTrustStatus, NotebookWidgetFactory, - StaticNotebook + StaticNotebook, + ToolbarItems } from '@jupyterlab/notebook'; import { IObservableList, IObservableUndoableList } from '@jupyterlab/observables'; - import { IPropertyInspectorProvider } from '@jupyterlab/property-inspector'; - import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; - import { ISettingRegistry } from '@jupyterlab/settingregistry'; - import { IStateDB } from '@jupyterlab/statedb'; - import { IStatusBar } from '@jupyterlab/statusbar'; - import { ITranslator, nullTranslator } from '@jupyterlab/translation'; - -import { buildIcon, notebookIcon } from '@jupyterlab/ui-components'; - +import { + addIcon, + buildIcon, + copyIcon, + cutIcon, + notebookIcon, + pasteIcon +} from '@jupyterlab/ui-components'; import { ArrayExt } from '@lumino/algorithm'; - import { CommandRegistry } from '@lumino/commands'; - import { JSONExt, JSONObject, @@ -92,13 +85,9 @@ import { ReadonlyPartialJSONObject, UUID } from '@lumino/coreutils'; - import { DisposableSet } from '@lumino/disposable'; - import { Message, MessageLoop } from '@lumino/messaging'; - import { Menu, Panel } from '@lumino/widgets'; - import { logNotebookOutput } from './nboutput'; /** @@ -274,6 +263,11 @@ const FACTORY = 'Notebook'; */ const FORMAT_EXCLUDE = ['notebook', 'python', 'custom']; +/** + * Setting Id storing the customized toolbar definition. + */ +const PANEL_SETTINGS = '@jupyterlab/notebook-extension:panel'; + /** * The notebook widget tracker provider. */ @@ -521,8 +515,10 @@ const widgetFactoryPlugin: JupyterFrontEndPlugin IEditorServices, IRenderMimeRegistry, ISessionContextDialogs, + IToolbarWidgetRegistry, ITranslator ], + optional: [ISettingRegistry], activate: activateWidgetFactory, autoStart: true }; @@ -700,8 +696,46 @@ function activateWidgetFactory( editorServices: IEditorServices, rendermime: IRenderMimeRegistry, sessionContextDialogs: ISessionContextDialogs, - translator: ITranslator + toolbarRegistry: IToolbarWidgetRegistry, + translator: ITranslator, + settingRegistry: ISettingRegistry | null ): NotebookWidgetFactory.IFactory { + const { commands } = app; + let toolbarFactory: + | ((widget: NotebookPanel) => DocumentRegistry.IToolbarItem[]) + | undefined; + + // Register notebook toolbar widgets + toolbarRegistry.registerFactory(FACTORY, 'save', panel => + DocToolbarItems.createSaveButton(commands, panel.context.fileChanged) + ); + toolbarRegistry.registerFactory(FACTORY, 'cellType', panel => + ToolbarItems.createCellTypeItem(panel, translator) + ); + toolbarRegistry.registerFactory(FACTORY, 'kernelName', panel => + Toolbar.createKernelNameItem( + panel.sessionContext, + sessionContextDialogs, + translator + ) + ); + toolbarRegistry.registerFactory( + FACTORY, + 'kernelStatus', + panel => Toolbar.createKernelStatusItem(panel.sessionContext, translator) + ); + + if (settingRegistry) { + // Create the factory + toolbarFactory = createToolbarFactory( + toolbarRegistry, + settingRegistry, + FACTORY, + PANEL_SETTINGS, + translator + ); + } + const factory = new NotebookWidgetFactory({ name: FACTORY, fileTypes: ['notebook'], @@ -709,12 +743,13 @@ function activateWidgetFactory( defaultFor: ['notebook'], preferKernel: true, canStartKernel: true, - rendermime: rendermime, + rendermime, contentFactory, editorConfig: StaticNotebook.defaultEditorConfig, notebookConfig: StaticNotebook.defaultNotebookConfig, mimeTypeService: editorServices.mimeTypeService, sessionDialogs: sessionContextDialogs, + toolbarFactory, translator: translator }); app.docRegistry.addWidgetFactory(factory); @@ -1664,6 +1699,7 @@ function addCommands( }); commands.addCommand(CommandIDs.cut, { label: trans.__('Cut Cells'), + caption: trans.__('Cut the selected cells'), execute: args => { const current = getCurrent(tracker, shell, args); @@ -1671,10 +1707,12 @@ function addCommands( return NotebookActions.cut(current.content); } }, + icon: args => (args.toolbar ? cutIcon : undefined), isEnabled }); commands.addCommand(CommandIDs.copy, { label: trans.__('Copy Cells'), + caption: trans.__('Copy the selected cells'), execute: args => { const current = getCurrent(tracker, shell, args); @@ -1682,10 +1720,12 @@ function addCommands( return NotebookActions.copy(current.content); } }, + icon: args => (args.toolbar ? copyIcon : ''), isEnabled }); commands.addCommand(CommandIDs.pasteBelow, { label: trans.__('Paste Cells Below'), + caption: trans.__('Paste cells from the clipboard'), execute: args => { const current = getCurrent(tracker, shell, args); @@ -1693,6 +1733,7 @@ function addCommands( return NotebookActions.paste(current.content, 'below'); } }, + icon: args => (args.toolbar ? pasteIcon : undefined), isEnabled }); commands.addCommand(CommandIDs.pasteAbove, { @@ -1785,6 +1826,7 @@ function addCommands( }); commands.addCommand(CommandIDs.insertBelow, { label: trans.__('Insert Cell Below'), + caption: trans.__('Insert a cell below'), execute: args => { const current = getCurrent(tracker, shell, args); @@ -1792,6 +1834,7 @@ function addCommands( return NotebookActions.insertBelow(current.content); } }, + icon: args => (args.toolbar ? addIcon : undefined), isEnabled }); commands.addCommand(CommandIDs.selectAbove, { @@ -2482,9 +2525,13 @@ function populateMenus( mainMenu.runMenu.codeRunners.add({ tracker, runLabel: (n: number) => trans.__('Run Selected Cells'), + runCaption: (n: number) => trans.__('Run the selected cells and advance'), runAllLabel: (n: number) => trans.__('Run All Cells'), + runAllCaption: (n: number) => trans.__('Run the all notebook cells'), restartAndRunAllLabel: (n: number) => trans.__('Restart Kernel and Run All Cells…'), + restartAndRunAllCaption: (n: number) => + trans.__('Restart the kernel, then re-run the whole notebook'), run: current => { const { context, content } = current; return NotebookActions.runAndAdvance( diff --git a/packages/notebook-extension/style/index.css b/packages/notebook-extension/style/index.css index 22d0418a3bd8..3e4db32dee43 100644 --- a/packages/notebook-extension/style/index.css +++ b/packages/notebook-extension/style/index.css @@ -10,10 +10,12 @@ @import url('~@jupyterlab/codeeditor/style/index.css'); @import url('~@jupyterlab/statusbar/style/index.css'); @import url('~@jupyterlab/rendermime/style/index.css'); +@import url('~@jupyterlab/docregistry/style/index.css'); @import url('~@jupyterlab/application/style/index.css'); @import url('~@jupyterlab/docmanager/style/index.css'); @import url('~@jupyterlab/filebrowser/style/index.css'); @import url('~@jupyterlab/cells/style/index.css'); +@import url('~@jupyterlab/docmanager-extension/style/index.css'); @import url('~@jupyterlab/launcher/style/index.css'); @import url('~@jupyterlab/logconsole/style/index.css'); @import url('~@jupyterlab/mainmenu/style/index.css'); diff --git a/packages/notebook-extension/style/index.js b/packages/notebook-extension/style/index.js index e9ffc384e8c5..93cee8e2cc03 100644 --- a/packages/notebook-extension/style/index.js +++ b/packages/notebook-extension/style/index.js @@ -10,10 +10,12 @@ import '@jupyterlab/apputils/style/index.js'; import '@jupyterlab/codeeditor/style/index.js'; import '@jupyterlab/statusbar/style/index.js'; import '@jupyterlab/rendermime/style/index.js'; +import '@jupyterlab/docregistry/style/index.js'; import '@jupyterlab/application/style/index.js'; import '@jupyterlab/docmanager/style/index.js'; import '@jupyterlab/filebrowser/style/index.js'; import '@jupyterlab/cells/style/index.js'; +import '@jupyterlab/docmanager-extension/style/index.js'; import '@jupyterlab/launcher/style/index.js'; import '@jupyterlab/logconsole/style/index.js'; import '@jupyterlab/mainmenu/style/index.js'; diff --git a/packages/notebook-extension/tsconfig.json b/packages/notebook-extension/tsconfig.json index ce13ef293b58..bbef527fa669 100644 --- a/packages/notebook-extension/tsconfig.json +++ b/packages/notebook-extension/tsconfig.json @@ -24,6 +24,12 @@ { "path": "../docmanager" }, + { + "path": "../docmanager-extension" + }, + { + "path": "../docregistry" + }, { "path": "../filebrowser" }, diff --git a/packages/notebook/src/default-toolbar.tsx b/packages/notebook/src/default-toolbar.tsx index 60698e47588d..2e97233d95eb 100644 --- a/packages/notebook/src/default-toolbar.tsx +++ b/packages/notebook/src/default-toolbar.tsx @@ -53,6 +53,9 @@ const TOOLBAR_CELLTYPE_DROPDOWN_CLASS = 'jp-Notebook-toolbarCellTypeDropdown'; export namespace ToolbarItems { /** * Create save button toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. */ export function createSaveButton( panel: NotebookPanel, @@ -100,6 +103,9 @@ export namespace ToolbarItems { /** * Create an insert toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. */ export function createInsertButton( panel: NotebookPanel, @@ -117,6 +123,9 @@ export namespace ToolbarItems { /** * Create a cut toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. */ export function createCutButton( panel: NotebookPanel, @@ -134,6 +143,9 @@ export namespace ToolbarItems { /** * Create a copy toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. */ export function createCopyButton( panel: NotebookPanel, @@ -151,6 +163,9 @@ export namespace ToolbarItems { /** * Create a paste toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. */ export function createPasteButton( panel: NotebookPanel, @@ -168,6 +183,9 @@ export namespace ToolbarItems { /** * Create a run toolbar item. + * + * @deprecated since v3.2 + * This is dead code now. */ export function createRunButton( panel: NotebookPanel, @@ -184,6 +202,9 @@ export namespace ToolbarItems { } /** * Create a restart run all toolbar item + * + * @deprecated since v3.2 + * This is dead code now. */ export function createRestartRunAllButton( panel: NotebookPanel, @@ -316,7 +337,7 @@ export class CellTypeSwitcher extends ReactWidget { } }; - render() { + render(): JSX.Element { let value = '-'; if (this._notebook.activeCell) { value = this._notebook.activeCell.model.type; diff --git a/packages/notebook/src/widget.ts b/packages/notebook/src/widget.ts index 0b7fa5c7c1a9..7f3817d1b281 100644 --- a/packages/notebook/src/widget.ts +++ b/packages/notebook/src/widget.ts @@ -355,7 +355,7 @@ export class StaticNotebook extends Widget { /** * Dispose of the resources held by the widget. */ - dispose() { + dispose(): void { // Do nothing if already disposed. if (this.isDisposed) { return; @@ -2565,7 +2565,9 @@ namespace Private { * #### Notes * This defaults the content factory to that in the `Notebook` namespace. */ - export function processNotebookOptions(options: Notebook.IOptions) { + export function processNotebookOptions( + options: Notebook.IOptions + ): Notebook.IOptions { if (options.contentFactory) { return options; } else { diff --git a/packages/settingregistry/src/plugin-schema.json b/packages/settingregistry/src/plugin-schema.json index 16e87b8a0230..e36cd68450fd 100644 --- a/packages/settingregistry/src/plugin-schema.json +++ b/packages/settingregistry/src/plugin-schema.json @@ -79,6 +79,19 @@ "type": "array", "default": [] }, + "jupyter.lab.toolbars": { + "properties": { + "^\\w[\\w-\\.]*$": { + "items": { + "$ref": "#/definitions/toolbarItem" + }, + "type": "array", + "default": [] + } + }, + "type": "object", + "default": {} + }, "jupyter.lab.transform": { "type": "boolean", "default": false @@ -206,6 +219,52 @@ }, "required": ["command", "keys", "selector"], "type": "object" + }, + "toolbarItem": { + "properties": { + "name": { + "title": "Unique name", + "type": "string" + }, + "args": { + "title": "Command arguments", + "type": "object" + }, + "command": { + "title": "Command id", + "type": "string", + "default": "" + }, + "disabled": { + "title": "Whether the item is ignored or not", + "type": "boolean", + "default": false + }, + "icon": { + "title": "Item icon id", + "description": "If defined, it will override the command icon", + "type": "string" + }, + "label": { + "title": "Item label", + "description": "If defined, it will override the command label", + "type": "string" + }, + "type": { + "title": "Item type", + "type": "string", + "enum": ["command", "spacer"] + }, + "rank": { + "title": "Item rank", + "type": "number", + "minimum": 0, + "default": 50 + } + }, + "required": ["name"], + "additionalProperties": false, + "type": "object" } } } diff --git a/packages/settingregistry/src/settingregistry.ts b/packages/settingregistry/src/settingregistry.ts index 87a2745fbd63..247936beb65c 100644 --- a/packages/settingregistry/src/settingregistry.ts +++ b/packages/settingregistry/src/settingregistry.ts @@ -1018,6 +1018,12 @@ export namespace SettingRegistry { return items; } + /** + * Remove disabled entries from menu items + * + * @param items Menu items + * @returns Filtered menu items + */ export function filterDisabledItems( items: T[] ): T[] { @@ -1129,6 +1135,69 @@ export namespace SettingRegistry { // Return all the shortcuts that should be registered return user.concat(defaults).filter(shortcut => !shortcut.disabled); } + + /** + * Merge two set of toolbar items. + * + * @param reference Reference set of toolbar items + * @param addition New items to add + * @param warn Whether to warn if item is duplicated; default to false + * @returns The merged set of items + */ + export function reconcileToolbarItems( + reference?: ISettingRegistry.IToolbarItem[], + addition?: ISettingRegistry.IToolbarItem[], + warn: boolean = false + ): ISettingRegistry.IToolbarItem[] | undefined { + if (!reference) { + return addition ? JSONExt.deepCopy(addition) : undefined; + } + if (!addition) { + return JSONExt.deepCopy(reference); + } + + const items = JSONExt.deepCopy(reference); + + // Merge array element depending on the type + addition.forEach(item => { + switch (item.type) { + case 'command': + if (item.command) { + const refIndex = items.findIndex( + ref => + ref.name === item.name && + ref.command === item.command && + JSONExt.deepEqual(ref.args ?? {}, item.args ?? {}) + ); + if (refIndex < 0) { + items.push({ ...item }); + } else { + if (warn) { + console.warn( + `Toolbar item for command '${item.command}' is duplicated.` + ); + } + items[refIndex] = { ...items[refIndex], ...item }; + } + } + break; + case 'spacer': + default: { + const refIndex = items.findIndex(ref => ref.name === item.name); + if (refIndex < 0) { + items.push({ ...item }); + } else { + if (warn) { + console.warn(`Toolbar item '${item.name}' is duplicated.`); + } + items[refIndex] = { ...items[refIndex], ...item }; + } + } + } + }); + + return items; + } } /** diff --git a/packages/settingregistry/src/tokens.ts b/packages/settingregistry/src/tokens.ts index e127b9626007..1eab8259686e 100644 --- a/packages/settingregistry/src/tokens.ts +++ b/packages/settingregistry/src/tokens.ts @@ -184,7 +184,7 @@ export namespace ISettingRegistry { | 'jp-menu-tabs'; /** - * Menu defined by a specific plugin + * An interface defining a menu. */ export interface IMenu extends PartialJSONObject { /** @@ -235,6 +235,9 @@ export namespace ISettingRegistry { disabled?: boolean; } + /** + * An interface describing a menu item. + */ export interface IMenuItem extends PartialJSONObject { /** * The type of the menu item. @@ -406,6 +409,15 @@ export namespace ISettingRegistry { */ 'jupyter.lab.setting-icon-label'?: string; + /** + * The JupyterLab toolbars created by a plugin's schema. + * + * #### Notes + * The toolbar items are grouped by document or widget factory name + * that will contain a toolbar. + */ + 'jupyter.lab.toolbars'?: { [factory: string]: IToolbarItem[] }; + /** * A flag that indicates plugin should be transformed before being used by * the setting registry. @@ -591,4 +603,63 @@ export namespace ISettingRegistry { */ selector: string; } + + /** + * An interface describing a toolbar item. + */ + export interface IToolbarItem extends PartialJSONObject { + /** + * Unique toolbar item name + */ + name: string; + + /** + * The command to execute when the item is triggered. + * + * The default value is an empty string. + */ + command?: string; + + /** + * The arguments for the command. + * + * The default value is an empty object. + */ + args?: PartialJSONObject; + + /** + * Whether the toolbar item is ignored (i.e. not created). `false` by default. + * + * #### Notes + * This allows an user to suppress toolbar items. + */ + disabled?: boolean; + + /** + * Item icon id + * + * #### Note + * The id will be looked for in the LabIcon registry. + * The command icon will be overridden by this label if defined. + */ + icon?: string; + + /** + * Item label + * + * #### Note + * The command label will be overridden by this label if defined. + */ + label?: string; + + /** + * The rank order of the toolbar item among its siblings. + */ + rank?: number; + + /** + * The type of the toolbar item. + */ + type?: 'command' | 'spacer'; + } } diff --git a/packages/settingregistry/test/settingregistry.spec.ts b/packages/settingregistry/test/settingregistry.spec.ts index aba5d8984e10..1663c2915233 100644 --- a/packages/settingregistry/test/settingregistry.spec.ts +++ b/packages/settingregistry/test/settingregistry.spec.ts @@ -621,6 +621,29 @@ describe('@jupyterlab/settingregistry', () => { }); }); + describe('reconcileToolbarItems', () => { + it('should merge toolbar items list', () => { + const a: ISettingRegistry.IToolbarItem[] = [ + { name: 'a' }, + { name: 'b', command: 'command-b' } + ]; + const b: ISettingRegistry.IToolbarItem[] = [ + { name: 'b', disabled: true }, + { name: 'c', type: 'spacer' }, + { name: 'd', command: 'command-d' } + ]; + + const merged = SettingRegistry.reconcileToolbarItems(a, b); + expect(merged).toHaveLength(4); + expect(merged![0].name).toEqual('a'); + expect(merged![1].name).toEqual('b'); + expect(merged![1].disabled).toEqual(true); + expect(merged![2].name).toEqual('c'); + expect(merged![2].type).toEqual('spacer'); + expect(merged![3].name).toEqual('d'); + }); + }); + describe('filterDisabledItems', () => { it('should remove disabled menu item', () => { const a: ISettingRegistry.IContextMenuItem[] = [