UNPKG

22.6 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { Cell, CodeCell, ICellModel, MarkdownCell } from '@jupyterlab/cells';
5import { IMarkdownParser, IRenderMime } from '@jupyterlab/rendermime';
6import {
7 TableOfContents,
8 TableOfContentsFactory,
9 TableOfContentsModel,
10 TableOfContentsUtils
11} from '@jupyterlab/toc';
12import { KernelError, NotebookActions } from './actions';
13import { NotebookPanel } from './panel';
14import { INotebookTracker } from './tokens';
15import { Notebook } from './widget';
16
17/**
18 * Cell running status
19 */
20export enum RunningStatus {
21 /**
22 * Cell is idle
23 */
24 Idle = -1,
25 /**
26 * Cell execution is unsuccessful
27 */
28 Error = -0.5,
29 /**
30 * Cell execution is scheduled
31 */
32 Scheduled = 0,
33 /**
34 * Cell is running
35 */
36 Running = 1
37}
38
39/**
40 * Interface describing a notebook cell heading.
41 */
42export interface INotebookHeading extends TableOfContents.IHeading {
43 /**
44 * Reference to a notebook cell.
45 */
46 cellRef: Cell;
47
48 /**
49 * Running status of the cells in the heading
50 */
51 isRunning: RunningStatus;
52
53 /**
54 * Index of the output containing the heading
55 */
56 outputIndex?: number;
57
58 /**
59 * Type of heading
60 */
61 type: Cell.HeadingType;
62}
63
64/**
65 * Table of content model for Notebook files.
66 */
67export class NotebookToCModel extends TableOfContentsModel<
68 INotebookHeading,
69 NotebookPanel
70> {
71 /**
72 * Constructor
73 *
74 * @param widget The widget to search in
75 * @param parser Markdown parser
76 * @param sanitizer Sanitizer
77 * @param configuration Default model configuration
78 */
79 constructor(
80 widget: NotebookPanel,
81 protected parser: IMarkdownParser | null,
82 protected sanitizer: IRenderMime.ISanitizer,
83 configuration?: TableOfContents.IConfig
84 ) {
85 super(widget, configuration);
86 this._runningCells = new Array<Cell>();
87 this._errorCells = new Array<Cell>();
88 this._cellToHeadingIndex = new WeakMap<Cell, number>();
89
90 void widget.context.ready.then(() => {
91 // Load configuration from metadata
92 this.setConfiguration({});
93 });
94
95 this.widget.context.model.metadataChanged.connect(
96 this.onMetadataChanged,
97 this
98 );
99 this.widget.content.activeCellChanged.connect(
100 this.onActiveCellChanged,
101 this
102 );
103 NotebookActions.executionScheduled.connect(this.onExecutionScheduled, this);
104 NotebookActions.executed.connect(this.onExecuted, this);
105 NotebookActions.outputCleared.connect(this.onOutputCleared, this);
106 this.headingsChanged.connect(this.onHeadingsChanged, this);
107 }
108
109 /**
110 * Type of document supported by the model.
111 *
112 * #### Notes
113 * A `data-document-type` attribute with this value will be set
114 * on the tree view `.jp-TableOfContents-content[data-document-type="..."]`
115 */
116 get documentType(): string {
117 return 'notebook';
118 }
119
120 /**
121 * Whether the model gets updated even if the table of contents panel
122 * is hidden or not.
123 */
124 protected get isAlwaysActive(): boolean {
125 return true;
126 }
127
128 /**
129 * List of configuration options supported by the model.
130 */
131 get supportedOptions(): (keyof TableOfContents.IConfig)[] {
132 return [
133 'baseNumbering',
134 'maximalDepth',
135 'numberingH1',
136 'numberHeaders',
137 'includeOutput',
138 'syncCollapseState'
139 ];
140 }
141
142 /**
143 * Get the headings of a given cell.
144 *
145 * @param cell Cell
146 * @returns The associated headings
147 */
148 getCellHeadings(cell: Cell): INotebookHeading[] {
149 const headings = new Array<INotebookHeading>();
150 let headingIndex = this._cellToHeadingIndex.get(cell);
151
152 if (headingIndex !== undefined) {
153 const candidate = this.headings[headingIndex];
154 headings.push(candidate);
155 while (
156 this.headings[headingIndex - 1] &&
157 this.headings[headingIndex - 1].cellRef === candidate.cellRef
158 ) {
159 headingIndex--;
160 headings.unshift(this.headings[headingIndex]);
161 }
162 }
163
164 return headings;
165 }
166
167 /**
168 * Dispose the object
169 */
170 dispose(): void {
171 if (this.isDisposed) {
172 return;
173 }
174
175 this.headingsChanged.disconnect(this.onHeadingsChanged, this);
176 this.widget.context?.model?.metadataChanged.disconnect(
177 this.onMetadataChanged,
178 this
179 );
180 this.widget.content?.activeCellChanged.disconnect(
181 this.onActiveCellChanged,
182 this
183 );
184 NotebookActions.executionScheduled.disconnect(
185 this.onExecutionScheduled,
186 this
187 );
188 NotebookActions.executed.disconnect(this.onExecuted, this);
189 NotebookActions.outputCleared.disconnect(this.onOutputCleared, this);
190
191 this._runningCells.length = 0;
192 this._errorCells.length = 0;
193
194 super.dispose();
195 }
196
197 /**
198 * Model configuration setter.
199 *
200 * @param c New configuration
201 */
202 setConfiguration(c: Partial<TableOfContents.IConfig>): void {
203 // Ensure configuration update
204 const metadataConfig = this.loadConfigurationFromMetadata();
205 super.setConfiguration({ ...this.configuration, ...metadataConfig, ...c });
206 }
207
208 /**
209 * Callback on heading collapse.
210 *
211 * @param options.heading The heading to change state (all headings if not provided)
212 * @param options.collapsed The new collapsed status (toggle existing status if not provided)
213 */
214 toggleCollapse(options: {
215 heading?: INotebookHeading;
216 collapsed?: boolean;
217 }): void {
218 super.toggleCollapse(options);
219 this.updateRunningStatus(this.headings);
220 }
221
222 /**
223 * Produce the headings for a document.
224 *
225 * @returns The list of new headings or `null` if nothing needs to be updated.
226 */
227 protected getHeadings(): Promise<INotebookHeading[] | null> {
228 const cells = this.widget.content.widgets;
229 const headings: INotebookHeading[] = [];
230 const documentLevels = new Array<number>();
231
232 // Generate headings by iterating through all notebook cells...
233 for (let i = 0; i < cells.length; i++) {
234 const cell: Cell = cells[i];
235 const model = cell.model;
236
237 switch (model.type) {
238 case 'code': {
239 // Collapsing cells is incompatible with output headings
240 if (
241 !this.configuration.syncCollapseState &&
242 this.configuration.includeOutput
243 ) {
244 headings.push(
245 ...TableOfContentsUtils.filterHeadings(
246 cell.headings,
247 this.configuration,
248 documentLevels
249 ).map(heading => {
250 return {
251 ...heading,
252 cellRef: cell,
253 collapsed: false,
254 isRunning: RunningStatus.Idle
255 };
256 })
257 );
258 }
259
260 break;
261 }
262 case 'markdown': {
263 const cellHeadings = TableOfContentsUtils.filterHeadings(
264 cell.headings,
265 this.configuration,
266 documentLevels
267 ).map((heading, index) => {
268 return {
269 ...heading,
270 cellRef: cell,
271 collapsed: false,
272 isRunning: RunningStatus.Idle
273 };
274 });
275 // If there are multiple headings, only collapse the highest heading (i.e. minimal level)
276 // consistent with the cell.headingInfo
277 if (
278 this.configuration.syncCollapseState &&
279 (cell as MarkdownCell).headingCollapsed
280 ) {
281 const minLevel = Math.min(...cellHeadings.map(h => h.level));
282 const minHeading = cellHeadings.find(h => h.level === minLevel);
283 minHeading!.collapsed = (cell as MarkdownCell).headingCollapsed;
284 }
285 headings.push(...cellHeadings);
286 break;
287 }
288 }
289
290 if (headings.length > 0) {
291 this._cellToHeadingIndex.set(cell, headings.length - 1);
292 }
293 }
294 this.updateRunningStatus(headings);
295 return Promise.resolve(headings);
296 }
297
298 /**
299 * Read table of content configuration from notebook metadata.
300 *
301 * @returns ToC configuration from metadata
302 */
303 protected loadConfigurationFromMetadata(): Partial<TableOfContents.IConfig> {
304 const nbModel = this.widget.content.model;
305 const newConfig: Partial<TableOfContents.IConfig> = {};
306
307 if (nbModel) {
308 for (const option in this.configMetadataMap) {
309 const keys = this.configMetadataMap[option];
310 for (const k of keys) {
311 let key = k;
312 const negate = key[0] === '!';
313 if (negate) {
314 key = key.slice(1);
315 }
316
317 const keyPath = key.split('/');
318 let value = nbModel.getMetadata(keyPath[0]);
319 for (let p = 1; p < keyPath.length; p++) {
320 value = (value ?? {})[keyPath[p]];
321 }
322
323 if (value !== undefined) {
324 if (typeof value === 'boolean' && negate) {
325 value = !value;
326 }
327 newConfig[option] = value;
328 }
329 }
330 }
331 }
332 return newConfig;
333 }
334
335 protected onActiveCellChanged(
336 notebook: Notebook,
337 cell: Cell<ICellModel>
338 ): void {
339 // Highlight the first title as active (if multiple titles are in the same cell)
340 const activeHeading = this.getCellHeadings(cell)[0];
341 this.setActiveHeading(activeHeading ?? null, false);
342 }
343
344 protected onHeadingsChanged(): void {
345 if (this.widget.content.activeCell) {
346 this.onActiveCellChanged(
347 this.widget.content,
348 this.widget.content.activeCell
349 );
350 }
351 }
352
353 protected onExecuted(
354 _: unknown,
355 args: {
356 notebook: Notebook;
357 cell: Cell;
358 success: boolean;
359 error: KernelError | null;
360 }
361 ): void {
362 this._runningCells.forEach((cell, index) => {
363 if (cell === args.cell) {
364 this._runningCells.splice(index, 1);
365
366 const headingIndex = this._cellToHeadingIndex.get(cell);
367 if (headingIndex !== undefined) {
368 const heading = this.headings[headingIndex];
369 // when the execution is not successful but errorName is undefined,
370 // the execution is interrupted by previous cells
371 if (args.success || args.error?.errorName === undefined) {
372 heading.isRunning = RunningStatus.Idle;
373 return;
374 }
375 heading.isRunning = RunningStatus.Error;
376 if (!this._errorCells.includes(cell)) {
377 this._errorCells.push(cell);
378 }
379 }
380 }
381 });
382
383 this.updateRunningStatus(this.headings);
384 this.stateChanged.emit();
385 }
386
387 protected onExecutionScheduled(
388 _: unknown,
389 args: { notebook: Notebook; cell: Cell }
390 ): void {
391 if (!this._runningCells.includes(args.cell)) {
392 this._runningCells.push(args.cell);
393 }
394 this._errorCells.forEach((cell, index) => {
395 if (cell === args.cell) {
396 this._errorCells.splice(index, 1);
397 }
398 });
399
400 this.updateRunningStatus(this.headings);
401 this.stateChanged.emit();
402 }
403
404 protected onOutputCleared(
405 _: unknown,
406 args: { notebook: Notebook; cell: Cell }
407 ): void {
408 this._errorCells.forEach((cell, index) => {
409 if (cell === args.cell) {
410 this._errorCells.splice(index, 1);
411
412 const headingIndex = this._cellToHeadingIndex.get(cell);
413 if (headingIndex !== undefined) {
414 const heading = this.headings[headingIndex];
415 heading.isRunning = RunningStatus.Idle;
416 }
417 }
418 });
419 this.updateRunningStatus(this.headings);
420 this.stateChanged.emit();
421 }
422
423 protected onMetadataChanged(): void {
424 this.setConfiguration({});
425 }
426
427 protected updateRunningStatus(headings: INotebookHeading[]): void {
428 // Update isRunning
429 this._runningCells.forEach((cell, index) => {
430 const headingIndex = this._cellToHeadingIndex.get(cell);
431 if (headingIndex !== undefined) {
432 const heading = this.headings[headingIndex];
433 // Running is prioritized over Scheduled, so if a heading is
434 // running don't change status
435 if (heading.isRunning !== RunningStatus.Running) {
436 heading.isRunning =
437 index > 0 ? RunningStatus.Scheduled : RunningStatus.Running;
438 }
439 }
440 });
441
442 this._errorCells.forEach((cell, index) => {
443 const headingIndex = this._cellToHeadingIndex.get(cell);
444 if (headingIndex !== undefined) {
445 const heading = this.headings[headingIndex];
446 // Running and Scheduled are prioritized over Error, so only if
447 // a heading is idle will it be set to Error
448 if (heading.isRunning === RunningStatus.Idle) {
449 heading.isRunning = RunningStatus.Error;
450 }
451 }
452 });
453
454 let globalIndex = 0;
455 while (globalIndex < headings.length) {
456 const heading = headings[globalIndex];
457 globalIndex++;
458 if (heading.collapsed) {
459 const maxIsRunning = Math.max(
460 heading.isRunning,
461 getMaxIsRunning(headings, heading.level)
462 );
463 heading.dataset = {
464 ...heading.dataset,
465 'data-running': maxIsRunning.toString()
466 };
467 } else {
468 heading.dataset = {
469 ...heading.dataset,
470 'data-running': heading.isRunning.toString()
471 };
472 }
473 }
474
475 function getMaxIsRunning(
476 headings: INotebookHeading[],
477 collapsedLevel: number
478 ): RunningStatus {
479 let maxIsRunning = RunningStatus.Idle;
480
481 while (globalIndex < headings.length) {
482 const heading = headings[globalIndex];
483 heading.dataset = {
484 ...heading.dataset,
485 'data-running': heading.isRunning.toString()
486 };
487
488 if (heading.level > collapsedLevel) {
489 globalIndex++;
490 maxIsRunning = Math.max(heading.isRunning, maxIsRunning);
491 if (heading.collapsed) {
492 maxIsRunning = Math.max(
493 maxIsRunning,
494 getMaxIsRunning(headings, heading.level)
495 );
496 heading.dataset = {
497 ...heading.dataset,
498 'data-running': maxIsRunning.toString()
499 };
500 }
501 } else {
502 break;
503 }
504 }
505
506 return maxIsRunning;
507 }
508 }
509
510 /**
511 * Mapping between configuration options and notebook metadata.
512 *
513 * If it starts with `!`, the boolean value of the configuration option is
514 * opposite to the one stored in metadata.
515 * If it contains `/`, the metadata data is nested.
516 */
517 protected configMetadataMap: {
518 [k: keyof TableOfContents.IConfig]: string[];
519 } = {
520 numberHeaders: ['toc-autonumbering', 'toc/number_sections'],
521 numberingH1: ['!toc/skip_h1_title'],
522 baseNumbering: ['toc/base_numbering']
523 };
524
525 private _runningCells: Cell[];
526 private _errorCells: Cell[];
527 private _cellToHeadingIndex: WeakMap<Cell, number>;
528}
529
530/**
531 * Table of content model factory for Notebook files.
532 */
533export class NotebookToCFactory extends TableOfContentsFactory<NotebookPanel> {
534 /**
535 * Constructor
536 *
537 * @param tracker Widget tracker
538 * @param parser Markdown parser
539 * @param sanitizer Sanitizer
540 */
541 constructor(
542 tracker: INotebookTracker,
543 protected parser: IMarkdownParser | null,
544 protected sanitizer: IRenderMime.ISanitizer
545 ) {
546 super(tracker);
547 }
548
549 /**
550 * Whether to scroll the active heading to the top
551 * of the document or not.
552 */
553 get scrollToTop(): boolean {
554 return this._scrollToTop;
555 }
556 set scrollToTop(v: boolean) {
557 this._scrollToTop = v;
558 }
559
560 /**
561 * Create a new table of contents model for the widget
562 *
563 * @param widget - widget
564 * @param configuration - Table of contents configuration
565 * @returns The table of contents model
566 */
567 protected _createNew(
568 widget: NotebookPanel,
569 configuration?: TableOfContents.IConfig
570 ): TableOfContentsModel<TableOfContents.IHeading, NotebookPanel> {
571 const model = new NotebookToCModel(
572 widget,
573 this.parser,
574 this.sanitizer,
575 configuration
576 );
577
578 // Connect model signals to notebook panel
579
580 let headingToElement = new WeakMap<INotebookHeading, Element | null>();
581
582 const onActiveHeadingChanged = (
583 model: NotebookToCModel,
584 heading: INotebookHeading | null
585 ) => {
586 if (heading) {
587 const onCellInViewport = async (cell: Cell): Promise<void> => {
588 if (!cell.inViewport) {
589 // Bail early
590 return;
591 }
592
593 const el = headingToElement.get(heading);
594
595 if (el) {
596 if (this.scrollToTop) {
597 el.scrollIntoView({ block: 'start' });
598 } else {
599 const widgetBox = widget.content.node.getBoundingClientRect();
600 const elementBox = el.getBoundingClientRect();
601
602 if (
603 elementBox.top > widgetBox.bottom ||
604 elementBox.bottom < widgetBox.top
605 ) {
606 el.scrollIntoView({ block: 'center' });
607 }
608 }
609 } else {
610 console.debug('scrolling to heading: using fallback strategy');
611 await widget.content.scrollToItem(
612 widget.content.activeCellIndex,
613 this.scrollToTop ? 'start' : undefined,
614 0
615 );
616 }
617 };
618
619 const cell = heading.cellRef;
620 const cells = widget.content.widgets;
621 const idx = cells.indexOf(cell);
622 // Switch to command mode to avoid entering Markdown cell in edit mode
623 // if the document was in edit mode
624 if (cell.model.type == 'markdown' && widget.content.mode != 'command') {
625 widget.content.mode = 'command';
626 }
627
628 widget.content.activeCellIndex = idx;
629
630 if (cell.inViewport) {
631 onCellInViewport(cell).catch(reason => {
632 console.error(
633 `Fail to scroll to cell to display the required heading (${reason}).`
634 );
635 });
636 } else {
637 widget.content
638 .scrollToItem(idx, this.scrollToTop ? 'start' : undefined)
639 .then(() => {
640 return onCellInViewport(cell);
641 })
642 .catch(reason => {
643 console.error(
644 `Fail to scroll to cell to display the required heading (${reason}).`
645 );
646 });
647 }
648 }
649 };
650
651 const findHeadingElement = (cell: Cell): void => {
652 model.getCellHeadings(cell).forEach(async heading => {
653 const elementId = await getIdForHeading(
654 heading,
655 this.parser!,
656 this.sanitizer
657 );
658
659 const selector = elementId
660 ? `h${heading.level}[id="${CSS.escape(elementId)}"]`
661 : `h${heading.level}`;
662
663 if (heading.outputIndex !== undefined) {
664 // Code cell
665 headingToElement.set(
666 heading,
667 TableOfContentsUtils.addPrefix(
668 (heading.cellRef as CodeCell).outputArea.widgets[
669 heading.outputIndex
670 ].node,
671 selector,
672 heading.prefix ?? ''
673 )
674 );
675 } else {
676 headingToElement.set(
677 heading,
678 TableOfContentsUtils.addPrefix(
679 heading.cellRef.node,
680 selector,
681 heading.prefix ?? ''
682 )
683 );
684 }
685 });
686 };
687
688 const onHeadingsChanged = (model: NotebookToCModel) => {
689 if (!this.parser) {
690 return;
691 }
692 // Clear all numbering items
693 TableOfContentsUtils.clearNumbering(widget.content.node);
694
695 // Create a new mapping
696 headingToElement = new WeakMap<INotebookHeading, Element | null>();
697
698 widget.content.widgets.forEach(cell => {
699 findHeadingElement(cell);
700 });
701 };
702
703 const onHeadingCollapsed = (
704 _: NotebookToCModel,
705 heading: INotebookHeading | null
706 ) => {
707 if (model.configuration.syncCollapseState) {
708 if (heading !== null) {
709 const cell = heading.cellRef as MarkdownCell;
710 if (cell.headingCollapsed !== (heading.collapsed ?? false)) {
711 cell.headingCollapsed = heading.collapsed ?? false;
712 }
713 } else {
714 const collapseState = model.headings[0]?.collapsed ?? false;
715 widget.content.widgets.forEach(cell => {
716 if (cell instanceof MarkdownCell) {
717 if (cell.headingInfo.level >= 0) {
718 cell.headingCollapsed = collapseState;
719 }
720 }
721 });
722 }
723 }
724 };
725 const onCellCollapsed = (_: unknown, cell: MarkdownCell) => {
726 if (model.configuration.syncCollapseState) {
727 const h = model.getCellHeadings(cell)[0];
728 if (h) {
729 model.toggleCollapse({
730 heading: h,
731 collapsed: cell.headingCollapsed
732 });
733 }
734 }
735 };
736
737 const onCellInViewportChanged = (_: unknown, cell: Cell) => {
738 if (cell.inViewport) {
739 findHeadingElement(cell);
740 } else {
741 // Needed to remove prefix in cell outputs
742 TableOfContentsUtils.clearNumbering(cell.node);
743 }
744 };
745
746 void widget.context.ready.then(() => {
747 onHeadingsChanged(model);
748
749 model.activeHeadingChanged.connect(onActiveHeadingChanged);
750 model.headingsChanged.connect(onHeadingsChanged);
751 model.collapseChanged.connect(onHeadingCollapsed);
752 widget.content.cellCollapsed.connect(onCellCollapsed);
753 widget.content.cellInViewportChanged.connect(onCellInViewportChanged);
754 widget.disposed.connect(() => {
755 model.activeHeadingChanged.disconnect(onActiveHeadingChanged);
756 model.headingsChanged.disconnect(onHeadingsChanged);
757 model.collapseChanged.disconnect(onHeadingCollapsed);
758 widget.content.cellCollapsed.disconnect(onCellCollapsed);
759 widget.content.cellInViewportChanged.disconnect(
760 onCellInViewportChanged
761 );
762 });
763 });
764
765 return model;
766 }
767
768 private _scrollToTop: boolean = true;
769}
770
771/**
772 * Get the element id for an heading
773 * @param heading Heading
774 * @param parser The markdownparser
775 * @returns The element id
776 */
777export async function getIdForHeading(
778 heading: INotebookHeading,
779 parser: IRenderMime.IMarkdownParser,
780 sanitizer: IRenderMime.ISanitizer
781) {
782 let elementId: string | null = null;
783 if (heading.type === Cell.HeadingType.Markdown) {
784 elementId = await TableOfContentsUtils.Markdown.getHeadingId(
785 parser,
786 // Type from TableOfContentsUtils.Markdown.IMarkdownHeading
787 (heading as any).raw,
788 heading.level,
789 sanitizer
790 );
791 } else if (heading.type === Cell.HeadingType.HTML) {
792 // Type from TableOfContentsUtils.IHTMLHeading
793 elementId = (heading as any).id;
794 }
795 return elementId;
796}