UNPKG

89.1 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { DOMUtils } from '@jupyterlab/apputils';
5import {
6 Cell,
7 CodeCell,
8 ICellModel,
9 ICodeCellModel,
10 IMarkdownCellModel,
11 IRawCellModel,
12 MarkdownCell,
13 RawCell
14} from '@jupyterlab/cells';
15import { CodeEditor, IEditorMimeTypeService } from '@jupyterlab/codeeditor';
16import { IChangedArgs } from '@jupyterlab/coreutils';
17import * as nbformat from '@jupyterlab/nbformat';
18import { IObservableList } from '@jupyterlab/observables';
19import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
20import type { IMapChange } from '@jupyter/ydoc';
21import { TableOfContentsUtils } from '@jupyterlab/toc';
22import { ITranslator, nullTranslator } from '@jupyterlab/translation';
23import { WindowedList } from '@jupyterlab/ui-components';
24import { ArrayExt, findIndex } from '@lumino/algorithm';
25import { MimeData } from '@lumino/coreutils';
26import { ElementExt } from '@lumino/domutils';
27import { Drag } from '@lumino/dragdrop';
28import { Message } from '@lumino/messaging';
29import { AttachedProperty } from '@lumino/properties';
30import { ISignal, Signal } from '@lumino/signaling';
31import { h, VirtualDOM } from '@lumino/virtualdom';
32import { PanelLayout, Widget } from '@lumino/widgets';
33import { NotebookActions } from './actions';
34import { CellList } from './celllist';
35import { DROP_SOURCE_CLASS, DROP_TARGET_CLASS } from './constants';
36import { INotebookHistory } from './history';
37import { INotebookModel } from './model';
38import { NotebookViewModel, NotebookWindowedLayout } from './windowing';
39import { NotebookFooter } from './notebookfooter';
40
41/**
42 * The data attribute added to a widget that has an active kernel.
43 */
44const KERNEL_USER = 'jpKernelUser';
45
46/**
47 * The data attribute added to a widget that can run code.
48 */
49const CODE_RUNNER = 'jpCodeRunner';
50
51/**
52 * The data attribute added to a widget that can undo.
53 */
54const UNDOER = 'jpUndoer';
55
56/**
57 * The class name added to notebook widgets.
58 */
59const NB_CLASS = 'jp-Notebook';
60
61/**
62 * The class name added to notebook widget cells.
63 */
64const NB_CELL_CLASS = 'jp-Notebook-cell';
65
66/**
67 * The class name added to a notebook in edit mode.
68 */
69const EDIT_CLASS = 'jp-mod-editMode';
70
71/**
72 * The class name added to a notebook in command mode.
73 */
74const COMMAND_CLASS = 'jp-mod-commandMode';
75
76/**
77 * The class name added to the active cell.
78 */
79const ACTIVE_CLASS = 'jp-mod-active';
80
81/**
82 * The class name added to selected cells.
83 */
84const SELECTED_CLASS = 'jp-mod-selected';
85
86/**
87 * The class name added to an active cell when there are other selected cells.
88 */
89const OTHER_SELECTED_CLASS = 'jp-mod-multiSelected';
90
91/**
92 * The class name added to unconfined images.
93 */
94const UNCONFINED_CLASS = 'jp-mod-unconfined';
95
96/**
97 * The class name added to the notebook when an element within it is focused
98 * and takes keyboard input, such as focused <input> or <div contenteditable>.
99 *
100 * This class is also effective when the focused element is in shadow DOM.
101 */
102const READ_WRITE_CLASS = 'jp-mod-readWrite';
103
104/**
105 * The class name added to drag images.
106 */
107const DRAG_IMAGE_CLASS = 'jp-dragImage';
108
109/**
110 * The class name added to singular drag images
111 */
112const SINGLE_DRAG_IMAGE_CLASS = 'jp-dragImage-singlePrompt';
113
114/**
115 * The class name added to the drag image cell content.
116 */
117const CELL_DRAG_CONTENT_CLASS = 'jp-dragImage-content';
118
119/**
120 * The class name added to the drag image cell content.
121 */
122const CELL_DRAG_PROMPT_CLASS = 'jp-dragImage-prompt';
123
124/**
125 * The class name added to the drag image cell content.
126 */
127const CELL_DRAG_MULTIPLE_BACK = 'jp-dragImage-multipleBack';
128
129/**
130 * The mimetype used for Jupyter cell data.
131 */
132const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells';
133
134/**
135 * The threshold in pixels to start a drag event.
136 */
137const DRAG_THRESHOLD = 5;
138
139/**
140 * Maximal remaining time for idle callback
141 *
142 * Ref: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API#getting_the_most_out_of_idle_callbacks
143 */
144const MAXIMUM_TIME_REMAINING = 50;
145
146/*
147 * The rendering mode for the notebook.
148 */
149type RenderingLayout = 'default' | 'side-by-side';
150
151/**
152 * The class attached to the heading collapser button
153 */
154const HEADING_COLLAPSER_CLASS = 'jp-collapseHeadingButton';
155
156/**
157 * The class that controls the visibility of "heading collapser" and "show hidden cells" buttons.
158 */
159const HEADING_COLLAPSER_VISBILITY_CONTROL_CLASS =
160 'jp-mod-showHiddenCellsButton';
161
162const SIDE_BY_SIDE_CLASS = 'jp-mod-sideBySide';
163
164/**
165 * The interactivity modes for the notebook.
166 */
167export type NotebookMode = 'command' | 'edit';
168
169if ((window as any).requestIdleCallback === undefined) {
170 // On Safari, requestIdleCallback is not available, so we use replacement functions for `idleCallbacks`
171 // See: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API#falling_back_to_settimeout
172 // eslint-disable-next-line @typescript-eslint/ban-types
173 (window as any).requestIdleCallback = function (handler: Function) {
174 let startTime = Date.now();
175 return setTimeout(function () {
176 handler({
177 didTimeout: false,
178 timeRemaining: function () {
179 return Math.max(0, 50.0 - (Date.now() - startTime));
180 }
181 });
182 }, 1);
183 };
184
185 (window as any).cancelIdleCallback = function (id: number) {
186 clearTimeout(id);
187 };
188}
189
190/**
191 * A widget which renders static non-interactive notebooks.
192 *
193 * #### Notes
194 * The widget model must be set separately and can be changed
195 * at any time. Consumers of the widget must account for a
196 * `null` model, and may want to listen to the `modelChanged`
197 * signal.
198 */
199export class StaticNotebook extends WindowedList {
200 /**
201 * Construct a notebook widget.
202 */
203 constructor(options: StaticNotebook.IOptions) {
204 const cells = new Array<Cell>();
205 const windowingActive =
206 (options.notebookConfig?.windowingMode ??
207 StaticNotebook.defaultNotebookConfig.windowingMode) === 'full';
208 super({
209 model: new NotebookViewModel(cells, {
210 overscanCount:
211 options.notebookConfig?.overscanCount ??
212 StaticNotebook.defaultNotebookConfig.overscanCount,
213 windowingActive
214 }),
215 layout: new NotebookWindowedLayout(),
216 renderer: options.renderer ?? WindowedList.defaultRenderer,
217 scrollbar: false
218 });
219 this.addClass(NB_CLASS);
220 this.cellsArray = cells;
221
222 this._idleCallBack = null;
223
224 this._editorConfig = StaticNotebook.defaultEditorConfig;
225 this._notebookConfig = StaticNotebook.defaultNotebookConfig;
226 this._mimetype = IEditorMimeTypeService.defaultMimeType;
227 this._notebookModel = null;
228 this._modelChanged = new Signal<this, void>(this);
229 this._modelContentChanged = new Signal<this, void>(this);
230
231 this.node.dataset[KERNEL_USER] = 'true';
232 this.node.dataset[UNDOER] = 'true';
233 this.node.dataset[CODE_RUNNER] = 'true';
234 this.rendermime = options.rendermime;
235 this.translator = options.translator || nullTranslator;
236 this.contentFactory = options.contentFactory;
237 this.editorConfig =
238 options.editorConfig || StaticNotebook.defaultEditorConfig;
239 this.notebookConfig =
240 options.notebookConfig || StaticNotebook.defaultNotebookConfig;
241 this._updateNotebookConfig();
242 this._mimetypeService = options.mimeTypeService;
243 this.renderingLayout = options.notebookConfig?.renderingLayout;
244 this.kernelHistory = options.kernelHistory;
245 }
246
247 get cellCollapsed(): ISignal<this, Cell> {
248 return this._cellCollapsed;
249 }
250
251 get cellInViewportChanged(): ISignal<this, Cell> {
252 return this._cellInViewportChanged;
253 }
254
255 /**
256 * A signal emitted when the model of the notebook changes.
257 */
258 get modelChanged(): ISignal<this, void> {
259 return this._modelChanged;
260 }
261
262 /**
263 * A signal emitted when the model content changes.
264 *
265 * #### Notes
266 * This is a convenience signal that follows the current model.
267 */
268 get modelContentChanged(): ISignal<this, void> {
269 return this._modelContentChanged;
270 }
271
272 /**
273 * A signal emitted when the rendering layout of the notebook changes.
274 */
275 get renderingLayoutChanged(): ISignal<this, RenderingLayout> {
276 return this._renderingLayoutChanged;
277 }
278
279 /**
280 * The cell factory used by the widget.
281 */
282 readonly contentFactory: StaticNotebook.IContentFactory;
283
284 /**
285 * The Rendermime instance used by the widget.
286 */
287 readonly rendermime: IRenderMimeRegistry;
288
289 /**
290 * Translator to be used by cell renderers
291 */
292 readonly translator: ITranslator;
293
294 /**
295 * The model for the widget.
296 */
297 get model(): INotebookModel | null {
298 return this._notebookModel;
299 }
300 set model(newValue: INotebookModel | null) {
301 newValue = newValue || null;
302 if (this._notebookModel === newValue) {
303 return;
304 }
305 const oldValue = this._notebookModel;
306 this._notebookModel = newValue;
307 // Trigger private, protected, and public changes.
308 this._onModelChanged(oldValue, newValue);
309 this.onModelChanged(oldValue, newValue);
310 this._modelChanged.emit(void 0);
311
312 // Trigger state change
313 this.viewModel.itemsList = newValue?.cells ?? null;
314 }
315
316 /**
317 * Get the mimetype for code cells.
318 */
319 get codeMimetype(): string {
320 return this._mimetype;
321 }
322
323 /**
324 * A read-only sequence of the widgets in the notebook.
325 */
326 get widgets(): ReadonlyArray<Cell> {
327 return this.cellsArray as ReadonlyArray<Cell>;
328 }
329
330 /**
331 * A configuration object for cell editor settings.
332 */
333 get editorConfig(): StaticNotebook.IEditorConfig {
334 return this._editorConfig;
335 }
336 set editorConfig(value: StaticNotebook.IEditorConfig) {
337 this._editorConfig = value;
338 this._updateEditorConfig();
339 }
340
341 /**
342 * A configuration object for notebook settings.
343 */
344 get notebookConfig(): StaticNotebook.INotebookConfig {
345 return this._notebookConfig;
346 }
347 set notebookConfig(value: StaticNotebook.INotebookConfig) {
348 this._notebookConfig = value;
349 this._updateNotebookConfig();
350 }
351
352 get renderingLayout(): RenderingLayout | undefined {
353 return this._renderingLayout;
354 }
355 set renderingLayout(value: RenderingLayout | undefined) {
356 this._renderingLayout = value;
357 if (this._renderingLayout === 'side-by-side') {
358 this.node.classList.add(SIDE_BY_SIDE_CLASS);
359 } else {
360 this.node.classList.remove(SIDE_BY_SIDE_CLASS);
361 }
362 this._renderingLayoutChanged.emit(this._renderingLayout ?? 'default');
363 }
364
365 /**
366 * Dispose of the resources held by the widget.
367 */
368 dispose(): void {
369 // Do nothing if already disposed.
370 if (this.isDisposed) {
371 return;
372 }
373 this._notebookModel = null;
374 (this.layout as NotebookWindowedLayout).header?.dispose();
375 super.dispose();
376 }
377
378 /**
379 * Move cells preserving widget view state.
380 *
381 * #### Notes
382 * This is required because at the model level a move is a deletion
383 * followed by an insertion. Hence the view state is not preserved.
384 *
385 * @param from The index of the cell to move
386 * @param to The new index of the cell
387 * @param n Number of cells to move
388 */
389 moveCell(from: number, to: number, n = 1): void {
390 if (!this.model) {
391 return;
392 }
393
394 const boundedTo = Math.min(this.model.cells.length - 1, Math.max(0, to));
395
396 if (boundedTo === from) {
397 return;
398 }
399
400 const viewModel: { [k: string]: any }[] = new Array(n);
401 for (let i = 0; i < n; i++) {
402 viewModel[i] = {};
403 const oldCell = this.widgets[from + i];
404 if (oldCell.model.type === 'markdown') {
405 for (const k of ['rendered', 'headingCollapsed']) {
406 // @ts-expect-error Cell has no index signature
407 viewModel[i][k] = oldCell[k];
408 }
409 }
410 }
411
412 this.model!.sharedModel.moveCells(from, boundedTo, n);
413
414 for (let i = 0; i < n; i++) {
415 const newCell = this.widgets[to + i];
416 const view = viewModel[i];
417 for (const state in view) {
418 // @ts-expect-error Cell has no index signature
419 newCell[state] = view[state];
420 }
421 }
422 }
423
424 /**
425 * Force rendering the cell outputs of a given cell if it is still a placeholder.
426 *
427 * #### Notes
428 * The goal of this method is to allow search on cell outputs (that is based
429 * on DOM tree introspection).
430 *
431 * @param index The cell index
432 */
433 renderCellOutputs(index: number): void {
434 const cell = this.viewModel.widgetRenderer(index) as Cell;
435 if (cell instanceof CodeCell && cell.isPlaceholder()) {
436 cell.dataset.windowedListIndex = `${index}`;
437 this.layout.insertWidget(index, cell);
438 if (this.notebookConfig.windowingMode === 'full') {
439 // We need to delay slightly the removal to let codemirror properly initialize
440 requestAnimationFrame(() => {
441 this.layout.removeWidget(cell);
442 });
443 }
444 }
445 }
446
447 /**
448 * Adds a message to the notebook as a header.
449 */
450 protected addHeader(): void {
451 const trans = this.translator.load('jupyterlab');
452 const info = new Widget();
453 info.node.textContent = trans.__(
454 'The notebook is empty. Click the + button on the toolbar to add a new cell.'
455 );
456 (this.layout as NotebookWindowedLayout).header = info;
457 }
458
459 /**
460 * Removes the header.
461 */
462 protected removeHeader(): void {
463 (this.layout as NotebookWindowedLayout).header?.dispose();
464 (this.layout as NotebookWindowedLayout).header = null;
465 }
466
467 /**
468 * Handle a new model.
469 *
470 * #### Notes
471 * This method is called after the model change has been handled
472 * internally and before the `modelChanged` signal is emitted.
473 * The default implementation is a no-op.
474 */
475 protected onModelChanged(
476 oldValue: INotebookModel | null,
477 newValue: INotebookModel | null
478 ): void {
479 // No-op.
480 }
481
482 /**
483 * Handle changes to the notebook model content.
484 *
485 * #### Notes
486 * The default implementation emits the `modelContentChanged` signal.
487 */
488 protected onModelContentChanged(model: INotebookModel, args: void): void {
489 this._modelContentChanged.emit(void 0);
490 }
491
492 /**
493 * Handle changes to the notebook model metadata.
494 *
495 * #### Notes
496 * The default implementation updates the mimetypes of the code cells
497 * when the `language_info` metadata changes.
498 */
499 protected onMetadataChanged(sender: INotebookModel, args: IMapChange): void {
500 switch (args.key) {
501 case 'language_info':
502 this._updateMimetype();
503 break;
504 default:
505 break;
506 }
507 }
508
509 /**
510 * Handle a cell being inserted.
511 *
512 * The default implementation is a no-op
513 */
514 protected onCellInserted(index: number, cell: Cell): void {
515 // This is a no-op.
516 }
517
518 /**
519 * Handle a cell being removed.
520 *
521 * The default implementation is a no-op
522 */
523 protected onCellRemoved(index: number, cell: Cell): void {
524 // This is a no-op.
525 }
526
527 /**
528 * A message handler invoked on an `'update-request'` message.
529 *
530 * #### Notes
531 * The default implementation of this handler is a no-op.
532 */
533 protected onUpdateRequest(msg: Message): void {
534 if (this.notebookConfig.windowingMode === 'defer') {
535 void this._runOnIdleTime();
536 } else {
537 super.onUpdateRequest(msg);
538 }
539 }
540
541 /**
542 * Handle a new model on the widget.
543 */
544 private _onModelChanged(
545 oldValue: INotebookModel | null,
546 newValue: INotebookModel | null
547 ): void {
548 if (oldValue) {
549 oldValue.contentChanged.disconnect(this.onModelContentChanged, this);
550 oldValue.metadataChanged.disconnect(this.onMetadataChanged, this);
551 oldValue.cells.changed.disconnect(this._onCellsChanged, this);
552 while (this.cellsArray.length) {
553 this._removeCell(0);
554 }
555 }
556 if (!newValue) {
557 this._mimetype = IEditorMimeTypeService.defaultMimeType;
558 return;
559 }
560 this._updateMimetype();
561 const cells = newValue.cells;
562 const collab = newValue.collaborative ?? false;
563 if (!collab && !cells.length) {
564 newValue.sharedModel.insertCell(0, {
565 cell_type: this.notebookConfig.defaultCell,
566 metadata:
567 this.notebookConfig.defaultCell === 'code'
568 ? {
569 // This is an empty cell created in empty notebook, thus is trusted
570 trusted: true
571 }
572 : {}
573 });
574 }
575 let index = -1;
576 for (const cell of cells) {
577 this._insertCell(++index, cell);
578 }
579 newValue.cells.changed.connect(this._onCellsChanged, this);
580 newValue.metadataChanged.connect(this.onMetadataChanged, this);
581 newValue.contentChanged.connect(this.onModelContentChanged, this);
582 }
583
584 /**
585 * Handle a change cells event.
586 */
587 protected _onCellsChanged(
588 sender: CellList,
589 args: IObservableList.IChangedArgs<ICellModel>
590 ): void {
591 this.removeHeader();
592 switch (args.type) {
593 case 'add': {
594 let index = 0;
595 index = args.newIndex;
596 for (const value of args.newValues) {
597 this._insertCell(index++, value);
598 }
599 this._updateDataWindowedListIndex(
600 args.newIndex,
601 this.model!.cells.length,
602 args.newValues.length
603 );
604 break;
605 }
606 case 'remove':
607 for (let length = args.oldValues.length; length > 0; length--) {
608 this._removeCell(args.oldIndex);
609 }
610 this._updateDataWindowedListIndex(
611 args.oldIndex,
612 this.model!.cells.length + args.oldValues.length,
613 -1 * args.oldValues.length
614 );
615 // Add default cell if there are no cells remaining.
616 if (!sender.length) {
617 const model = this.model;
618 // Add the cell in a new context to avoid triggering another
619 // cell changed event during the handling of this signal.
620 requestAnimationFrame(() => {
621 if (model && !model.isDisposed && !model.sharedModel.cells.length) {
622 model.sharedModel.insertCell(0, {
623 cell_type: this.notebookConfig.defaultCell,
624 metadata:
625 this.notebookConfig.defaultCell === 'code'
626 ? {
627 // This is an empty cell created in empty notebook, thus is trusted
628 trusted: true
629 }
630 : {}
631 });
632 }
633 });
634 }
635 break;
636 default:
637 return;
638 }
639
640 if (!this.model!.sharedModel.cells.length) {
641 this.addHeader();
642 }
643
644 this.update();
645 }
646
647 /**
648 * Create a cell widget and insert into the notebook.
649 */
650 private _insertCell(index: number, cell: ICellModel): void {
651 let widget: Cell;
652 switch (cell.type) {
653 case 'code':
654 widget = this._createCodeCell(cell as ICodeCellModel);
655 widget.model.mimeType = this._mimetype;
656 break;
657 case 'markdown':
658 widget = this._createMarkdownCell(cell as IMarkdownCellModel);
659 if (cell.sharedModel.getSource() === '') {
660 (widget as MarkdownCell).rendered = false;
661 }
662 break;
663 default:
664 widget = this._createRawCell(cell as IRawCellModel);
665 }
666 widget.inViewportChanged.connect(this._onCellInViewportChanged, this);
667 widget.addClass(NB_CELL_CLASS);
668
669 ArrayExt.insert(this.cellsArray, index, widget);
670 this.onCellInserted(index, widget);
671
672 this._scheduleCellRenderOnIdle();
673 }
674
675 /**
676 * Create a code cell widget from a code cell model.
677 */
678 private _createCodeCell(model: ICodeCellModel): CodeCell {
679 const rendermime = this.rendermime;
680 const contentFactory = this.contentFactory;
681 const editorConfig = this.editorConfig.code;
682 const options: CodeCell.IOptions = {
683 contentFactory,
684 editorConfig,
685 inputHistoryScope: this.notebookConfig.inputHistoryScope,
686 maxNumberOutputs: this.notebookConfig.maxNumberOutputs,
687 model,
688 placeholder: this._notebookConfig.windowingMode !== 'none',
689 rendermime,
690 translator: this.translator
691 };
692 const cell = this.contentFactory.createCodeCell(options);
693 cell.syncCollapse = true;
694 cell.syncEditable = true;
695 cell.syncScrolled = true;
696 cell.outputArea.inputRequested.connect((_, stdin) => {
697 this._onInputRequested(cell).catch(reason => {
698 console.error('Failed to scroll to cell requesting input.', reason);
699 });
700 stdin.disposed.connect(() => {
701 // The input field is removed from the DOM after the user presses Enter.
702 // This causes focus to be lost if we don't explicitly re-focus
703 // somewhere else.
704 cell.node.focus();
705 });
706 });
707 return cell;
708 }
709
710 /**
711 * Create a markdown cell widget from a markdown cell model.
712 */
713 private _createMarkdownCell(model: IMarkdownCellModel): MarkdownCell {
714 const rendermime = this.rendermime;
715 const contentFactory = this.contentFactory;
716 const editorConfig = this.editorConfig.markdown;
717 const options: MarkdownCell.IOptions = {
718 contentFactory,
719 editorConfig,
720 model,
721 placeholder: this._notebookConfig.windowingMode !== 'none',
722 rendermime,
723 showEditorForReadOnlyMarkdown:
724 this._notebookConfig.showEditorForReadOnlyMarkdown
725 };
726 const cell = this.contentFactory.createMarkdownCell(options);
727 cell.syncCollapse = true;
728 cell.syncEditable = true;
729 // Connect collapsed signal for each markdown cell widget
730 cell.headingCollapsedChanged.connect(this._onCellCollapsed, this);
731 return cell;
732 }
733
734 /**
735 * Create a raw cell widget from a raw cell model.
736 */
737 private _createRawCell(model: IRawCellModel): RawCell {
738 const contentFactory = this.contentFactory;
739 const editorConfig = this.editorConfig.raw;
740 const options: RawCell.IOptions = {
741 editorConfig,
742 model,
743 contentFactory,
744 placeholder: this._notebookConfig.windowingMode !== 'none'
745 };
746 const cell = this.contentFactory.createRawCell(options);
747 cell.syncCollapse = true;
748 cell.syncEditable = true;
749 return cell;
750 }
751
752 /**
753 * Remove a cell widget.
754 */
755 private _removeCell(index: number): void {
756 const widget = this.cellsArray[index];
757 widget.parent = null;
758 ArrayExt.removeAt(this.cellsArray, index);
759 this.onCellRemoved(index, widget);
760 widget.dispose();
761 }
762
763 /**
764 * Update the mimetype of the notebook.
765 */
766 private _updateMimetype(): void {
767 const info = this._notebookModel?.getMetadata('language_info');
768 if (!info) {
769 return;
770 }
771 this._mimetype = this._mimetypeService.getMimeTypeByLanguage(info);
772 for (const widget of this.widgets) {
773 if (widget.model.type === 'code') {
774 widget.model.mimeType = this._mimetype;
775 }
776 }
777 }
778
779 /**
780 * Callback when a cell collapsed status changes.
781 *
782 * @param cell Cell changed
783 * @param collapsed New collapsed status
784 */
785 private _onCellCollapsed(cell: Cell, collapsed: boolean): void {
786 NotebookActions.setHeadingCollapse(cell, collapsed, this);
787 this._cellCollapsed.emit(cell);
788 }
789
790 /**
791 * Callback when a cell viewport status changes.
792 *
793 * @param cell Cell changed
794 */
795 private _onCellInViewportChanged(cell: Cell): void {
796 this._cellInViewportChanged.emit(cell);
797 }
798
799 /**
800 * Ensure to load in the DOM a cell requesting an user input
801 *
802 * @param cell Cell requesting an input
803 */
804 private async _onInputRequested(cell: Cell): Promise<void> {
805 if (!cell.inViewport) {
806 const cellIndex = this.widgets.findIndex(c => c === cell);
807 if (cellIndex >= 0) {
808 await this.scrollToItem(cellIndex);
809
810 const inputEl = cell.node.querySelector('.jp-Stdin');
811 if (inputEl) {
812 ElementExt.scrollIntoViewIfNeeded(this.node, inputEl);
813 (inputEl as HTMLElement).focus();
814 }
815 }
816 }
817 }
818
819 private _scheduleCellRenderOnIdle() {
820 if (this.notebookConfig.windowingMode !== 'none' && !this.isDisposed) {
821 if (!this._idleCallBack) {
822 this._idleCallBack = requestIdleCallback(
823 (deadline: IdleDeadline) => {
824 this._idleCallBack = null;
825
826 // In case of timeout, render for some time even if it means freezing the UI
827 // This avoids the cells to never be loaded.
828 void this._runOnIdleTime(
829 deadline.didTimeout
830 ? MAXIMUM_TIME_REMAINING
831 : deadline.timeRemaining()
832 );
833 },
834 {
835 timeout: 3000
836 }
837 );
838 }
839 }
840 }
841
842 private _updateDataWindowedListIndex(
843 start: number,
844 end: number,
845 delta: number
846 ): void {
847 for (
848 let cellIdx = 0;
849 cellIdx < this.viewportNode.childElementCount;
850 cellIdx++
851 ) {
852 const cell = this.viewportNode.children[cellIdx];
853 const globalIndex = parseInt(
854 (cell as HTMLElement).dataset.windowedListIndex!,
855 10
856 );
857 if (globalIndex >= start && globalIndex < end) {
858 (cell as HTMLElement).dataset.windowedListIndex = `${
859 globalIndex + delta
860 }`;
861 }
862 }
863 }
864
865 /**
866 * Update editor settings for notebook cells.
867 */
868 private _updateEditorConfig() {
869 for (let i = 0; i < this.widgets.length; i++) {
870 const cell = this.widgets[i];
871 let config: Record<string, any> = {};
872 switch (cell.model.type) {
873 case 'code':
874 config = this._editorConfig.code;
875 break;
876 case 'markdown':
877 config = this._editorConfig.markdown;
878 break;
879 default:
880 config = this._editorConfig.raw;
881 break;
882 }
883 cell.updateEditorConfig({ ...config });
884 }
885 }
886
887 private async _runOnIdleTime(
888 remainingTime: number = MAXIMUM_TIME_REMAINING
889 ): Promise<void> {
890 const startTime = Date.now();
891 let cellIdx = 0;
892 while (
893 Date.now() - startTime < remainingTime &&
894 cellIdx < this.cellsArray.length
895 ) {
896 const cell = this.cellsArray[cellIdx];
897 if (cell.isPlaceholder()) {
898 switch (this.notebookConfig.windowingMode) {
899 case 'defer':
900 await this._updateForDeferMode(cell, cellIdx);
901 break;
902 case 'full':
903 this._renderCSSAndJSOutputs(cell, cellIdx);
904 break;
905 }
906 }
907 cellIdx++;
908 }
909
910 // If the notebook is not fully rendered
911 if (cellIdx < this.cellsArray.length) {
912 // If we are defering the cell rendering and the rendered cells do
913 // not fill the viewport yet
914 if (
915 this.notebookConfig.windowingMode === 'defer' &&
916 this.viewportNode.clientHeight < this.node.clientHeight
917 ) {
918 // Spend more time rendering cells to fill the viewport
919 await this._runOnIdleTime();
920 } else {
921 this._scheduleCellRenderOnIdle();
922 }
923 } else {
924 if (this._idleCallBack) {
925 window.cancelIdleCallback(this._idleCallBack);
926 this._idleCallBack = null;
927 }
928 }
929 }
930
931 private async _updateForDeferMode(
932 cell: Cell<ICellModel>,
933 cellIdx: number
934 ): Promise<void> {
935 cell.dataset.windowedListIndex = `${cellIdx}`;
936 this.layout.insertWidget(cellIdx, cell);
937 await cell.ready;
938 }
939
940 private _renderCSSAndJSOutputs(
941 cell: Cell<ICellModel>,
942 cellIdx: number
943 ): void {
944 // Only render cell with text/html outputs containing scripts or/and styles
945 // Note:
946 // We don't need to render JavaScript mimetype outputs because they get
947 // directly evaluate without adding DOM elements (see @jupyterlab/javascript-extension)
948 if (cell instanceof CodeCell) {
949 for (
950 let outputIdx = 0;
951 outputIdx < (cell.model.outputs?.length ?? 0);
952 outputIdx++
953 ) {
954 const output = cell.model.outputs.get(outputIdx);
955 const html = (output.data['text/html'] as string) ?? '';
956 if (
957 html.match(
958 /(<style[^>]*>[^<]*<\/style[^>]*>|<script[^>]*>.*?<\/script[^>]*>)/gims
959 )
960 ) {
961 this.renderCellOutputs(cellIdx);
962 break;
963 }
964 }
965 }
966 }
967
968 /**
969 * Apply updated notebook settings.
970 */
971 private _updateNotebookConfig() {
972 // Apply scrollPastEnd setting.
973 this.toggleClass(
974 'jp-mod-scrollPastEnd',
975 this._notebookConfig.scrollPastEnd
976 );
977 // Control visibility of heading collapser UI
978 this.toggleClass(
979 HEADING_COLLAPSER_VISBILITY_CONTROL_CLASS,
980 this._notebookConfig.showHiddenCellsButton
981 );
982 // Control editor visibility for read-only Markdown cells
983 const showEditorForReadOnlyMarkdown =
984 this._notebookConfig.showEditorForReadOnlyMarkdown;
985 if (showEditorForReadOnlyMarkdown !== undefined) {
986 for (const cell of this.cellsArray) {
987 if (cell.model.type === 'markdown') {
988 (cell as MarkdownCell).showEditorForReadOnly =
989 showEditorForReadOnlyMarkdown;
990 }
991 }
992 }
993
994 this.viewModel.windowingActive =
995 this._notebookConfig.windowingMode === 'full';
996 }
997
998 protected cellsArray: Array<Cell>;
999
1000 private _cellCollapsed = new Signal<this, Cell>(this);
1001 private _cellInViewportChanged = new Signal<this, Cell>(this);
1002 private _editorConfig: StaticNotebook.IEditorConfig;
1003 private _idleCallBack: number | null;
1004 private _mimetype: string;
1005 private _mimetypeService: IEditorMimeTypeService;
1006 readonly kernelHistory: INotebookHistory | undefined;
1007 private _modelChanged: Signal<this, void>;
1008 private _modelContentChanged: Signal<this, void>;
1009 private _notebookConfig: StaticNotebook.INotebookConfig;
1010 private _notebookModel: INotebookModel | null;
1011 private _renderingLayout: RenderingLayout | undefined;
1012 private _renderingLayoutChanged = new Signal<this, RenderingLayout>(this);
1013}
1014
1015/**
1016 * The namespace for the `StaticNotebook` class statics.
1017 */
1018export namespace StaticNotebook {
1019 /**
1020 * An options object for initializing a static notebook.
1021 */
1022 export interface IOptions {
1023 /**
1024 * The rendermime instance used by the widget.
1025 */
1026 rendermime: IRenderMimeRegistry;
1027
1028 /**
1029 * The language preference for the model.
1030 */
1031 languagePreference?: string;
1032
1033 /**
1034 * A factory for creating content.
1035 */
1036 contentFactory: IContentFactory;
1037
1038 /**
1039 * A configuration object for the cell editor settings.
1040 */
1041 editorConfig?: IEditorConfig;
1042
1043 /**
1044 * A configuration object for notebook settings.
1045 */
1046 notebookConfig?: INotebookConfig;
1047
1048 /**
1049 * The service used to look up mime types.
1050 */
1051 mimeTypeService: IEditorMimeTypeService;
1052
1053 /**
1054 * The application language translator.
1055 */
1056 translator?: ITranslator;
1057
1058 /**
1059 * The kernel history retrieval object
1060 */
1061 kernelHistory?: INotebookHistory;
1062
1063 /**
1064 * The renderer used by the underlying windowed list.
1065 */
1066 renderer?: WindowedList.IRenderer;
1067 }
1068
1069 /**
1070 * A factory for creating notebook content.
1071 *
1072 * #### Notes
1073 * This extends the content factory of the cell itself, which extends the content
1074 * factory of the output area and input area. The result is that there is a single
1075 * factory for creating all child content of a notebook.
1076 */
1077 export interface IContentFactory extends Cell.IContentFactory {
1078 /**
1079 * Create a new code cell widget.
1080 */
1081 createCodeCell(options: CodeCell.IOptions): CodeCell;
1082
1083 /**
1084 * Create a new markdown cell widget.
1085 */
1086 createMarkdownCell(options: MarkdownCell.IOptions): MarkdownCell;
1087
1088 /**
1089 * Create a new raw cell widget.
1090 */
1091 createRawCell(options: RawCell.IOptions): RawCell;
1092 }
1093
1094 /**
1095 * A config object for the cell editors.
1096 */
1097 export interface IEditorConfig {
1098 /**
1099 * Config options for code cells.
1100 */
1101 readonly code: Record<string, any>;
1102 /**
1103 * Config options for markdown cells.
1104 */
1105 readonly markdown: Record<string, any>;
1106 /**
1107 * Config options for raw cells.
1108 */
1109 readonly raw: Record<string, any>;
1110 }
1111
1112 /**
1113 * Default configuration options for cell editors.
1114 */
1115 export const defaultEditorConfig: IEditorConfig = {
1116 code: {
1117 lineNumbers: false,
1118 lineWrap: false,
1119 matchBrackets: true,
1120 tabFocusable: false
1121 },
1122 markdown: {
1123 lineNumbers: false,
1124 lineWrap: true,
1125 matchBrackets: false,
1126 tabFocusable: false
1127 },
1128 raw: {
1129 lineNumbers: false,
1130 lineWrap: true,
1131 matchBrackets: false,
1132 tabFocusable: false
1133 }
1134 };
1135
1136 /**
1137 * A config object for the notebook widget
1138 */
1139 export interface INotebookConfig {
1140 /**
1141 * The default type for new notebook cells.
1142 */
1143 defaultCell: nbformat.CellType;
1144
1145 /**
1146 * Defines if the document can be undo/redo.
1147 */
1148 disableDocumentWideUndoRedo: boolean;
1149
1150 /**
1151 * Whether to display notification if code cell is run while kernel is still initializing.
1152 */
1153 enableKernelInitNotification: boolean;
1154
1155 /**
1156 * Defines the maximum number of outputs per cell.
1157 */
1158 maxNumberOutputs: number;
1159
1160 /**
1161 * Whether to split stdin line history by kernel session or keep globally accessible.
1162 */
1163 inputHistoryScope: 'global' | 'session';
1164
1165 /**
1166 * Number of cells to render in addition to those
1167 * visible in the viewport.
1168 *
1169 * ### Notes
1170 * In 'full' windowing mode, this is the number of cells above and below the
1171 * viewport.
1172 * In 'defer' windowing mode, this is the number of cells to render initially
1173 * in addition to the one of the viewport.
1174 */
1175 overscanCount: number;
1176
1177 /**
1178 * Should timing be recorded in metadata
1179 */
1180 recordTiming: boolean;
1181
1182 /**
1183 * Defines the rendering layout to use.
1184 */
1185 renderingLayout: RenderingLayout;
1186
1187 /**
1188 * Enable scrolling past the last cell
1189 */
1190 scrollPastEnd: boolean;
1191
1192 /**
1193 * Show hidden cells button if collapsed
1194 */
1195 showHiddenCellsButton: boolean;
1196
1197 /**
1198 * Should an editor be shown for read-only markdown
1199 */
1200 showEditorForReadOnlyMarkdown?: boolean;
1201
1202 /**
1203 * Override the side-by-side left margin.
1204 */
1205 sideBySideLeftMarginOverride: string;
1206
1207 /**
1208 * Override the side-by-side right margin.
1209 */
1210 sideBySideRightMarginOverride: string;
1211
1212 /**
1213 * Side-by-side output ratio.
1214 */
1215 sideBySideOutputRatio: number;
1216
1217 /**
1218 * Windowing mode
1219 *
1220 * - 'defer': Wait for idle CPU cycles to attach out of viewport cells
1221 * - 'full': Attach to the DOM only cells in viewport
1222 * - 'none': Attach all cells to the viewport
1223 */
1224 windowingMode: 'defer' | 'full' | 'none';
1225 accessKernelHistory?: boolean;
1226 }
1227
1228 /**
1229 * Default configuration options for notebooks.
1230 */
1231 export const defaultNotebookConfig: INotebookConfig = {
1232 enableKernelInitNotification: false,
1233 showHiddenCellsButton: true,
1234 scrollPastEnd: true,
1235 defaultCell: 'code',
1236 recordTiming: false,
1237 inputHistoryScope: 'global',
1238 maxNumberOutputs: 50,
1239 showEditorForReadOnlyMarkdown: true,
1240 disableDocumentWideUndoRedo: true,
1241 renderingLayout: 'default',
1242 sideBySideLeftMarginOverride: '10px',
1243 sideBySideRightMarginOverride: '10px',
1244 sideBySideOutputRatio: 1,
1245 overscanCount: 1,
1246 windowingMode: 'full',
1247 accessKernelHistory: false
1248 };
1249
1250 /**
1251 * The default implementation of an `IContentFactory`.
1252 */
1253 export class ContentFactory
1254 extends Cell.ContentFactory
1255 implements IContentFactory
1256 {
1257 /**
1258 * Create a new code cell widget.
1259 *
1260 * #### Notes
1261 * If no cell content factory is passed in with the options, the one on the
1262 * notebook content factory is used.
1263 */
1264 createCodeCell(options: CodeCell.IOptions): CodeCell {
1265 return new CodeCell(options).initializeState();
1266 }
1267
1268 /**
1269 * Create a new markdown cell widget.
1270 *
1271 * #### Notes
1272 * If no cell content factory is passed in with the options, the one on the
1273 * notebook content factory is used.
1274 */
1275 createMarkdownCell(options: MarkdownCell.IOptions): MarkdownCell {
1276 return new MarkdownCell(options).initializeState();
1277 }
1278
1279 /**
1280 * Create a new raw cell widget.
1281 *
1282 * #### Notes
1283 * If no cell content factory is passed in with the options, the one on the
1284 * notebook content factory is used.
1285 */
1286 createRawCell(options: RawCell.IOptions): RawCell {
1287 return new RawCell(options).initializeState();
1288 }
1289 }
1290
1291 /**
1292 * A namespace for the static notebook content factory.
1293 */
1294 export namespace ContentFactory {
1295 /**
1296 * Options for the content factory.
1297 */
1298 export interface IOptions extends Cell.ContentFactory.IOptions {}
1299 }
1300}
1301
1302/**
1303 * A notebook widget that supports interactivity.
1304 */
1305export class Notebook extends StaticNotebook {
1306 /**
1307 * Construct a notebook widget.
1308 */
1309 constructor(options: Notebook.IOptions) {
1310 super({
1311 renderer: {
1312 createOuter(): HTMLElement {
1313 return document.createElement('div');
1314 },
1315
1316 createViewport(): HTMLElement {
1317 const el = document.createElement('div');
1318 el.setAttribute('role', 'feed');
1319 el.setAttribute('aria-label', 'Cells');
1320 return el;
1321 },
1322
1323 createScrollbar(): HTMLOListElement {
1324 return document.createElement('ol');
1325 },
1326
1327 createScrollbarItem(
1328 notebook: Notebook,
1329 index: number,
1330 model: ICellModel
1331 ): HTMLLIElement {
1332 const li = document.createElement('li');
1333 li.appendChild(document.createTextNode(`${index + 1}`));
1334 if (notebook.activeCellIndex === index) {
1335 li.classList.add('jp-mod-active');
1336 }
1337 if (notebook.selectedCells.some(cell => model === cell.model)) {
1338 li.classList.add('jp-mod-selected');
1339 }
1340 return li;
1341 }
1342 },
1343 ...options
1344 });
1345 // Allow the node to scroll while dragging items.
1346 this.outerNode.setAttribute('data-lm-dragscroll', 'true');
1347 this.activeCellChanged.connect(this._updateSelectedCells, this);
1348 this.jumped.connect((_, index: number) => (this.activeCellIndex = index));
1349 this.selectionChanged.connect(this._updateSelectedCells, this);
1350
1351 this.addFooter();
1352 }
1353
1354 /**
1355 * List of selected and active cells
1356 */
1357 get selectedCells(): Cell[] {
1358 return this._selectedCells;
1359 }
1360
1361 /**
1362 * Adds a footer to the notebook.
1363 */
1364 protected addFooter(): void {
1365 const info = new NotebookFooter(this);
1366 (this.layout as NotebookWindowedLayout).footer = info;
1367 }
1368
1369 /**
1370 * Handle a change cells event.
1371 */
1372 protected _onCellsChanged(
1373 sender: CellList,
1374 args: IObservableList.IChangedArgs<ICellModel>
1375 ): void {
1376 const activeCellId = this.activeCell?.model.id;
1377 super._onCellsChanged(sender, args);
1378 if (activeCellId) {
1379 const newActiveCellIndex = this.model?.sharedModel.cells.findIndex(
1380 cell => cell.getId() === activeCellId
1381 );
1382 if (newActiveCellIndex != null) {
1383 this.activeCellIndex = newActiveCellIndex;
1384 }
1385 }
1386 }
1387
1388 /**
1389 * A signal emitted when the active cell changes.
1390 *
1391 * #### Notes
1392 * This can be due to the active index changing or the
1393 * cell at the active index changing.
1394 */
1395 get activeCellChanged(): ISignal<this, Cell | null> {
1396 return this._activeCellChanged;
1397 }
1398
1399 /**
1400 * A signal emitted when the state of the notebook changes.
1401 */
1402 get stateChanged(): ISignal<this, IChangedArgs<any>> {
1403 return this._stateChanged;
1404 }
1405
1406 /**
1407 * A signal emitted when the selection state of the notebook changes.
1408 */
1409 get selectionChanged(): ISignal<this, void> {
1410 return this._selectionChanged;
1411 }
1412
1413 /**
1414 * The interactivity mode of the notebook.
1415 */
1416 get mode(): NotebookMode {
1417 return this._mode;
1418 }
1419 set mode(newValue: NotebookMode) {
1420 this.setMode(newValue);
1421 }
1422
1423 /**
1424 * Set the notebook mode.
1425 *
1426 * @param newValue Notebook mode
1427 * @param options Control mode side-effect
1428 * @param options.focus Whether to ensure focus (default) or not when setting the mode.
1429 */
1430 protected setMode(
1431 newValue: NotebookMode,
1432 options: { focus?: boolean } = {}
1433 ): void {
1434 const setFocus = options.focus ?? true;
1435 const activeCell = this.activeCell;
1436 if (!activeCell) {
1437 newValue = 'command';
1438 }
1439 if (newValue === this._mode) {
1440 if (setFocus) {
1441 this._ensureFocus();
1442 }
1443 return;
1444 }
1445 // Post an update request.
1446 this.update();
1447 const oldValue = this._mode;
1448 this._mode = newValue;
1449
1450 if (newValue === 'edit') {
1451 // Edit mode deselects all cells.
1452 for (const widget of this.widgets) {
1453 this.deselect(widget);
1454 }
1455 // Edit mode unrenders an active markdown widget.
1456 if (activeCell instanceof MarkdownCell) {
1457 activeCell.rendered = false;
1458 }
1459 activeCell!.inputHidden = false;
1460 } else {
1461 if (setFocus) {
1462 void NotebookActions.focusActiveCell(this, {
1463 // Do not await the active cell because that creates a bug. If the user
1464 // is editing a code cell and presses Accel Shift C to open the command
1465 // palette, then the command palette opens before
1466 // activeCell.node.focus() is called, which closes the command palette.
1467 // To the end user, it looks as if all the keyboard shortcut did was
1468 // move focus from the cell editor to the cell as a whole.
1469 waitUntilReady: false,
1470 preventScroll: true
1471 });
1472 }
1473 }
1474 this._stateChanged.emit({ name: 'mode', oldValue, newValue });
1475 if (setFocus) {
1476 this._ensureFocus();
1477 }
1478 }
1479
1480 /**
1481 * The active cell index of the notebook.
1482 *
1483 * #### Notes
1484 * The index will be clamped to the bounds of the notebook cells.
1485 */
1486 get activeCellIndex(): number {
1487 if (!this.model) {
1488 return -1;
1489 }
1490 return this.widgets.length ? this._activeCellIndex : -1;
1491 }
1492 set activeCellIndex(newValue: number) {
1493 const oldValue = this._activeCellIndex;
1494 if (!this.model || !this.widgets.length) {
1495 newValue = -1;
1496 } else {
1497 newValue = Math.max(newValue, 0);
1498 newValue = Math.min(newValue, this.widgets.length - 1);
1499 }
1500
1501 this._activeCellIndex = newValue;
1502 const cell = this.widgets[newValue] ?? null;
1503 (this.layout as NotebookWindowedLayout).activeCell = cell;
1504 const cellChanged = cell !== this._activeCell;
1505 if (cellChanged) {
1506 // Post an update request.
1507 this.update();
1508 this._activeCell = cell;
1509 }
1510
1511 if (cellChanged || newValue != oldValue) {
1512 this._activeCellChanged.emit(cell);
1513 }
1514
1515 if (this.mode === 'edit' && cell instanceof MarkdownCell) {
1516 cell.rendered = false;
1517 }
1518 this._ensureFocus();
1519 if (newValue === oldValue) {
1520 return;
1521 }
1522 this._trimSelections();
1523 this._stateChanged.emit({ name: 'activeCellIndex', oldValue, newValue });
1524 }
1525
1526 /**
1527 * Get the active cell widget.
1528 *
1529 * #### Notes
1530 * This is a cell or `null` if there is no active cell.
1531 */
1532 get activeCell(): Cell | null {
1533 return this._activeCell;
1534 }
1535
1536 get lastClipboardInteraction(): 'copy' | 'cut' | 'paste' | null {
1537 return this._lastClipboardInteraction;
1538 }
1539 set lastClipboardInteraction(newValue: 'copy' | 'cut' | 'paste' | null) {
1540 this._lastClipboardInteraction = newValue;
1541 }
1542
1543 /**
1544 * Dispose of the resources held by the widget.
1545 */
1546 dispose(): void {
1547 if (this.isDisposed) {
1548 return;
1549 }
1550 this._activeCell = null;
1551 super.dispose();
1552 }
1553
1554 /**
1555 * Move cells preserving widget view state.
1556 *
1557 * #### Notes
1558 * This is required because at the model level a move is a deletion
1559 * followed by an insertion. Hence the view state is not preserved.
1560 *
1561 * @param from The index of the cell to move
1562 * @param to The new index of the cell
1563 * @param n Number of cells to move
1564 */
1565 moveCell(from: number, to: number, n = 1): void {
1566 // Save active cell id to be restored
1567 const newActiveCellIndex =
1568 from <= this.activeCellIndex && this.activeCellIndex < from + n
1569 ? this.activeCellIndex + to - from - (from > to ? 0 : n - 1)
1570 : -1;
1571 const isSelected = this.widgets
1572 .slice(from, from + n)
1573 .map(w => this.isSelected(w));
1574
1575 super.moveCell(from, to, n);
1576
1577 if (newActiveCellIndex >= 0) {
1578 this.activeCellIndex = newActiveCellIndex;
1579 }
1580 if (from > to) {
1581 isSelected.forEach((selected, idx) => {
1582 if (selected) {
1583 this.select(this.widgets[to + idx]);
1584 }
1585 });
1586 } else {
1587 isSelected.forEach((selected, idx) => {
1588 if (selected) {
1589 this.select(this.widgets[to - n + 1 + idx]);
1590 }
1591 });
1592 }
1593 }
1594
1595 /**
1596 * Select a cell widget.
1597 *
1598 * #### Notes
1599 * It is a no-op if the value does not change.
1600 * It will emit the `selectionChanged` signal.
1601 */
1602 select(widget: Cell): void {
1603 if (Private.selectedProperty.get(widget)) {
1604 return;
1605 }
1606 Private.selectedProperty.set(widget, true);
1607 this._selectionChanged.emit(void 0);
1608 this.update();
1609 }
1610
1611 /**
1612 * Deselect a cell widget.
1613 *
1614 * #### Notes
1615 * It is a no-op if the value does not change.
1616 * It will emit the `selectionChanged` signal.
1617 */
1618 deselect(widget: Cell): void {
1619 if (!Private.selectedProperty.get(widget)) {
1620 return;
1621 }
1622 Private.selectedProperty.set(widget, false);
1623 this._selectionChanged.emit(void 0);
1624 this.update();
1625 }
1626
1627 /**
1628 * Whether a cell is selected.
1629 */
1630 isSelected(widget: Cell): boolean {
1631 return Private.selectedProperty.get(widget);
1632 }
1633
1634 /**
1635 * Whether a cell is selected or is the active cell.
1636 */
1637 isSelectedOrActive(widget: Cell): boolean {
1638 if (widget === this._activeCell) {
1639 return true;
1640 }
1641 return Private.selectedProperty.get(widget);
1642 }
1643
1644 /**
1645 * Deselect all of the cells.
1646 */
1647 deselectAll(): void {
1648 let changed = false;
1649 for (const widget of this.widgets) {
1650 if (Private.selectedProperty.get(widget)) {
1651 changed = true;
1652 }
1653 Private.selectedProperty.set(widget, false);
1654 }
1655 if (changed) {
1656 this._selectionChanged.emit(void 0);
1657 }
1658 // Make sure we have a valid active cell.
1659 this.activeCellIndex = this.activeCellIndex; // eslint-disable-line
1660 this.update();
1661 }
1662
1663 /**
1664 * Move the head of an existing contiguous selection to extend the selection.
1665 *
1666 * @param index - The new head of the existing selection.
1667 *
1668 * #### Notes
1669 * If there is no existing selection, the active cell is considered an
1670 * existing one-cell selection.
1671 *
1672 * If the new selection is a single cell, that cell becomes the active cell
1673 * and all cells are deselected.
1674 *
1675 * There is no change if there are no cells (i.e., activeCellIndex is -1).
1676 */
1677 extendContiguousSelectionTo(index: number): void {
1678 let { head, anchor } = this.getContiguousSelection();
1679 let i: number;
1680
1681 // Handle the case of no current selection.
1682 if (anchor === null || head === null) {
1683 if (index === this.activeCellIndex) {
1684 // Already collapsed selection, nothing more to do.
1685 return;
1686 }
1687
1688 // We will start a new selection below.
1689 head = this.activeCellIndex;
1690 anchor = this.activeCellIndex;
1691 }
1692
1693 // Move the active cell. We do this before the collapsing shortcut below.
1694 this.activeCellIndex = index;
1695
1696 // Make sure the index is valid, according to the rules for setting and clipping the
1697 // active cell index. This may change the index.
1698 index = this.activeCellIndex;
1699
1700 // Collapse the selection if it is only the active cell.
1701 if (index === anchor) {
1702 this.deselectAll();
1703 return;
1704 }
1705
1706 let selectionChanged = false;
1707
1708 if (head < index) {
1709 if (head < anchor) {
1710 Private.selectedProperty.set(this.widgets[head], false);
1711 selectionChanged = true;
1712 }
1713
1714 // Toggle everything strictly between head and index except anchor.
1715 for (i = head + 1; i < index; i++) {
1716 if (i !== anchor) {
1717 Private.selectedProperty.set(
1718 this.widgets[i],
1719 !Private.selectedProperty.get(this.widgets[i])
1720 );
1721 selectionChanged = true;
1722 }
1723 }
1724 } else if (index < head) {
1725 if (anchor < head) {
1726 Private.selectedProperty.set(this.widgets[head], false);
1727 selectionChanged = true;
1728 }
1729
1730 // Toggle everything strictly between index and head except anchor.
1731 for (i = index + 1; i < head; i++) {
1732 if (i !== anchor) {
1733 Private.selectedProperty.set(
1734 this.widgets[i],
1735 !Private.selectedProperty.get(this.widgets[i])
1736 );
1737 selectionChanged = true;
1738 }
1739 }
1740 }
1741
1742 // Anchor and index should *always* be selected.
1743 if (!Private.selectedProperty.get(this.widgets[anchor])) {
1744 selectionChanged = true;
1745 }
1746 Private.selectedProperty.set(this.widgets[anchor], true);
1747
1748 if (!Private.selectedProperty.get(this.widgets[index])) {
1749 selectionChanged = true;
1750 }
1751 Private.selectedProperty.set(this.widgets[index], true);
1752
1753 if (selectionChanged) {
1754 this._selectionChanged.emit(void 0);
1755 }
1756 }
1757
1758 /**
1759 * Get the head and anchor of a contiguous cell selection.
1760 *
1761 * The head of a contiguous selection is always the active cell.
1762 *
1763 * If there are no cells selected, `{head: null, anchor: null}` is returned.
1764 *
1765 * Throws an error if the currently selected cells do not form a contiguous
1766 * selection.
1767 */
1768 getContiguousSelection():
1769 | { head: number; anchor: number }
1770 | { head: null; anchor: null } {
1771 const cells = this.widgets;
1772 const first = ArrayExt.findFirstIndex(cells, c => this.isSelected(c));
1773
1774 // Return early if no cells are selected.
1775 if (first === -1) {
1776 return { head: null, anchor: null };
1777 }
1778
1779 const last = ArrayExt.findLastIndex(
1780 cells,
1781 c => this.isSelected(c),
1782 -1,
1783 first
1784 );
1785
1786 // Check that the selection is contiguous.
1787 for (let i = first; i <= last; i++) {
1788 if (!this.isSelected(cells[i])) {
1789 throw new Error('Selection not contiguous');
1790 }
1791 }
1792
1793 // Check that the active cell is one of the endpoints of the selection.
1794 const activeIndex = this.activeCellIndex;
1795 if (first !== activeIndex && last !== activeIndex) {
1796 throw new Error('Active cell not at endpoint of selection');
1797 }
1798
1799 // Determine the head and anchor of the selection.
1800 if (first === activeIndex) {
1801 return { head: first, anchor: last };
1802 } else {
1803 return { head: last, anchor: first };
1804 }
1805 }
1806
1807 /**
1808 * Scroll so that the given cell is in view. Selects and activates cell.
1809 *
1810 * @param cell - A cell in the notebook widget.
1811 * @param align - Type of alignment.
1812 *
1813 */
1814 async scrollToCell(
1815 cell: Cell,
1816 align: WindowedList.ScrollToAlign = 'auto'
1817 ): Promise<void> {
1818 try {
1819 await this.scrollToItem(
1820 this.widgets.findIndex(c => c === cell),
1821 align
1822 );
1823 } catch (r) {
1824 //no-op
1825 }
1826 // change selection and active cell:
1827 this.deselectAll();
1828 this.select(cell);
1829 cell.activate();
1830 }
1831
1832 private _parseFragment(fragment: string): Private.IFragmentData | undefined {
1833 const cleanedFragment = fragment.slice(1);
1834
1835 if (!cleanedFragment) {
1836 // Bail early
1837 return;
1838 }
1839
1840 const parts = cleanedFragment.split('=');
1841 if (parts.length === 1) {
1842 // Default to heading if no prefix is given.
1843 return {
1844 kind: 'heading',
1845 value: cleanedFragment
1846 };
1847 }
1848 return {
1849 kind: parts[0] as any,
1850 value: parts.slice(1).join('=')
1851 };
1852 }
1853
1854 /**
1855 * Set URI fragment identifier.
1856 */
1857 async setFragment(fragment: string): Promise<void> {
1858 const parsedFragment = this._parseFragment(fragment);
1859
1860 if (!parsedFragment) {
1861 // Bail early
1862 return;
1863 }
1864
1865 let result;
1866
1867 switch (parsedFragment.kind) {
1868 case 'heading':
1869 result = await this._findHeading(parsedFragment.value);
1870 break;
1871 case 'cell-id':
1872 result = this._findCellById(parsedFragment.value);
1873 break;
1874 default:
1875 console.warn(
1876 `Unknown target type for URI fragment ${fragment}, interpreting as a heading`
1877 );
1878 result = await this._findHeading(
1879 parsedFragment.kind + '=' + parsedFragment.value
1880 );
1881 break;
1882 }
1883
1884 if (result == null) {
1885 return;
1886 }
1887 let { cell, element } = result;
1888
1889 if (!cell.inViewport) {
1890 await this.scrollToCell(cell, 'center');
1891 }
1892
1893 if (element == null) {
1894 element = cell.node;
1895 }
1896 const widgetBox = this.node.getBoundingClientRect();
1897 const elementBox = element.getBoundingClientRect();
1898
1899 if (
1900 elementBox.top > widgetBox.bottom ||
1901 elementBox.bottom < widgetBox.top
1902 ) {
1903 element.scrollIntoView({ block: 'center' });
1904 }
1905 }
1906
1907 /**
1908 * Handle the DOM events for the widget.
1909 *
1910 * @param event - The DOM event sent to the widget.
1911 *
1912 * #### Notes
1913 * This method implements the DOM `EventListener` interface and is
1914 * called in response to events on the notebook panel's node. It should
1915 * not be called directly by user code.
1916 */
1917 handleEvent(event: Event): void {
1918 if (!this.model) {
1919 return;
1920 }
1921
1922 switch (event.type) {
1923 case 'contextmenu':
1924 if (event.eventPhase === Event.CAPTURING_PHASE) {
1925 this._evtContextMenuCapture(event as PointerEvent);
1926 }
1927 break;
1928 case 'mousedown':
1929 if (event.eventPhase === Event.CAPTURING_PHASE) {
1930 this._evtMouseDownCapture(event as MouseEvent);
1931 } else {
1932 // Skip processing the event when it resulted from a toolbar button click
1933 if (!event.defaultPrevented) {
1934 this._evtMouseDown(event as MouseEvent);
1935 }
1936 }
1937 break;
1938 case 'mouseup':
1939 if (event.currentTarget === document) {
1940 this._evtDocumentMouseup(event as MouseEvent);
1941 }
1942 break;
1943 case 'mousemove':
1944 if (event.currentTarget === document) {
1945 this._evtDocumentMousemove(event as MouseEvent);
1946 }
1947 break;
1948 case 'keydown':
1949 // This works because CodeMirror does not stop the event propagation
1950 this._ensureFocus(true);
1951 break;
1952 case 'dblclick':
1953 this._evtDblClick(event as MouseEvent);
1954 break;
1955 case 'focusin':
1956 this._evtFocusIn(event as MouseEvent);
1957 break;
1958 case 'focusout':
1959 this._evtFocusOut(event as MouseEvent);
1960 break;
1961 case 'lm-dragenter':
1962 this._evtDragEnter(event as Drag.Event);
1963 break;
1964 case 'lm-dragleave':
1965 this._evtDragLeave(event as Drag.Event);
1966 break;
1967 case 'lm-dragover':
1968 this._evtDragOver(event as Drag.Event);
1969 break;
1970 case 'lm-drop':
1971 this._evtDrop(event as Drag.Event);
1972 break;
1973 default:
1974 super.handleEvent(event);
1975 break;
1976 }
1977 }
1978
1979 /**
1980 * Handle `after-attach` messages for the widget.
1981 */
1982 protected onAfterAttach(msg: Message): void {
1983 super.onAfterAttach(msg);
1984 const node = this.node;
1985 node.addEventListener('contextmenu', this, true);
1986 node.addEventListener('mousedown', this, true);
1987 node.addEventListener('mousedown', this);
1988 node.addEventListener('keydown', this);
1989 node.addEventListener('dblclick', this);
1990
1991 node.addEventListener('focusin', this);
1992 node.addEventListener('focusout', this);
1993 // Capture drag events for the notebook widget
1994 // in order to preempt the drag/drop handlers in the
1995 // code editor widgets, which can take text data.
1996 node.addEventListener('lm-dragenter', this, true);
1997 node.addEventListener('lm-dragleave', this, true);
1998 node.addEventListener('lm-dragover', this, true);
1999 node.addEventListener('lm-drop', this, true);
2000 }
2001
2002 /**
2003 * Handle `before-detach` messages for the widget.
2004 */
2005 protected onBeforeDetach(msg: Message): void {
2006 const node = this.node;
2007 node.removeEventListener('contextmenu', this, true);
2008 node.removeEventListener('mousedown', this, true);
2009 node.removeEventListener('mousedown', this);
2010 node.removeEventListener('keydown', this);
2011 node.removeEventListener('dblclick', this);
2012 node.removeEventListener('focusin', this);
2013 node.removeEventListener('focusout', this);
2014 node.removeEventListener('lm-dragenter', this, true);
2015 node.removeEventListener('lm-dragleave', this, true);
2016 node.removeEventListener('lm-dragover', this, true);
2017 node.removeEventListener('lm-drop', this, true);
2018 document.removeEventListener('mousemove', this, true);
2019 document.removeEventListener('mouseup', this, true);
2020 super.onBeforeAttach(msg);
2021 }
2022
2023 /**
2024 * A message handler invoked on an `'after-show'` message.
2025 */
2026 protected onAfterShow(msg: Message): void {
2027 super.onAfterShow(msg);
2028 this._checkCacheOnNextResize = true;
2029 }
2030
2031 /**
2032 * A message handler invoked on a `'resize'` message.
2033 */
2034 protected onResize(msg: Widget.ResizeMessage): void {
2035 // TODO
2036 if (!this._checkCacheOnNextResize) {
2037 return super.onResize(msg);
2038 }
2039 super.onResize(msg);
2040 this._checkCacheOnNextResize = false;
2041 const cache = this._cellLayoutStateCache;
2042 const width = parseInt(this.node.style.width, 10);
2043 if (cache) {
2044 if (width === cache.width) {
2045 // Cache identical, do nothing
2046 return;
2047 }
2048 }
2049 // Update cache
2050 this._cellLayoutStateCache = { width };
2051
2052 // Fallback:
2053 for (const w of this.widgets) {
2054 if (w instanceof Cell && w.inViewport) {
2055 w.editorWidget?.update();
2056 }
2057 }
2058 }
2059
2060 /**
2061 * A message handler invoked on an `'before-hide'` message.
2062 */
2063 protected onBeforeHide(msg: Message): void {
2064 super.onBeforeHide(msg);
2065 // Update cache
2066 const width = parseInt(this.node.style.width, 10);
2067 this._cellLayoutStateCache = { width };
2068 }
2069
2070 /**
2071 * Handle `'activate-request'` messages.
2072 */
2073 protected onActivateRequest(msg: Message): void {
2074 super.onActivateRequest(msg);
2075 this._ensureFocus(true);
2076 }
2077
2078 /**
2079 * Handle `update-request` messages sent to the widget.
2080 */
2081 protected onUpdateRequest(msg: Message): void {
2082 super.onUpdateRequest(msg);
2083 const activeCell = this.activeCell;
2084
2085 // Set the appropriate classes on the cells.
2086 if (this.mode === 'edit') {
2087 this.addClass(EDIT_CLASS);
2088 this.removeClass(COMMAND_CLASS);
2089 } else {
2090 this.addClass(COMMAND_CLASS);
2091 this.removeClass(EDIT_CLASS);
2092 }
2093
2094 let count = 0;
2095 for (const widget of this.widgets) {
2096 // Set tabIndex to -1 to allow calling .focus() on cell without allowing
2097 // focus via tab key. This allows focus (document.activeElement) to move
2098 // up and down the document, cell by cell, when the user presses J/K or
2099 // ArrowDown/ArrowUp, but (unlike tabIndex = 0) does not add the notebook
2100 // cells (which could be numerous) to the set of nodes that the user would
2101 // have to visit when pressing the tab key to move about the UI.
2102 widget.node.tabIndex = -1;
2103 widget.removeClass(ACTIVE_CLASS);
2104 widget.removeClass(OTHER_SELECTED_CLASS);
2105 if (this.isSelectedOrActive(widget)) {
2106 widget.addClass(SELECTED_CLASS);
2107 count++;
2108 } else {
2109 widget.removeClass(SELECTED_CLASS);
2110 }
2111 }
2112
2113 if (activeCell) {
2114 activeCell.addClass(ACTIVE_CLASS);
2115 activeCell.addClass(SELECTED_CLASS);
2116 // Set tab index to 0 on the active cell so that if the user tabs away from
2117 // the notebook then tabs back, they will return to the cell where they
2118 // left off.
2119 activeCell.node.tabIndex = 0;
2120 if (count > 1) {
2121 activeCell.addClass(OTHER_SELECTED_CLASS);
2122 }
2123 }
2124 }
2125
2126 /**
2127 * Handle a cell being inserted.
2128 */
2129 protected onCellInserted(index: number, cell: Cell): void {
2130 void cell.ready.then(() => {
2131 if (!cell.isDisposed) {
2132 cell.editor!.edgeRequested.connect(this._onEdgeRequest, this);
2133 }
2134 });
2135 // If the insertion happened above, increment the active cell
2136 // index, otherwise it stays the same.
2137 this.activeCellIndex =
2138 index <= this.activeCellIndex
2139 ? this.activeCellIndex + 1
2140 : this.activeCellIndex;
2141 }
2142
2143 /**
2144 * Handle a cell being removed.
2145 */
2146 protected onCellRemoved(index: number, cell: Cell): void {
2147 // If the removal happened above, decrement the active
2148 // cell index, otherwise it stays the same.
2149 this.activeCellIndex =
2150 index <= this.activeCellIndex
2151 ? this.activeCellIndex - 1
2152 : this.activeCellIndex;
2153 if (this.isSelected(cell)) {
2154 this._selectionChanged.emit(void 0);
2155 }
2156 }
2157
2158 /**
2159 * Handle a new model.
2160 */
2161 protected onModelChanged(
2162 oldValue: INotebookModel,
2163 newValue: INotebookModel
2164 ): void {
2165 super.onModelChanged(oldValue, newValue);
2166
2167 // Try to set the active cell index to 0.
2168 // It will be set to `-1` if there is no new model or the model is empty.
2169 this.activeCellIndex = 0;
2170 }
2171
2172 /**
2173 * Handle edge request signals from cells.
2174 */
2175 private _onEdgeRequest(
2176 editor: CodeEditor.IEditor,
2177 location: CodeEditor.EdgeLocation
2178 ): void {
2179 const prev = this.activeCellIndex;
2180 if (location === 'top') {
2181 this.activeCellIndex--;
2182 // Move the cursor to the first position on the last line.
2183 if (this.activeCellIndex < prev) {
2184 const editor = this.activeCell!.editor;
2185 if (editor) {
2186 const lastLine = editor.lineCount - 1;
2187 editor.setCursorPosition({ line: lastLine, column: 0 });
2188 }
2189 }
2190 } else if (location === 'bottom') {
2191 this.activeCellIndex++;
2192 // Move the cursor to the first character.
2193 if (this.activeCellIndex > prev) {
2194 const editor = this.activeCell!.editor;
2195 if (editor) {
2196 editor.setCursorPosition({ line: 0, column: 0 });
2197 }
2198 }
2199 }
2200 this.mode = 'edit';
2201 }
2202
2203 /**
2204 * Ensure that the notebook has proper focus.
2205 */
2206 private _ensureFocus(force = false): void {
2207 // No-op is the footer has the focus.
2208 const footer = (this.layout as NotebookWindowedLayout).footer;
2209 if (footer && document.activeElement === footer.node) {
2210 return;
2211 }
2212 const activeCell = this.activeCell;
2213 if (this.mode === 'edit' && activeCell) {
2214 // Test for !== true to cover hasFocus is false and editor is not yet rendered.
2215 if (activeCell.editor?.hasFocus() !== true || !activeCell.inViewport) {
2216 if (activeCell.inViewport) {
2217 activeCell.editor?.focus();
2218 } else {
2219 this.scrollToItem(this.activeCellIndex)
2220 .then(() => {
2221 void activeCell.ready.then(() => {
2222 activeCell.editor?.focus();
2223 });
2224 })
2225 .catch(reason => {
2226 // no-op
2227 });
2228 }
2229 }
2230 }
2231 if (
2232 force &&
2233 activeCell &&
2234 !activeCell.node.contains(document.activeElement)
2235 ) {
2236 void NotebookActions.focusActiveCell(this, {
2237 preventScroll: true
2238 });
2239 }
2240 }
2241
2242 /**
2243 * Find the cell index containing the target html element.
2244 *
2245 * #### Notes
2246 * Returns -1 if the cell is not found.
2247 */
2248 private _findCell(node: HTMLElement): number {
2249 // Trace up the DOM hierarchy to find the root cell node.
2250 // Then find the corresponding child and select it.
2251 let n: HTMLElement | null = node;
2252 while (n && n !== this.node) {
2253 if (n.classList.contains(NB_CELL_CLASS)) {
2254 const i = ArrayExt.findFirstIndex(
2255 this.widgets,
2256 widget => widget.node === n
2257 );
2258 if (i !== -1) {
2259 return i;
2260 }
2261 break;
2262 }
2263 n = n.parentElement;
2264 }
2265 return -1;
2266 }
2267
2268 /**
2269 * Find the target of html mouse event and cell index containing this target.
2270 *
2271 * #### Notes
2272 * Returned index is -1 if the cell is not found.
2273 */
2274 private _findEventTargetAndCell(event: MouseEvent): [HTMLElement, number] {
2275 let target = event.target as HTMLElement;
2276 let index = this._findCell(target);
2277 if (index === -1) {
2278 // `event.target` sometimes gives an orphaned node in Firefox 57, which
2279 // can have `null` anywhere in its parent line. If we fail to find a cell
2280 // using `event.target`, try again using a target reconstructed from the
2281 // position of the click event.
2282 target = document.elementFromPoint(
2283 event.clientX,
2284 event.clientY
2285 ) as HTMLElement;
2286 index = this._findCell(target);
2287 }
2288 return [target, index];
2289 }
2290
2291 /**
2292 * Find heading with given ID in any of the cells.
2293 */
2294 async _findHeading(queryId: string): Promise<Private.IScrollTarget | null> {
2295 // Loop on cells, get headings and search for first matching id.
2296 for (let cellIdx = 0; cellIdx < this.widgets.length; cellIdx++) {
2297 const cell = this.widgets[cellIdx];
2298 if (
2299 cell.model.type === 'raw' ||
2300 (cell.model.type === 'markdown' && !(cell as MarkdownCell).rendered)
2301 ) {
2302 // Bail early
2303 continue;
2304 }
2305 for (const heading of cell.headings) {
2306 let id: string | undefined | null = '';
2307 switch (heading.type) {
2308 case Cell.HeadingType.HTML:
2309 id = (heading as TableOfContentsUtils.IHTMLHeading).id;
2310 break;
2311 case Cell.HeadingType.Markdown:
2312 {
2313 const mdHeading =
2314 heading as any as TableOfContentsUtils.Markdown.IMarkdownHeading;
2315 id = await TableOfContentsUtils.Markdown.getHeadingId(
2316 this.rendermime.markdownParser!,
2317 mdHeading.raw,
2318 mdHeading.level,
2319 this.rendermime.sanitizer
2320 );
2321 }
2322 break;
2323 }
2324 if (id === queryId) {
2325 const element = this.node.querySelector(
2326 `h${heading.level}[id="${CSS.escape(id)}"]`
2327 ) as HTMLElement;
2328
2329 return {
2330 cell,
2331 element
2332 };
2333 }
2334 }
2335 }
2336 return null;
2337 }
2338
2339 /**
2340 * Find cell by its unique ID.
2341 */
2342 _findCellById(queryId: string): Private.IScrollTarget | null {
2343 for (let cellIdx = 0; cellIdx < this.widgets.length; cellIdx++) {
2344 const cell = this.widgets[cellIdx];
2345 if (cell.model.id === queryId) {
2346 return {
2347 cell
2348 };
2349 }
2350 }
2351 return null;
2352 }
2353
2354 /**
2355 * Handle `contextmenu` event.
2356 */
2357 private _evtContextMenuCapture(event: PointerEvent): void {
2358 // Allow the event to propagate un-modified if the user
2359 // is holding the shift-key (and probably requesting
2360 // the native context menu).
2361 if (event.shiftKey) {
2362 return;
2363 }
2364
2365 const [target, index] = this._findEventTargetAndCell(event);
2366 const widget = this.widgets[index];
2367
2368 if (widget && widget.editorWidget?.node.contains(target)) {
2369 // Prevent CodeMirror from focusing the editor.
2370 // TODO: find an editor-agnostic solution.
2371 event.preventDefault();
2372 }
2373 }
2374
2375 /**
2376 * Handle `mousedown` event in the capture phase for the widget.
2377 */
2378 private _evtMouseDownCapture(event: MouseEvent): void {
2379 const { button, shiftKey } = event;
2380
2381 const [target, index] = this._findEventTargetAndCell(event);
2382 const widget = this.widgets[index];
2383
2384 // On OS X, the context menu may be triggered with ctrl-left-click. In
2385 // Firefox, ctrl-left-click gives an event with button 2, but in Chrome,
2386 // ctrl-left-click gives an event with button 0 with the ctrl modifier.
2387 if (
2388 button === 2 &&
2389 !shiftKey &&
2390 widget &&
2391 widget.editorWidget?.node.contains(target)
2392 ) {
2393 this.mode = 'command';
2394
2395 // Prevent CodeMirror from focusing the editor.
2396 // TODO: find an editor-agnostic solution.
2397 event.preventDefault();
2398 }
2399 }
2400
2401 /**
2402 * Handle `mousedown` events for the widget.
2403 */
2404 private _evtMouseDown(event: MouseEvent): void {
2405 const { button, shiftKey } = event;
2406
2407 // We only handle main or secondary button actions.
2408 if (!(button === 0 || button === 2)) {
2409 return;
2410 }
2411
2412 // Shift right-click gives the browser default behavior.
2413 if (shiftKey && button === 2) {
2414 return;
2415 }
2416
2417 const [target, index] = this._findEventTargetAndCell(event);
2418 const widget = this.widgets[index];
2419
2420 let targetArea: 'input' | 'prompt' | 'cell' | 'notebook';
2421 if (widget) {
2422 if (widget.editorWidget?.node.contains(target)) {
2423 targetArea = 'input';
2424 } else if (widget.promptNode?.contains(target)) {
2425 targetArea = 'prompt';
2426 } else {
2427 targetArea = 'cell';
2428 }
2429 } else {
2430 targetArea = 'notebook';
2431 }
2432
2433 // Make sure we go to command mode if the click isn't in the cell editor If
2434 // we do click in the cell editor, the editor handles the focus event to
2435 // switch to edit mode.
2436 if (targetArea !== 'input') {
2437 this.mode = 'command';
2438 }
2439
2440 if (targetArea === 'notebook') {
2441 this.deselectAll();
2442 } else if (targetArea === 'prompt' || targetArea === 'cell') {
2443 // We don't want to prevent the default selection behavior
2444 // if there is currently text selected in an output.
2445 const hasSelection = (window.getSelection() ?? '').toString() !== '';
2446 if (
2447 button === 0 &&
2448 shiftKey &&
2449 !hasSelection &&
2450 !['INPUT', 'OPTION'].includes(target.tagName)
2451 ) {
2452 // Prevent browser selecting text in prompt or output
2453 event.preventDefault();
2454
2455 // Shift-click - extend selection
2456 try {
2457 this.extendContiguousSelectionTo(index);
2458 } catch (e) {
2459 console.error(e);
2460 this.deselectAll();
2461 return;
2462 }
2463 // Enter selecting mode
2464 this._mouseMode = 'select';
2465 document.addEventListener('mouseup', this, true);
2466 document.addEventListener('mousemove', this, true);
2467 } else if (button === 0 && !shiftKey) {
2468 // Prepare to start a drag if we are on the drag region.
2469 if (targetArea === 'prompt') {
2470 // Prepare for a drag start
2471 this._dragData = {
2472 pressX: event.clientX,
2473 pressY: event.clientY,
2474 index: index
2475 };
2476
2477 // Enter possible drag mode
2478 this._mouseMode = 'couldDrag';
2479 document.addEventListener('mouseup', this, true);
2480 document.addEventListener('mousemove', this, true);
2481 event.preventDefault();
2482 }
2483
2484 if (!this.isSelectedOrActive(widget)) {
2485 this.deselectAll();
2486 this.activeCellIndex = index;
2487 }
2488 } else if (button === 2) {
2489 if (!this.isSelectedOrActive(widget)) {
2490 this.deselectAll();
2491 this.activeCellIndex = index;
2492 }
2493 event.preventDefault();
2494 }
2495 } else if (targetArea === 'input') {
2496 if (button === 2 && !this.isSelectedOrActive(widget)) {
2497 this.deselectAll();
2498 this.activeCellIndex = index;
2499 }
2500 }
2501
2502 // If we didn't set focus above, make sure we get focus now.
2503 this._ensureFocus(true);
2504 }
2505
2506 /**
2507 * Handle the `'mouseup'` event on the document.
2508 */
2509 private _evtDocumentMouseup(event: MouseEvent): void {
2510 event.preventDefault();
2511 event.stopPropagation();
2512
2513 // Remove the event listeners we put on the document
2514 document.removeEventListener('mousemove', this, true);
2515 document.removeEventListener('mouseup', this, true);
2516
2517 if (this._mouseMode === 'couldDrag') {
2518 // We didn't end up dragging if we are here, so treat it as a click event.
2519
2520 const [, index] = this._findEventTargetAndCell(event);
2521
2522 this.deselectAll();
2523 this.activeCellIndex = index;
2524 // Focus notebook if active cell changes but does not have focus.
2525 if (!this.activeCell!.node.contains(document.activeElement)) {
2526 void NotebookActions.focusActiveCell(this);
2527 }
2528 }
2529
2530 this._mouseMode = null;
2531 }
2532
2533 /**
2534 * Handle the `'mousemove'` event for the widget.
2535 */
2536 private _evtDocumentMousemove(event: MouseEvent): void {
2537 event.preventDefault();
2538 event.stopPropagation();
2539
2540 // If in select mode, update the selection
2541 switch (this._mouseMode) {
2542 case 'select': {
2543 const target = event.target as HTMLElement;
2544 const index = this._findCell(target);
2545 if (index !== -1) {
2546 this.extendContiguousSelectionTo(index);
2547 }
2548 break;
2549 }
2550 case 'couldDrag': {
2551 // Check for a drag initialization.
2552 const data = this._dragData!;
2553 const dx = Math.abs(event.clientX - data.pressX);
2554 const dy = Math.abs(event.clientY - data.pressY);
2555 if (dx >= DRAG_THRESHOLD || dy >= DRAG_THRESHOLD) {
2556 this._mouseMode = null;
2557 this._startDrag(data.index, event.clientX, event.clientY);
2558 }
2559 break;
2560 }
2561 default:
2562 break;
2563 }
2564 }
2565
2566 /**
2567 * Handle the `'lm-dragenter'` event for the widget.
2568 */
2569 private _evtDragEnter(event: Drag.Event): void {
2570 if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
2571 return;
2572 }
2573 event.preventDefault();
2574 event.stopPropagation();
2575 const target = event.target as HTMLElement;
2576 const index = this._findCell(target);
2577 if (index === -1) {
2578 return;
2579 }
2580
2581 const widget = this.cellsArray[index];
2582 widget.node.classList.add(DROP_TARGET_CLASS);
2583 }
2584
2585 /**
2586 * Handle the `'lm-dragleave'` event for the widget.
2587 */
2588 private _evtDragLeave(event: Drag.Event): void {
2589 if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
2590 return;
2591 }
2592 event.preventDefault();
2593 event.stopPropagation();
2594 const elements = this.node.getElementsByClassName(DROP_TARGET_CLASS);
2595 if (elements.length) {
2596 (elements[0] as HTMLElement).classList.remove(DROP_TARGET_CLASS);
2597 }
2598 }
2599
2600 /**
2601 * Handle the `'lm-dragover'` event for the widget.
2602 */
2603 private _evtDragOver(event: Drag.Event): void {
2604 if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
2605 return;
2606 }
2607 event.preventDefault();
2608 event.stopPropagation();
2609 event.dropAction = event.proposedAction;
2610 const elements = this.node.getElementsByClassName(DROP_TARGET_CLASS);
2611 if (elements.length) {
2612 (elements[0] as HTMLElement).classList.remove(DROP_TARGET_CLASS);
2613 }
2614 const target = event.target as HTMLElement;
2615 const index = this._findCell(target);
2616 if (index === -1) {
2617 return;
2618 }
2619 const widget = this.cellsArray[index];
2620 widget.node.classList.add(DROP_TARGET_CLASS);
2621 }
2622
2623 /**
2624 * Handle the `'lm-drop'` event for the widget.
2625 */
2626 private _evtDrop(event: Drag.Event): void {
2627 if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
2628 return;
2629 }
2630 event.preventDefault();
2631 event.stopPropagation();
2632 if (event.proposedAction === 'none') {
2633 event.dropAction = 'none';
2634 return;
2635 }
2636
2637 let target = event.target as HTMLElement;
2638 while (target && target.parentElement) {
2639 if (target.classList.contains(DROP_TARGET_CLASS)) {
2640 target.classList.remove(DROP_TARGET_CLASS);
2641 break;
2642 }
2643 target = target.parentElement;
2644 }
2645
2646 // Model presence should be checked before calling event handlers
2647 const model = this.model!;
2648
2649 const source: Notebook = event.source;
2650 if (source === this) {
2651 // Handle the case where we are moving cells within
2652 // the same notebook.
2653 event.dropAction = 'move';
2654 const toMove: Cell[] = event.mimeData.getData('internal:cells');
2655
2656 // For collapsed markdown headings with hidden "child" cells, move all
2657 // child cells as well as the markdown heading.
2658 const cell = toMove[toMove.length - 1];
2659 if (cell instanceof MarkdownCell && cell.headingCollapsed) {
2660 const nextParent = NotebookActions.findNextParentHeading(cell, source);
2661 if (nextParent > 0) {
2662 const index = findIndex(source.widgets, (possibleCell: Cell) => {
2663 return cell.model.id === possibleCell.model.id;
2664 });
2665 toMove.push(...source.widgets.slice(index + 1, nextParent));
2666 }
2667 }
2668
2669 // Compute the to/from indices for the move.
2670 let fromIndex = ArrayExt.firstIndexOf(this.widgets, toMove[0]);
2671 let toIndex = this._findCell(target);
2672 // This check is needed for consistency with the view.
2673 if (toIndex !== -1 && toIndex > fromIndex) {
2674 toIndex -= 1;
2675 } else if (toIndex === -1) {
2676 // If the drop is within the notebook but not on any cell,
2677 // most often this means it is past the cell areas, so
2678 // set it to move the cells to the end of the notebook.
2679 toIndex = this.widgets.length - 1;
2680 }
2681 // Don't move if we are within the block of selected cells.
2682 if (toIndex >= fromIndex && toIndex < fromIndex + toMove.length) {
2683 return;
2684 }
2685
2686 // Move the cells one by one
2687 this.moveCell(fromIndex, toIndex, toMove.length);
2688 } else {
2689 // Handle the case where we are copying cells between
2690 // notebooks.
2691 event.dropAction = 'copy';
2692 // Find the target cell and insert the copied cells.
2693 let index = this._findCell(target);
2694 if (index === -1) {
2695 index = this.widgets.length;
2696 }
2697 const start = index;
2698 const values = event.mimeData.getData(JUPYTER_CELL_MIME);
2699 // Insert the copies of the original cells.
2700 // We preserve trust status of pasted cells by not modifying metadata.
2701 model.sharedModel.insertCells(index, values);
2702 // Select the inserted cells.
2703 this.deselectAll();
2704 this.activeCellIndex = start;
2705 this.extendContiguousSelectionTo(index - 1);
2706 }
2707 void NotebookActions.focusActiveCell(this);
2708 }
2709
2710 /**
2711 * Start a drag event.
2712 */
2713 private _startDrag(index: number, clientX: number, clientY: number): void {
2714 const cells = this.model!.cells;
2715 const selected: nbformat.ICell[] = [];
2716 const toMove: Cell[] = [];
2717 let i = -1;
2718 for (const widget of this.widgets) {
2719 const cell = cells.get(++i);
2720 if (this.isSelectedOrActive(widget)) {
2721 widget.addClass(DROP_SOURCE_CLASS);
2722 selected.push(cell.toJSON());
2723 toMove.push(widget);
2724 }
2725 }
2726 const activeCell = this.activeCell;
2727 let dragImage: HTMLElement | null = null;
2728 let countString: string;
2729 if (activeCell?.model.type === 'code') {
2730 const executionCount = (activeCell.model as ICodeCellModel)
2731 .executionCount;
2732 countString = ' ';
2733 if (executionCount) {
2734 countString = executionCount.toString();
2735 }
2736 } else {
2737 countString = '';
2738 }
2739
2740 // Create the drag image.
2741 dragImage = Private.createDragImage(
2742 selected.length,
2743 countString,
2744 activeCell?.model.sharedModel.getSource().split('\n')[0].slice(0, 26) ??
2745 ''
2746 );
2747
2748 // Set up the drag event.
2749 this._drag = new Drag({
2750 mimeData: new MimeData(),
2751 dragImage,
2752 supportedActions: 'copy-move',
2753 proposedAction: 'copy',
2754 source: this
2755 });
2756 this._drag.mimeData.setData(JUPYTER_CELL_MIME, selected);
2757 // Add mimeData for the fully reified cell widgets, for the
2758 // case where the target is in the same notebook and we
2759 // can just move the cells.
2760 this._drag.mimeData.setData('internal:cells', toMove);
2761 // Add mimeData for the text content of the selected cells,
2762 // allowing for drag/drop into plain text fields.
2763 const textContent = toMove
2764 .map(cell => cell.model.sharedModel.getSource())
2765 .join('\n');
2766 this._drag.mimeData.setData('text/plain', textContent);
2767
2768 // Remove mousemove and mouseup listeners and start the drag.
2769 document.removeEventListener('mousemove', this, true);
2770 document.removeEventListener('mouseup', this, true);
2771 this._mouseMode = null;
2772 void this._drag.start(clientX, clientY).then(action => {
2773 if (this.isDisposed) {
2774 return;
2775 }
2776 this._drag = null;
2777 for (const widget of toMove) {
2778 widget.removeClass(DROP_SOURCE_CLASS);
2779 }
2780 });
2781 }
2782
2783 /**
2784 * Update the notebook node with class indicating read-write state.
2785 */
2786 private _updateReadWrite(): void {
2787 const inReadWrite = DOMUtils.hasActiveEditableElement(this.node);
2788 this.node.classList.toggle(READ_WRITE_CLASS, inReadWrite);
2789 }
2790
2791 /**
2792 * Handle `focus` events for the widget.
2793 */
2794 private _evtFocusIn(event: FocusEvent): void {
2795 // Update read-write class state.
2796 this._updateReadWrite();
2797
2798 const target = event.target as HTMLElement;
2799 const index = this._findCell(target);
2800 if (index !== -1) {
2801 const widget = this.widgets[index];
2802 // If the editor itself does not have focus, ensure command mode.
2803 if (widget.editorWidget && !widget.editorWidget.node.contains(target)) {
2804 this.setMode('command', { focus: false });
2805 }
2806
2807 // Cell index needs to be updated before changing mode,
2808 // otherwise the previous cell may get un-rendered.
2809 this.activeCellIndex = index;
2810
2811 // If the editor has focus, ensure edit mode.
2812 const node = widget.editorWidget?.node;
2813 if (node?.contains(target)) {
2814 this.setMode('edit', { focus: false });
2815 }
2816 } else {
2817 // No cell has focus, ensure command mode.
2818 this.setMode('command', { focus: false });
2819
2820 // Prevents the parent element to get the focus.
2821 event.preventDefault();
2822
2823 // Check if the focus was previously in the active cell to avoid focus looping
2824 // between the cell and the cell toolbar.
2825 const source = event.relatedTarget as HTMLElement;
2826
2827 // Focuses on the active cell if the focus did not come from it.
2828 // Otherwise focus on the footer element (add cell button).
2829 if (this._activeCell && !this._activeCell.node.contains(source)) {
2830 this._activeCell.ready
2831 .then(() => {
2832 this._activeCell?.node.focus({
2833 preventScroll: true
2834 });
2835 })
2836 .catch(() => {
2837 (this.layout as NotebookWindowedLayout).footer?.node.focus({
2838 preventScroll: true
2839 });
2840 });
2841 } else {
2842 (this.layout as NotebookWindowedLayout).footer?.node.focus({
2843 preventScroll: true
2844 });
2845 }
2846 }
2847 }
2848
2849 /**
2850 * Handle `focusout` events for the notebook.
2851 */
2852 private _evtFocusOut(event: FocusEvent): void {
2853 // Update read-write class state.
2854 this._updateReadWrite();
2855
2856 const relatedTarget = event.relatedTarget as HTMLElement;
2857
2858 // Bail if the window is losing focus, to preserve edit mode. This test
2859 // assumes that we explicitly focus things rather than calling blur()
2860 if (!relatedTarget) {
2861 return;
2862 }
2863
2864 // Bail if the item gaining focus is another cell,
2865 // and we should not be entering command mode.
2866 const index = this._findCell(relatedTarget);
2867 if (index !== -1) {
2868 const widget = this.widgets[index];
2869 if (widget.editorWidget?.node.contains(relatedTarget)) {
2870 return;
2871 }
2872 }
2873
2874 // Otherwise enter command mode if not already.
2875 if (this.mode !== 'command') {
2876 this.setMode('command', { focus: false });
2877 }
2878 }
2879
2880 /**
2881 * Handle `dblclick` events for the widget.
2882 */
2883 private _evtDblClick(event: MouseEvent): void {
2884 const model = this.model;
2885 if (!model) {
2886 return;
2887 }
2888 this.deselectAll();
2889
2890 const [target, index] = this._findEventTargetAndCell(event);
2891
2892 if (
2893 (event.target as HTMLElement).classList.contains(HEADING_COLLAPSER_CLASS)
2894 ) {
2895 return;
2896 }
2897 if (index === -1) {
2898 return;
2899 }
2900 this.activeCellIndex = index;
2901 if (model.cells.get(index).type === 'markdown') {
2902 const widget = this.widgets[index] as MarkdownCell;
2903 widget.rendered = false;
2904 } else if (target.localName === 'img') {
2905 target.classList.toggle(UNCONFINED_CLASS);
2906 }
2907 }
2908
2909 /**
2910 * Remove selections from inactive cells to avoid
2911 * spurious cursors.
2912 */
2913 private _trimSelections(): void {
2914 for (let i = 0; i < this.widgets.length; i++) {
2915 if (i !== this._activeCellIndex) {
2916 const cell = this.widgets[i];
2917 if (!cell.model.isDisposed && cell.editor) {
2918 cell.model.selections.delete(cell.editor.uuid);
2919 }
2920 }
2921 }
2922 }
2923
2924 private _activeCellIndex = -1;
2925 private _activeCell: Cell | null = null;
2926 private _mode: NotebookMode = 'command';
2927 private _drag: Drag | null = null;
2928 private _dragData: {
2929 pressX: number;
2930 pressY: number;
2931 index: number;
2932 } | null = null;
2933 private _mouseMode: 'select' | 'couldDrag' | null = null;
2934 private _activeCellChanged = new Signal<this, Cell | null>(this);
2935 private _stateChanged = new Signal<this, IChangedArgs<any>>(this);
2936 private _selectionChanged = new Signal<this, void>(this);
2937
2938 // Attributes for optimized cell refresh:
2939 private _cellLayoutStateCache?: { width: number };
2940 private _checkCacheOnNextResize = false;
2941
2942 private _lastClipboardInteraction: 'copy' | 'cut' | 'paste' | null = null;
2943 private _updateSelectedCells(): void {
2944 this._selectedCells = this.widgets.filter(cell =>
2945 this.isSelectedOrActive(cell)
2946 );
2947 if (this.kernelHistory) {
2948 this.kernelHistory.reset();
2949 }
2950 }
2951 private _selectedCells: Cell[] = [];
2952}
2953
2954/**
2955 * The namespace for the `Notebook` class statics.
2956 */
2957export namespace Notebook {
2958 /**
2959 * An options object for initializing a notebook widget.
2960 */
2961 export interface IOptions extends StaticNotebook.IOptions {}
2962
2963 /**
2964 * The content factory for the notebook widget.
2965 */
2966 export interface IContentFactory extends StaticNotebook.IContentFactory {}
2967
2968 /**
2969 * The default implementation of a notebook content factory..
2970 *
2971 * #### Notes
2972 * Override methods on this class to customize the default notebook factory
2973 * methods that create notebook content.
2974 */
2975 export class ContentFactory extends StaticNotebook.ContentFactory {}
2976
2977 /**
2978 * A namespace for the notebook content factory.
2979 */
2980 export namespace ContentFactory {
2981 /**
2982 * An options object for initializing a notebook content factory.
2983 */
2984 export interface IOptions extends StaticNotebook.ContentFactory.IOptions {}
2985 }
2986}
2987
2988/**
2989 * A namespace for private data.
2990 */
2991namespace Private {
2992 /**
2993 * An attached property for the selected state of a cell.
2994 */
2995 export const selectedProperty = new AttachedProperty<Cell, boolean>({
2996 name: 'selected',
2997 create: () => false
2998 });
2999
3000 /**
3001 * A custom panel layout for the notebook.
3002 */
3003 export class NotebookPanelLayout extends PanelLayout {
3004 /**
3005 * A message handler invoked on an `'update-request'` message.
3006 *
3007 * #### Notes
3008 * This is a reimplementation of the base class method,
3009 * and is a no-op.
3010 */
3011 protected onUpdateRequest(msg: Message): void {
3012 // This is a no-op.
3013 }
3014 }
3015
3016 /**
3017 * Create a cell drag image.
3018 */
3019 export function createDragImage(
3020 count: number,
3021 promptNumber: string,
3022 cellContent: string
3023 ): HTMLElement {
3024 if (count > 1) {
3025 if (promptNumber !== '') {
3026 return VirtualDOM.realize(
3027 h.div(
3028 h.div(
3029 { className: DRAG_IMAGE_CLASS },
3030 h.span(
3031 { className: CELL_DRAG_PROMPT_CLASS },
3032 '[' + promptNumber + ']:'
3033 ),
3034 h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent)
3035 ),
3036 h.div({ className: CELL_DRAG_MULTIPLE_BACK }, '')
3037 )
3038 );
3039 } else {
3040 return VirtualDOM.realize(
3041 h.div(
3042 h.div(
3043 { className: DRAG_IMAGE_CLASS },
3044 h.span({ className: CELL_DRAG_PROMPT_CLASS }),
3045 h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent)
3046 ),
3047 h.div({ className: CELL_DRAG_MULTIPLE_BACK }, '')
3048 )
3049 );
3050 }
3051 } else {
3052 if (promptNumber !== '') {
3053 return VirtualDOM.realize(
3054 h.div(
3055 h.div(
3056 { className: `${DRAG_IMAGE_CLASS} ${SINGLE_DRAG_IMAGE_CLASS}` },
3057 h.span(
3058 { className: CELL_DRAG_PROMPT_CLASS },
3059 '[' + promptNumber + ']:'
3060 ),
3061 h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent)
3062 )
3063 )
3064 );
3065 } else {
3066 return VirtualDOM.realize(
3067 h.div(
3068 h.div(
3069 { className: `${DRAG_IMAGE_CLASS} ${SINGLE_DRAG_IMAGE_CLASS}` },
3070 h.span({ className: CELL_DRAG_PROMPT_CLASS }),
3071 h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent)
3072 )
3073 )
3074 );
3075 }
3076 }
3077 }
3078
3079 /**
3080 * Information about resolved scroll target defined by URL fragment.
3081 */
3082 export interface IScrollTarget {
3083 /**
3084 * Target cell.
3085 */
3086 cell: Cell;
3087 /**
3088 * Element to scroll to within the cell.
3089 */
3090 element?: HTMLElement;
3091 }
3092
3093 /**
3094 * Parsed fragment identifier data.
3095 */
3096 export interface IFragmentData {
3097 /**
3098 * The kind of notebook element targetted by the fragment identifier.
3099 */
3100 kind: 'heading' | 'cell-id';
3101 /*
3102 * The value of the fragment query.
3103 */
3104 value: string;
3105 }
3106}