@jupyterlab/cells
Version:
JupyterLab - Notebook Cells
1,878 lines (1,673 loc) • 63.6 kB
text/typescript
/* -----------------------------------------------------------------------------
| 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