@jupyterlab/notebook
Version:
JupyterLab - Notebook
1,780 lines (1,614 loc) • 98.7 kB
text/typescript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
import { DOMUtils } from '@jupyterlab/apputils';
import {
Cell,
CodeCell,
ICellModel,
ICodeCellModel,
IMarkdownCellModel,
IRawCellModel,
MarkdownCell,
RawCell
} from '@jupyterlab/cells';
import { CodeEditor, IEditorMimeTypeService } from '@jupyterlab/codeeditor';
import { IChangedArgs } from '@jupyterlab/coreutils';
import * as nbformat from '@jupyterlab/nbformat';
import { IObservableList } from '@jupyterlab/observables';
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
import type { IMapChange } from '@jupyter/ydoc';
import { TableOfContentsUtils } from '@jupyterlab/toc';
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
import { WindowedList } from '@jupyterlab/ui-components';
import { ArrayExt, findIndex } from '@lumino/algorithm';
import { MimeData } from '@lumino/coreutils';
import { ElementExt } from '@lumino/domutils';
import { Drag } from '@lumino/dragdrop';
import { Message } from '@lumino/messaging';
import { AttachedProperty } from '@lumino/properties';
import { ISignal, Signal } from '@lumino/signaling';
import { h, VirtualDOM } from '@lumino/virtualdom';
import { PanelLayout, Widget } from '@lumino/widgets';
import { NotebookActions } from './actions';
import { CellList } from './celllist';
import { DROP_SOURCE_CLASS, DROP_TARGET_CLASS } from './constants';
import { INotebookHistory } from './history';
import { INotebookModel } from './model';
import { NotebookViewModel, NotebookWindowedLayout } from './windowing';
import { NotebookFooter } from './notebookfooter';
import { CodeCellModel } from '../../cells/src/model';
/**
* The data attribute added to a widget that has an active kernel.
*/
const KERNEL_USER = 'jpKernelUser';
/**
* The data attribute added to a widget that can run code.
*/
const CODE_RUNNER = 'jpCodeRunner';
/**
* The data attribute added to a widget that can undo.
*/
const UNDOER = 'jpUndoer';
/**
* The class name added to notebook widgets.
*/
const NB_CLASS = 'jp-Notebook';
/**
* The class name added to notebook widget cells.
*/
const NB_CELL_CLASS = 'jp-Notebook-cell';
/**
* The class name added to a notebook in edit mode.
*/
const EDIT_CLASS = 'jp-mod-editMode';
/**
* The class name added to a notebook in command mode.
*/
const COMMAND_CLASS = 'jp-mod-commandMode';
/**
* The class name added to the active cell.
*/
const ACTIVE_CLASS = 'jp-mod-active';
/**
* The class name added to selected cells.
*/
const SELECTED_CLASS = 'jp-mod-selected';
/**
* The class name added to the cell when dirty.
*/
const DIRTY_CLASS = 'jp-mod-dirty';
/**
* The class name added to an active cell when there are other selected cells.
*/
const OTHER_SELECTED_CLASS = 'jp-mod-multiSelected';
/**
* The class name added to unconfined images.
*/
const UNCONFINED_CLASS = 'jp-mod-unconfined';
/**
* The class name added to the notebook when an element within it is focused
* and takes keyboard input, such as focused <input> or <div contenteditable>.
*
* This class is also effective when the focused element is in shadow DOM.
*/
const READ_WRITE_CLASS = 'jp-mod-readWrite';
/**
* The class name added to drag images.
*/
const DRAG_IMAGE_CLASS = 'jp-dragImage';
/**
* The class name added to singular drag images
*/
const SINGLE_DRAG_IMAGE_CLASS = 'jp-dragImage-singlePrompt';
/**
* The class name added to the drag image cell content.
*/
const CELL_DRAG_CONTENT_CLASS = 'jp-dragImage-content';
/**
* The class name added to the drag image cell content.
*/
const CELL_DRAG_PROMPT_CLASS = 'jp-dragImage-prompt';
/**
* The class name added to the drag image cell content.
*/
const CELL_DRAG_MULTIPLE_BACK = 'jp-dragImage-multipleBack';
/**
* The mimetype used for Jupyter cell data.
*/
const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells';
/**
* The threshold in pixels to start a drag event.
*/
const DRAG_THRESHOLD = 5;
/**
* Maximal remaining time for idle callback
*
* Ref: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API#getting_the_most_out_of_idle_callbacks
*/
const MAXIMUM_TIME_REMAINING = 50;
/*
* The rendering mode for the notebook.
*/
type RenderingLayout = 'default' | 'side-by-side';
/**
* The class attached to the heading collapser button
*/
const HEADING_COLLAPSER_CLASS = 'jp-collapseHeadingButton';
/**
* The class that controls the visibility of "heading collapser" and "show hidden cells" buttons.
*/
const HEADING_COLLAPSER_VISBILITY_CONTROL_CLASS =
'jp-mod-showHiddenCellsButton';
const SIDE_BY_SIDE_CLASS = 'jp-mod-sideBySide';
/**
* The interactivity modes for the notebook.
*/
export type NotebookMode = 'command' | 'edit';
if ((window as any).requestIdleCallback === undefined) {
// On Safari, requestIdleCallback is not available, so we use replacement functions for `idleCallbacks`
// See: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API#falling_back_to_settimeout
// eslint-disable-next-line @typescript-eslint/ban-types
(window as any).requestIdleCallback = function (handler: Function) {
let startTime = Date.now();
return setTimeout(function () {
handler({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, 50.0 - (Date.now() - startTime));
}
});
}, 1);
};
(window as any).cancelIdleCallback = function (id: number) {
clearTimeout(id);
};
}
/**
* A widget which renders static non-interactive notebooks.
*
* #### Notes
* The widget model must be set separately and can be changed
* at any time. Consumers of the widget must account for a
* `null` model, and may want to listen to the `modelChanged`
* signal.
*/
export class StaticNotebook extends WindowedList<NotebookViewModel> {
/**
* Construct a notebook widget.
*/
constructor(options: StaticNotebook.IOptions) {
const cells = new Array<Cell>();
const windowingActive =
(options.notebookConfig?.windowingMode ??
StaticNotebook.defaultNotebookConfig.windowingMode) === 'full';
super({
model: new NotebookViewModel(cells, {
overscanCount:
options.notebookConfig?.overscanCount ??
StaticNotebook.defaultNotebookConfig.overscanCount,
windowingActive
}),
layout: new NotebookWindowedLayout(),
renderer: options.renderer ?? WindowedList.defaultRenderer,
scrollbar: false
});
this.addClass(NB_CLASS);
this.cellsArray = cells;
this._idleCallBack = null;
this._editorConfig = StaticNotebook.defaultEditorConfig;
this._notebookConfig = StaticNotebook.defaultNotebookConfig;
this._mimetype = IEditorMimeTypeService.defaultMimeType;
this._notebookModel = null;
this._modelChanged = new Signal<this, void>(this);
this._modelContentChanged = new Signal<this, void>(this);
this.node.dataset[KERNEL_USER] = 'true';
this.node.dataset[UNDOER] = 'true';
this.node.dataset[CODE_RUNNER] = 'true';
this.rendermime = options.rendermime;
this.translator = options.translator || nullTranslator;
this.contentFactory = options.contentFactory;
this.editorConfig =
options.editorConfig || StaticNotebook.defaultEditorConfig;
this.notebookConfig =
options.notebookConfig || StaticNotebook.defaultNotebookConfig;
this._updateNotebookConfig();
this._mimetypeService = options.mimeTypeService;
this.renderingLayout = options.notebookConfig?.renderingLayout;
this.kernelHistory = options.kernelHistory;
}
get cellCollapsed(): ISignal<this, Cell> {
return this._cellCollapsed;
}
get cellInViewportChanged(): ISignal<this, Cell> {
return this._cellInViewportChanged;
}
/**
* A signal emitted when the model of the notebook changes.
*/
get modelChanged(): ISignal<this, void> {
return this._modelChanged;
}
/**
* A signal emitted when the model content changes.
*
* #### Notes
* This is a convenience signal that follows the current model.
*/
get modelContentChanged(): ISignal<this, void> {
return this._modelContentChanged;
}
/**
* A signal emitted when the rendering layout of the notebook changes.
*/
get renderingLayoutChanged(): ISignal<this, RenderingLayout> {
return this._renderingLayoutChanged;
}
/**
* The cell factory used by the widget.
*/
readonly contentFactory: StaticNotebook.IContentFactory;
/**
* The Rendermime instance used by the widget.
*/
readonly rendermime: IRenderMimeRegistry;
/**
* Translator to be used by cell renderers
*/
readonly translator: ITranslator;
/**
* The model for the widget.
*/
get model(): INotebookModel | null {
return this._notebookModel;
}
set model(newValue: INotebookModel | null) {
newValue = newValue || null;
if (this._notebookModel === newValue) {
return;
}
const oldValue = this._notebookModel;
this._notebookModel = newValue;
// Trigger private, protected, and public changes.
this._onModelChanged(oldValue, newValue);
this.onModelChanged(oldValue, newValue);
this._modelChanged.emit(void 0);
// Trigger state change
this.viewModel.itemsList = newValue?.cells ?? null;
}
/**
* Get the mimetype for code cells.
*/
get codeMimetype(): string {
return this._mimetype;
}
/**
* A read-only sequence of the widgets in the notebook.
*/
get widgets(): ReadonlyArray<Cell> {
return this.cellsArray as ReadonlyArray<Cell>;
}
/**
* A configuration object for cell editor settings.
*/
get editorConfig(): StaticNotebook.IEditorConfig {
return this._editorConfig;
}
set editorConfig(value: StaticNotebook.IEditorConfig) {
this._editorConfig = value;
this._updateEditorConfig();
}
/**
* A configuration object for notebook settings.
*/
get notebookConfig(): StaticNotebook.INotebookConfig {
return this._notebookConfig;
}
set notebookConfig(value: StaticNotebook.INotebookConfig) {
this._notebookConfig = value;
this._updateNotebookConfig();
}
get renderingLayout(): RenderingLayout | undefined {
return this._renderingLayout;
}
set renderingLayout(value: RenderingLayout | undefined) {
this._renderingLayout = value;
if (this._renderingLayout === 'side-by-side') {
this.node.classList.add(SIDE_BY_SIDE_CLASS);
} else {
this.node.classList.remove(SIDE_BY_SIDE_CLASS);
}
this._renderingLayoutChanged.emit(this._renderingLayout ?? 'default');
}
/**
* Dispose of the resources held by the widget.
*/
dispose(): void {
// Do nothing if already disposed.
if (this.isDisposed) {
return;
}
this._notebookModel = null;
(this.layout as NotebookWindowedLayout).header?.dispose();
super.dispose();
}
/**
* Move cells preserving widget view state.
*
* #### Notes
* This is required because at the model level a move is a deletion
* followed by an insertion. Hence the view state is not preserved.
*
* @param from The index of the cell to move
* @param to The new index of the cell
* @param n Number of cells to move
*/
moveCell(from: number, to: number, n = 1): void {
if (!this.model) {
return;
}
const boundedTo = Math.min(this.model.cells.length - 1, Math.max(0, to));
if (boundedTo === from) {
return;
}
const viewModel: { [k: string]: any }[] = new Array(n);
let dirtyState: boolean[] = new Array(n);
for (let i = 0; i < n; i++) {
viewModel[i] = {};
const oldCell = this.widgets[from + i];
if (oldCell.model.type === 'markdown') {
for (const k of ['rendered', 'headingCollapsed']) {
// @ts-expect-error Cell has no index signature
viewModel[i][k] = oldCell[k];
}
} else if (oldCell.model.type === 'code') {
const oldCodeCell = oldCell.model as ICodeCellModel;
dirtyState[i] = oldCodeCell.isDirty;
}
}
this.model!.sharedModel.moveCells(from, boundedTo, n);
for (let i = 0; i < n; i++) {
const newCell = this.widgets[to + i];
const view = viewModel[i];
for (const state in view) {
// @ts-expect-error Cell has no index signature
newCell[state] = view[state];
}
if (from > to) {
if (this.widgets[to + i].model.type === 'code') {
(this.widgets[to + i].model as CodeCellModel).isDirty = dirtyState[i];
}
} else {
if (this.widgets[to + i - n + 1].model.type === 'code') {
(this.widgets[to + i - n + 1].model as CodeCellModel).isDirty =
dirtyState[i];
}
}
}
}
/**
* Force rendering the cell outputs of a given cell if it is still a placeholder.
*
* #### Notes
* The goal of this method is to allow search on cell outputs (that is based
* on DOM tree introspection).
*
* @param index The cell index
*/
renderCellOutputs(index: number): void {
const cell = this.viewModel.widgetRenderer(index) as Cell;
if (cell instanceof CodeCell && cell.isPlaceholder()) {
cell.dataset.windowedListIndex = `${index}`;
this.layout.insertWidget(index, cell);
if (this.notebookConfig.windowingMode === 'full') {
// We need to delay slightly the removal to let codemirror properly initialize
requestAnimationFrame(() => {
this.layout.removeWidget(cell);
});
}
}
}
/**
* Adds a message to the notebook as a header.
*/
protected addHeader(): void {
const trans = this.translator.load('jupyterlab');
const info = new Widget();
info.node.textContent = trans.__(
'The notebook is empty. Click the + button on the toolbar to add a new cell.'
);
(this.layout as NotebookWindowedLayout).header = info;
}
/**
* Removes the header.
*/
protected removeHeader(): void {
(this.layout as NotebookWindowedLayout).header?.dispose();
(this.layout as NotebookWindowedLayout).header = null;
}
/**
* Handle a new model.
*
* #### Notes
* This method is called after the model change has been handled
* internally and before the `modelChanged` signal is emitted.
* The default implementation is a no-op.
*/
protected onModelChanged(
oldValue: INotebookModel | null,
newValue: INotebookModel | null
): void {
// No-op.
}
/**
* Handle changes to the notebook model content.
*
* #### Notes
* The default implementation emits the `modelContentChanged` signal.
*/
protected onModelContentChanged(model: INotebookModel, args: void): void {
this._modelContentChanged.emit(void 0);
}
/**
* Handle changes to the notebook model metadata.
*
* #### Notes
* The default implementation updates the mimetypes of the code cells
* when the `language_info` metadata changes.
*/
protected onMetadataChanged(sender: INotebookModel, args: IMapChange): void {
switch (args.key) {
case 'language_info':
this._updateMimetype();
break;
default:
break;
}
}
/**
* Handle a cell being inserted.
*
* The default implementation is a no-op
*/
protected onCellInserted(index: number, cell: Cell): void {
// This is a no-op.
}
/**
* Handle a cell being removed.
*
* The default implementation is a no-op
*/
protected onCellRemoved(index: number, cell: Cell): void {
// This is a no-op.
}
/**
* A message handler invoked on an `'update-request'` message.
*
* #### Notes
* The default implementation of this handler is a no-op.
*/
protected onUpdateRequest(msg: Message): void {
if (this.notebookConfig.windowingMode === 'defer') {
void this._runOnIdleTime();
} else {
super.onUpdateRequest(msg);
}
}
/**
* Handle a new model on the widget.
*/
private _onModelChanged(
oldValue: INotebookModel | null,
newValue: INotebookModel | null
): void {
if (oldValue) {
oldValue.contentChanged.disconnect(this.onModelContentChanged, this);
oldValue.metadataChanged.disconnect(this.onMetadataChanged, this);
oldValue.cells.changed.disconnect(this._onCellsChanged, this);
while (this.cellsArray.length) {
this._removeCell(0);
}
}
if (!newValue) {
this._mimetype = IEditorMimeTypeService.defaultMimeType;
return;
}
this._updateMimetype();
const cells = newValue.cells;
const collab = newValue.collaborative ?? false;
if (!collab && !cells.length) {
newValue.sharedModel.insertCell(0, {
cell_type: this.notebookConfig.defaultCell,
metadata:
this.notebookConfig.defaultCell === 'code'
? {
// This is an empty cell created in empty notebook, thus is trusted
trusted: true
}
: {}
});
}
let index = -1;
for (const cell of cells) {
this._insertCell(++index, cell);
}
newValue.cells.changed.connect(this._onCellsChanged, this);
newValue.metadataChanged.connect(this.onMetadataChanged, this);
newValue.contentChanged.connect(this.onModelContentChanged, this);
}
/**
* Handle a change cells event.
*/
protected _onCellsChanged(
sender: CellList,
args: IObservableList.IChangedArgs<ICellModel>
): void {
this.removeHeader();
switch (args.type) {
case 'add': {
let index = 0;
index = args.newIndex;
for (const value of args.newValues) {
this._insertCell(index++, value);
}
this._updateDataWindowedListIndex(
args.newIndex,
this.model!.cells.length,
args.newValues.length
);
break;
}
case 'remove':
for (let length = args.oldValues.length; length > 0; length--) {
this._removeCell(args.oldIndex);
}
this._updateDataWindowedListIndex(
args.oldIndex,
this.model!.cells.length + args.oldValues.length,
-1 * args.oldValues.length
);
// Add default cell if there are no cells remaining.
if (!sender.length) {
const model = this.model;
// Add the cell in a new context to avoid triggering another
// cell changed event during the handling of this signal.
requestAnimationFrame(() => {
if (model && !model.isDisposed && !model.sharedModel.cells.length) {
model.sharedModel.insertCell(0, {
cell_type: this.notebookConfig.defaultCell,
metadata:
this.notebookConfig.defaultCell === 'code'
? {
// This is an empty cell created in empty notebook, thus is trusted
trusted: true
}
: {}
});
}
});
}
break;
default:
return;
}
if (!this.model!.sharedModel.cells.length) {
this.addHeader();
}
this.update();
}
/**
* Create a cell widget and insert into the notebook.
*/
private _insertCell(index: number, cell: ICellModel): void {
let widget: Cell;
switch (cell.type) {
case 'code':
widget = this._createCodeCell(cell as ICodeCellModel);
widget.model.mimeType = this._mimetype;
break;
case 'markdown':
widget = this._createMarkdownCell(cell as IMarkdownCellModel);
if (cell.sharedModel.getSource() === '') {
(widget as MarkdownCell).rendered = false;
}
break;
default:
widget = this._createRawCell(cell as IRawCellModel);
}
widget.inViewportChanged.connect(this._onCellInViewportChanged, this);
widget.addClass(NB_CELL_CLASS);
ArrayExt.insert(this.cellsArray, index, widget);
this.onCellInserted(index, widget);
this._scheduleCellRenderOnIdle();
}
/**
* Create a code cell widget from a code cell model.
*/
private _createCodeCell(model: ICodeCellModel): CodeCell {
const rendermime = this.rendermime;
const contentFactory = this.contentFactory;
const editorConfig = this.editorConfig.code;
const options: CodeCell.IOptions = {
contentFactory,
editorConfig,
inputHistoryScope: this.notebookConfig.inputHistoryScope,
showInputPlaceholder: this.notebookConfig.showInputPlaceholder,
maxNumberOutputs: this.notebookConfig.maxNumberOutputs,
model,
placeholder: this._notebookConfig.windowingMode !== 'none',
rendermime,
translator: this.translator
};
const cell = this.contentFactory.createCodeCell(options);
cell.syncCollapse = true;
cell.syncEditable = true;
cell.syncScrolled = true;
cell.outputArea.inputRequested.connect((_, stdin) => {
this._onInputRequested(cell).catch(reason => {
console.error('Failed to scroll to cell requesting input.', reason);
});
stdin.disposed.connect(() => {
// The input field is removed from the DOM after the user presses Enter.
// This causes focus to be lost if we don't explicitly re-focus
// somewhere else.
cell.node.focus();
});
});
return cell;
}
/**
* Create a markdown cell widget from a markdown cell model.
*/
private _createMarkdownCell(model: IMarkdownCellModel): MarkdownCell {
const rendermime = this.rendermime;
const contentFactory = this.contentFactory;
const editorConfig = this.editorConfig.markdown;
const options: MarkdownCell.IOptions = {
contentFactory,
editorConfig,
model,
placeholder: this._notebookConfig.windowingMode !== 'none',
rendermime,
showEditorForReadOnlyMarkdown:
this._notebookConfig.showEditorForReadOnlyMarkdown
};
const cell = this.contentFactory.createMarkdownCell(options);
cell.syncCollapse = true;
cell.syncEditable = true;
// Connect collapsed signal for each markdown cell widget
cell.headingCollapsedChanged.connect(this._onCellCollapsed, this);
return cell;
}
/**
* Create a raw cell widget from a raw cell model.
*/
private _createRawCell(model: IRawCellModel): RawCell {
const contentFactory = this.contentFactory;
const editorConfig = this.editorConfig.raw;
const options: RawCell.IOptions = {
editorConfig,
model,
contentFactory,
placeholder: this._notebookConfig.windowingMode !== 'none'
};
const cell = this.contentFactory.createRawCell(options);
cell.syncCollapse = true;
cell.syncEditable = true;
return cell;
}
/**
* Remove a cell widget.
*/
private _removeCell(index: number): void {
const widget = this.cellsArray[index];
widget.parent = null;
ArrayExt.removeAt(this.cellsArray, index);
this.onCellRemoved(index, widget);
widget.dispose();
}
/**
* Update the mimetype of the notebook.
*/
private _updateMimetype(): void {
const info = this._notebookModel?.getMetadata('language_info');
if (!info) {
return;
}
this._mimetype = this._mimetypeService.getMimeTypeByLanguage(info);
for (const widget of this.widgets) {
if (widget.model.type === 'code') {
widget.model.mimeType = this._mimetype;
}
}
}
/**
* Callback when a cell collapsed status changes.
*
* @param cell Cell changed
* @param collapsed New collapsed status
*/
private _onCellCollapsed(cell: Cell, collapsed: boolean): void {
NotebookActions.setHeadingCollapse(cell, collapsed, this);
this._cellCollapsed.emit(cell);
}
/**
* Callback when a cell viewport status changes.
*
* @param cell Cell changed
*/
private _onCellInViewportChanged(cell: Cell): void {
this._cellInViewportChanged.emit(cell);
}
/**
* Ensure to load in the DOM a cell requesting an user input
*
* @param cell Cell requesting an input
*/
private async _onInputRequested(cell: Cell): Promise<void> {
if (!cell.inViewport) {
const cellIndex = this.widgets.findIndex(c => c === cell);
if (cellIndex >= 0) {
await this.scrollToItem(cellIndex);
const inputEl = cell.node.querySelector('.jp-Stdin');
if (inputEl) {
ElementExt.scrollIntoViewIfNeeded(this.node, inputEl);
(inputEl as HTMLElement).focus();
}
}
}
}
private _scheduleCellRenderOnIdle() {
if (this.notebookConfig.windowingMode !== 'none' && !this.isDisposed) {
if (!this._idleCallBack) {
this._idleCallBack = requestIdleCallback(
(deadline: IdleDeadline) => {
this._idleCallBack = null;
// In case of timeout, render for some time even if it means freezing the UI
// This avoids the cells to never be loaded.
void this._runOnIdleTime(
deadline.didTimeout
? MAXIMUM_TIME_REMAINING
: deadline.timeRemaining()
);
},
{
timeout: 3000
}
);
}
}
}
private _updateDataWindowedListIndex(
start: number,
end: number,
delta: number
): void {
for (
let cellIdx = 0;
cellIdx < this.viewportNode.childElementCount;
cellIdx++
) {
const cell = this.viewportNode.children[cellIdx];
const globalIndex = parseInt(
(cell as HTMLElement).dataset.windowedListIndex!,
10
);
if (globalIndex >= start && globalIndex < end) {
(cell as HTMLElement).dataset.windowedListIndex = `${
globalIndex + delta
}`;
}
}
}
/**
* Update editor settings for notebook cells.
*/
private _updateEditorConfig() {
for (let i = 0; i < this.widgets.length; i++) {
const cell = this.widgets[i];
let config: Record<string, any> = {};
switch (cell.model.type) {
case 'code':
config = this._editorConfig.code;
break;
case 'markdown':
config = this._editorConfig.markdown;
break;
default:
config = this._editorConfig.raw;
break;
}
cell.updateEditorConfig({ ...config });
}
}
private async _runOnIdleTime(
remainingTime: number = MAXIMUM_TIME_REMAINING
): Promise<void> {
const startTime = Date.now();
let cellIdx = 0;
while (
Date.now() - startTime < remainingTime &&
cellIdx < this.cellsArray.length
) {
const cell = this.cellsArray[cellIdx];
if (cell.isPlaceholder()) {
if (['defer', 'full'].includes(this.notebookConfig.windowingMode)) {
await this._updateForDeferMode(cell, cellIdx);
if (this.notebookConfig.windowingMode === 'full') {
// We need to delay slightly the removal to let codemirror properly initialize
requestAnimationFrame(() => {
this.viewModel.setEstimatedWidgetSize(
cell.model.id,
cell.node.getBoundingClientRect().height
);
this.layout.removeWidget(cell);
});
}
}
}
cellIdx++;
}
// If the notebook is not fully rendered
if (cellIdx < this.cellsArray.length) {
// If we are deferring the cell rendering and the rendered cells do
// not fill the viewport yet
if (
this.notebookConfig.windowingMode === 'defer' &&
this.viewportNode.clientHeight < this.node.clientHeight
) {
// Spend more time rendering cells to fill the viewport
await this._runOnIdleTime();
} else {
this._scheduleCellRenderOnIdle();
}
} else {
if (this._idleCallBack) {
window.cancelIdleCallback(this._idleCallBack);
this._idleCallBack = null;
}
}
}
private async _updateForDeferMode(
cell: Cell<ICellModel>,
cellIdx: number
): Promise<void> {
cell.dataset.windowedListIndex = `${cellIdx}`;
this.layout.insertWidget(cellIdx, cell);
await cell.ready;
}
/**
* Apply updated notebook settings.
*/
private _updateNotebookConfig() {
// Apply scrollPastEnd setting.
this.toggleClass(
'jp-mod-scrollPastEnd',
this._notebookConfig.scrollPastEnd
);
// Control visibility of heading collapser UI
this.toggleClass(
HEADING_COLLAPSER_VISBILITY_CONTROL_CLASS,
this._notebookConfig.showHiddenCellsButton
);
// Control editor visibility for read-only Markdown cells
const showEditorForReadOnlyMarkdown =
this._notebookConfig.showEditorForReadOnlyMarkdown;
if (showEditorForReadOnlyMarkdown !== undefined) {
for (const cell of this.cellsArray) {
if (cell.model.type === 'markdown') {
(cell as MarkdownCell).showEditorForReadOnly =
showEditorForReadOnlyMarkdown;
}
}
}
this.viewModel.windowingActive =
this._notebookConfig.windowingMode === 'full';
}
protected cellsArray: Array<Cell>;
private _cellCollapsed = new Signal<this, Cell>(this);
private _cellInViewportChanged = new Signal<this, Cell>(this);
private _editorConfig: StaticNotebook.IEditorConfig;
private _idleCallBack: number | null;
private _mimetype: string;
private _mimetypeService: IEditorMimeTypeService;
readonly kernelHistory: INotebookHistory | undefined;
private _modelChanged: Signal<this, void>;
private _modelContentChanged: Signal<this, void>;
private _notebookConfig: StaticNotebook.INotebookConfig;
private _notebookModel: INotebookModel | null;
private _renderingLayout: RenderingLayout | undefined;
private _renderingLayoutChanged = new Signal<this, RenderingLayout>(this);
}
/**
* The namespace for the `StaticNotebook` class statics.
*/
export namespace StaticNotebook {
/**
* An options object for initializing a static notebook.
*/
export interface IOptions {
/**
* The rendermime instance used by the widget.
*/
rendermime: IRenderMimeRegistry;
/**
* The language preference for the model.
*/
languagePreference?: string;
/**
* A factory for creating content.
*/
contentFactory: IContentFactory;
/**
* A configuration object for the cell editor settings.
*/
editorConfig?: IEditorConfig;
/**
* A configuration object for notebook settings.
*/
notebookConfig?: INotebookConfig;
/**
* The service used to look up mime types.
*/
mimeTypeService: IEditorMimeTypeService;
/**
* The application language translator.
*/
translator?: ITranslator;
/**
* The kernel history retrieval object
*/
kernelHistory?: INotebookHistory;
/**
* The renderer used by the underlying windowed list.
*/
renderer?: WindowedList.IRenderer;
}
/**
* A factory for creating notebook content.
*
* #### Notes
* This extends the content factory of the cell itself, which extends the content
* factory of the output area and input area. The result is that there is a single
* factory for creating all child content of a notebook.
*/
export interface IContentFactory extends Cell.IContentFactory {
/**
* Create a new code cell widget.
*/
createCodeCell(options: CodeCell.IOptions): CodeCell;
/**
* Create a new markdown cell widget.
*/
createMarkdownCell(options: MarkdownCell.IOptions): MarkdownCell;
/**
* Create a new raw cell widget.
*/
createRawCell(options: RawCell.IOptions): RawCell;
}
/**
* A config object for the cell editors.
*/
export interface IEditorConfig {
/**
* Config options for code cells.
*/
readonly code: Record<string, any>;
/**
* Config options for markdown cells.
*/
readonly markdown: Record<string, any>;
/**
* Config options for raw cells.
*/
readonly raw: Record<string, any>;
}
/**
* Default configuration options for cell editors.
*/
export const defaultEditorConfig: IEditorConfig = {
code: {
lineNumbers: false,
lineWrap: false,
matchBrackets: true,
tabFocusable: false
},
markdown: {
lineNumbers: false,
lineWrap: true,
matchBrackets: false,
tabFocusable: false
},
raw: {
lineNumbers: false,
lineWrap: true,
matchBrackets: false,
tabFocusable: false
}
};
/**
* A config object for the notebook widget
*/
export interface INotebookConfig {
/**
* The default type for new notebook cells.
*/
defaultCell: nbformat.CellType;
/**
* Defines if the document can be undo/redo.
*/
disableDocumentWideUndoRedo: boolean;
/**
* Whether to display notification if code cell is run while kernel is still initializing.
*/
enableKernelInitNotification: boolean;
/**
* Defines the maximum number of outputs per cell.
*/
maxNumberOutputs: number;
/**
* Show placeholder text for standard input
*/
showInputPlaceholder: boolean;
/**
* Whether to split stdin line history by kernel session or keep globally accessible.
*/
inputHistoryScope: 'global' | 'session';
/**
* Number of cells to render in addition to those
* visible in the viewport.
*
* ### Notes
* In 'full' windowing mode, this is the number of cells above and below the
* viewport.
* In 'defer' windowing mode, this is the number of cells to render initially
* in addition to the one of the viewport.
*/
overscanCount: number;
/**
* Should timing be recorded in metadata
*/
recordTiming: boolean;
/**
* Defines the rendering layout to use.
*/
renderingLayout: RenderingLayout;
/**
* Automatically render markdown when the cursor leaves a markdown cell
*/
autoRenderMarkdownCells: boolean;
/**
* Enable scrolling past the last cell
*/
scrollPastEnd: boolean;
/**
* Show hidden cells button if collapsed
*/
showHiddenCellsButton: boolean;
/**
* Should an editor be shown for read-only markdown
*/
showEditorForReadOnlyMarkdown?: boolean;
/**
* Override the side-by-side left margin.
*/
sideBySideLeftMarginOverride: string;
/**
* Override the side-by-side right margin.
*/
sideBySideRightMarginOverride: string;
/**
* Side-by-side output ratio.
*/
sideBySideOutputRatio: number;
/**
* Windowing mode
*
* - 'defer': Wait for idle CPU cycles to attach out of viewport cells
* - 'full': Attach to the DOM only cells in viewport
* - 'none': Attach all cells to the viewport
*/
windowingMode: 'defer' | 'full' | 'none';
accessKernelHistory?: boolean;
}
/**
* Default configuration options for notebooks.
*/
export const defaultNotebookConfig: INotebookConfig = {
enableKernelInitNotification: false,
showHiddenCellsButton: true,
scrollPastEnd: true,
defaultCell: 'code',
recordTiming: false,
inputHistoryScope: 'global',
maxNumberOutputs: 50,
showEditorForReadOnlyMarkdown: true,
disableDocumentWideUndoRedo: true,
autoRenderMarkdownCells: false,
renderingLayout: 'default',
sideBySideLeftMarginOverride: '10px',
sideBySideRightMarginOverride: '10px',
sideBySideOutputRatio: 1,
overscanCount: 1,
windowingMode: 'full',
accessKernelHistory: false,
showInputPlaceholder: true
};
/**
* The default implementation of an `IContentFactory`.
*/
export class ContentFactory
extends Cell.ContentFactory
implements IContentFactory
{
/**
* Create a new code cell widget.
*
* #### Notes
* If no cell content factory is passed in with the options, the one on the
* notebook content factory is used.
*/
createCodeCell(options: CodeCell.IOptions): CodeCell {
return new CodeCell(options).initializeState();
}
/**
* Create a new markdown cell widget.
*
* #### Notes
* If no cell content factory is passed in with the options, the one on the
* notebook content factory is used.
*/
createMarkdownCell(options: MarkdownCell.IOptions): MarkdownCell {
return new MarkdownCell(options).initializeState();
}
/**
* Create a new raw cell widget.
*
* #### Notes
* If no cell content factory is passed in with the options, the one on the
* notebook content factory is used.
*/
createRawCell(options: RawCell.IOptions): RawCell {
return new RawCell(options).initializeState();
}
}
/**
* A namespace for the static notebook content factory.
*/
export namespace ContentFactory {
/**
* Options for the content factory.
*/
export interface IOptions extends Cell.ContentFactory.IOptions {}
}
}
/**
* A virtual scrollbar item representing a notebook cell.
*/
class ScrollbarItem implements WindowedList.IRenderer.IScrollbarItem {
/**
* Construct a scrollbar item.
*/
constructor(options: { notebook: Notebook; model: ICellModel }) {
// Note: there should be no DOM operations in the constructor
this._model = options.model;
this._notebook = options.notebook;
}
/**
* Render the scrollbar item as an HTML element.
*/
render = (props: { index: number }) => {
if (!this._element) {
this._element = this._createElement();
this._notebook.activeCellChanged.connect(this._updateActive);
this._notebook.selectionChanged.connect(this._updateSelection);
if (this._model.type === 'code') {
const model = this._model as ICodeCellModel;
model.outputs.changed.connect(this._updatePrompt);
model.stateChanged.connect(this._updateState);
}
}
// Add cell type (code/markdown/raw)
if (this._model.type != this._element.dataset.type) {
this._element.dataset.type = this._model.type;
}
const source = this._model.sharedModel.source;
const trimmedSource =
source.length > 10000 ? source.substring(0, 10000) : source;
if (trimmedSource !== this._source.textContent) {
this._source.textContent = trimmedSource;
}
this._updateActive();
this._updateSelection();
this._updatePrompt();
this._updateDirty();
return this._element;
};
/**
* Unique item key used for caching.
*/
get key(): string {
return this._model.id;
}
/**
* Test whether the item has been disposed.
*/
get isDisposed(): boolean {
// Ensure the state is up-to-date in case if the model was disposed
// (the model can be disposed when cells are moved/recreated).
if (!this._isDisposed && this._model.isDisposed) {
this.dispose();
}
return this._isDisposed;
}
/**
* Dispose of the resources held by the item.
*/
dispose = () => {
this._isDisposed = true;
this._notebook.activeCellChanged.disconnect(this._updateActive);
this._notebook.selectionChanged.disconnect(this._updateSelection);
if (this._model.type === 'code') {
const model = this._model as ICodeCellModel;
if (model.outputs) {
model.outputs.changed.disconnect(this._updatePrompt);
model.stateChanged.disconnect(this._updateState);
}
}
};
private _updateState = (
_: ICellModel,
change: IChangedArgs<
any,
any,
'trusted' | 'isDirty' | 'executionCount' | 'executionState'
>
) => {
switch (change.name) {
case 'executionCount':
case 'executionState':
this._updatePrompt();
break;
case 'isDirty': {
this._updateDirty();
break;
}
}
};
private _updateDirty() {
if (this._model.type !== 'code' || !this._element) {
return;
}
const model = this._model as ICodeCellModel;
const wasDirty = this._element.classList.contains(DIRTY_CLASS);
if (wasDirty !== model.isDirty) {
if (model.isDirty) {
this._element.classList.add(DIRTY_CLASS);
} else {
this._element.classList.remove(DIRTY_CLASS);
}
}
}
private _updatePrompt = () => {
if (this._model.type !== 'code') {
return;
}
const model = this._model as ICodeCellModel;
let hasError = false;
for (let i = 0; i < model.outputs.length; i++) {
const output = model.outputs.get(i);
if (output.type === 'error') {
hasError = true;
break;
}
}
let content: string;
let state: string = '';
if (hasError) {
content = '[!]';
state = 'error';
} else if (model.executionState == 'running') {
content = '[*]';
} else if (model.executionCount) {
content = `[${model.executionCount}]`;
} else {
content = '[ ]';
}
if (this._executionIndicator.textContent !== content) {
this._executionIndicator.textContent = content;
}
if (this._element!.dataset.output !== state) {
this._element!.dataset.output = state;
}
};
private _createElement() {
const li = document.createElement('li');
const executionIndicator = (this._executionIndicator =
document.createElement('div'));
executionIndicator.className = 'jp-scrollbarItem-executionIndicator';
const source = (this._source = document.createElement('div'));
source.className = 'jp-scrollbarItem-source';
li.append(executionIndicator);
li.append(source);
return li;
}
private _executionIndicator: HTMLElement;
private _source: HTMLElement;
private _updateActive = () => {
if (!this._element) {
this._element = this._createElement();
}
const li = this._element;
const wasActive = li.classList.contains(ACTIVE_CLASS);
if (this._notebook.activeCell?.model === this._model) {
if (!wasActive) {
li.classList.add(ACTIVE_CLASS);
}
} else if (wasActive) {
li.classList.remove(ACTIVE_CLASS);
// Needed due to order in which selection and active changed signals fire
li.classList.remove(SELECTED_CLASS);
}
};
private _updateSelection = () => {
if (!this._element) {
this._element = this._createElement();
}
const li = this._element;
const wasSelected = li.classList.contains(SELECTED_CLASS);
if (this._notebook.selectedCells.some(cell => this._model === cell.model)) {
if (!wasSelected) {
li.classList.add(SELECTED_CLASS);
}
} else if (wasSelected) {
li.classList.remove(SELECTED_CLASS);
}
};
private _model: ICellModel;
private _notebook: Notebook;
private _isDisposed: boolean = false;
private _element: HTMLElement | null = null;
}
/**
* A notebook widget that supports interactivity.
*/
export class Notebook extends StaticNotebook {
/**
* Construct a notebook widget.
*/
constructor(options: Notebook.IOptions) {
super({
renderer: {
createOuter(): HTMLElement {
return document.createElement('div');
},
createViewport(): HTMLElement {
const el = document.createElement('div');
el.setAttribute('role', 'feed');
el.setAttribute('aria-label', 'Cells');
return el;
},
createScrollbar(): HTMLOListElement {
return document.createElement('ol');
},
createScrollbarViewportIndicator(): HTMLElement {
return document.createElement('div');
},
createScrollbarItem(
notebook: Notebook,
_index: number,
model: ICellModel
): WindowedList.IRenderer.IScrollbarItem {
return new ScrollbarItem({
notebook,
model
});
}
},
...options
});
// Allow the node to scroll while dragging items.
this.outerNode.setAttribute('data-lm-dragscroll', 'true');
this.activeCellChanged.connect(this._updateSelectedCells, this);
this.jumped.connect((_, index: number) => (this.activeCellIndex = index));
this.selectionChanged.connect(this._updateSelectedCells, this);
this.addFooter();
}
/**
* List of selected and active cells
*/
get selectedCells(): Cell[] {
return this._selectedCells;
}
/**
* Adds a footer to the notebook.
*/
protected addFooter(): void {
const info = new NotebookFooter(this);
(this.layout as NotebookWindowedLayout).footer = info;
}
/**
* Handle a change cells event.
*/
protected _onCellsChanged(
sender: CellList,
args: IObservableList.IChangedArgs<ICellModel>
): void {
const activeCellId = this.activeCell?.model.id;
super._onCellsChanged(sender, args);
if (activeCellId) {
const newActiveCellIndex = this.model?.sharedModel.cells.findIndex(
cell => cell.getId() === activeCellId
);
if (newActiveCellIndex != null) {
this.activeCellIndex = newActiveCellIndex;
}
}
}
/**
* A signal emitted when the active cell changes.
*
* #### Notes
* This can be due to the active index changing or the
* cell at the active index changing.
*/
get activeCellChanged(): ISignal<this, Cell | null> {
return this._activeCellChanged;
}
/**
* A signal emitted when the state of the notebook changes.
*/
get stateChanged(): ISignal<this, IChangedArgs<any>> {
return this._stateChanged;
}
/**
* A signal emitted when the selection state of the notebook changes.
*/
get selectionChanged(): ISignal<this, void> {
return this._selectionChanged;
}
/**
* The interactivity mode of the notebook.
*/
get mode(): NotebookMode {
return this._mode;
}
set mode(newValue: NotebookMode) {
this.setMode(newValue);
}
/**
* Set the notebook mode.
*
* @param newValue Notebook mode
* @param options Control mode side-effect
* @param options.focus Whether to ensure focus (default) or not when setting the mode.
*/
protected setMode(
newValue: NotebookMode,
options: { focus?: boolean } = {}
): void {
const setFocus = options.focus ?? true;
const activeCell = this.activeCell;
if (!activeCell) {
newValue = 'command';
}
if (newValue === this._mode) {
if (setFocus) {
this._ensureFocus();
}
return;
}
// Post an update request.
this.update();
const oldValue = this._mode;
this._mode = newValue;
if (newValue === 'edit') {
// Edit mode deselects all cells.
for (const widget of this.widgets) {
this.deselect(widget);
}
// Edit mode unrenders an active markdown widget.
if (activeCell instanceof MarkdownCell) {
activeCell.rendered = false;
}
activeCell!.inputHidden = false;
} else {
if (setFocus) {
void NotebookActions.focusActiveCell(this, {
// Do not await the active cell because that creates a bug. If the user
// is editing a code cell and presses Accel Shift C to open the command
// palette, then the command palette opens before
// activeCell.node.focus() is called, which closes the command palette.
// To the end user, it looks as if all the keyboard shortcut did was
// move focus from the cell editor to the cell as a whole.
waitUntilReady: false,
preventScroll: true
});
}
}
this._stateChanged.emit({ name: 'mode', oldValue, newValue });
if (setFocus) {
this._ensureFocus();
}
}
/**
* The active cell index of the notebook.
*
* #### Notes
* The index will be clamped to the bounds of the notebook cells.
*/
get activeCellIndex(): number {
if (!this.model) {
return -1;
}
return this.widgets.length ? this._activeCellIndex : -1;
}
set activeCellIndex(newValue: number) {
const oldValue = this._activeCellIndex;
if (!this.model || !this.widgets.length) {
newValue = -1;
} else {
newValue = Math.max(newValue, 0);
newValue = Math.min(newValue, this.widgets.length - 1);
}
this._activeCellIndex = newValue;
const oldCell = this.widgets[oldValue] ?? null;
const cell = this.widgets[newValue] ?? null;
(this.layout as NotebookWindowedLayout).activeCell = cell;
const cellChanged = cell !== this._activeCell;
if (cellChanged) {
// Post an update request.
this.update();
this._activeCell = cell;
}
if (cellChanged || newValue != oldValue) {
this._activeCellChanged.emit(cell);
}
if (this.mode === 'edit') {
if (cell instanceof MarkdownCell) {
cell.rendered = false;
}
if (
this.notebookConfig.autoRenderMarkdownCells &&
cellChanged &&
oldCell instanceof MarkdownCell
) {
oldCell.rendered = true;
}
}
this._ensureFocus();
if (newValue === oldValue) {
return;
}
this._trimSelections();
this._stateChanged.emit({ name: 'activeCellIndex', oldValue, newValue });
}
/**
* Get the active cell widget.
*
* #### Notes
* This is a cell or `null` if there is no active cell.
*/
get activeCell(): Cell | null {
return this._activeCell;
}
get lastClipboardInteraction(): 'copy' | 'cut' | 'paste' | null {
return this._lastClipboardInteraction;
}
set lastClipboardInteraction(newValue: 'copy' | 'cut' | 'paste' | null) {
this._lastClipboardInteraction = newValue;
}
/**
* Dispose of the resources held by the widget.
*/
dispose(): void {
if (this.isDisposed) {
return;
}
this._activeCell = null;
super.dispose();
}
/**
* Move cells preserving widget view state.
*
* #### Notes
* This is required because at the model level a move