UNPKG

@jupyterlab/apputils

Version:
451 lines (411 loc) 13.9 kB
/* * Copyright (c) Jupyter Development Team. * Distributed under the terms of the Modified BSD License. */ import { IObservableList, ObservableList } from '@jupyterlab/observables'; import { ISettingRegistry, SettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator, TranslationBundle } from '@jupyterlab/translation'; import { Toolbar } from '@jupyterlab/ui-components'; import { findIndex } 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; const TOOLBAR_KEY = 'jupyter.lab.toolbars'; /** * Display warning when the toolbar definition have been modified. * * @param trans Translation bundle */ async function displayInformation(trans: TranslationBundle): Promise<void> { 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(); } } /** * Set the toolbar definition by accumulating all settings definition. * * The list will be populated only with the enabled items. * * @param toolbarItems Observable list to populate * @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 setToolbarItems( toolbarItems: IObservableList<ISettingRegistry.IToolbarItem>, registry: ISettingRegistry, factoryName: string, pluginId: string, translator: ITranslator, propertyId: string = 'toolbar' ): Promise<void> { const trans = translator.load('jupyterlab'); let canonical: ISettingRegistry.ISchema | null = null; let loaded: { [name: string]: ISettingRegistry.IToolbarItem[] } = {}; let listenPlugin = true; try { /** * 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 = {}; const pluginDefaults = Object.keys(registry.plugins) // Filter out the current plugin (will be listed when reloading) // because we control its addition after the mapping step .filter(plugin => plugin !== pluginId) .map(plugin => { const items = (registry.plugins[plugin]!.schema[TOOLBAR_KEY] ?? {})[ factoryName ] ?? []; loaded[plugin] = items; return items; }) .concat([(schema[TOOLBAR_KEY] ?? {})[factoryName] ?? []]) .reduceRight( (acc, val) => SettingRegistry.reconcileToolbarItems(acc, val, true), [] )!; // Apply default value as last step to take into account overrides.json // The standard toolbars default is [] as the plugin must use // `jupyter.lab.toolbars.<factory>` to define its default value. schema.properties![propertyId].default = SettingRegistry.reconcileToolbarItems( pluginDefaults, schema.properties![propertyId].default as any[], true )!.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 ) ?? [] ).sort( (a, b) => (a.rank ?? DEFAULT_TOOLBAR_ITEM_RANK) - (b.rank ?? DEFAULT_TOOLBAR_ITEM_RANK) ); 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 }; } }); } catch (error) { if (error.name === 'TransformError') { // Assume the existing transformer is the toolbar builder transformer // from another factory set up. listenPlugin = false; } else { throw error; } } // Repopulate the canonical variable after the setting registry has // preloaded all initial plugins. const settings = await registry.load(pluginId); // React to customization by the user settings.changed.connect(() => { const newItems: ISettingRegistry.IToolbarItem[] = (settings.composite[propertyId] as any) ?? []; transferSettings(newItems); }); const transferSettings = (newItems: ISettingRegistry.IToolbarItem[]) => { // This is not optimal but safer because a toolbar item with the same // name cannot be inserted (it will be a no-op). But that could happen // if the settings are changing the items order. toolbarItems.clear(); toolbarItems.pushAll(newItems.filter(item => !item.disabled)); }; // Initialize the toolbar transferSettings((settings.composite[propertyId] as any) ?? []); // React to plugin changes if no other transformer exists, otherwise bail. if (!listenPlugin) { return; } registry.pluginChanged.connect(async (sender, plugin) => { // Since the plugin storing the toolbar definition is transformed above, // if it has changed, it means that a request to reload was triggered. // Hence the toolbar definitions from the other plugins have been // automatically reset during the transform step. if (plugin === pluginId) { return; } // If a plugin changed its toolbar items const oldItems = loaded[plugin] ?? []; const newItems = (registry.plugins[plugin]!.schema[TOOLBAR_KEY] ?? {})[factoryName] ?? []; if (!JSONExt.deepEqual(oldItems, newItems)) { if (loaded[plugin]) { // The plugin has changed, request the user to reload the UI await displayInformation(trans); } else { if (newItems.length > 0) { // Empty the default values to avoid toolbar settings collisions. canonical = null; const schema = registry.plugins[pluginId]!.schema; schema.properties!.toolbar.default = []; // Run again the transformations. await registry.load(pluginId, true); } } } }); } /** * 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 factory */ export function createToolbarFactory( toolbarRegistry: IToolbarWidgetRegistry, settingsRegistry: ISettingRegistry, factoryName: string, pluginId: string, translator: ITranslator, propertyId: string = 'toolbar' ): (widget: Widget) => IObservableList<ToolbarRegistry.IToolbarItem> { const items = new ObservableList<ISettingRegistry.IToolbarItem>({ itemCmp: (a, b) => JSONExt.deepEqual(a as any, b as any) }); // Get toolbar definition from the settings setToolbarItems( items, settingsRegistry, factoryName, pluginId, translator, propertyId ).catch(reason => { console.error( `Failed to load toolbar items for factory ${factoryName} from ${pluginId}`, reason ); }); return (widget: Widget) => { const updateToolbar = ( list: IObservableList<ToolbarRegistry.IWidget>, change: IObservableList.IChangedArgs<ToolbarRegistry.IWidget> ) => { switch (change.type) { case 'move': toolbar.move(change.oldIndex, change.newIndex); break; case 'add': change.newValues.forEach(item => toolbar.push({ name: item.name, widget: toolbarRegistry.createWidget(factoryName, widget, item) }) ); break; case 'remove': change.oldValues.forEach(() => toolbar.remove(change.oldIndex)); break; case 'set': change.newValues.forEach(item => toolbar.set(change.newIndex, { name: item.name, widget: toolbarRegistry.createWidget(factoryName, widget, item) }) ); break; } }; const updateWidget = ( registry: IToolbarWidgetRegistry, itemName: string ) => { const itemIndex = Array.from(items).findIndex( item => item.name === itemName ); if (itemIndex >= 0) { toolbar.set(itemIndex, { name: itemName, widget: toolbarRegistry.createWidget( factoryName, widget, items.get(itemIndex) ) }); } }; const toolbar = new ObservableList<ToolbarRegistry.IToolbarItem>({ values: Array.from(items).map(item => { return { name: item.name, widget: toolbarRegistry.createWidget(factoryName, widget, item) }; }) }); // Re-render the widget if a new factory has been added. toolbarRegistry.factoryAdded.connect(updateWidget); items.changed.connect(updateToolbar); widget.disposed.connect(() => { items.changed.disconnect(updateToolbar); toolbarRegistry.factoryAdded.disconnect(updateWidget); }); return toolbar; }; } /** * Set the toolbar items of a widget from a factory * * @param widget Widget with the toolbar to set * @param factory Toolbar items factory * @param toolbar Separated toolbar if widget is a raw widget */ export function setToolbar( widget: Toolbar.IWidgetToolbar | Widget, factory: ( widget: Widget ) => | IObservableList<ToolbarRegistry.IToolbarItem> | ToolbarRegistry.IToolbarItem[], toolbar?: Toolbar ): void { // @ts-expect-error Widget has no toolbar if (!widget.toolbar && !toolbar) { console.log( `Widget ${widget.id} has no 'toolbar' and no explicit toolbar was provided.` ); return; } // @ts-expect-error Widget has no toolbar const toolbar_ = (widget.toolbar as Toolbar) ?? toolbar; const items = factory(widget); if (Array.isArray(items)) { items.forEach(({ name, widget: item }) => { toolbar_.addItem(name, item); }); } else { const updateToolbar = ( list: IObservableList<ToolbarRegistry.IToolbarItem>, changes: IObservableList.IChangedArgs<ToolbarRegistry.IToolbarItem> ) => { switch (changes.type) { case 'add': changes.newValues.forEach((item, index) => { toolbar_.insertItem( changes.newIndex + index, item.name, item.widget ); }); break; case 'move': changes.oldValues.forEach(item => { item.widget.parent = null; }); changes.newValues.forEach((item, index) => { toolbar_.insertItem( changes.newIndex + index, item.name, item.widget ); }); break; case 'remove': changes.oldValues.forEach(item => { item.widget.parent = null; }); break; case 'set': changes.oldValues.forEach(item => { item.widget.parent = null; }); changes.newValues.forEach((item, index) => { const existingIndex = findIndex( toolbar_.names(), name => item.name === name ); if (existingIndex >= 0) { Array.from(toolbar_.children())[existingIndex].parent = null; } toolbar_.insertItem( changes.newIndex + index, item.name, item.widget ); }); break; } }; updateToolbar(items, { newIndex: 0, newValues: Array.from(items), oldIndex: 0, oldValues: [], type: 'add' }); items.changed.connect(updateToolbar); widget.disposed.connect(() => { items.changed.disconnect(updateToolbar); }); } }