UNPKG

@jupyterlab/notebook

Version:
659 lines (611 loc) 20.5 kB
/* * Copyright (c) Jupyter Development Team. * Distributed under the terms of the Modified BSD License. */ import { Cell, CodeCell, CodeCellModel } from '@jupyterlab/cells'; import { WindowedLayout, WindowedList, WindowedListModel } from '@jupyterlab/ui-components'; import { Message, MessageLoop } from '@lumino/messaging'; import { Debouncer, Throttler } from '@lumino/polling'; import { Widget } from '@lumino/widgets'; import { DROP_SOURCE_CLASS, DROP_TARGET_CLASS } from './constants'; /** * Check whether the element is in a scrolling notebook. * Traverses open shadow DOM roots if needed. */ function isInScrollingNotebook(element: Element | null): boolean { if (!element) { return false; } const notebook = element.closest('.jp-WindowedPanel-viewport') as | HTMLElement | undefined; if (notebook && notebook.dataset.isScrolling == 'true') { return true; } const root = element.getRootNode(); return !!( root && root instanceof ShadowRoot && isInScrollingNotebook(root.host) ); } /** * Check whether the element is part of a CodeMirror editor. */ function isCodeMirrorElement(element: Element | null): boolean { if (!element) { return false; } return !!element.closest('.cm-editor'); } /** * Subclass IntersectionObserver to allow suspending callbacks when notebook is scrolling. */ window.IntersectionObserver = class extends window.IntersectionObserver { constructor( protected callback: IntersectionObserverCallback, options: IntersectionObserverInit ) { super(entries => { this._delayCallbackInScrollingNotebook(entries); }, options); this._throttler = new Throttler( entries => { // keep delaying until no longer in scrolling notebook this._delayCallbackInScrollingNotebook(entries); }, { limit: 1000, edge: 'trailing' } ); } private _delayCallbackInScrollingNotebook = ( entries: IntersectionObserverEntry[] ) => { const entriesInScrollingNotebook = []; const nonOutputEntries = []; for (const entry of entries) { // Do not delay callbacks to CodeMirror editor logic if ( isInScrollingNotebook(entry.target) && !isCodeMirrorElement(entry.target) ) { entriesInScrollingNotebook.push(entry); } else { nonOutputEntries.push(entry); } } if (nonOutputEntries.length) { this.callback(nonOutputEntries, this); } if (entriesInScrollingNotebook.length) { void this._throttler.invoke(entriesInScrollingNotebook); } }; private _throttler: Throttler; }; /** * Subclass ResizeObserver to allow suspending callbacks when notebook is scrolling. */ window.ResizeObserver = class extends window.ResizeObserver { constructor(protected callback: ResizeObserverCallback) { super(entries => { this._delayCallbackInScrollingNotebook(entries); }); this._throttler = new Throttler( entries => { // keep delaying until no longer in scrolling notebook this._delayCallbackInScrollingNotebook(entries); }, { limit: 1000, edge: 'trailing' } ); } private _delayCallbackInScrollingNotebook = ( entries: ResizeObserverEntry[] ) => { const entriesInScrollingNotebook = []; const nonOutputEntries = []; for (const entry of entries) { if (isInScrollingNotebook(entry.target)) { entriesInScrollingNotebook.push(entry); } else { nonOutputEntries.push(entry); } } if (nonOutputEntries.length) { this.callback(nonOutputEntries, this); } if (entriesInScrollingNotebook.length) { void this._throttler.invoke(entriesInScrollingNotebook); } }; private _throttler: Throttler; }; /** * Notebook view model for the windowed list. */ export class NotebookViewModel extends WindowedListModel { /** * Default cell height */ static DEFAULT_CELL_SIZE = 39; /** * Default editor line height */ static DEFAULT_EDITOR_LINE_HEIGHT = 17; /** * Default cell margin (top + bottom) */ static DEFAULT_CELL_MARGIN = 22; /** * Construct a notebook windowed list model. */ constructor( protected cells: Cell[], options?: WindowedList.IModelOptions ) { super(options); // Set default cell size this._estimatedWidgetSize = NotebookViewModel.DEFAULT_CELL_SIZE; } /** * Cell size estimator * * @param index Cell index * @returns Cell height in pixels */ estimateWidgetSize = (index: number): number => { const cell = this.cells[index]; if (!cell) { // This should not happen, but if it does, // do not throw if cell was deleted in the meantime console.warn( `estimateWidgetSize requested for cell ${index} in notebook with only ${this.cells.length} cells` ); return 0; } const model = cell.model; const height = this.cellsEstimatedHeight.get(model.id); if (typeof height === 'number') { return height; } const nLines = model.sharedModel.getSource().split('\n').length; let outputsLines = 0; if (model instanceof CodeCellModel && !model.isDisposed) { for (let outputIdx = 0; outputIdx < model.outputs.length; outputIdx++) { const output = model.outputs.get(outputIdx); const data = output.data['text/plain']; if (typeof data === 'string') { outputsLines += data.split('\n').length; } else if (Array.isArray(data)) { outputsLines += data.join('').split('\n').length; } } } return ( NotebookViewModel.DEFAULT_EDITOR_LINE_HEIGHT * (nLines + outputsLines) + NotebookViewModel.DEFAULT_CELL_MARGIN ); }; /** * Set an estimated height for a cell * * @param cellId Cell ID * @param size Cell height */ setEstimatedWidgetSize(cellId: string, size: number | null): void { if (size === null) { if (this.cellsEstimatedHeight.has(cellId)) { this.cellsEstimatedHeight.delete(cellId); } } else { this.cellsEstimatedHeight.set(cellId, size); this._emitEstimatedHeightChanged.invoke().catch(error => { console.error( 'Fail to trigger an update following a estimated height update.', error ); }); } } /** * Render the cell at index. * * @param index Cell index * @returns Cell widget */ widgetRenderer = (index: number): Widget => { return this.cells[index]; }; /** * Threshold used to decide if the cell should be scrolled to in the `smart` mode. * Defaults to scrolling when less than a full line of the cell is visible. */ readonly scrollDownThreshold = NotebookViewModel.DEFAULT_CELL_MARGIN / 2 + NotebookViewModel.DEFAULT_EDITOR_LINE_HEIGHT; /** * Threshold used to decide if the cell should be scrolled to in the `smart` mode. * Defaults to scrolling when the cell margin or more is invisible. */ readonly scrollUpThreshold = NotebookViewModel.DEFAULT_CELL_MARGIN / 2; /** * Mapping between the cell ids and the cell estimated heights * * This height is not refreshed with the changes to the document. * It is only used to measure cells outside the viewport on CPU * idle cycle to improve UX scrolling. */ protected cellsEstimatedHeight = new Map<string, number>(); private _emitEstimatedHeightChanged = new Debouncer(() => { this._stateChanged.emit({ name: 'estimatedWidgetSize', newValue: null, oldValue: null }); }); } /** * Windowed list layout for the notebook. */ export class NotebookWindowedLayout extends WindowedLayout { private _header: Widget | null = null; private _footer: Widget | null = null; /** * Notebook's header */ get header(): Widget | null { return this._header; } set header(header: Widget | null) { if (this._header && this._header.isAttached) { Widget.detach(this._header); } this._header = header; if (this._header && this.parent?.isAttached) { Widget.attach(this._header, this.parent!.node); } } /** * Notebook widget's footer */ get footer(): Widget | null { return this._footer; } set footer(footer: Widget | null) { if (this._footer && this._footer.isAttached) { Widget.detach(this._footer); } this._footer = footer; if (this._footer && this.parent?.isAttached) { Widget.attach(this._footer, this.parent!.outerNode); } } /** * Notebook's active cell */ get activeCell(): Widget | null { return this._activeCell; } set activeCell(widget: Widget | null) { this._activeCell = widget; } private _activeCell: Widget | null; /** * Dispose the layout * */ dispose(): void { if (this.isDisposed) { return; } this._header?.dispose(); this._footer?.dispose(); super.dispose(); } /** * * A message handler invoked on a `'child-removed'` message. * * * @param widget - The widget to remove from the layout. * * #### Notes * A widget is automatically removed from the layout when its `parent` * is set to `null`. This method should only be invoked directly when * removing a widget from a layout which has yet to be installed on a * parent widget. * * This method does *not* modify the widget's `parent`. */ removeWidget(widget: Widget): void { const index = this.widgets.indexOf(widget); // We need to deal with code cell widget not in viewport (aka not in this.widgets) but still // partly attached if (index >= 0) { this.removeWidgetAt(index); } // If the layout is parented, detach the widget from the DOM. else if (widget === this._willBeRemoved && this.parent) { this.detachWidget(index, widget); } } /** * Attach a widget to the parent's DOM node. * * @param index - The current index of the widget in the layout. * * @param widget - The widget to attach to the parent. * * #### Notes * This method is called automatically by the panel layout at the * appropriate time. It should not be called directly by user code. * * The default implementation adds the widgets's node to the parent's * node at the proper location, and sends the appropriate attach * messages to the widget if the parent is attached to the DOM. * * Subclasses may reimplement this method to control how the widget's * node is added to the parent's node. */ protected attachWidget(index: number, widget: Widget): void { // Status may change in onBeforeAttach const wasPlaceholder = (widget as Cell).isPlaceholder(); // Initialized sub-widgets or attached them for CodeCell // Because this reattaches all sub-widget to the DOM which leads // to a loss of focus, we do not call it for soft-hidden cells. const isSoftHidden = this._isSoftHidden(widget); if (this.parent!.isAttached && !isSoftHidden) { MessageLoop.sendMessage(widget, Widget.Msg.BeforeAttach); } if (isSoftHidden) { // Restore visibility for active, or previously active cell this._toggleSoftVisibility(widget, true); } if ( !wasPlaceholder && widget instanceof CodeCell && widget.node.parentElement ) { // We don't remove code cells to preserve outputs internal state widget.node.style.display = ''; // Reset cache this._topHiddenCodeCells = -1; if (this.parent!.isAttached && !widget.isAttached) { widget.setFlag(Widget.Flag.IsAttached); } } else if (!isSoftHidden) { // Look up the next sibling reference node. const siblingIndex = this._findNearestChildBinarySearch( this.parent!.viewportNode.childElementCount - 1, 0, parseInt(widget.dataset.windowedListIndex!, 10) + 1 ); let ref = this.parent!.viewportNode.children[siblingIndex]; // Insert the widget's node before the sibling. this.parent!.viewportNode.insertBefore(widget.node, ref); // Send an `'after-attach'` message if the parent is attached. // Event listeners will be added here // Some widgets are updating/resetting when attached, so // we should not recall this each time a cell move into the // viewport. if (this.parent!.isAttached) { MessageLoop.sendMessage(widget, Widget.Msg.AfterAttach); } } (widget as Cell).inViewport = true; } /** * Detach a widget from the parent's DOM node. * * @param index - The previous index of the widget in the layout. * * @param widget - The widget to detach from the parent. * * #### Notes * This method is called automatically by the panel layout at the * appropriate time. It should not be called directly by user code. * * The default implementation removes the widget's node from the * parent's node, and sends the appropriate detach messages to the * widget if the parent is attached to the DOM. * * Subclasses may reimplement this method to control how the widget's * node is removed from the parent's node. */ protected detachWidget(index: number, widget: Widget): void { (widget as Cell).inViewport = false; // Note: `index` is relative to the displayed cells, not all cells, // hence we compare with the widget itself. if (widget === this.activeCell && widget !== this._willBeRemoved) { // Do not change display of the active cell to allow user to continue providing input // into the code mirror editor when out of view. We still hide the cell so to prevent // minor visual glitches when scrolling. this._toggleSoftVisibility(widget, false); // Return before sending "AfterDetach" message to CodeCell // to prevent removing contents of the active cell. return; } // Do not apply `display: none` on cells which contain: // - `<defs>` as multiple browsers (Chrome and Firefox) // are bugged and do not respect the spec here, see // https://github.com/jupyterlab/jupyterlab/issues/16952 // https://issues.chromium.org/issues/40324398 // https://bugzilla.mozilla.org/show_bug.cgi?id=376027 // - elements containing `.myst` class as jupyterlab-myst // re-renders cause height jitter and scrolling issues; // in future this could be addressed by the proposal // to allow renderers to specify hiding mode, see // https://github.com/jupyterlab/jupyterlab/issues/17331 const requiresSoftHiding = widget.node.querySelector('defs,.myst'); if (requiresSoftHiding) { this._toggleSoftVisibility(widget, false); return; } // TODO we could improve this further by discarding also the code cell without outputs if ( // We detach the code cell currently dragged otherwise it won't be attached at the correct position widget instanceof CodeCell && !widget.node.classList.contains(DROP_SOURCE_CLASS) && widget !== this._willBeRemoved ) { // We don't remove code cells to preserve outputs internal state // Transform does not work because the widget height is kept (at least in FF) widget.node.style.display = 'none'; // Reset cache this._topHiddenCodeCells = -1; } else { // Send a `'before-detach'` message if the parent is attached. // This should not be called every time a cell leaves the viewport // as it will remove listeners that won't be added back as afterAttach // is shunted to avoid unwanted update/reset. if (this.parent!.isAttached) { // Event listeners will be removed here MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach); } // Remove the widget's node from the parent. this.parent!.viewportNode.removeChild(widget.node); // Ensure to clean up drop target class if the widget move out of the viewport widget.node.classList.remove(DROP_TARGET_CLASS); } if (this.parent!.isAttached) { // Detach sub widget of CodeCell except the OutputAreaWrapper MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach); } } /** * Move a widget in the parent's DOM node. * * @param fromIndex - The previous index of the widget in the layout. * * @param toIndex - The current index of the widget in the layout. * * @param widget - The widget to move in the parent. * * #### Notes * This method is called automatically by the panel layout at the * appropriate time. It should not be called directly by user code. * * The default implementation moves the widget's node to the proper * location in the parent's node and sends the appropriate attach and * detach messages to the widget if the parent is attached to the DOM. * * Subclasses may reimplement this method to control how the widget's * node is moved in the parent's node. */ protected moveWidget( fromIndex: number, toIndex: number, widget: Widget ): void { // Optimize move without de-/attaching as motion appends with parent attached // Case fromIndex === toIndex, already checked in PanelLayout.insertWidget if (this._topHiddenCodeCells < 0) { this._topHiddenCodeCells = 0; for ( let idx = 0; idx < this.parent!.viewportNode.children.length; idx++ ) { const n = this.parent!.viewportNode.children[idx]; if ((n as HTMLElement).style.display == 'none') { this._topHiddenCodeCells++; } else { break; } } } const ref = this.parent!.viewportNode.children[toIndex + this._topHiddenCodeCells]; if (fromIndex < toIndex) { ref.insertAdjacentElement('afterend', widget.node); } else { ref.insertAdjacentElement('beforebegin', widget.node); } } protected onAfterAttach(msg: Message): void { super.onAfterAttach(msg); if (this._header && !this._header.isAttached) { Widget.attach( this._header, this.parent!.node, this.parent!.node.firstElementChild as HTMLElement | null ); } if (this._footer && !this._footer.isAttached) { Widget.attach(this._footer, this.parent!.outerNode); } } protected onBeforeDetach(msg: Message): void { if (this._header?.isAttached) { Widget.detach(this._header); } if (this._footer?.isAttached) { Widget.detach(this._footer); } super.onBeforeDetach(msg); } /** * A message handler invoked on a `'child-removed'` message. * * @param msg Message */ protected onChildRemoved(msg: Widget.ChildMessage): void { this._willBeRemoved = msg.child; super.onChildRemoved(msg); this._willBeRemoved = null; } /** * Toggle "soft" visibility of the widget. * * #### Notes * To ensure that user events reach the CodeMirror editor, this method * does not toggle `display` nor `visibility` which have side effects, * but instead hides it in the compositor and ensures that the bounding * box is has an area equal to zero. * To ensure we do not trigger style recalculation, we set the styles * directly on the node instead of using a class. */ private _toggleSoftVisibility(widget: Widget, show: boolean): void { if (show) { widget.node.style.opacity = ''; widget.node.style.height = ''; widget.node.style.padding = ''; } else { widget.node.style.opacity = '0'; // Both padding and height need to be set to zero // to ensure bounding box collapses to invisible. widget.node.style.height = '0'; widget.node.style.padding = '0'; } } private _isSoftHidden(widget: Widget): boolean { return widget.node.style.opacity === '0'; } private _findNearestChildBinarySearch( high: number, low: number, index: number ): number { while (low <= high) { const middle = low + Math.floor((high - low) / 2); const currentIndex = parseInt( (this.parent!.viewportNode.children[middle] as HTMLElement).dataset .windowedListIndex!, 10 ); if (currentIndex === index) { return middle; } else if (currentIndex < index) { low = middle + 1; } else if (currentIndex > index) { high = middle - 1; } } if (low > 0) { return low; } else { return 0; } } private _willBeRemoved: Widget | null = null; private _topHiddenCodeCells: number = -1; }