// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { Cell, ICellModel } from '@jupyterlab/cells'; import { CodeEditor, JSONEditor } from '@jupyterlab/codeeditor'; import { ObservableJSON } from '@jupyterlab/observables'; import { IMapChange } from '@jupyter/ydoc'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { Collapser } from '@jupyterlab/ui-components'; import { ArrayExt } from '@lumino/algorithm'; import { ReadonlyPartialJSONValue } from '@lumino/coreutils'; import { ConflatableMessage, Message, MessageLoop } from '@lumino/messaging'; import { PanelLayout, Widget } from '@lumino/widgets'; import { INotebookModel } from './model'; import { NotebookPanel } from './panel'; import { INotebookTools, INotebookTracker } from './tokens'; class RankedPanel extends Widget { constructor() { super(); this.layout = new PanelLayout(); this.addClass('jp-RankedPanel'); } addWidget(widget: Widget, rank: number): void { const rankItem = { widget, rank }; const index = ArrayExt.upperBound(this._items, rankItem, Private.itemCmp); ArrayExt.insert(this._items, index, rankItem); const layout = this.layout as PanelLayout; layout.insertWidget(index, widget); } /** * Handle the removal of a child * */ protected onChildRemoved(msg: Widget.ChildMessage): void { const index = ArrayExt.findFirstIndex( this._items, item => item.widget === msg.child ); if (index !== -1) { ArrayExt.removeAt(this._items, index); } } private _items: Private.IRankItem[] = []; } /** * A widget that provides metadata tools. */ export class NotebookTools extends Widget implements INotebookTools { /** * Construct a new NotebookTools object. */ constructor(options: NotebookTools.IOptions) { super(); this.addClass('jp-NotebookTools'); this.translator = options.translator || nullTranslator; this._tools = []; this.layout = new PanelLayout(); this._tracker = options.tracker; this._tracker.currentChanged.connect( this._onActiveNotebookPanelChanged, this ); this._tracker.activeCellChanged.connect(this._onActiveCellChanged, this); this._tracker.selectionChanged.connect(this._onSelectionChanged, this); this._onActiveNotebookPanelChanged(); this._onActiveCellChanged(); this._onSelectionChanged(); } /** * The active cell widget. */ get activeCell(): Cell | null { return this._tracker.activeCell; } /** * The currently selected cells. */ get selectedCells(): Cell[] { const panel = this._tracker.currentWidget; if (!panel) { return []; } const notebook = panel.content; return notebook.widgets.filter(cell => notebook.isSelectedOrActive(cell)); } /** * The current notebook. */ get activeNotebookPanel(): NotebookPanel | null { return this._tracker.currentWidget; } /** * Add a cell tool item. */ addItem(options: NotebookTools.IAddOptions): void { const tool = options.tool; const rank = options.rank ?? 100; let section: RankedPanel; const extendedTool = this._tools.find( extendedTool => extendedTool.section === options.section ); if (extendedTool) section = extendedTool.panel; else { throw new Error(`The section ${options.section} does not exist`); } tool.addClass('jp-NotebookTools-tool'); section.addWidget(tool, rank); // TODO: perhaps the necessary notebookTools functionality should be // consolidated into a single object, rather than a broad reference to this. tool.notebookTools = this; // Trigger the tool to update its active notebook and cell. MessageLoop.sendMessage(tool, NotebookTools.ActiveNotebookPanelMessage); MessageLoop.sendMessage(tool, NotebookTools.ActiveCellMessage); } /* * Add a section to the notebook tool with its widget */ addSection(options: NotebookTools.IAddSectionOptions): void { const sectionName = options.sectionName; const label = options.label || options.sectionName; const widget = options.tool; let rank = options.rank ?? null; const newSection = new RankedPanel(); newSection.title.label = label; if (widget) newSection.addWidget(widget, 0); this._tools.push({ section: sectionName, panel: newSection, rank: rank }); if (rank != null) (this.layout as PanelLayout).insertWidget( rank, new Collapser({ widget: newSection }) ); else { // If no rank is provided, try to add the new section before the AdvancedTools. let advancedToolsRank = null; const layout = this.layout as PanelLayout; for (let i = 0; i < layout.widgets.length; i++) { let w = layout.widgets[i]; if (w instanceof Collapser) { if (w.widget.id === 'advancedToolsSection') { advancedToolsRank = i; break; } } } if (advancedToolsRank !== null) (this.layout as PanelLayout).insertWidget( advancedToolsRank, new Collapser({ widget: newSection }) ); else (this.layout as PanelLayout).addWidget( new Collapser({ widget: newSection }) ); } } /** * Handle a change to the notebook panel. */ private _onActiveNotebookPanelChanged(): void { if ( this._prevActiveNotebookModel && !this._prevActiveNotebookModel.isDisposed ) { this._prevActiveNotebookModel.metadataChanged.disconnect( this._onActiveNotebookPanelMetadataChanged, this ); } const activeNBModel = this.activeNotebookPanel && this.activeNotebookPanel.content ? this.activeNotebookPanel.content.model : null; this._prevActiveNotebookModel = activeNBModel; if (activeNBModel) { activeNBModel.metadataChanged.connect( this._onActiveNotebookPanelMetadataChanged, this ); } for (const widget of this._toolChildren()) { MessageLoop.sendMessage(widget, NotebookTools.ActiveNotebookPanelMessage); } } /** * Handle a change to the active cell. */ private _onActiveCellChanged(): void { if (this._prevActiveCell && !this._prevActiveCell.isDisposed) { this._prevActiveCell.metadataChanged.disconnect( this._onActiveCellMetadataChanged, this ); } const activeCell = this.activeCell ? this.activeCell.model : null; this._prevActiveCell = activeCell; if (activeCell) { activeCell.metadataChanged.connect( this._onActiveCellMetadataChanged, this ); } for (const widget of this._toolChildren()) { MessageLoop.sendMessage(widget, NotebookTools.ActiveCellMessage); } } /** * Handle a change in the selection. */ private _onSelectionChanged(): void { for (const widget of this._toolChildren()) { MessageLoop.sendMessage(widget, NotebookTools.SelectionMessage); } } /** * Handle a change in the active cell metadata. */ private _onActiveNotebookPanelMetadataChanged( sender: INotebookModel, args: IMapChange ): void { const message = new ObservableJSON.ChangeMessage( 'activenotebookpanel-metadata-changed', { oldValue: undefined, newValue: undefined, ...args } ); for (const widget of this._toolChildren()) { MessageLoop.sendMessage(widget, message); } } /** * Handle a change in the notebook model metadata. */ private _onActiveCellMetadataChanged( sender: ICellModel, args: IMapChange ): void { const message = new ObservableJSON.ChangeMessage( 'activecell-metadata-changed', { newValue: undefined, oldValue: undefined, ...args } ); for (const widget of this._toolChildren()) { MessageLoop.sendMessage(widget, message); } } private *_toolChildren() { for (let tool of this._tools) { yield* tool.panel.children(); } } translator: ITranslator; private _tools: Array; private _tracker: INotebookTracker; private _prevActiveCell: ICellModel | null; private _prevActiveNotebookModel: INotebookModel | null; } /** * The namespace for NotebookTools class statics. */ export namespace NotebookTools { /** * A type alias for a readonly partial JSON tuples `[option, value]`. * `option` should be localized. * * Note: Partial here means that JSON object attributes can be `undefined`. */ export type ReadonlyPartialJSONOptionValueArray = [ ReadonlyPartialJSONValue | undefined, ReadonlyPartialJSONValue ][]; /** * Interface for an extended panel section. */ export interface IToolPanel { /** * The name of the section. */ section: string; /** * The associated panel, only one for a section. */ panel: RankedPanel; /** * The rank of the section on the notebooktools panel. */ rank?: number | null; } /** * The options used to create a NotebookTools object. */ export interface IOptions { /** * The notebook tracker used by the notebook tools. */ tracker: INotebookTracker; /** * Language translator. */ translator?: ITranslator; } /** * The options used to add an item to the notebook tools. */ export interface IAddOptions { /** * The tool to add to the notebook tools area. */ tool: INotebookTools.ITool; /** * The section to which the tool should be added. */ section: string; /** * The rank order of the widget among its siblings. */ rank?: number; } /** * The options used to add a section to the notebook tools. */ export interface IAddSectionOptions { /** * The name of the new section. */ sectionName: string; /** * The tool to add to the notebook tools area. */ tool?: INotebookTools.ITool; /** * The label of the new section. */ label?: string; /** * The rank order of the section among its siblings. */ rank?: number; } /** * A singleton conflatable `'activenotebookpanel-changed'` message. */ export const ActiveNotebookPanelMessage = new ConflatableMessage( 'activenotebookpanel-changed' ); /** * A singleton conflatable `'activecell-changed'` message. */ export const ActiveCellMessage = new ConflatableMessage('activecell-changed'); /** * A singleton conflatable `'selection-changed'` message. */ export const SelectionMessage = new ConflatableMessage('selection-changed'); /** * The base notebook tool, meant to be subclassed. */ export class Tool extends Widget implements INotebookTools.ITool { /** * The notebook tools object. */ notebookTools: INotebookTools; dispose(): void { super.dispose(); if (this.notebookTools) { this.notebookTools = null!; } } /** * Process a message sent to the widget. * * @param msg - The message sent to the widget. */ processMessage(msg: Message): void { super.processMessage(msg); switch (msg.type) { case 'activenotebookpanel-changed': this.onActiveNotebookPanelChanged(msg); break; case 'activecell-changed': this.onActiveCellChanged(msg); break; case 'selection-changed': this.onSelectionChanged(msg); break; case 'activecell-metadata-changed': this.onActiveCellMetadataChanged(msg as ObservableJSON.ChangeMessage); break; case 'activenotebookpanel-metadata-changed': this.onActiveNotebookPanelMetadataChanged( msg as ObservableJSON.ChangeMessage ); break; default: break; } } /** * Handle a change to the notebook panel. * * #### Notes * The default implementation is a no-op. */ protected onActiveNotebookPanelChanged(msg: Message): void { /* no-op */ } /** * Handle a change to the active cell. * * #### Notes * The default implementation is a no-op. */ protected onActiveCellChanged(msg: Message): void { /* no-op */ } /** * Handle a change to the selection. * * #### Notes * The default implementation is a no-op. */ protected onSelectionChanged(msg: Message): void { /* no-op */ } /** * Handle a change to the metadata of the active cell. * * #### Notes * The default implementation is a no-op. */ protected onActiveCellMetadataChanged( msg: ObservableJSON.ChangeMessage ): void { /* no-op */ } /** * Handle a change to the metadata of the active cell. * * #### Notes * The default implementation is a no-op. */ protected onActiveNotebookPanelMetadataChanged( msg: ObservableJSON.ChangeMessage ): void { /* no-op */ } } /** * A raw metadata editor. */ export class MetadataEditorTool extends Tool { /** * Construct a new raw metadata tool. */ constructor(options: MetadataEditorTool.IOptions) { super(); const { editorFactory } = options; this.addClass('jp-MetadataEditorTool'); const layout = (this.layout = new PanelLayout()); this._editorFactory = editorFactory; this._editorLabel = options.label || 'Edit Metadata'; this.createEditor(); const titleNode = new Widget({ node: document.createElement('label') }); titleNode.node.textContent = options.label || 'Edit Metadata'; layout.addWidget(titleNode); layout.addWidget(this.editor); } /** * The editor used by the tool. */ get editor(): JSONEditor { return this._editor; } /** * Handle a change to the notebook. */ protected onActiveNotebookPanelChanged(msg: Message): void { this.editor.dispose(); if (this.notebookTools.activeNotebookPanel) { this.createEditor(); } } protected createEditor() { this._editor = new JSONEditor({ editorFactory: this._editorFactory }); this.editor.title.label = this._editorLabel; (this.layout as PanelLayout).addWidget(this.editor); } private _editor: JSONEditor; private _editorLabel: string; private _editorFactory: CodeEditor.Factory; } /** * The namespace for `MetadataEditorTool` static data. */ export namespace MetadataEditorTool { /** * The options used to initialize a metadata editor tool. */ export interface IOptions { /** * The editor factory used by the tool. */ editorFactory: CodeEditor.Factory; /** * The label for the JSON editor */ label?: string; /** * Initial collapse state, defaults to true. */ collapsed?: boolean; /** * Language translator. */ translator?: ITranslator; } } } /** * A namespace for private data. */ namespace Private { /** * An object which holds a widget and its sort rank. */ export interface IRankItem { /** * The widget for the item. */ widget: T; /** * The sort rank of the menu. */ rank: number; } /** * A comparator function for widget rank items. */ export function itemCmp(first: IRankItem, second: IRankItem): number { return first.rank - second.rank; } }