UNPKG

@jupyterlab/cells

Version:
1,878 lines (1,673 loc) 63.6 kB
/* ----------------------------------------------------------------------------- | Copyright (c) Jupyter Development Team. | Distributed under the terms of the Modified BSD License. |----------------------------------------------------------------------------*/ import { Extension } from '@codemirror/state'; import { EditorView } from '@codemirror/view'; import { AttachmentsResolver } from '@jupyterlab/attachments'; import { ISessionContext } from '@jupyterlab/apputils'; import { ActivityMonitor, IChangedArgs, URLExt } from '@jupyterlab/coreutils'; import { CodeEditor, CodeEditorWrapper } from '@jupyterlab/codeeditor'; import { DirListing } from '@jupyterlab/filebrowser'; import * as nbformat from '@jupyterlab/nbformat'; import { IOutputPrompt, IStdin, OutputArea, OutputPrompt, SimplifiedOutputArea, Stdin } from '@jupyterlab/outputarea'; import { imageRendererFactory, IRenderMime, IRenderMimeRegistry, MimeModel } from '@jupyterlab/rendermime'; import { Kernel, KernelMessage } from '@jupyterlab/services'; import { IMapChange } from '@jupyter/ydoc'; import { TableOfContentsUtils } from '@jupyterlab/toc'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { addIcon } from '@jupyterlab/ui-components'; import { JSONObject, PromiseDelegate, UUID } from '@lumino/coreutils'; import { some } from '@lumino/algorithm'; import { Drag } from '@lumino/dragdrop'; import { Message, MessageLoop } from '@lumino/messaging'; import { Debouncer } from '@lumino/polling'; import { ISignal, Signal } from '@lumino/signaling'; import { Panel, PanelLayout, Widget } from '@lumino/widgets'; import { InputCollapser, OutputCollapser } from './collapser'; import { CellFooter, CellHeader, ICellFooter, ICellHeader } from './headerfooter'; import { IInputPrompt, InputArea, InputPrompt } from './inputarea'; import { CellModel, IAttachmentsCellModel, ICellModel, ICodeCellModel, IMarkdownCellModel, IRawCellModel } from './model'; import { InputPlaceholder, OutputPlaceholder } from './placeholder'; import { ResizeHandle } from './resizeHandle'; /** * The CSS class added to cell widgets. */ const CELL_CLASS = 'jp-Cell'; /** * The CSS class added to the cell header. */ const CELL_HEADER_CLASS = 'jp-Cell-header'; /** * The CSS class added to the cell footer. */ const CELL_FOOTER_CLASS = 'jp-Cell-footer'; /** * The CSS class added to the cell input wrapper. */ const CELL_INPUT_WRAPPER_CLASS = 'jp-Cell-inputWrapper'; /** * The CSS class added to the cell output wrapper. */ const CELL_OUTPUT_WRAPPER_CLASS = 'jp-Cell-outputWrapper'; /** * The CSS class added to the cell input area. */ const CELL_INPUT_AREA_CLASS = 'jp-Cell-inputArea'; /** * The CSS class added to the cell output area. */ const CELL_OUTPUT_AREA_CLASS = 'jp-Cell-outputArea'; /** * The CSS class added to the cell input collapser. */ const CELL_INPUT_COLLAPSER_CLASS = 'jp-Cell-inputCollapser'; /** * The CSS class added to the cell output collapser. */ const CELL_OUTPUT_COLLAPSER_CLASS = 'jp-Cell-outputCollapser'; /** * The class name added to the cell when dirty. */ const DIRTY_CLASS = 'jp-mod-dirty'; /** * The class name added to code cells. */ const CODE_CELL_CLASS = 'jp-CodeCell'; /** * The class name added to markdown cells. */ const MARKDOWN_CELL_CLASS = 'jp-MarkdownCell'; /** * The class name added to rendered markdown output widgets. */ const MARKDOWN_OUTPUT_CLASS = 'jp-MarkdownOutput'; const MARKDOWN_HEADING_COLLAPSED = 'jp-MarkdownHeadingCollapsed'; const HEADING_COLLAPSER_CLASS = 'jp-collapseHeadingButton'; const SHOW_HIDDEN_CELLS_CLASS = 'jp-showHiddenCellsButton'; /** * The class name added to raw cells. */ const RAW_CELL_CLASS = 'jp-RawCell'; /** * The class name added to a rendered input area. */ const RENDERED_CLASS = 'jp-mod-rendered'; const NO_OUTPUTS_CLASS = 'jp-mod-noOutputs'; /** * The text applied to an empty markdown cell. */ const DEFAULT_MARKDOWN_TEXT = 'Type Markdown and LaTeX: $ α^2 $'; /** * The timeout to wait for change activity to have ceased before rendering. */ const RENDER_TIMEOUT = 1000; /** * The mime type for a rich contents drag object. */ const CONTENTS_MIME_RICH = 'application/x-jupyter-icontentsrich'; /** **************************************************************************** * Cell ******************************************************************************/ /** * A base cell widget. */ export class Cell<T extends ICellModel = ICellModel> extends Widget { /** * Construct a new base cell widget. */ constructor(options: Cell.IOptions<T>) { super(); this.addClass(CELL_CLASS); const model = (this._model = options.model); this.contentFactory = options.contentFactory; this.layout = options.layout ?? new PanelLayout(); // Set up translator for aria labels this.translator = options.translator ?? nullTranslator; this._editorConfig = options.editorConfig ?? {}; this._placeholder = true; this._inViewport = false; this.placeholder = options.placeholder ?? true; model.metadataChanged.connect(this.onMetadataChanged, this); } /** * Initialize view state from model. * * #### Notes * Should be called after construction. For convenience, returns this, so it * can be chained in the construction, like `new Foo().initializeState();` */ initializeState(): this { this.loadCollapseState(); this.loadEditableState(); return this; } /** * The content factory used by the widget. */ readonly contentFactory: Cell.IContentFactory; /** * Signal to indicate that widget has changed visibly (in size, in type, etc) */ get displayChanged(): ISignal<this, void> { return this._displayChanged; } /** * Whether the cell is in viewport or not. */ get inViewport(): boolean { return this._inViewport; } set inViewport(v: boolean) { if (this._inViewport !== v) { this._inViewport = v; this._inViewportChanged.emit(this._inViewport); } } /** * Will emit true just after the node is attached to the DOM * Will emit false just before the node is detached of the DOM */ get inViewportChanged(): ISignal<Cell, boolean> { return this._inViewportChanged; } /** * Whether the cell is a placeholder not yet fully rendered or not. */ protected get placeholder(): boolean { return this._placeholder; } protected set placeholder(v: boolean) { if (this._placeholder !== v && v === false) { this.initializeDOM(); this._placeholder = v; this._ready.resolve(); } } /** * Get the prompt node used by the cell. */ get promptNode(): HTMLElement | null { if (this.placeholder) { return null; } if (!this._inputHidden) { return this._input!.promptNode; } else { return (this._inputPlaceholder!.node as HTMLElement) .firstElementChild as HTMLElement; } } /** * Get the CodeEditorWrapper used by the cell. */ get editorWidget(): CodeEditorWrapper | null { return this._input?.editorWidget ?? null; } /** * Get the CodeEditor used by the cell. */ get editor(): CodeEditor.IEditor | null { return this._input?.editor ?? null; } /** * Editor configuration */ get editorConfig(): Record<string, any> { return this._editorConfig; } /** * Cell headings */ get headings(): Cell.IHeading[] { return new Array<Cell.IHeading>(); } /** * Get the model used by the cell. */ get model(): T { return this._model; } /** * Get the input area for the cell. */ get inputArea(): InputArea | null { return this._input; } /** * The read only state of the cell. */ get readOnly(): boolean { return this._readOnly; } set readOnly(value: boolean) { if (value === this._readOnly) { return; } this._readOnly = value; if (this.syncEditable) { this.saveEditableState(); } this.update(); } /** * Whether the cell is a placeholder that defer rendering * * #### Notes * You can wait for the promise `Cell.ready` to wait for the * cell to be rendered. */ isPlaceholder(): boolean { return this.placeholder; } /** * Save view editable state to model */ saveEditableState(): void { const { sharedModel } = this.model; const current = sharedModel.getMetadata('editable') as unknown as boolean; if ( (this.readOnly && current === false) || (!this.readOnly && current === undefined) ) { return; } if (this.readOnly) { sharedModel.setMetadata('editable', false); } else { sharedModel.deleteMetadata('editable'); } } /** * Load view editable state from model. */ loadEditableState(): void { this.readOnly = (this.model.sharedModel.getMetadata('editable') as unknown as boolean) === false; } /** * A promise that resolves when the widget renders for the first time. */ get ready(): Promise<void> { return this._ready.promise; } /** * Set the prompt for the widget. */ setPrompt(value: string): void { this.prompt = value; this._input?.setPrompt(value); } /** * The view state of input being hidden. */ get inputHidden(): boolean { return this._inputHidden; } set inputHidden(value: boolean) { if (this._inputHidden === value) { return; } if (!this.placeholder) { const layout = this._inputWrapper!.layout as PanelLayout; if (value) { this._input!.parent = null; if (this._inputPlaceholder) { this._inputPlaceholder.text = this.model.sharedModel .getSource() .split('\n')?.[0]; } layout.addWidget(this._inputPlaceholder!); } else { this._inputPlaceholder!.parent = null; layout.addWidget(this._input!); } } this._inputHidden = value; if (this.syncCollapse) { this.saveCollapseState(); } this.handleInputHidden(value); } /** * Save view collapse state to model */ saveCollapseState(): void { const jupyter = { ...(this.model.getMetadata('jupyter') as any) }; if ( (this.inputHidden && jupyter.source_hidden === true) || (!this.inputHidden && jupyter.source_hidden === undefined) ) { return; } if (this.inputHidden) { jupyter.source_hidden = true; } else { delete jupyter.source_hidden; } if (Object.keys(jupyter).length === 0) { this.model.deleteMetadata('jupyter'); } else { this.model.setMetadata('jupyter', jupyter); } } /** * Revert view collapse state from model. */ loadCollapseState(): void { const jupyter = (this.model.getMetadata('jupyter') as any) ?? {}; this.inputHidden = !!jupyter.source_hidden; } /** * Handle the input being hidden. * * #### Notes * This is called by the `inputHidden` setter so that subclasses * can perform actions upon the input being hidden without accessing * private state. */ protected handleInputHidden(value: boolean): void { return; } /** * Whether to sync the collapse state to the cell model. */ get syncCollapse(): boolean { return this._syncCollapse; } set syncCollapse(value: boolean) { if (this._syncCollapse === value) { return; } this._syncCollapse = value; if (value) { this.loadCollapseState(); } } /** * Whether to sync the editable state to the cell model. */ get syncEditable(): boolean { return this._syncEditable; } set syncEditable(value: boolean) { if (this._syncEditable === value) { return; } this._syncEditable = value; if (value) { this.loadEditableState(); } } /** * Clone the cell, using the same model. */ clone(): Cell<T> { const constructor = this.constructor as typeof Cell; return new constructor({ model: this.model, contentFactory: this.contentFactory, placeholder: false, translator: this.translator }); } /** * Dispose of the resources held by the widget. */ dispose(): void { // Do nothing if already disposed. if (this.isDisposed) { return; } this._resizeDebouncer.dispose(); this._input = null!; this._model = null!; this._inputWrapper = null!; this._inputPlaceholder = null!; super.dispose(); } /** * Update the editor configuration with the partial provided dictionary. * * @param v Partial editor configuration */ updateEditorConfig(v: Record<string, any>): void { this._editorConfig = { ...this._editorConfig, ...v }; if (this.editor) { this.editor.setOptions(this._editorConfig); } } /** * Create children widgets. */ protected initializeDOM(): void { if (!this.placeholder) { return; } const contentFactory = this.contentFactory; const model = this._model; // Header const header = contentFactory.createCellHeader(); header.addClass(CELL_HEADER_CLASS); (this.layout as PanelLayout).addWidget(header); // Input const inputWrapper = (this._inputWrapper = new Panel()); inputWrapper.addClass(CELL_INPUT_WRAPPER_CLASS); const inputCollapser = new InputCollapser(); inputCollapser.addClass(CELL_INPUT_COLLAPSER_CLASS); const input = (this._input = new InputArea({ model, contentFactory, editorOptions: this.getEditorOptions() })); input.addClass(CELL_INPUT_AREA_CLASS); inputWrapper.addWidget(inputCollapser); inputWrapper.addWidget(input); (this.layout as PanelLayout).addWidget(inputWrapper); this._inputPlaceholder = new InputPlaceholder({ callback: () => { this.inputHidden = !this.inputHidden; }, text: input.model.sharedModel.getSource().split('\n')[0], translator: this.translator }); input.model.contentChanged.connect((sender, args) => { if (this._inputPlaceholder && this.inputHidden) { this._inputPlaceholder.text = sender.sharedModel .getSource() .split('\n')?.[0]; } }); if (this.inputHidden) { input.parent = null; (inputWrapper.layout as PanelLayout).addWidget(this._inputPlaceholder!); } // Footer const footer = this.contentFactory.createCellFooter(); footer.addClass(CELL_FOOTER_CLASS); (this.layout as PanelLayout).addWidget(footer); } /** * Get the editor options at initialization. * * @returns Editor options */ protected getEditorOptions(): InputArea.IOptions['editorOptions'] { return { config: this.editorConfig }; } /** * Handle `before-attach` messages. */ protected onBeforeAttach(msg: Message): void { if (this.placeholder) { this.placeholder = false; } } /** * Handle `after-attach` messages. */ protected onAfterAttach(msg: Message): void { this.update(); } /** * Handle `'activate-request'` messages. */ protected onActivateRequest(msg: Message): void { this.editor?.focus(); } /** * Handle `resize` messages. */ protected onResize(msg: Widget.ResizeMessage): void { void this._resizeDebouncer.invoke(); } /** * Handle `update-request` messages. */ protected onUpdateRequest(msg: Message): void { if (!this._model) { return; } // Handle read only state. if (this.editor?.getOption('readOnly') !== this._readOnly) { this.editor?.setOption('readOnly', this._readOnly); } } protected onContentChanged() { if (this.inputHidden && this._inputPlaceholder) { this._inputPlaceholder.text = this.model.sharedModel .getSource() .split('\n')?.[0]; } } /** * Handle changes in the metadata. */ protected onMetadataChanged(model: CellModel, args: IMapChange): void { switch (args.key) { case 'jupyter': if (this.syncCollapse) { this.loadCollapseState(); } break; case 'editable': if (this.syncEditable) { this.loadEditableState(); } break; default: break; } } protected prompt = ''; protected translator: ITranslator; protected _displayChanged = new Signal<this, void>(this); private _editorConfig: Record<string, any> = {}; private _input: InputArea | null; private _inputHidden = false; private _inputWrapper: Widget | null; private _inputPlaceholder: InputPlaceholder | null; private _inViewport: boolean; private _inViewportChanged: Signal<Cell, boolean> = new Signal<Cell, boolean>( this ); private _model: T; private _placeholder: boolean; private _readOnly = false; private _ready = new PromiseDelegate<void>(); private _resizeDebouncer = new Debouncer(() => { this._displayChanged.emit(); }, 0); private _syncCollapse = false; private _syncEditable = false; } /** * The namespace for the `Cell` class statics. */ export namespace Cell { /** * An options object for initializing a cell widget. */ export interface IOptions<T extends ICellModel> { /** * The model used by the cell. */ model: T; /** * The factory object for customizable cell children. */ contentFactory: IContentFactory; /** * The configuration options for the text editor widget. */ editorConfig?: Record<string, any>; /** * Editor extensions to be added. */ editorExtensions?: Extension[]; /** * Cell widget layout. */ layout?: PanelLayout; /** * The maximum number of output items to display in cell output. */ maxNumberOutputs?: number; /** * Whether to split stdin line history by kernel session or keep globally accessible. */ inputHistoryScope?: 'global' | 'session'; /** * Whether this cell is a placeholder for future rendering. */ placeholder?: boolean; /** * The application language translator. */ translator?: ITranslator; } /** * Cell heading */ export interface IHeading { /** * Heading text. */ text: string; /** * HTML heading level. */ level: number; /** * Index of the output containing the heading */ outputIndex?: number; /** * Type of heading */ type: HeadingType; } /** * Type of headings */ export enum HeadingType { /** * Heading from HTML output */ HTML, /** * Heading from Markdown cell or Markdown output */ Markdown } /** * The factory object for customizable cell children. * * This is used to allow users of cells to customize child content. * * This inherits from `OutputArea.IContentFactory` to avoid needless nesting and * provide a single factory object for all notebook/cell/outputarea related * widgets. */ export interface IContentFactory extends OutputArea.IContentFactory, InputArea.IContentFactory { /** * Create a new cell header for the parent widget. */ createCellHeader(): ICellHeader; /** * Create a new cell header for the parent widget. */ createCellFooter(): ICellFooter; } /** * The default implementation of an `IContentFactory`. * * This includes a CodeMirror editor factory to make it easy to use out of the box. */ export class ContentFactory implements IContentFactory { /** * Create a content factory for a cell. */ constructor(options: ContentFactory.IOptions) { this._editorFactory = options.editorFactory; } /** * The readonly editor factory that create code editors */ get editorFactory(): CodeEditor.Factory { return this._editorFactory; } /** * Create a new cell header for the parent widget. */ createCellHeader(): ICellHeader { return new CellHeader(); } /** * Create a new cell footer for the parent widget. */ createCellFooter(): ICellFooter { return new CellFooter(); } /** * Create an input prompt. */ createInputPrompt(): IInputPrompt { return new InputPrompt(); } /** * Create the output prompt for the widget. */ createOutputPrompt(): IOutputPrompt { return new OutputPrompt(); } /** * Create an stdin widget. */ createStdin(options: Stdin.IOptions): IStdin { return new Stdin(options); } private _editorFactory: CodeEditor.Factory; } /** * A namespace for cell content factory. */ export namespace ContentFactory { /** * Options for the content factory. */ export interface IOptions { /** * The editor factory used by the content factory. */ editorFactory: CodeEditor.Factory; } } } /** **************************************************************************** * CodeCell ******************************************************************************/ /** * Code cell layout * * It will not detached the output area when the cell is detached. */ export class CodeCellLayout extends PanelLayout { /** * A message handler invoked on a `'before-attach'` message. * * #### Notes * The default implementation of this method forwards the message * to all widgets. It assumes all widget nodes are attached to the * parent widget node. * * This may be reimplemented by subclasses as needed. */ protected onBeforeAttach(msg: Message): void { let beforeOutputArea = true; const outputAreaWrapper = this.parent!.node.firstElementChild; for (const widget of this) { if (outputAreaWrapper) { if (widget.node === outputAreaWrapper) { beforeOutputArea = false; } else { MessageLoop.sendMessage(widget, msg); if (beforeOutputArea) { this.parent!.node.insertBefore(widget.node, outputAreaWrapper); } else { this.parent!.node.appendChild(widget.node); } // Force setting isVisible to true as it requires the parent widget to be // visible. But that flag will be set only during the `onAfterAttach` call. if (!this.parent!.isHidden) { widget.setFlag(Widget.Flag.IsVisible); } // Not called in NotebookWindowedLayout to avoid outputArea // widgets unwanted update or reset. MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach); } } } } /** * A message handler invoked on an `'after-detach'` message. * * #### Notes * The default implementation of this method forwards the message * to all widgets. It assumes all widget nodes are attached to the * parent widget node. * * This may be reimplemented by subclasses as needed. */ protected onAfterDetach(msg: Message): void { for (const widget of this) { // TODO we could improve this further by removing outputs based // on their mime type (for example plain/text or markdown could safely be detached) // If the cell is out of the view port, its children are already detached -> skip detaching if ( !widget.hasClass(CELL_OUTPUT_WRAPPER_CLASS) && widget.node.isConnected ) { // Not called in NotebookWindowedLayout for windowed notebook MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach); this.parent!.node.removeChild(widget.node); MessageLoop.sendMessage(widget, msg); } } } } /** * A widget for a code cell. */ export class CodeCell extends Cell<ICodeCellModel> { /** * Construct a code cell widget. */ constructor(options: CodeCell.IOptions) { super({ layout: new CodeCellLayout(), ...options, placeholder: true }); this.addClass(CODE_CELL_CLASS); const trans = this.translator.load('jupyterlab'); // Only save options not handled by parent constructor. const rendermime = (this._rendermime = options.rendermime); const contentFactory = this.contentFactory; const model = this.model; this.maxNumberOutputs = options.maxNumberOutputs; // Note that modifying the below label warrants one to also modify // the same in this._outputLengthHandler. Ideally, this label must // have been a constant and used in both places but it is not done // so because of limitations in the translation manager. const ariaLabel = model.outputs.length === 0 ? trans.__('Code Cell Content') : trans.__('Code Cell Content with Output'); this.node.setAttribute('aria-label', ariaLabel); const output = (this._output = new OutputArea({ model: this.model.outputs, rendermime, contentFactory: contentFactory, maxNumberOutputs: this.maxNumberOutputs, translator: this.translator, promptOverlay: true, inputHistoryScope: options.inputHistoryScope })); output.addClass(CELL_OUTPUT_AREA_CLASS); output.toggleScrolling.connect(() => { this.outputsScrolled = !this.outputsScrolled; }); // Defer setting placeholder as OutputArea must be instantiated before initializing the DOM this.placeholder = options.placeholder ?? true; model.outputs.changed.connect(this.onOutputChanged, this); model.outputs.stateChanged.connect(this.onOutputChanged, this); model.stateChanged.connect(this.onStateChanged, this); } /** * Maximum number of outputs to display. */ protected maxNumberOutputs: number | undefined; /** * Create children widgets. */ protected initializeDOM(): void { if (!this.placeholder) { return; } super.initializeDOM(); this.setPrompt(this.prompt); // Insert the output before the cell footer. const outputWrapper = (this._outputWrapper = new Panel()); outputWrapper.addClass(CELL_OUTPUT_WRAPPER_CLASS); const outputCollapser = new OutputCollapser(); outputCollapser.addClass(CELL_OUTPUT_COLLAPSER_CLASS); outputWrapper.addWidget(outputCollapser); // Set a CSS if there are no outputs, and connect a signal for future // changes to the number of outputs. This is for conditional styling // if there are no outputs. if (this.model.outputs.length === 0) { this.addClass(NO_OUTPUTS_CLASS); } this._output.outputLengthChanged.connect(this._outputLengthHandler, this); outputWrapper.addWidget(this._output); const layout = this.layout as PanelLayout; layout.insertWidget(layout.widgets.length - 1, new ResizeHandle(this.node)); layout.insertWidget(layout.widgets.length - 1, outputWrapper); if (this.model.isDirty) { this.addClass(DIRTY_CLASS); } this._outputPlaceholder = new OutputPlaceholder({ callback: () => { this.outputHidden = !this.outputHidden; }, text: this.getOutputPlaceholderText(), translator: this.translator }); const layoutWrapper = outputWrapper.layout as PanelLayout; if (this.outputHidden) { layoutWrapper.removeWidget(this._output); layoutWrapper.addWidget(this._outputPlaceholder); if (this.inputHidden && !outputWrapper.isHidden) { this._outputWrapper!.hide(); } } const trans = this.translator.load('jupyterlab'); const ariaLabel = this.model.outputs.length === 0 ? trans.__('Code Cell Content') : trans.__('Code Cell Content with Output'); this.node.setAttribute('aria-label', ariaLabel); } protected getOutputPlaceholderText(): string | undefined { const firstOutput = this.model.outputs.get(0); const outputData = firstOutput?.data; if (!outputData) { return undefined; } const supportedOutputTypes = [ 'text/html', 'image/svg+xml', 'application/pdf', 'text/markdown', 'text/plain', 'application/vnd.jupyter.stderr', 'application/vnd.jupyter.stdout', 'text' ]; const preferredOutput = supportedOutputTypes.find(mt => { const data = firstOutput.data[mt]; return (Array.isArray(data) ? typeof data[0] : typeof data) === 'string'; }); const dataToDisplay = firstOutput.data[preferredOutput ?? '']; if (dataToDisplay !== undefined) { return ( Array.isArray(dataToDisplay) ? dataToDisplay : (dataToDisplay as string)?.split('\n') )?.find(part => part !== ''); } return undefined; } /** * Initialize view state from model. * * #### Notes * Should be called after construction. For convenience, returns this, so it * can be chained in the construction, like `new Foo().initializeState();` */ initializeState(): this { super.initializeState(); this.loadScrolledState(); this.setPrompt(`${this.model.executionCount || ''}`); return this; } get headings(): Cell.IHeading[] { if (!this._headingsCache) { const headings: Cell.IHeading[] = []; // Iterate over the code cell outputs to check for Markdown or HTML from which we can generate ToC headings... const outputs = this.model.outputs; for (let j = 0; j < outputs.length; j++) { const m = outputs.get(j); let htmlType: string | null = null; let mdType: string | null = null; Object.keys(m.data).forEach(t => { if (!mdType && TableOfContentsUtils.Markdown.isMarkdown(t)) { mdType = t; } else if (!htmlType && TableOfContentsUtils.isHTML(t)) { htmlType = t; } }); // Parse HTML output if (htmlType) { headings.push( ...TableOfContentsUtils.getHTMLHeadings( this._rendermime.sanitizer.sanitize(m.data[htmlType] as string) ).map(heading => { return { ...heading, outputIndex: j, type: Cell.HeadingType.HTML }; }) ); } else if (mdType) { headings.push( ...TableOfContentsUtils.Markdown.getHeadings( m.data[mdType] as string ).map(heading => { return { ...heading, outputIndex: j, type: Cell.HeadingType.Markdown }; }) ); } } this._headingsCache = headings; } return [...this._headingsCache!]; } /** * Get the output area for the cell. */ get outputArea(): OutputArea { return this._output; } /** * The view state of output being collapsed. */ get outputHidden(): boolean { return this._outputHidden; } set outputHidden(value: boolean) { if (this._outputHidden === value) { return; } if (!this.placeholder) { const layout = this._outputWrapper!.layout as PanelLayout; if (value) { layout.removeWidget(this._output); layout.addWidget(this._outputPlaceholder!); if (this.inputHidden && !this._outputWrapper!.isHidden) { this._outputWrapper!.hide(); } if (this._outputPlaceholder) { this._outputPlaceholder.text = this.getOutputPlaceholderText() ?? ''; } } else { if (this._outputWrapper!.isHidden) { this._outputWrapper!.show(); } layout.removeWidget(this._outputPlaceholder!); layout.addWidget(this._output); } } this._outputHidden = value; if (this.syncCollapse) { this.saveCollapseState(); } } /** * Save view collapse state to model */ saveCollapseState(): void { // Because collapse state for a code cell involves two different pieces of // metadata (the `collapsed` and `jupyter` metadata keys), we block reacting // to changes in metadata until we have fully committed our changes. // Otherwise setting one key can trigger a write to the other key to // maintain the synced consistency. this.model.sharedModel.transact(() => { super.saveCollapseState(); const collapsed = this.model.getMetadata('collapsed'); if ( (this.outputHidden && collapsed === true) || (!this.outputHidden && collapsed === undefined) ) { return; } // Do not set jupyter.outputs_hidden since it is redundant. See // and https://github.com/jupyter/nbformat/issues/137 if (this.outputHidden) { this.model.setMetadata('collapsed', true); } else { this.model.deleteMetadata('collapsed'); } }, false); } /** * Revert view collapse state from model. * * We consider the `collapsed` metadata key as the source of truth for outputs * being hidden. */ loadCollapseState(): void { super.loadCollapseState(); this.outputHidden = !!this.model.getMetadata('collapsed'); } /** * Whether the output is in a scrolled state? */ get outputsScrolled(): boolean { return this._outputsScrolled; } set outputsScrolled(value: boolean) { this.toggleClass('jp-mod-outputsScrolled', value); this._outputsScrolled = value; if (this.syncScrolled) { this.saveScrolledState(); } } /** * Save view collapse state to model */ saveScrolledState(): void { const current = this.model.getMetadata('scrolled'); if ( (this.outputsScrolled && current === true) || (!this.outputsScrolled && current === undefined) ) { return; } if (this.outputsScrolled) { this.model.setMetadata('scrolled', true); } else { this.model.deleteMetadata('scrolled'); } } /** * Revert view collapse state from model. */ loadScrolledState(): void { // We don't have the notion of 'auto' scrolled, so we make it false. if (this.model.getMetadata('scrolled') === 'auto') { this.outputsScrolled = false; } else { this.outputsScrolled = !!this.model.getMetadata('scrolled'); } } /** * Whether to sync the scrolled state to the cell model. */ get syncScrolled(): boolean { return this._syncScrolled; } set syncScrolled(value: boolean) { if (this._syncScrolled === value) { return; } this._syncScrolled = value; if (value) { this.loadScrolledState(); } } /** * Handle the input being hidden. * * #### Notes * This method is called by the case cell implementation and is * subclasses here so the code cell can watch to see when input * is hidden without accessing private state. */ protected handleInputHidden(value: boolean): void { if (this.placeholder) { return; } if (!value && this._outputWrapper!.isHidden) { this._outputWrapper!.show(); } else if (value && !this._outputWrapper!.isHidden && this._outputHidden) { this._outputWrapper!.hide(); } } /** * Clone the cell, using the same model. */ clone(): CodeCell { const constructor = this.constructor as typeof CodeCell; return new constructor({ model: this.model, contentFactory: this.contentFactory, rendermime: this._rendermime, placeholder: false, translator: this.translator }); } /** * Clone the OutputArea alone, returning a simplified output area, using the same model. */ cloneOutputArea(): OutputArea { return new SimplifiedOutputArea({ model: this.model.outputs!, contentFactory: this.contentFactory, rendermime: this._rendermime }); } /** * Dispose of the resources used by the widget. */ dispose(): void { if (this.isDisposed) { return; } this._output.outputLengthChanged.disconnect( this._outputLengthHandler, this ); this._rendermime = null!; this._output = null!; this._outputWrapper = null!; this._outputPlaceholder = null!; super.dispose(); } /** * Handle changes in the model. */ protected onStateChanged(model: ICellModel, args: IChangedArgs<any>): void { switch (args.name) { case 'executionCount': this.setPrompt(`${(model as ICodeCellModel).executionCount || ''}`); break; case 'isDirty': if ((model as ICodeCellModel).isDirty) { this.addClass(DIRTY_CLASS); } else { this.removeClass(DIRTY_CLASS); } break; default: break; } } /** * Callback on output changes */ protected onOutputChanged(): void { this._headingsCache = null; if (this._outputPlaceholder && this.outputHidden) { this._outputPlaceholder.text = this.getOutputPlaceholderText() ?? ''; } } /** * Handle changes in the metadata. */ protected onMetadataChanged(model: CellModel, args: IMapChange): void { switch (args.key) { case 'scrolled': if (this.syncScrolled) { this.loadScrolledState(); } break; case 'collapsed': if (this.syncCollapse) { this.loadCollapseState(); } break; default: break; } super.onMetadataChanged(model, args); } /** * Handle changes in the number of outputs in the output area. */ private _outputLengthHandler(sender: OutputArea, args: number) { const force = args === 0 ? true : false; this.toggleClass(NO_OUTPUTS_CLASS, force); const trans = this.translator.load('jupyterlab'); const ariaLabel = force ? trans.__('Code Cell Content') : trans.__('Code Cell Content with Output'); this.node.setAttribute('aria-label', ariaLabel); } private _headingsCache: Cell.IHeading[] | null = null; private _rendermime: IRenderMimeRegistry; private _outputHidden = false; private _outputsScrolled: boolean; private _outputWrapper: Widget | null = null; private _outputPlaceholder: OutputPlaceholder | null = null; private _output: OutputArea; private _syncScrolled = false; } /** * The namespace for the `CodeCell` class statics. */ export namespace CodeCell { /** * An options object for initializing a base cell widget. */ export interface IOptions extends Cell.IOptions<ICodeCellModel> { /** * Code cell layout. */ layout?: CodeCellLayout; /** * The mime renderer for the cell widget. */ rendermime: IRenderMimeRegistry; } /** * Execute a cell given a client session. */ export async function execute( cell: CodeCell, sessionContext: ISessionContext, metadata?: JSONObject ): Promise<KernelMessage.IExecuteReplyMsg | void> { const model = cell.model; const code = model.sharedModel.getSource(); if (!code.trim() || !sessionContext.session?.kernel) { model.sharedModel.transact(() => { model.clearExecution(); }, false); return; } const cellId = { cellId: model.sharedModel.getId() }; metadata = { ...model.metadata, ...metadata, ...cellId }; const { recordTiming } = metadata; model.sharedModel.transact(() => { model.clearExecution(); cell.outputHidden = false; }, false); cell.setPrompt('*'); model.trusted = true; let future: | Kernel.IFuture< KernelMessage.IExecuteRequestMsg, KernelMessage.IExecuteReplyMsg > | undefined; try { const msgPromise = OutputArea.execute( code, cell.outputArea, sessionContext, metadata ); // cell.outputArea.future assigned synchronously in `execute` if (recordTiming) { const recordTimingHook = (msg: KernelMessage.IIOPubMessage) => { let label: string; switch (msg.header.msg_type) { case 'status': label = `status.${ (msg as KernelMessage.IStatusMsg).content.execution_state }`; break; case 'execute_input': label = 'execute_input'; break; default: return true; } // If the data is missing, estimate it to now // Date was added in 5.1: https://jupyter-client.readthedocs.io/en/stable/messaging.html#message-header const value = msg.header.date || new Date().toISOString(); const timingInfo: any = Object.assign( {}, model.getMetadata('execution') ); timingInfo[`iopub.${label}`] = value; model.setMetadata('execution', timingInfo); return true; }; cell.outputArea.future.registerMessageHook(recordTimingHook); } else { model.deleteMetadata('execution'); } // Save this execution's future so we can compare in the catch below. future = cell.outputArea.future; const msg = (await msgPromise)!; model.executionCount = msg.content.execution_count; if (recordTiming) { const timingInfo = Object.assign( {}, model.getMetadata('execution') as any ); const started = msg.metadata.started as string; // Started is not in the API, but metadata IPyKernel sends if (started) { timingInfo['shell.execute_reply.started'] = started; } // Per above, the 5.0 spec does not assume date, so we estimate is required const finished = msg.header.date as string; timingInfo['shell.execute_reply'] = finished || new Date().toISOString(); model.setMetadata('execution', timingInfo); } return msg; } catch (e) { // If we started executing, and the cell is still indicating this // execution, clear the prompt. if (future && !cell.isDisposed && cell.outputArea.future === future) { cell.setPrompt(''); } throw e; } } } /** * `AttachmentsCell` - A base class for a cell widget that allows * attachments to be drag/drop'd or pasted onto it */ export abstract class AttachmentsCell< T extends IAttachmentsCellModel > extends Cell<T> { /** * Handle the DOM events for the widget. * * @param event - The DOM event sent to the widget. * * #### Notes * This method implements the DOM `EventListener` interface and is * called in response to events on the notebook panel's node. It should * not be called directly by user code. */ handleEvent(event: Event): void { switch (event.type) { case 'lm-dragover': this._evtDragOver(event as Drag.Event); break; case 'lm-drop': this._evtDrop(event as Drag.Event); break; default: break; } } /** * Get the editor options at initialization. * * @returns Editor options */ protected getEditorOptions(): InputArea.IOptions['editorOptions'] { const base = super.getEditorOptions() ?? {}; base.extensions = [ ...(base.extensions ?? []), EditorView.domEventHandlers({ dragenter: (event: DragEvent) => { event.preventDefault(); }, dragover: (event: DragEvent) => { event.preventDefault(); }, drop: (event: DragEvent) => { this._evtNativeDrop(event); }, paste: (event: ClipboardEvent) => { this._evtPaste(event); } }) ]; return base; } /** * Modify the cell source to include a reference to the attachment. */ protected abstract updateCellSourceWithAttachment( attachmentName: string, URI?: string ): void; /** * Handle `after-attach` messages for the widget. */ protected onAfterAttach(msg: Message): void { super.onAfterAttach(msg); const node = this.node; node.addEventListener('lm-dragover', this); node.addEventListener('lm-drop', this); } /** * A message handler invoked on a `'before-detach'` * message */ protected onBeforeDetach(msg: Message): void { const node = this.node; node.removeEventListener('lm-dragover', this); node.removeEventListener('lm-drop', this); super.onBeforeDetach(msg); } private _evtDragOver(event: Drag.Event) { const supportedMimeType = some(imageRendererFactory.mimeTypes, mimeType => { if (!event.mimeData.hasData(CONTENTS_MIME_RICH)) { return false; } const data = event.mimeData.getData( CONTENTS_MIME_RICH ) as DirListing.IContentsThunk; return data.model.mimetype === mimeType; }); if (!supportedMimeType) { return; } event.preventDefault(); event.stopPropagation(); event.dropAction = event.proposedAction; } /** * Handle the `paste` event for the widget */ private _evtPaste(event: ClipboardEvent): void { if (event.clipboardData) { const items = event.clipboardData.items; for (let i = 0; i < items.length; i++) { if (items[i].type === 'text/plain') { // Skip if this text is the path to a file if (i < items.length - 1 && items[i + 1].kind === 'file') { continue; } items[i].getAsString(text => { this.editor!.replaceSelection?.(text); }); } this._attachFiles(event.clipboardData.items); } } event.preventDefault(); } /** * Handle the `drop` event for the widget */ private _evtNativeDrop(event: DragEvent): void { if (event.dataTransfer) { this._attachFiles(event.dataTransfer.items); } event.preventDefault(); } /** * Handle the `'lm-drop'` event for the widget. */ private _evtDrop(event: Drag.Event): void { const supportedMimeTypes = event.mimeData.types().filter(mimeType => { if (mimeType === CONTENTS_MIME_RICH) { const data = event.mimeData.getData( CONTENTS_MIME_RICH ) as DirListing.IContentsThunk; return ( imageRendererFactory.mimeTypes.indexOf(data.model.mimetype) !== -1 ); } return imageRendererFactory.mimeTypes.indexOf(mimeType) !== -1; }); if (supportedMimeTypes.length === 0) { return; } event.preventDefault(); event.stopPropagation(); if (event.proposedAction === 'none') { event.dropAction = 'none'; return; } event.dropAction = 'copy'; for (const mimeType of supportedMimeTypes) { if (mimeType === CONTENTS_MIME_RICH) { const { model, withContent } = event.mimeData.getData( CONTENTS_MIME_RICH ) as DirListing.IContentsThunk; if (model.type === 'file') { const URI = this._generateURI(model.name); this.updateCellSourceWithAttachment(model.name, URI); void withContent().then(fullModel => { this.model.attachments.set(URI, { [fullModel.mimetype]: fullModel.content }); }); } } else { // Pure mimetype, no useful name to infer const URI = this._generateURI(); this.model.attachments.set(URI, { [mimeType]: event.mimeData.getData(mimeType) }); this.updateCellSourceWithAttachment(URI, URI); } } } /** * Attaches all DataTransferItems (obtained from * clipboard or native drop events) to the cell */ private _attachFiles(items: DataTransferItemList) { for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file') { const blob = item.getAsFile(); if (blob) { this._attachFile(blob); } } } } /** * Takes in a file object and adds it to * the cell attachments */ private _attachFile(blob: File) { const reader = new FileReader(); reader.onload = evt => { const { href, protocol } = URLExt.parse(reader.result as string); if (protocol !== 'data:') { return; } const dataURIRegex = /([\w+\/\+]+)?(?:;(charset=[\w\d-]*|base64))?,(.*)/; const matches = dataURIRegex.exec(href); if (!matches || matches.length !== 4) { return; } const mimeType = matches[1]; const encodedData = matches[3]; const bundle: nbformat.IMimeBundle = { [mimeType]: encodedData }; const URI = this._generateURI(blob.name); if (mimeType.startsWith('image/')) { this.model.attachments.set(URI, bundle); this.updateCellSourceWithAttachment(blob.name, URI); } }; reader.onerror = evt => { console.error(`Failed to attach ${blob.name}` + evt); }; reader.readAsDataURL(blob); } /** * Generates a unique URI for a file * while preserving the file extension. */ private _generateURI(name = ''): string { const lastIndex = name.lastIndexOf('.'); return lastIndex !== -1 ? UUID.uuid4().concat(name.substring(lastIndex)) : UUID.uuid4(); } } /** **************************************************************************** * MarkdownCell ******************************************************************************/ /** * A widget for a Markdown cell. * * #### Notes * Things get complicated if we want the rendered text to update * any time the text changes, the text editor model changes, * or the input area model changes. We don't support automatically * updating the rendered text in all of these cases. */ export class MarkdownCell extends AttachmentsCell<IMarkdownCellModel> { /** * Construct a Markdown cell widget. */ constructor(options: MarkdownCell.IOptions) { super({ ...options, placeholder: true }); this.addClass(MARKDOWN_CELL_CLASS); this.model.contentChanged.connect(this.onContentChanged, this); const trans = this.translator.load('jupyterlab'); this.node.setAttribute('aria-label', trans.__('Markdown Cell Content')); // Ensure we can resolve attachments: this._rendermime = options.rendermime.clone({ r