@jupyterlab/notebook
Version:
JupyterLab - Notebook
3,210 lines • 120 kB
JavaScript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/* eslint-disable @typescript-eslint/no-explicit-any */
import { DOMUtils } from '@jupyterlab/apputils';
import { Cell, CodeCell, MarkdownCell, RawCell } from '@jupyterlab/cells';
import { IEditorMimeTypeService } from '@jupyterlab/codeeditor';
import { TableOfContentsUtils } from '@jupyterlab/toc';
import { nullTranslator } from '@jupyterlab/translation';
import { WindowedList } from '@jupyterlab/ui-components';
import { ArrayExt, findIndex } from '@lumino/algorithm';
import { JSONExt, MimeData } from '@lumino/coreutils';
import { ElementExt } from '@lumino/domutils';
import { Drag } from '@lumino/dragdrop';
import { AttachedProperty } from '@lumino/properties';
import { Signal } from '@lumino/signaling';
import { h, VirtualDOM } from '@lumino/virtualdom';
import { PanelLayout, Widget } from '@lumino/widgets';
import { NotebookActions } from './actions';
import { DROP_SOURCE_CLASS, DROP_TARGET_CLASS } from './constants';
import { NotebookViewModel, NotebookWindowedLayout } from './windowing';
import { NotebookFooter } from './notebookfooter';
/**
* 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 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';
if (window.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
window.requestIdleCallback = function (handler) {
let startTime = Date.now();
return setTimeout(function () {
handler({
didTimeout: false,
timeRemaining: function () {
return Math.max(0, 50.0 - (Date.now() - startTime));
}
});
}, 1);
};
window.cancelIdleCallback = function (id) {
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 {
/**
* Construct a notebook widget.
*/
constructor(options) {
var _a, _b, _c, _d, _e, _f;
const cells = new Array();
const windowingActive = ((_b = (_a = options.notebookConfig) === null || _a === void 0 ? void 0 : _a.windowingMode) !== null && _b !== void 0 ? _b : StaticNotebook.defaultNotebookConfig.windowingMode) === 'full';
super({
model: new NotebookViewModel(cells, {
overscanCount: (_d = (_c = options.notebookConfig) === null || _c === void 0 ? void 0 : _c.overscanCount) !== null && _d !== void 0 ? _d : StaticNotebook.defaultNotebookConfig.overscanCount,
windowingActive
}),
layout: new NotebookWindowedLayout(),
renderer: (_e = options.renderer) !== null && _e !== void 0 ? _e : WindowedList.defaultRenderer,
scrollbar: false
});
this._cellCollapsed = new Signal(this);
this._cellInViewportChanged = new Signal(this);
this._renderingLayoutChanged = new Signal(this);
this._contentVisibilityObserver = null;
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);
this._modelContentChanged = new Signal(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 = (_f = options.notebookConfig) === null || _f === void 0 ? void 0 : _f.renderingLayout;
this.kernelHistory = options.kernelHistory;
}
get cellCollapsed() {
return this._cellCollapsed;
}
get cellInViewportChanged() {
return this._cellInViewportChanged;
}
/**
* A signal emitted when the model of the notebook changes.
*/
get modelChanged() {
return this._modelChanged;
}
/**
* A signal emitted when the model content changes.
*
* #### Notes
* This is a convenience signal that follows the current model.
*/
get modelContentChanged() {
return this._modelContentChanged;
}
/**
* A signal emitted when the rendering layout of the notebook changes.
*/
get renderingLayoutChanged() {
return this._renderingLayoutChanged;
}
/**
* The model for the widget.
*/
get model() {
return this._notebookModel;
}
set model(newValue) {
var _a;
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 = (_a = newValue === null || newValue === void 0 ? void 0 : newValue.cells) !== null && _a !== void 0 ? _a : null;
}
/**
* Get the mimetype for code cells.
*/
get codeMimetype() {
return this._mimetype;
}
/**
* A read-only sequence of the widgets in the notebook.
*/
get widgets() {
return this.cellsArray;
}
/**
* A configuration object for cell editor settings.
*/
get editorConfig() {
return this._editorConfig;
}
set editorConfig(value) {
this._editorConfig = value;
this._updateEditorConfig();
}
/**
* A configuration object for notebook settings.
*/
get notebookConfig() {
return this._notebookConfig;
}
set notebookConfig(value) {
this._notebookConfig = value;
this._updateNotebookConfig();
}
get renderingLayout() {
return this._renderingLayout;
}
set renderingLayout(value) {
var _a;
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((_a = this._renderingLayout) !== null && _a !== void 0 ? _a : 'default');
}
/**
* Dispose of the resources held by the widget.
*/
dispose() {
var _a;
// Do nothing if already disposed.
if (this.isDisposed) {
return;
}
this._notebookModel = null;
(_a = this.layout.header) === null || _a === void 0 ? void 0 : _a.dispose();
// Disconnect the content-visibility observer
if (this._contentVisibilityObserver) {
this._contentVisibilityObserver.disconnect();
this._contentVisibilityObserver = null;
}
super.dispose();
}
onBeforeDetach(msg) {
// Disconnect the content-visibility observer
if (this._contentVisibilityObserver) {
this._contentVisibilityObserver.disconnect();
this._contentVisibilityObserver = null;
}
super.onBeforeDetach(msg);
}
/**
* 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, to, n = 1) {
if (!this.model) {
return;
}
const boundedTo = Math.min(this.model.cells.length - 1, Math.max(0, to));
if (boundedTo === from) {
return;
}
const viewModel = new Array(n);
let dirtyState = 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;
dirtyState[i] = oldCodeCell.isDirty;
}
}
this.model.sharedModel.moveCells(from, boundedTo, n);
for (let i = 0; i < n; i++) {
const newIndex = from > boundedTo ? boundedTo + i : boundedTo - n + 1 + i;
const newCell = this.widgets[newIndex];
const view = viewModel[i];
for (const state in view) {
// @ts-expect-error Cell has no index signature
newCell[state] = view[state];
}
if (from > boundedTo) {
if (this.widgets[boundedTo + i].model.type === 'code') {
this.widgets[boundedTo + i].model.isDirty =
dirtyState[i];
}
}
else {
if (this.widgets[boundedTo + i - n + 1].model.type === 'code') {
this.widgets[boundedTo + i - n + 1].model.isDirty =
dirtyState[i];
}
}
}
this._refreshCollapsedHeadingVisibility();
}
/**
* 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) {
const cell = this.viewModel.widgetRenderer(index);
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.
*/
addHeader() {
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.header = info;
}
/**
* Removes the header.
*/
removeHeader() {
var _a;
(_a = this.layout.header) === null || _a === void 0 ? void 0 : _a.dispose();
this.layout.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.
*/
onModelChanged(oldValue, newValue) {
// No-op.
}
/**
* Handle changes to the notebook model content.
*
* #### Notes
* The default implementation emits the `modelContentChanged` signal.
*/
onModelContentChanged(model, args) {
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.
*/
onMetadataChanged(sender, args) {
switch (args.key) {
case 'language_info':
this._updateMimetype();
break;
default:
break;
}
}
/**
* Handle a cell being inserted.
*
* The default implementation is a no-op
*/
onCellInserted(index, cell) {
// This is a no-op.
}
/**
* Handle a cell being removed.
*
* The default implementation is a no-op
*/
onCellRemoved(index, cell) {
// This is a no-op.
}
/**
* A message handler invoked on an `'update-request'` message.
*
* In defer-like mode, the default logic for inserting cells
* is skipped, enabling the notebook to render cells in small
* batches, without blocking the UI.
*/
onUpdateRequest(msg) {
if (['defer', 'contentVisibility'].includes(this.notebookConfig.windowingMode)) {
void this._runOnIdleTime();
}
else {
super.onUpdateRequest(msg);
}
}
/**
* Handle a new model on the widget.
*/
_onModelChanged(oldValue, newValue) {
var _a;
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 = (_a = newValue.collaborative) !== null && _a !== void 0 ? _a : 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);
}
this._syncMarkdownCellTrust();
newValue.cells.changed.connect(this._onCellsChanged, this);
newValue.metadataChanged.connect(this.onMetadataChanged, this);
newValue.contentChanged.connect(this.onModelContentChanged, this);
}
/**
* Handle a change cells event.
*/
_onCellsChanged(sender, args) {
this.removeHeader();
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
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, args.newValues.length);
break;
}
case 'remove':
for (let length = args.oldValues.length; length > 0; length--) {
this._removeCell(args.oldIndex);
}
this._updateDataWindowedListIndex(args.oldIndex, -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._syncMarkdownCellTrust();
this.update();
}
/**
* Create a cell widget and insert into the notebook.
*/
_insertCell(index, cell) {
let widget;
switch (cell.type) {
case 'code':
widget = this._createCodeCell(cell);
widget.model.mimeType = this._mimetype;
break;
case 'markdown':
widget = this._createMarkdownCell(cell);
if (cell.sharedModel.getSource() === '') {
widget.rendered = false;
}
break;
default:
widget = this._createRawCell(cell);
}
cell.stateChanged.connect(this._onCellStateChanged, this);
widget.inViewportChanged.connect(this._onCellInViewportChanged, this);
widget.addClass(NB_CELL_CLASS);
ArrayExt.insert(this.cellsArray, index, widget);
this._syncMarkdownCellTrust(widget);
this.onCellInserted(index, widget);
this._scheduleCellRenderOnIdle();
}
/**
* Create a code cell widget from a code cell model.
*/
_createCodeCell(model) {
const rendermime = this.rendermime;
const contentFactory = this.contentFactory;
const editorConfig = this.editorConfig.code;
const options = {
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);
if (cell.syncCollapse === undefined) {
cell.syncCollapse = true;
}
if (cell.syncEditable === undefined) {
cell.syncEditable = true;
}
if (cell.syncScrolled === undefined) {
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.
*/
_createMarkdownCell(model) {
const rendermime = this.rendermime;
const contentFactory = this.contentFactory;
const editorConfig = this.editorConfig.markdown;
const options = {
contentFactory,
editorConfig,
model,
placeholder: this._notebookConfig.windowingMode !== 'none',
rendermime,
showEditorForReadOnlyMarkdown: this._notebookConfig.showEditorForReadOnlyMarkdown
};
const cell = this.contentFactory.createMarkdownCell(options);
if (cell.syncCollapse === undefined) {
cell.syncCollapse = true;
}
if (cell.syncEditable === undefined) {
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.
*/
_createRawCell(model) {
const contentFactory = this.contentFactory;
const editorConfig = this.editorConfig.raw;
const options = {
editorConfig,
model,
contentFactory,
placeholder: this._notebookConfig.windowingMode !== 'none'
};
const cell = this.contentFactory.createRawCell(options);
if (cell.syncCollapse === undefined) {
cell.syncCollapse = true;
}
if (cell.syncEditable === undefined) {
cell.syncEditable = true;
}
return cell;
}
/**
* Remove a cell widget.
*/
_removeCell(index) {
const widget = this.cellsArray[index];
widget.model.stateChanged.disconnect(this._onCellStateChanged, this);
widget.parent = null;
ArrayExt.removeAt(this.cellsArray, index);
this.onCellRemoved(index, widget);
widget.dispose();
}
_shouldTrustMarkdown() {
// Note: this returns false in a notebook without trsuted code cells;
// This is intended since only Code cells carry trust status on disk.
if (!this._notebookModel) {
return false;
}
let hasCodeCell = false;
for (const cell of this._notebookModel.cells) {
if (cell.type === 'code') {
hasCodeCell = true;
if (!cell.trusted) {
return false;
}
}
}
return hasCodeCell;
}
_syncMarkdownCellTrust(cell) {
const trusted = this._shouldTrustMarkdown();
const trustHandler = this.rendermime.trustHandler;
if (!trustHandler) {
return;
}
const cells = cell ? [cell] : this.widgets;
for (const widget of cells) {
if (!(widget instanceof MarkdownCell)) {
continue;
}
if (trusted) {
trustHandler.markTrusted(widget.node);
}
else {
trustHandler.unmarkTrusted(widget.node);
}
}
}
_onCellStateChanged(model, args) {
if (args.name === 'trusted') {
this._syncMarkdownCellTrust();
}
}
/**
* Update the mimetype of the notebook.
*/
_updateMimetype() {
var _a;
const info = (_a = this._notebookModel) === null || _a === void 0 ? void 0 : _a.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
*/
_onCellCollapsed(cell, collapsed) {
cell
.getHeadings()
.then(() => {
// Heading parsing is async; ignore stale callbacks that no longer match
// the current collapsed state.
if (cell.isDisposed ||
(cell instanceof MarkdownCell && cell.headingCollapsed !== collapsed)) {
return;
}
NotebookActions.setHeadingCollapse(cell, collapsed, this);
this._cellCollapsed.emit(cell);
})
.catch(error => {
console.warn('Failed to resolve headings: ', error);
});
}
/**
* Recompute hidden cells from the current collapsed heading structure.
*/
_refreshCollapsedHeadingVisibility() {
for (const cell of this.widgets) {
cell.setHidden(false);
}
for (const cell of this.widgets) {
if (!(cell instanceof MarkdownCell) || !cell.headingCollapsed) {
continue;
}
if (cell.headingsResolved) {
NotebookActions.setHeadingCollapse(cell, true, this);
continue;
}
cell
.getHeadings()
.then(() => {
if (!cell.isDisposed && cell.headingCollapsed) {
this._refreshCollapsedHeadingVisibility();
}
})
.catch(error => {
console.warn('Failed to resolve headings: ', error);
});
}
}
/**
* Callback when a cell viewport status changes.
*
* @param cell Cell changed
*/
_onCellInViewportChanged(cell) {
this._cellInViewportChanged.emit(cell);
}
/**
* Ensure to load in the DOM a cell requesting an user input
*
* @param cell Cell requesting an input
*/
async _onInputRequested(cell) {
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.focus();
}
}
}
}
_scheduleCellRenderOnIdle() {
if (this.notebookConfig.windowingMode !== 'none' && !this.isDisposed) {
if (!this._idleCallBack) {
this._idleCallBack = requestIdleCallback((deadline) => {
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 });
}
}
}
_updateDataWindowedListIndex(start, delta) {
for (const cell of this.cellsArray) {
const globalIndex = parseInt(cell.dataset.windowedListIndex, 10);
if (globalIndex >= start) {
cell.node.dataset.windowedListIndex = `${globalIndex + delta}`;
}
}
}
/**
* Update editor settings for notebook cells.
*/
_updateEditorConfig() {
for (let i = 0; i < this.widgets.length; i++) {
const cell = this.widgets[i];
let config = {};
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 });
}
}
async _runOnIdleTime(remainingTime = MAXIMUM_TIME_REMAINING) {
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', 'contentVisibility'].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);
});
}
else if (this.notebookConfig.windowingMode === 'contentVisibility') {
// Update height estimate in the view model
const height = cell.node.getBoundingClientRect().height;
this.viewModel.setEstimatedWidgetSize(cell.model.id, height);
cell.node.style.containIntrinsicSize = `auto ${height}px`;
}
}
}
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;
}
}
}
async _updateForDeferMode(cell, cellIdx) {
cell.dataset.windowedListIndex = `${cellIdx}`;
this.layout.insertWidget(cellIdx, cell);
await cell.ready;
}
/**
* Apply updated notebook settings.
*/
_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.showEditorForReadOnly =
showEditorForReadOnlyMarkdown;
}
}
}
this.viewModel.windowingActive =
this._notebookConfig.windowingMode === 'full';
// Apply content visibility when notebook settings update (without reload)
const isContentVisibility = this._notebookConfig.windowingMode === 'contentVisibility';
this.viewportNode.classList.toggle('jp-content-visibility-mode', isContentVisibility);
if (isContentVisibility) {
requestAnimationFrame(() => {
this.cellsArray.forEach((cell, i) => {
const estHeight = this._viewModel.estimateWidgetSize(i);
cell.node.style.containIntrinsicSize = `auto ${estHeight}px`;
});
});
this._setupContentVisibilityObserver();
}
else {
// Remove intrinsic size styling when disabling content visibility
this.cellsArray.forEach(cell => {
cell.node.style.removeProperty('contain-intrinsic-size');
cell.node.style.removeProperty('content-visibility');
cell.node.style.removeProperty('contain');
});
// Disconnect observer if it exists
if (this._contentVisibilityObserver) {
this._contentVisibilityObserver.disconnect();
this._contentVisibilityObserver = null;
}
}
}
onAfterAttach(msg) {
super.onAfterAttach(msg);
// Apply content visibility when notebook widget is attached to the DOM
if (this._notebookConfig.windowingMode === 'contentVisibility') {
this.viewportNode.classList.toggle('jp-content-visibility-mode', true);
// Update intrinsic sizes for all cells initially
requestAnimationFrame(() => {
this.cellsArray.forEach((cell, i) => {
const estHeight = this._viewModel.estimateWidgetSize(i);
cell.node.style.containIntrinsicSize = `auto ${estHeight}px`;
cell.node.style.contentVisibility = 'auto';
cell.node.style.contain = 'layout style paint';
});
});
// Use the real browser viewport for visibility detection
this._setupContentVisibilityObserver();
}
}
_setupContentVisibilityObserver() {
var _a;
if (!this._contentVisibilityObserver) {
// Create observer only once
this._contentVisibilityObserver = new IntersectionObserver(entries => {
for (const entry of entries) {
const cell = entry.target;
if (entry.isIntersecting) {
cell.style.contentVisibility = 'visible';
cell.style.contain = 'style';
}
else {
cell.style.contentVisibility = 'auto';
cell.style.contain = 'layout style paint';
}
}
}, { root: null, threshold: 0.1 });
}
// Observe all existing cells
this.cellsArray.forEach(cell => this._contentVisibilityObserver.observe(cell.node));
// Watch for newly added cells and set intrinsic size for them too
(_a = this.model) === null || _a === void 0 ? void 0 : _a.cells.changed.connect(() => {
requestAnimationFrame(() => {
this.cellsArray.forEach((cell, i) => {
const estHeight = this._viewModel.estimateWidgetSize(i);
cell.node.style.containIntrinsicSize = `auto ${estHeight}px`;
this._contentVisibilityObserver.observe(cell.node);
});
});
});
}
}
/**
* The namespace for the `StaticNotebook` class statics.
*/
(function (StaticNotebook) {
/**
* Default configuration options for cell editors.
*/
StaticNotebook.defaultEditorConfig = {
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
}
};
/**
* Default configuration options for notebooks.
*/
StaticNotebook.defaultNotebookConfig = {
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: 'contentVisibility',
accessKernelHistory: false,
showInputPlaceholder: true,
showMinimap: false
};
/**
* The default implementation of an `IContentFactory`.
*/
class ContentFactory extends Cell.ContentFactory {
/**
* 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) {
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) {
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) {
return new RawCell(options).initializeState();
}
}
StaticNotebook.ContentFactory = ContentFactory;
})(StaticNotebook || (StaticNotebook = {}));
/**
* A virtual scrollbar item representing a notebook cell.
*/
class ScrollbarItem {
/**
* Construct a scrollbar item.
*/
constructor(options) {
/**
* Render the scrollbar item as an HTML element.
*/
this.render = (props) => {
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;
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;
};
/**
* Dispose of the resources held by the item.
*/
this.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;
if (model.outputs) {
model.outputs.changed.disconnect(this._updatePrompt);
model.stateChanged.disconnect(this._updateState);
}
}
};
this._updateState = (_, change) => {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (change.name) {
case 'executionCount':
case 'executionState':
this._updatePrompt();
break;
case 'isDirty': {
this._updateDirty();
break;
}
}
};
this._updatePrompt = () => {
if (this._model.type !== 'code') {
return;
}
const model = this._model;
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;
let state = '';
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;
}
};
this._updateActive = () => {
var _a;
if (!this._element) {
this._element = this._createElement();
}
const li = this._element;
const wasActive = li.classList.contains(ACTIVE_CLASS);
if (((_a = this._notebook.activeCell) === null || _a === void 0 ? void 0 : _a.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);
}
};
this._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);
}
};
this._isDisposed = false;
this._element = null;
// Note: there should be no DOM operations in the constructor
this._model = options.model;
this._notebook = options.notebook;
}
/**
* Unique item key used for caching.
*/
get key() {
return this._model.id;
}
/**
* Test whether the item has been disposed.
*/
get isDisposed() {
// 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;
}
_updateDirty() {
if (this._model.type !== 'code' || !this._element) {
return;
}
const model = this._model;
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);
}
}
}
_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;
}
}
/**
* A notebook widget that supports interactivity.
*/
export class Notebook extends StaticNotebook {
/**
* Construct a notebook widget.
*/
constructor(options) {
const trans = (options.translator || nullTranslator).load('jupyterlab');
super({
renderer: {
createOuter() {
return document.createElement('div');
},
createViewport() {
const el = document.createElement('div');
el.setAttribute('role', 'feed');
el.setAttribute('aria-label', trans.__('Cells'));
return el;
},
createScrollbar() {
return document.createElement('ol');
},
createScrollbarViewportIndicator() {
return document.createElement('div');
},
createScrollbarItem(notebook, _index, model) {
return new ScrollbarItem({
notebook,
model
});
}
},
...options
});
this._lastModifiedCellBackStack = [];
this._lastModifiedCellForwardStack = [];
this._activeCellIndex = -1;
this._activeCell = null;
this._mode = 'command';
this._drag = null;
this._dragData = null;
this._selectData = null;
this._mouseMode = null;
this._activeCellChanged = new Signal(this);
this._stateChanged = new Signal(this);
this._selectionChanged = new Signal(this);
this._cellsPasted = new Signal(this);
this._localCopy = [];
this._checkCacheOnNextResize = false;
this._lastClipboardInteraction = null;
this._selectedCells = [];
// 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) => (this.activeCellIndex = index));
this.selectionChanged.connect(this._updateSelectedCells, this);
this.addFooter();
}
/**
* List of selected and active cells
*/
get selectedCells() {
return this._selectedCells;
}
/**
* Adds a footer to the notebook.
*/
addFooter() {
const info = new NotebookFooter(this);
this.layout.footer = info;
}
/**
* Handle a change cells event.
*/
_onCellsChanged(sender, args) {
var _a, _b;
const activeCellId = (_a = this.activeCell) === null || _a === void 0 ? void 0 : _a.model.id;
super._onCellsChanged(sender, args);
if (activeCellId) {
const newActiveCellIndex = (_b = this.model) === null || _b === void 0 ? void 0 : _b.sharedModel.cells.findIndex(cell => cell.getId() === activeCellId);
if (newActiveCellIndex != null) {
this.activeCellIndex = newActiveCellIndex;
}
}
switch (args.type) {
case 'add':
case 'set':
args.oldValues.forEach(model => {
model.contentChanged.disconnect(this._onCellContentChanged, this);
});
args.newValues.forEach(model => {
model.contentChanged.connect(this._onCellContentChanged, this);
});
break;
case 'remove':
case 'clear':
args.oldValues.forEach(model => {
if (model) {
model.contentChanged.disconnect(this._onCellContentChanged, this);
}
});
break;
case 'move':
break;
default:
break;
}
}
/**
* 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() {
return this._activeCellChanged;
}
/**
* A signal emitted when the state of the notebook changes.
*/
get stateChanged() {
return this._stateChanged;
}
/**
* A signal emitted when the selection state of the notebook changes.
*/
get selectionChanged() {
return this._selectionChanged;
}
/**
* A signal emitted when cells are pasted to the notebook.
*/
get cellsPasted() {
return this._cellsPasted;
}
/**
* The interactivity mode of the notebook.
*/
get mode() {
return this._mode;
}
set mode(newValue) {
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.
*/
setMode(newValue, options = {}) {
var _a;
const setFocus = (_a = options.focus) !== null && _a !== void 0 ? _a : 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() {
if (!this.model) {
return -1;
}
return this.widgets.length ? this._activeCellIndex : -1;
}
set activeCellIndex(newValue) {
var _a, _b;
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 = (_a = this.widgets[oldValue]) !== null && _a !== void 0 ? _a : null;
const cell = (_b = this.widgets[newValue]) !== null && _b !== void 0 ? _b : null;
this.layout.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() {
return this._activeCell;
}
get lastClipboardInteraction() {
return this._lastClipboardInteraction;
}
set lastClipboardInteraction(newValue) {
this._lastClipboardInteraction = newValue;
}
/**
* This function should be called when a clipboard interaction involve notebook cells.
*
* @param interaction - the clipboard interaction (copy, cut or paste).
* @param cells - the cells copied, cut or pasted.
*/
recordCellClipboardInteraction(interaction, cells) {
const lastInteraction = this._lastClipboardInteraction;
if (interaction === 'copy' || interaction === 'cut') {
this._localCopy = cells;
}
else if (interaction === 'paste') {
const pasted = cells;
// Check if the current clipboard match the previously copied/cut cells.
const isLocal = ArrayExt.shallowEqual(pasted, this._localCopy, JSONExt.deepEqual);
// Emit a signal with the last interaction and the number of pasted cells.
this._cellsPasted.emit({
previousInteraction: isLocal ? lastInteraction : null,
cellCount: pasted.length
});
}
// Update the last clipboard interaction.
this.lastClipboardInteraction = interaction;
}
/**
* Dispose of the resources held by the widget.
*/
dispose() {
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 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, to, n = 1) {
// Save active cell id to be restored
const newActiveCellIndex = from <= this.activeCellIndex && this.activeCellIndex < from + n
? this.activeCellIndex + to - from - (from > to ? 0 : n - 1)
: -1;
const isSelected = this.widgets
.slice(from, from + n)
.map(w => this.isSelected(w));
super.moveCell(from, to, n);
if (newActiveCellIndex >= 0) {
this.activeCellIndex = newActiveCellIndex;
}
if (from > to) {
isSelected.forEach((selected, idx) => {
if (selected) {
this.select(this.widgets[to + idx]);
}
});
}
else {
isSelected.forEach((selected, idx) => {
if (selected) {
this.select(this.widgets[to - n + 1 + idx]);
}
});
}
}
/**
* Select a cell widget.
*
* #### Notes
* It is a no-op if the value does not change.
* It will emit the `selectionChanged` signal.
*/
select(widget) {
if (Private.selectedProperty.get(widget)) {
return;
}
Private.selectedProperty.set(widget, true);
this._selectionChanged.emit(void 0);
this.update();
}
/**
* Deselect a cell widget.
*
* #### Notes
* It is a no-op if the value does not change.
* It will emit the `selectionChanged` signal.
*/
deselect(widget) {
if (!Private.selectedProperty.get(widget)) {
return;
}
Private.selectedProperty.set(widget, false);
this._selectionChanged.emit(void 0);
this.update();
}
/**
* Whether a cell is selected.
*/
isSelected(widget) {
return Private.selectedProperty.get(widget);
}
/**
* Whether a cell is selected or is the active cell.
*/
isSelectedOrActive(widget) {
if (widget === this._activeCell) {
return true;
}
return Private.selectedProperty.get(widget);
}
/**
* Deselect all of the cells.
*/
deselectAll() {
let changed = false;
for (const widget of this.widgets) {
if (Private.selectedProperty.get(widget)) {
changed = true;
}
Private.selectedProperty.set(widget, false);
}
if (changed) {
this._selectionChanged.emit(void 0);
}
// Make sure we have a valid active cell.
// eslint-disable-next-line no-self-assign
this.activeCellIndex = this.activeCellIndex;
this.update();
}
/**
* Move the head of an existing contiguous selection to extend the selection.
*
* @param index - The new head of the existing selection.
*
* #### Notes
* If there is no existing selection, the active cell is considered an
* existing one-cell selection.
*
* If the new selection is a single cell, that cell becomes the active cell
* and all cells are deselected.
*
* There is no change if there are no cells (i.e., activeCellIndex is -1).
*/
extendContiguousSelectionTo(index) {
let { head, anchor } = this.getContiguousSelection();
let i;
// Handle the case of no current selection.
if (anchor === null || head === null) {
if (index === this.activeCellIndex) {
// Already collapsed selection, nothing more to do.
return;
}
// We will start a new selection below.
head = this.activeCellIndex;
anchor = this.activeCellIndex;
}
// Move the active cell. We do this before the collapsing shortcut below.
this.activeCellIndex = index;
// Make sure the index is valid, according to the rules for setting and clipping the
// active cell index. This may change the index.
index = this.activeCellIndex;
// Collapse the selection if it is only the active cell.
if (index === anchor) {
this.deselectAll();
return;
}
let selectionChanged = false;
if (head < index) {
if (head < anchor) {
Private.selectedProperty.set(this.widgets[head], false);
selectionChanged = true;
}
// Toggle everything strictly between head and index except anchor.
for (i = head + 1; i < index; i++) {
if (i !== anchor) {
Private.selectedProperty.set(this.widgets[i], !Private.selectedProperty.get(this.widgets[i]));
selectionChanged = true;
}
}
}
else if (index < head) {
if (anchor < head) {
Private.selectedProperty.set(this.widgets[head], false);
selectionChanged = true;
}
// Toggle everything strictly between index and head except anchor.
for (i = index + 1; i < head; i++) {
if (i !== anchor) {
Private.selectedProperty.set(this.widgets[i], !Private.selectedProperty.get(this.widgets[i]));
selectionChanged = true;
}
}
}
// Anchor and index should *always* be selected.
if (!Private.selectedProperty.get(this.widgets[anchor])) {
selectionChanged = true;
}
Private.selectedProperty.set(this.widgets[anchor], true);
if (!Private.selectedProperty.get(this.widgets[index])) {
selectionChanged = true;
}
Private.selectedProperty.set(this.widgets[index], true);
if (selectionChanged) {
this._selectionChanged.emit(void 0);
}
}
/**
* Get the head and anchor of a contiguous cell selection.
*
* The head of a contiguous selection is always the active cell.
*
* If there are no cells selected, `{head: null, anchor: null}` is returned.
*
* Throws an error if the currently selected cells do not form a contiguous
* selection.
*/
getContiguousSelection() {
const cells = this.widgets;
const first = ArrayExt.findFirstIndex(cells, c => this.isSelected(c));
// Return early if no cells are selected.
if (first === -1) {
return { head: null, anchor: null };
}
const last = ArrayExt.findLastIndex(cells, c => this.isSelected(c), -1, first);
// Check that the selection is contiguous.
for (let i = first; i <= last; i++) {
if (!this.isSelected(cells[i])) {
throw new Error('Selection not contiguous');
}
}
// Check that the active cell is one of the endpoints of the selection.
const activeIndex = this.activeCellIndex;
if (first !== activeIndex && last !== activeIndex) {
throw new Error('Active cell not at endpoint of selection');
}
// Determine the head and anchor of the selection.
if (first === activeIndex) {
return { head: first, anchor: last };
}
else {
return { head: last, anchor: first };
}
}
/**
* Scroll so that the given cell is in view. Selects and activates cell.
*
* @param cell - A cell in the notebook widget.
* @param align - Type of alignment.
*
*/
async scrollToCell(cell, align = 'auto') {
try {
await this.scrollToItem(this.widgets.findIndex(c => c === cell), align);
}
catch (r) {
//no-op
}
// change selection and active cell:
this.deselectAll();
this.select(cell);
cell.activate();
}
_parseFragment(fragment) {
const cleanedFragment = fragment.slice(1);
if (!cleanedFragment) {
// Bail early
return;
}
const parts = cleanedFragment.split('=');
if (parts.length === 1) {
// Default to legacy if no prefix is given.
return {
kind: 'legacy',
value: cleanedFragment
};
}
return {
kind: parts[0],
value: parts.slice(1).join('=')
};
}
/**
* Set URI fragment identifier.
*/
async setFragment(fragment) {
const parsedFragment = this._parseFragment(fragment);
if (!parsedFragment) {
// Bail early
return;
}
let result;
switch (parsedFragment.kind) {
case 'heading':
result = await this._findHeading(parsedFragment.value);
break;
case 'cell-id':
result = this._findCellById(parsedFragment.value);
break;
case 'legacy':
result = await this._findLegacy(parsedFragment.value);
break;
default:
console.warn(`Unknown target type for URI fragment ${fragment}, interpreting as a heading`);
result = await this._findLegacy(parsedFragment.kind + '=' + parsedFragment.value);
break;
}
if (result == null) {
return;
}
let { cell, element } = result;
if (!cell.inViewport) {
await this.scrollToCell(cell, 'center');
}
if (element == null) {
element = cell.node;
}
// Scroll notebook to set the target on top.
element.scrollIntoView();
}
/**
* 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) {
if (!this.model) {
return;
}
switch (event.type) {
case 'contextmenu':
if (event.eventPhase === Event.CAPTURING_PHASE) {
this._evtContextMenuCapture(event);
}
break;
case 'mousedown':
if (event.eventPhase === Event.CAPTURING_PHASE) {
this._evtMouseDownCapture(event);
}
else {
// Skip processing the event when it resulted from a toolbar button click
if (!event.defaultPrevented) {
this._evtMouseDown(event);
}
}
break;
case 'mouseup':
if (event.currentTarget === document) {
this._evtDocumentMouseup(event);
}
break;
case 'mousemove':
if (event.currentTarget === document) {
this._evtDocumentMousemove(event);
}
break;
case 'keydown':
// This works because CodeMirror does not stop the event propagation
this._ensureFocus(true);
break;
case 'dblclick':
this._evtDblClick(event);
break;
case 'focusin':
this._evtFocusIn(event);
break;
case 'focusout':
this._evtFocusOut(event);
break;
case 'lm-dragenter':
this._evtDragEnter(event);
break;
case 'lm-dragleave':
this._evtDragLeave(event);
break;
case 'lm-dragover':
this._evtDragOver(event);
break;
case 'lm-drop':
this._evtDrop(event);
break;
default:
super.handleEvent(event);
break;
}
}
/**
* Pop and return the top cell from the back stack, pushing to forward stack.
* @experimental
*/
popLastModifiedCell() {
var _a;
const activeId = (_a = this.activeCell) === null || _a === void 0 ? void 0 : _a.model.id;
while (this._lastModifiedCellBackStack.length) {
const id = this._lastModifiedCellBackStack.pop();
// Skip active cell
if (id === activeId) {
this._lastModifiedCellForwardStack.push(id);
continue;
}
const cell = this.widgets.find(c => c.model.id === id);
if (cell && !cell.isDisposed) {
this._lastModifiedCellForwardStack.push(id);
return cell;
}
}
return null;
}
/**
* Pop and return the top cell from the forward stack, pushing to back stack.
* @experimental
*/
popNextModifiedCell() {
var _a;
const activeId = (_a = this.activeCell) === null || _a === void 0 ? void 0 : _a.model.id;
while (this._lastModifiedCellForwardStack.length) {
const id = this._lastModifiedCellForwardStack.pop();
if (id === activeId) {
this._lastModifiedCellBackStack.push(id);
continue;
}
const cell = this.widgets.find(c => c.model.id === id);
if (cell && !cell.isDisposed) {
this._lastModifiedCellBackStack.push(id);
return cell;
}
}
return null;
}
/**
* Check if there is a navigable cell in the back stack.
* @experimental
*/
hasNavigableModifiedCellBack() {
var _a;
const activeId = (_a = this.activeCell) === null || _a === void 0 ? void 0 : _a.model.id;
for (let i = this._lastModifiedCellBackStack.length - 1; i >= 0; i--) {
const id = this._lastModifiedCellBackStack[i];
if (id !== activeId) {
const cell = this.widgets.find(c => c.model.id === id);
if (cell && !cell.isDisposed) {
return true;
}
}
}
return false;
}
/**
* Check if there is a navigable cell in the forward stack.
* @experimental
*/
hasNavigableModifiedCellForward() {
var _a;
const activeId = (_a = this.activeCell) === null || _a === void 0 ? void 0 : _a.model.id;
for (let i = this._lastModifiedCellForwardStack.length - 1; i >= 0; i--) {
const id = this._lastModifiedCellForwardStack[i];
if (id !== activeId) {
const cell = this.widgets.find(c => c.model.id === id);
if (cell && !cell.isDisposed) {
return true;
}
}
}
return false;
}
/**
* Handle `after-attach` messages for the widget.
*/
onAfterAttach(msg) {
super.onAfterAttach(msg);
const node = this.node;
node.addEventListener('contextmenu', this, true);
node.addEventListener('mousedown', this, true);
node.addEventListener('mousedown', this);
node.addEventListener('keydown', this);
node.addEventListener('dblclick', this);
node.addEventListener('focusin', this);
node.addEventListener('focusout', this);
// Capture drag events for the notebook widget
// in order to preempt the drag/drop handlers in the
// code editor widgets, which can take text data.
node.addEventListener('lm-dragenter', this, true);
node.addEventListener('lm-dragleave', this, true);
node.addEventListener('lm-dragover', this, true);
node.addEventListener('lm-drop', this, true);
}
/**
* Handle `before-detach` messages for the widget.
*/
onBeforeDetach(msg) {
const node = this.node;
node.removeEventListener('contextmenu', this, true);
node.removeEventListener('mousedown', this, true);
node.removeEventListener('mousedown', this);
node.removeEventListener('keydown', this);
node.removeEventListener('dblclick', this);
node.removeEventListener('focusin', this);
node.removeEventListener('focusout', this);
node.removeEventListener('lm-dragenter', this, true);
node.removeEventListener('lm-dragleave', this, true);
node.removeEventListener('lm-dragover', this, true);
node.removeEventListener('lm-drop', this, true);
document.removeEventListener('mousemove', this, true);
document.removeEventListener('mouseup', this, true);
super.onBeforeDetach(msg);
}
/**
* A message handler invoked on an `'after-show'` message.
*/
onAfterShow(msg) {
super.onAfterShow(msg);
this._checkCacheOnNextResize = true;
}
/**
* A message handler invoked on a `'resize'` message.
*/
onResize(msg) {
var _a;
// TODO
if (!this._checkCacheOnNextResize) {
return super.onResize(msg);
}
super.onResize(msg);
this._checkCacheOnNextResize = false;
const cache = this._cellLayoutStateCache;
const width = parseInt(this.node.style.width, 10);
if (cache) {
if (width === cache.width) {
// Cache identical, do nothing
return;
}
}
// Update cache
this._cellLayoutStateCache = { width };
// Fallback:
for (const w of this.widgets) {
if (w instanceof Cell && w.inViewport) {
(_a = w.editorWidget) === null || _a === void 0 ? void 0 : _a.update();
}
}
}
/**
* A message handler invoked on an `'before-hide'` message.
*/
onBeforeHide(msg) {
super.onBeforeHide(msg);
// Update cache
const width = parseInt(this.node.style.width, 10);
this._cellLayoutStateCache = { width };
}
/**
* Handle `'activate-request'` messages.
*/
onActivateRequest(msg) {
super.onActivateRequest(msg);
this._ensureFocus(true);
}
/**
* Handle `update-request` messages sent to the widget.
*/
onUpdateRequest(msg) {
super.onUpdateRequest(msg);
const activeCell = this.activeCell;
// Set the appropriate classes on the cells.
if (this.mode === 'edit') {
this.addClass(EDIT_CLASS);
this.removeClass(COMMAND_CLASS);
}
else {
this.addClass(COMMAND_CLASS);
this.removeClass(EDIT_CLASS);
}
let count = 0;
for (const widget of this.widgets) {
// Set tabIndex to -1 to allow calling .focus() on cell without allowing
// focus via tab key. This allows focus (document.activeElement) to move
// up and down the document, cell by cell, when the user presses J/K or
// ArrowDown/ArrowUp, but (unlike tabIndex = 0) does not add the notebook
// cells (which could be numerous) to the set of nodes that the user would
// have to visit when pressing the tab key to move about the UI.
// NOTE: we need to be very careful to avoid modifying DOM to avoid triggering layout on scroll
if (widget === activeCell) {
activeCell.addClass(ACTIVE_CLASS);
activeCell.addClass(SELECTED_CLASS);
// Set tab index to 0 on the active cell so that if the user tabs away from
// the notebook then tabs back, they will return to the cell where they
// left off.
activeCell.node.tabIndex = 0;
}
else {
widget.node.tabIndex = -1;
widget.removeClass(ACTIVE_CLASS);
widget.removeClass(OTHER_SELECTED_CLASS);
}
if (this.isSelectedOrActive(widget)) {
widget.addClass(SELECTED_CLASS);
count++;
}
else {
widget.removeClass(SELECTED_CLASS);
}
}
if (activeCell && count > 1) {
activeCell.addClass(OTHER_SELECTED_CLASS);
}
}
/**
* Handle a cell being inserted.
*/
onCellInserted(index, cell) {
void cell.ready.then(() => {
if (!cell.isDisposed) {
cell.editor.edgeRequested.connect(this._onEdgeRequest, this);
}
});
cell.scrollRequested.connect((_emitter, scrollRequest) => {
if (cell !== this.activeCell) {
// Do nothing for cells other than the active cell
// to avoid scroll requests from editor extensions
// stealing user focus (this may be revisited).
return;
}
if (!scrollRequest.defaultPrevented) {
// Nothing to do if scroll request was already handled.
return;
}
// Node which allows to scroll the notebook
const scroller = this.outerNode;
if (cell.inViewport) {
// If cell got scrolled to the viewport in the meantime,
// proceed with scrolling within the cell.
return scrollRequest.scrollWithinCell({ scroller });
}
// If cell is not in the viewport and needs scrolling,
// first scroll to the cell and then scroll within the cell.
this.scrollToItem(this.activeCellIndex)
.then(() => {
void cell.ready.then(() => {
scrollRequest.scrollWithinCell({ scroller });
});
})
.catch(reason => {
// no-op
});
});
// If the insertion happened above, increment the active cell
// index, otherwise it stays the same.
this.activeCellIndex =
index <= this.activeCellIndex
? this.activeCellIndex + 1
: this.activeCellIndex;
}
/**
* Handle a cell being removed.
*/
onCellRemoved(index, cell) {
// If the removal happened above, decrement the active
// cell index, otherwise it stays the same.
this.activeCellIndex =
index <= this.activeCellIndex
? this.activeCellIndex - 1
: this.activeCellIndex;
if (this.isSelected(cell)) {
this._selectionChanged.emit(void 0);
}
}
/**
* Handle a new model.
*/
onModelChanged(oldValue, newValue) {
super.onModelChanged(oldValue, newValue);
// Try to set the active cell index to 0.
// It will be set to `-1` if there is no new model or the model is empty.
this.activeCellIndex = 0;
}
/**
* Handle edge request signals from cells.
*/
_onEdgeRequest(editor, location) {
// Ensure edit mode but do not focus yet; we ensure the edit mode
// with focus trigger at the end, as a side-effect of `this.mode = 'edit'`,
// which could be simplified in the future.
this.setMode('edit', { focus: false });
const prev = this.activeCellIndex;
if (location === 'top') {
this.activeCellIndex--;
// Move the cursor to the first position on the last line.
if (this.activeCellIndex < prev) {
const editor = this.activeCell.editor;
if (editor) {
const lastLine = editor.lineCount - 1;
editor.setCursorPosition({ line: lastLine, column: 0 });
}
}
}
else if (location === 'bottom') {
this.activeCellIndex++;
// Move the cursor to the first character.
if (this.activeCellIndex > prev) {
const editor = this.activeCell.editor;
if (editor) {
editor.setCursorPosition({ line: 0, column: 0 });
}
}
}
// This is potentially no longer needed with the `setMode` call at the top.
this.mode = 'edit';
}
/**
* Ensure that the notebook has proper focus.
*/
_ensureFocus(force = false) {
var _a, _b;
const activeElement = document.activeElement;
if (!force && activeElement && !this.node.contains(activeElement)) {
return;
}
// No-op is the footer has the focus.
const footer = this.layout.footer;
if (footer && document.activeElement === footer.node) {
return;
}
const activeCell = this.activeCell;
if (this.mode === 'edit' && activeCell) {
// Test for !== true to cover hasFocus is false and editor is not yet rendered.
if (((_a = activeCell.editor) === null || _a === void 0 ? void 0 : _a.hasFocus()) !== true) {
if (activeCell.inViewport) {
(_b = activeCell.editor) === null || _b === void 0 ? void 0 : _b.focus();
}
else {
this.scrollToItem(this.activeCellIndex)
.then(() => {
void activeCell.ready.then(() => {
var _a;
(_a = activeCell.editor) === null || _a === void 0 ? void 0 : _a.focus();
});
})
.catch(reason => {
// no-op
});
}
}
}
if (force &&
activeCell &&
!activeCell.node.contains(document.activeElement)) {
void NotebookActions.focusActiveCell(this, {
preventScroll: true
});
}
}
/**
* Find the cell containing a node, traversing open shadow roots.
*
* Returned cell is `null` if the node is not in a cell of this notebook.
*/
_cellContaining(node) {
let current = node;
while (current) {
const element = current instanceof Element ? current : current.parentElement;
if (element) {
const index = this._findCell(element);
if (index !== -1) {
return this.widgets[index];
}
}
const root = (element !== null && element !== void 0 ? element : current).getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return null;
}
/**
* Whether a shift-click on `target` should be left to the browser to handle
* as an extension of the current text selection, rather than be turned into
* a cell range selection.
*
* This is the case when the click lands in a region of a cell whose text the
* browser selects - the output area of a code cell, or the rendered input of
* a rendered markdown cell - and the current selection starts in such a
* region too; see https://github.com/jupyterlab/jupyterlab/issues/4800. A
* click on a cell prompt, on cell chrome, or on a cell without such a region
* (an unrendered markdown cell, a code cell without outputs) always selects
* cells.
*
* #### Notes
* Only the anchor of the selection is considered, because a shift-click
* moves the focus and leaves the anchor in place. The anchor may sit in a
* different cell than the one clicked, which is what allows a selection to
* be extended across the outputs of several cells; and disregarding the
* focus keeps this working when a drag overshot the edge of an output and
* left the focus outside of it.
*
* This trade-off between extending the text selection and extending the cell
* selection can be adjusted in the future. For now the text selection wins to
* preserve the behaviour from before refactor that users may be accustomed to.
*/
_isExtendingTextSelection(selection, targetCell, target) {
if (!selection || selection.isCollapsed) {
return false;
}
if (!Private.isInTextRegion(targetCell, target)) {
return false;
}
const { anchorNode } = selection;
// The selection usually starts in the very cell which was clicked, which
// can be checked without having to locate the cell of the anchor.
if (Private.isInTextRegion(targetCell, anchorNode)) {
return true;
}
const anchorCell = this._cellContaining(anchorNode);
return !!anchorCell && Private.isInTextRegion(anchorCell, anchorNode);
}
/**
* Find the cell index containing the target html element.
*
* #### Notes
* Returns -1 if the cell is not found.
*/
_findCell(node) {
// Trace up the DOM hierarchy to find the root cell node.
// Then find the corresponding child and select it.
let n = node;
while (n && n !== this.node) {
if (n.classList.contains(NB_CELL_CLASS)) {
const i = ArrayExt.findFirstIndex(this.widgets, widget => widget.node === n);
if (i !== -1) {
return i;
}
break;
}
n = n.parentElement;
}
return -1;
}
/**
* Find the target of html mouse event and cell index containing this target.
*
* #### Notes
* Returned index is -1 if the cell is not found.
*/
_findEventTargetAndCell(event) {
let target = event.target;
let index = this._findCell(target);
if (index === -1) {
// `event.target` sometimes gives an orphaned node in Firefox 57, which
// can have `null` anywhere in its parent line. If we fail to find a cell
// using `event.target`, try again using a target reconstructed from the
// position of the click event.
target = document.elementFromPoint(event.clientX, event.clientY);
index = this._findCell(target);
}
return [target, index];
}
/**
* Find heading with given ID in any of the cells.
*/
async _findHeading(queryId) {
var _a;
// Loop on cells, get headings and search for first matching id.
for (let cellIdx = 0; cellIdx < this.widgets.length; cellIdx++) {
const cell = this.widgets[cellIdx];
if (cell.model.type === 'raw' ||
(cell.model.type === 'markdown' && !cell.rendered)) {
// Bail early
continue;
}
for (const heading of cell.headings) {
let id = '';
switch (heading.type) {
case Cell.HeadingType.HTML:
id = heading.id;
break;
case Cell.HeadingType.Markdown:
{
const mdHeading = heading;
id = await TableOfContentsUtils.Markdown.getHeadingId(this.rendermime.markdownParser, mdHeading.raw, mdHeading.level, this.rendermime.sanitizer);
}
break;
}
if (id === queryId) {
const attribute = ((_a = this.rendermime.sanitizer.allowNamedProperties) !== null && _a !== void 0 ? _a : false)
? 'id'
: 'data-jupyter-id';
const element = this.node.querySelector(`h${heading.level}[${attribute}="${CSS.escape(id)}"]`);
return {
cell,
element
};
}
}
}
return null;
}
/**
* Find cell by its unique ID.
*/
_findCellById(queryId) {
for (let cellIdx = 0; cellIdx < this.widgets.length; cellIdx++) {
const cell = this.widgets[cellIdx];
if (cell.model.id === queryId) {
return {
cell
};
}
}
return null;
}
/**
* Find anchor target by any ID in any of the cell.
*/
async _findLegacy(queryId) {
var _a;
// Loop on cells, search for first matching id.
for (let cellIdx = 0; cellIdx < this.widgets.length; cellIdx++) {
const cell = this.widgets[cellIdx];
if (cell.model.type === 'raw' ||
(cell.model.type === 'markdown' && !cell.rendered)) {
// Bail early
continue;
}
const attribute = ((_a = this.rendermime.sanitizer.allowNamedProperties) !== null && _a !== void 0 ? _a : false)
? 'id'
: 'data-jupyter-id';
const element = cell.node.querySelector(`[${attribute}="${CSS.escape(queryId)}"]`);
if (element !== null) {
return {
cell,
element: element
};
}
}
return null;
}
/**
* Handle `contextmenu` event.
*/
_evtContextMenuCapture(event) {
var _a;
// Allow the event to propagate un-modified if the user
// is holding the shift-key (and probably requesting
// the native context menu).
if (event.shiftKey) {
return;
}
const [target, index] = this._findEventTargetAndCell(event);
const widget = this.widgets[index];
if (widget && ((_a = widget.editorWidget) === null || _a === void 0 ? void 0 : _a.node.contains(target))) {
// Prevent CodeMirror from focusing the editor.
// TODO: find an editor-agnostic solution.
if (!target.closest('[data-jp-suppress-context-menu]')) {
event.preventDefault();
}
}
}
/**
* Handle `mousedown` event in the capture phase for the widget.
*/
_evtMouseDownCapture(event) {
var _a;
const { button, shiftKey } = event;
const [target, index] = this._findEventTargetAndCell(event);
const widget = this.widgets[index];
// On OS X, the context menu may be triggered with ctrl-left-click. In
// Firefox, ctrl-left-click gives an event with button 2, but in Chrome,
// ctrl-left-click gives an event with button 0 with the ctrl modifier.
if (button === 2 &&
!shiftKey &&
widget &&
((_a = widget.editorWidget) === null || _a === void 0 ? void 0 : _a.node.contains(target))) {
this.mode = 'command';
// Prevent CodeMirror from focusing the editor.
// TODO: find an editor-agnostic solution.
event.preventDefault();
}
}
/**
* Handle `mousedown` events for the widget.
*/
_evtMouseDown(event) {
var _a, _b, _c;
const { button, shiftKey } = event;
// We only handle main or secondary button actions.
if (!(button === 0 || button === 2)) {
return;
}
// Shift right-click gives the browser default behavior.
if (shiftKey && button === 2) {
return;
}
const [target, index] = this._findEventTargetAndCell(event);
const widget = this.widgets[index];
let targetArea;
if (widget) {
if ((_a = widget.editorWidget) === null || _a === void 0 ? void 0 : _a.node.contains(target)) {
targetArea = 'input';
}
else if ((_b = widget.promptNode) === null || _b === void 0 ? void 0 : _b.contains(target)) {
targetArea = 'prompt';
}
else {
targetArea = 'cell';
}
}
else {
targetArea = 'notebook';
}
// Make sure we go to command mode if the click isn't in the cell editor If
// we do click in the cell editor, the editor handles the focus event to
// switch to edit mode.
if (targetArea !== 'input') {
this.mode = 'command';
}
if (targetArea === 'notebook') {
this.deselectAll();
}
else if (targetArea === 'prompt' || targetArea === 'cell') {
if (button === 0 &&
shiftKey &&
!['INPUT', 'OPTION'].includes(target.tagName) &&
// We don't want to prevent the default selection behavior when the
// user is extending a text selection into the clicked region.
!this._isExtendingTextSelection(window.getSelection(), widget, target)) {
// Prevent browser selecting text in prompt or output
event.preventDefault();
// Preventing the default also stops the browser from collapsing any
// text selection which is already there; drop it explicitly so that
// stale highlighted text is not left over the new cell selection.
(_c = window.getSelection()) === null || _c === void 0 ? void 0 : _c.removeAllRanges();
// Shift-click - extend selection
try {
this.extendContiguousSelectionTo(index);
}
catch (e) {
console.error(e);
this.deselectAll();
return;
}
// Enter selecting mode
this._mouseMode = 'select';
// We don't want to block the shift-click mouse up handler
// when the current cell is (and remains) the active cell.
this._selectData = {
startedOnActiveCell: index == this.activeCellIndex,
startingCellIndex: this.activeCellIndex
};
document.addEventListener('mouseup', this, true);
document.addEventListener('mousemove', this, true);
}
else if (button === 0 && !shiftKey) {
// Prepare to start a drag if we are on the drag region.
if (targetArea === 'prompt') {
// Prepare for a drag start
this._dragData = {
pressX: event.clientX,
pressY: event.clientY,
index: index
};
// Enter possible drag mode
this._mouseMode = 'couldDrag';
document.addEventListener('mouseup', this, true);
document.addEventListener('mousemove', this, true);
event.preventDefault();
}
if (!this.isSelectedOrActive(widget)) {
this.deselectAll();
this.activeCellIndex = index;
}
}
else if (button === 2) {
if (!this.isSelectedOrActive(widget)) {
this.deselectAll();
this.activeCellIndex = index;
}
event.preventDefault();
}
}
else if (targetArea === 'input') {
if (button === 2 && !this.isSelectedOrActive(widget)) {
this.deselectAll();
this.activeCellIndex = index;
}
}
// If we didn't set focus above, make sure we get focus now.
this._ensureFocus(true);
}
/**
* Handle the `'mouseup'` event on the document.
*/
_evtDocumentMouseup(event) {
const [, index] = this._findEventTargetAndCell(event);
let shouldPreventDefault = true;
if (this._mouseMode === 'select' && this._selectData) {
// User did not move the mouse over to a difference cell, so there was no selection
const { startedOnActiveCell, startingCellIndex } = this._selectData;
if (startedOnActiveCell && index === startingCellIndex) {
shouldPreventDefault = false;
}
this._selectData = null;
}
if (shouldPreventDefault) {
event.preventDefault();
event.stopPropagation();
}
// Remove the event listeners we put on the document
document.removeEventListener('mousemove', this, true);
document.removeEventListener('mouseup', this, true);
if (this._mouseMode === 'couldDrag') {
// We didn't end up dragging if we are here, so treat it as a click event.
this.deselectAll();
this.activeCellIndex = index;
// Focus notebook if active cell changes but does not have focus.
if (!this.activeCell.node.contains(document.activeElement)) {
void NotebookActions.focusActiveCell(this);
}
}
this._mouseMode = null;
}
/**
* Handle the `'mousemove'` event for the widget.
*/
_evtDocumentMousemove(event) {
event.preventDefault();
event.stopPropagation();
// If in select mode, update the selection
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (this._mouseMode) {
case 'select': {
const target = event.target;
const index = this._findCell(target);
if (index !== -1) {
this.extendContiguousSelectionTo(index);
}
break;
}
case 'couldDrag': {
// Check for a drag initialization.
const data = this._dragData;
const dx = Math.abs(event.clientX - data.pressX);
const dy = Math.abs(event.clientY - data.pressY);
if (dx >= DRAG_THRESHOLD || dy >= DRAG_THRESHOLD) {
this._mouseMode = null;
this._startDrag(data.index, event.clientX, event.clientY);
}
break;
}
default:
break;
}
}
/**
* Handle the `'lm-dragenter'` event for the widget.
*/
_evtDragEnter(event) {
if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
return;
}
event.preventDefault();
event.stopPropagation();
const target = event.target;
const index = this._findCell(target);
if (index === -1) {
return;
}
const widget = this.cellsArray[index];
widget.node.classList.add(DROP_TARGET_CLASS);
}
/**
* Handle the `'lm-dragleave'` event for the widget.
*/
_evtDragLeave(event) {
if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
return;
}
event.preventDefault();
event.stopPropagation();
const elements = this.node.getElementsByClassName(DROP_TARGET_CLASS);
if (elements.length) {
elements[0].classList.remove(DROP_TARGET_CLASS);
}
}
/**
* Handle the `'lm-dragover'` event for the widget.
*/
_evtDragOver(event) {
if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dropAction = event.proposedAction;
const elements = this.node.getElementsByClassName(DROP_TARGET_CLASS);
if (elements.length) {
elements[0].classList.remove(DROP_TARGET_CLASS);
}
const target = event.target;
const index = this._findCell(target);
if (index === -1) {
return;
}
const widget = this.cellsArray[index];
widget.node.classList.add(DROP_TARGET_CLASS);
}
/**
* Handle the `'lm-drop'` event for the widget.
*/
_evtDrop(event) {
if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.proposedAction === 'none') {
event.dropAction = 'none';
return;
}
let target = event.target;
while (target && target.parentElement) {
if (target.classList.contains(DROP_TARGET_CLASS)) {
target.classList.remove(DROP_TARGET_CLASS);
break;
}
target = target.parentElement;
}
// Model presence should be checked before calling event handlers
const model = this.model;
const source = event.source;
if (source === this) {
// Handle the case where we are moving cells within
// the same notebook.
event.dropAction = 'move';
const toMove = event.mimeData.getData('internal:cells');
// For collapsed markdown headings with hidden "child" cells, move all
// child cells as well as the markdown heading.
const cell = toMove[toMove.length - 1];
if (cell instanceof MarkdownCell && cell.headingCollapsed) {
const nextParent = NotebookActions.findNextParentHeading(cell, source);
if (nextParent > 0) {
const index = findIndex(source.widgets, (possibleCell) => {
return cell.model.id === possibleCell.model.id;
});
toMove.push(...source.widgets.slice(index + 1, nextParent));
}
}
// Compute the to/from indices for the move.
let fromIndex = ArrayExt.firstIndexOf(this.widgets, toMove[0]);
let toIndex = this._findCell(target);
// This check is needed for consistency with the view.
if (toIndex !== -1 && toIndex > fromIndex) {
toIndex -= 1;
}
else if (toIndex === -1) {
// If the drop is within the notebook but not on any cell,
// most often this means it is past the cell areas, so
// set it to move the cells to the end of the notebook.
toIndex = this.widgets.length - 1;
}
// Don't move if we are within the block of selected cells.
if (toIndex >= fromIndex && toIndex < fromIndex + toMove.length) {
return;
}
// Move the selected block of cells, preserving in-flight executions.
NotebookActions.moveCells(this, fromIndex, toIndex, toMove.length);
}
else {
// Handle the case where we are copying cells between
// notebooks.
event.dropAction = 'copy';
// Find the target cell and insert the copied cells.
let index = this._findCell(target);
if (index === -1) {
index = this.widgets.length;
}
const start = index;
const values = event.mimeData.getData(JUPYTER_CELL_MIME);
// Insert the copies of the original cells.
// We preserve trust status of pasted cells by not modifying metadata.
model.sharedModel.insertCells(index, values);
// Select the inserted cells.
this.deselectAll();
this.activeCellIndex = start;
this.extendContiguousSelectionTo(index - 1);
}
void NotebookActions.focusActiveCell(this);
}
/**
* Start a drag event.
*/
_startDrag(index, clientX, clientY) {
var _a;
const cells = this.model.cells;
const selected = [];
const toMove = [];
let i = -1;
for (const widget of this.widgets) {
const cell = cells.get(++i);
if (this.isSelectedOrActive(widget)) {
widget.addClass(DROP_SOURCE_CLASS);
selected.push(cell.toJSON());
toMove.push(widget);
}
}
const activeCell = this.activeCell;
let dragImage = null;
let countString;
if ((activeCell === null || activeCell === void 0 ? void 0 : activeCell.model.type) === 'code') {
const executionCount = activeCell.model
.executionCount;
countString = ' ';
if (executionCount) {
countString = executionCount.toString();
}
}
else {
countString = '';
}
// Create the drag image.
dragImage = Private.createDragImage(selected.length, countString, (_a = activeCell === null || activeCell === void 0 ? void 0 : activeCell.model.sharedModel.getSource().split('\n')[0].slice(0, 26)) !== null && _a !== void 0 ? _a : '');
// Set up the drag event.
this._drag = new Drag({
mimeData: new MimeData(),
dragImage,
supportedActions: 'copy-move',
proposedAction: 'copy',
source: this
});
this._drag.mimeData.setData(JUPYTER_CELL_MIME, selected);
// Add mimeData for the fully reified cell widgets, for the
// case where the target is in the same notebook and we
// can just move the cells.
this._drag.mimeData.setData('internal:cells', toMove);
// Add mimeData for the text content of the selected cells,
// allowing for drag/drop into plain text fields.
const textContent = toMove
.map(cell => cell.model.sharedModel.getSource())
.join('\n');
this._drag.mimeData.setData('text/plain', textContent);
// Remove mousemove and mouseup listeners and start the drag.
document.removeEventListener('mousemove', this, true);
document.removeEventListener('mouseup', this, true);
this._mouseMode = null;
void this._drag.start(clientX, clientY).then(action => {
if (this.isDisposed) {
return;
}
this._drag = null;
for (const widget of toMove) {
widget.removeClass(DROP_SOURCE_CLASS);
}
});
}
/**
* Update the notebook node with class indicating read-write state.
*/
_updateReadWrite() {
const inReadWrite = DOMUtils.hasActiveEditableElement(this.node);
if (this.node.classList.contains(READ_WRITE_CLASS) === inReadWrite) {
return;
}
this.node.classList.toggle(READ_WRITE_CLASS, inReadWrite);
}
/**
* Handle `focus` events for the widget.
*/
_evtFocusIn(event) {
var _a, _b;
// Update read-write class state.
this._updateReadWrite();
const target = event.target;
const index = this._findCell(target);
if (index !== -1) {
const widget = this.widgets[index];
// If the editor itself does not have focus, ensure command mode.
if (widget.editorWidget && !widget.editorWidget.node.contains(target)) {
this.setMode('command', { focus: false });
}
// Cell index needs to be updated before changing mode,
// otherwise the previous cell may get un-rendered.
this.activeCellIndex = index;
// If the editor has focus, ensure edit mode.
const node = (_a = widget.editorWidget) === null || _a === void 0 ? void 0 : _a.node;
if (node === null || node === void 0 ? void 0 : node.contains(target)) {
this.setMode('edit', { focus: false });
}
}
else {
// No cell has focus, ensure command mode.
this.setMode('command', { focus: false });
// Prevents the parent element to get the focus.
event.preventDefault();
// Check if the focus was previously in the active cell to avoid focus looping
// between the cell and the cell toolbar.
const source = event.relatedTarget;
// Focuses on the active cell if the focus did not come from it.
// Otherwise focus on the footer element (add cell button).
if (this._activeCell && !this._activeCell.node.contains(source)) {
this._activeCell.ready
.then(() => {
var _a;
(_a = this._activeCell) === null || _a === void 0 ? void 0 : _a.node.focus({
preventScroll: true
});
})
.catch(() => {
var _a;
(_a = this.layout.footer) === null || _a === void 0 ? void 0 : _a.node.focus({
preventScroll: true
});
});
}
else {
(_b = this.layout.footer) === null || _b === void 0 ? void 0 : _b.node.focus({
preventScroll: true
});
}
}
}
/**
* Handle `focusout` events for the notebook.
*/
_evtFocusOut(event) {
var _a;
const relatedTarget = event.relatedTarget;
// Bail if the window is losing focus, to preserve edit mode. This test
// assumes that we explicitly focus things rather than calling blur()
if (!relatedTarget) {
// Update read-write class state.
this._updateReadWrite();
return;
}
// Bail if the item gaining focus is another cell,
// and we should not be entering command mode.
const index = this._findCell(relatedTarget);
if (index !== -1) {
const widget = this.widgets[index];
if ((_a = widget.editorWidget) === null || _a === void 0 ? void 0 : _a.node.contains(relatedTarget)) {
// Do not update read-write state as there is no real loss of focus,
// instead we are just switching to a different cell
return;
}
}
// Otherwise enter command mode if not already.
if (this.mode !== 'command') {
this.setMode('command', { focus: false });
}
// Update read-write class state.
this._updateReadWrite();
}
/**
* Handle `dblclick` events for the widget.
*/
_evtDblClick(event) {
const model = this.model;
if (!model) {
return;
}
this.deselectAll();
const [target, index] = this._findEventTargetAndCell(event);
if (event.target.classList.contains(HEADING_COLLAPSER_CLASS)) {
return;
}
if (index === -1) {
return;
}
this.activeCellIndex = index;
if (model.cells.get(index).type === 'markdown') {
const widget = this.widgets[index];
widget.rendered = false;
}
else if (target.localName === 'img') {
target.classList.toggle(UNCONFINED_CLASS);
}
}
/**
* Remove selections from inactive cells to avoid
* spurious cursors.
*/
_trimSelections() {
for (let i = 0; i < this.widgets.length; i++) {
if (i !== this._activeCellIndex) {
const cell = this.widgets[i];
if (!cell.model.isDisposed && cell.editor) {
cell.model.selections.delete(cell.editor.uuid);
// Collapse the editor's visual selection to the cursor position
// to avoid multiple cells showing highlighted text.
const cursor = cell.editor.getCursorPosition();
cell.editor.setCursorPosition(cursor, { scroll: false });
}
}
}
}
/**
* Handle a cell content change.
*/
_onCellContentChanged(sender) {
const id = sender.id;
const backStack = this._lastModifiedCellBackStack;
// Do nothing if the last modified cell is the same as this one
if (backStack.length > 0 && backStack[backStack.length - 1] === id) {
return;
}
// Remove if already present elsewhere in the stack to move it to the top
const idx = backStack.lastIndexOf(id);
if (idx !== -1) {
backStack.splice(idx, 1);
}
backStack.push(id);
if (backStack.length > Notebook.MAX_MODIFIED_STACK) {
backStack.shift();
}
// Clear forward stack when a new cell is modified
this._lastModifiedCellForwardStack = [];
this._stateChanged.emit({
name: 'lastModifiedCellStack',
oldValue: null,
newValue: null
});
}
_updateSelectedCells() {
this._selectedCells = this.widgets.filter(cell => this.isSelectedOrActive(cell));
if (this.kernelHistory) {
this.kernelHistory.reset();
}
}
}
Notebook.MAX_MODIFIED_STACK = 50;
/**
* The namespace for the `Notebook` class statics.
*/
(function (Notebook) {
/**
* The default implementation of a notebook content factory..
*
* #### Notes
* Override methods on this class to customize the default notebook factory
* methods that create notebook content.
*/
class ContentFactory extends StaticNotebook.ContentFactory {
}
Notebook.ContentFactory = ContentFactory;
})(Notebook || (Notebook = {}));
/**
* A namespace for private data.
*/
var Private;
(function (Private) {
/**
* An attached property for the selected state of a cell.
*/
Private.selectedProperty = new AttachedProperty({
name: 'selected',
create: () => false
});
/**
* A custom panel layout for the notebook.
*/
class NotebookPanelLayout extends PanelLayout {
/**
* A message handler invoked on an `'update-request'` message.
*
* #### Notes
* This is a reimplementation of the base class method,
* and is a no-op.
*/
onUpdateRequest(msg) {
// This is a no-op.
}
}
Private.NotebookPanelLayout = NotebookPanelLayout;
/**
* Check whether `node` is `parent` or a descendant of it.
*
* Unlike `Node.contains()` this traverses out of open shadow roots, so a
* node rendered by a widget library which attaches a shadow root (such as
* Panel or Bokeh) is still recognised as belonging to its host subtree.
*/
function containsDeep(parent, node) {
let current = node;
while (current) {
if (parent.contains(current)) {
return true;
}
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return false;
}
/**
* Whether a cell exposes an output area.
*
* #### Notes
* This is a structural check rather than `instanceof CodeCell` so that it
* still holds when a federated extension loads its own copy of
* `@jupyterlab/cells`.
*/
function hasOutputArea(cell) {
return 'outputArea' in cell;
}
/**
* The regions of a cell whose text the browser, rather than the notebook,
* is responsible for selecting: the output area of a code cell and the
* rendered input of a cell which renders it, such as a rendered markdown
* cell.
*/
function textRegionsOf(cell) {
var _a;
const regions = [];
if (hasOutputArea(cell)) {
regions.push(cell.outputArea.node);
}
const rendered = (_a = cell.inputArea) === null || _a === void 0 ? void 0 : _a.renderedInput;
if (rendered) {
regions.push(rendered.node);
}
return regions;
}
/**
* Whether a node is in a region of a cell whose text the browser selects.
*/
function isInTextRegion(cell, node) {
return textRegionsOf(cell).some(region => containsDeep(region, node));
}
Private.isInTextRegion = isInTextRegion;
/**
* Create a cell drag image.
*/
function createDragImage(count, promptNumber, cellContent) {
if (count > 1) {
if (promptNumber !== '') {
return VirtualDOM.realize(h.div(h.div({ className: DRAG_IMAGE_CLASS }, h.span({ className: CELL_DRAG_PROMPT_CLASS }, '[' + promptNumber + ']:'), h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent)), h.div({ className: CELL_DRAG_MULTIPLE_BACK }, '')));
}
else {
return VirtualDOM.realize(h.div(h.div({ className: DRAG_IMAGE_CLASS }, h.span({ className: CELL_DRAG_PROMPT_CLASS }), h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent)), h.div({ className: CELL_DRAG_MULTIPLE_BACK }, '')));
}
}
else {
if (promptNumber !== '') {
return VirtualDOM.realize(h.div(h.div({ className: `${DRAG_IMAGE_CLASS} ${SINGLE_DRAG_IMAGE_CLASS}` }, h.span({ className: CELL_DRAG_PROMPT_CLASS }, '[' + promptNumber + ']:'), h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent))));
}
else {
return VirtualDOM.realize(h.div(h.div({ className: `${DRAG_IMAGE_CLASS} ${SINGLE_DRAG_IMAGE_CLASS}` }, h.span({ className: CELL_DRAG_PROMPT_CLASS }), h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent))));
}
}
}
Private.createDragImage = createDragImage;
})(Private || (Private = {}));
//# sourceMappingURL=widget.js.map