UNPKG

@jupyterlab/notebook

Version:
595 lines 24.2 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { Cell, MarkdownCell } from '@jupyterlab/cells'; import { TableOfContentsFactory, TableOfContentsModel, TableOfContentsUtils } from '@jupyterlab/toc'; import { NotebookActions } from './actions'; /** * Cell running status */ export var RunningStatus; (function (RunningStatus) { /** * Cell is idle */ RunningStatus[RunningStatus["Idle"] = -1] = "Idle"; /** * Cell execution is unsuccessful */ RunningStatus[RunningStatus["Error"] = -0.5] = "Error"; /** * Cell execution is scheduled */ RunningStatus[RunningStatus["Scheduled"] = 0] = "Scheduled"; /** * Cell is running */ RunningStatus[RunningStatus["Running"] = 1] = "Running"; })(RunningStatus || (RunningStatus = {})); /** * Table of content model for Notebook files. */ export class NotebookToCModel extends TableOfContentsModel { /** * Constructor * * @param widget The widget to search in * @param parser Markdown parser * @param sanitizer Sanitizer * @param configuration Default model configuration */ constructor(widget, parser, sanitizer, configuration) { super(widget, configuration); this.parser = parser; this.sanitizer = sanitizer; /** * Mapping between configuration options and notebook metadata. * * If it starts with `!`, the boolean value of the configuration option is * opposite to the one stored in metadata. * If it contains `/`, the metadata data is nested. */ this.configMetadataMap = { numberHeaders: ['toc-autonumbering', 'toc/number_sections'], numberingH1: ['!toc/skip_h1_title'], baseNumbering: ['toc/base_numbering'] }; this._runningCells = new Array(); this._errorCells = new Array(); this._cellToHeadingIndex = new WeakMap(); void widget.context.ready.then(() => { // Load configuration from metadata this.setConfiguration({}); }); this.widget.context.model.metadataChanged.connect(this.onMetadataChanged, this); this.widget.content.activeCellChanged.connect(this.onActiveCellChanged, this); NotebookActions.executionScheduled.connect(this.onExecutionScheduled, this); NotebookActions.executed.connect(this.onExecuted, this); NotebookActions.outputCleared.connect(this.onOutputCleared, this); this.headingsChanged.connect(this.onHeadingsChanged, this); } /** * Type of document supported by the model. * * #### Notes * A `data-document-type` attribute with this value will be set * on the tree view `.jp-TableOfContents-content[data-document-type="..."]` */ get documentType() { return 'notebook'; } /** * Whether the model gets updated even if the table of contents panel * is hidden or not. */ get isAlwaysActive() { return true; } /** * List of configuration options supported by the model. */ get supportedOptions() { return [ 'baseNumbering', 'maximalDepth', 'numberingH1', 'numberHeaders', 'includeOutput', 'syncCollapseState' ]; } /** * Get the headings of a given cell. * * @param cell Cell * @returns The associated headings */ getCellHeadings(cell) { const headings = new Array(); let headingIndex = this._cellToHeadingIndex.get(cell); if (headingIndex !== undefined) { const candidate = this.headings[headingIndex]; headings.push(candidate); while (this.headings[headingIndex - 1] && this.headings[headingIndex - 1].cellRef === candidate.cellRef) { headingIndex--; headings.unshift(this.headings[headingIndex]); } } return headings; } /** * Dispose the object */ dispose() { var _a, _b, _c; if (this.isDisposed) { return; } this.headingsChanged.disconnect(this.onHeadingsChanged, this); (_b = (_a = this.widget.context) === null || _a === void 0 ? void 0 : _a.model) === null || _b === void 0 ? void 0 : _b.metadataChanged.disconnect(this.onMetadataChanged, this); (_c = this.widget.content) === null || _c === void 0 ? void 0 : _c.activeCellChanged.disconnect(this.onActiveCellChanged, this); NotebookActions.executionScheduled.disconnect(this.onExecutionScheduled, this); NotebookActions.executed.disconnect(this.onExecuted, this); NotebookActions.outputCleared.disconnect(this.onOutputCleared, this); this._runningCells.length = 0; this._errorCells.length = 0; super.dispose(); } /** * Model configuration setter. * * @param c New configuration */ setConfiguration(c) { // Ensure configuration update const metadataConfig = this.loadConfigurationFromMetadata(); super.setConfiguration({ ...this.configuration, ...metadataConfig, ...c }); } /** * Callback on heading collapse. * * @param options.heading The heading to change state (all headings if not provided) * @param options.collapsed The new collapsed status (toggle existing status if not provided) */ toggleCollapse(options) { super.toggleCollapse(options); this.updateRunningStatus(this.headings); } /** * Produce the headings for a document. * * @returns The list of new headings or `null` if nothing needs to be updated. */ getHeadings() { const cells = this.widget.content.widgets; const headings = []; const documentLevels = new Array(); // Generate headings by iterating through all notebook cells... for (let i = 0; i < cells.length; i++) { const cell = cells[i]; const model = cell.model; switch (model.type) { case 'code': { // Collapsing cells is incompatible with output headings if (!this.configuration.syncCollapseState && this.configuration.includeOutput) { headings.push(...TableOfContentsUtils.filterHeadings(cell.headings, this.configuration, documentLevels).map(heading => { return { ...heading, cellRef: cell, collapsed: false, isRunning: RunningStatus.Idle }; })); } break; } case 'markdown': { const cellHeadings = TableOfContentsUtils.filterHeadings(cell.headings, this.configuration, documentLevels).map((heading, index) => { return { ...heading, cellRef: cell, collapsed: false, isRunning: RunningStatus.Idle }; }); // If there are multiple headings, only collapse the highest heading (i.e. minimal level) // consistent with the cell.headingInfo if (this.configuration.syncCollapseState && cell.headingCollapsed) { const minLevel = Math.min(...cellHeadings.map(h => h.level)); const minHeading = cellHeadings.find(h => h.level === minLevel); minHeading.collapsed = cell.headingCollapsed; } headings.push(...cellHeadings); break; } } if (headings.length > 0) { this._cellToHeadingIndex.set(cell, headings.length - 1); } else { // If no headings were found, remove the cell from the map this._cellToHeadingIndex.delete(cell); } } this.updateRunningStatus(headings); return Promise.resolve(headings); } /** * Test if two headings are equal or not. * * @param heading1 First heading * @param heading2 Second heading * @returns Whether the headings are equal. */ isHeadingEqual(heading1, heading2) { return (super.isHeadingEqual(heading1, heading2) && heading1.cellRef === heading2.cellRef); } /** * Read table of content configuration from notebook metadata. * * @returns ToC configuration from metadata */ loadConfigurationFromMetadata() { const nbModel = this.widget.content.model; const newConfig = {}; if (nbModel) { for (const option in this.configMetadataMap) { const keys = this.configMetadataMap[option]; for (const k of keys) { let key = k; const negate = key[0] === '!'; if (negate) { key = key.slice(1); } const keyPath = key.split('/'); let value = nbModel.getMetadata(keyPath[0]); for (let p = 1; p < keyPath.length; p++) { value = (value !== null && value !== void 0 ? value : {})[keyPath[p]]; } if (value !== undefined) { if (typeof value === 'boolean' && negate) { value = !value; } newConfig[option] = value; } } } } return newConfig; } onActiveCellChanged(notebook, cell) { // Highlight the first title as active (if multiple titles are in the same cell) const activeHeading = this.getCellHeadings(cell)[0]; this.setActiveHeading(activeHeading !== null && activeHeading !== void 0 ? activeHeading : null, false); } onHeadingsChanged() { if (this.widget.content.activeCell) { this.onActiveCellChanged(this.widget.content, this.widget.content.activeCell); } } onExecuted(_, args) { this._runningCells.forEach((cell, index) => { var _a; if (cell === args.cell) { this._runningCells.splice(index, 1); const headingIndex = this._cellToHeadingIndex.get(cell); if (headingIndex !== undefined) { const heading = this.headings[headingIndex]; // when the execution is not successful but errorName is undefined, // the execution is interrupted by previous cells if (args.success || ((_a = args.error) === null || _a === void 0 ? void 0 : _a.errorName) === undefined) { heading.isRunning = RunningStatus.Idle; return; } heading.isRunning = RunningStatus.Error; if (!this._errorCells.includes(cell)) { this._errorCells.push(cell); } } } }); this.updateRunningStatus(this.headings); this.stateChanged.emit(); } onExecutionScheduled(_, args) { if (!this._runningCells.includes(args.cell)) { this._runningCells.push(args.cell); } this._errorCells.forEach((cell, index) => { if (cell === args.cell) { this._errorCells.splice(index, 1); } }); this.updateRunningStatus(this.headings); this.stateChanged.emit(); } onOutputCleared(_, args) { this._errorCells.forEach((cell, index) => { if (cell === args.cell) { this._errorCells.splice(index, 1); const headingIndex = this._cellToHeadingIndex.get(cell); if (headingIndex !== undefined) { const heading = this.headings[headingIndex]; heading.isRunning = RunningStatus.Idle; } } }); this.updateRunningStatus(this.headings); this.stateChanged.emit(); } onMetadataChanged() { this.setConfiguration({}); } updateRunningStatus(headings) { // Update isRunning this._runningCells.forEach((cell, index) => { const headingIndex = this._cellToHeadingIndex.get(cell); if (headingIndex !== undefined) { const heading = this.headings[headingIndex]; // Running is prioritized over Scheduled, so if a heading is // running don't change status if (heading.isRunning !== RunningStatus.Running) { heading.isRunning = index > 0 ? RunningStatus.Scheduled : RunningStatus.Running; } } }); this._errorCells.forEach((cell, index) => { const headingIndex = this._cellToHeadingIndex.get(cell); if (headingIndex !== undefined) { const heading = this.headings[headingIndex]; // Running and Scheduled are prioritized over Error, so only if // a heading is idle will it be set to Error if (heading.isRunning === RunningStatus.Idle) { heading.isRunning = RunningStatus.Error; } } }); let globalIndex = 0; while (globalIndex < headings.length) { const heading = headings[globalIndex]; globalIndex++; if (heading.collapsed) { const maxIsRunning = Math.max(heading.isRunning, getMaxIsRunning(headings, heading.level)); heading.dataset = { ...heading.dataset, 'data-running': maxIsRunning.toString() }; } else { heading.dataset = { ...heading.dataset, 'data-running': heading.isRunning.toString() }; } } function getMaxIsRunning(headings, collapsedLevel) { let maxIsRunning = RunningStatus.Idle; while (globalIndex < headings.length) { const heading = headings[globalIndex]; heading.dataset = { ...heading.dataset, 'data-running': heading.isRunning.toString() }; if (heading.level > collapsedLevel) { globalIndex++; maxIsRunning = Math.max(heading.isRunning, maxIsRunning); if (heading.collapsed) { maxIsRunning = Math.max(maxIsRunning, getMaxIsRunning(headings, heading.level)); heading.dataset = { ...heading.dataset, 'data-running': maxIsRunning.toString() }; } } else { break; } } return maxIsRunning; } } } /** * Table of content model factory for Notebook files. */ export class NotebookToCFactory extends TableOfContentsFactory { /** * Constructor * * @param tracker Widget tracker * @param parser Markdown parser * @param sanitizer Sanitizer */ constructor(tracker, parser, sanitizer) { super(tracker); this.parser = parser; this.sanitizer = sanitizer; this._scrollToTop = true; } /** * Whether to scroll the active heading to the top * of the document or not. */ get scrollToTop() { return this._scrollToTop; } set scrollToTop(v) { this._scrollToTop = v; } /** * Create a new table of contents model for the widget * * @param widget - widget * @param configuration - Table of contents configuration * @returns The table of contents model */ _createNew(widget, configuration) { const model = new NotebookToCModel(widget, this.parser, this.sanitizer, configuration); // Connect model signals to notebook panel let headingToElement = new WeakMap(); const onActiveHeadingChanged = (model, heading) => { if (heading) { const onCellInViewport = async (cell) => { if (!cell.inViewport) { // Bail early return; } const el = headingToElement.get(heading); if (el) { if (this.scrollToTop) { el.scrollIntoView({ block: 'start' }); } else { const widgetBox = widget.content.node.getBoundingClientRect(); const elementBox = el.getBoundingClientRect(); if (elementBox.top > widgetBox.bottom || elementBox.bottom < widgetBox.top) { el.scrollIntoView({ block: 'center' }); } } } else { console.debug('scrolling to heading: using fallback strategy'); await widget.content.scrollToItem(widget.content.activeCellIndex, this.scrollToTop ? 'start' : undefined, 0); } }; const cell = heading.cellRef; const cells = widget.content.widgets; const idx = cells.indexOf(cell); // Switch to command mode to avoid entering Markdown cell in edit mode // if the document was in edit mode if (cell.model.type == 'markdown' && widget.content.mode != 'command') { widget.content.mode = 'command'; } widget.content.activeCellIndex = idx; if (cell.inViewport) { onCellInViewport(cell).catch(reason => { console.error(`Fail to scroll to cell to display the required heading (${reason}).`); }); } else { widget.content .scrollToItem(idx, this.scrollToTop ? 'start' : undefined) .then(() => { return onCellInViewport(cell); }) .catch(reason => { console.error(`Fail to scroll to cell to display the required heading (${reason}).`); }); } } }; const findHeadingElement = (cell) => { model.getCellHeadings(cell).forEach(async (heading) => { var _a, _b, _c; const elementId = await getIdForHeading(heading, this.parser, this.sanitizer); const attribute = ((_a = this.sanitizer.allowNamedProperties) !== null && _a !== void 0 ? _a : false) ? 'id' : 'data-jupyter-id'; const selector = elementId ? `h${heading.level}[${attribute}="${CSS.escape(elementId)}"]` : `h${heading.level}`; if (heading.outputIndex !== undefined) { // Code cell headingToElement.set(heading, TableOfContentsUtils.addPrefix(heading.cellRef.outputArea.widgets[heading.outputIndex].node, selector, (_b = heading.prefix) !== null && _b !== void 0 ? _b : '')); } else { headingToElement.set(heading, TableOfContentsUtils.addPrefix(heading.cellRef.node, selector, (_c = heading.prefix) !== null && _c !== void 0 ? _c : '')); } }); }; const onHeadingsChanged = (model) => { if (!this.parser) { return; } // Clear all numbering items TableOfContentsUtils.clearNumbering(widget.content.node); // Create a new mapping headingToElement = new WeakMap(); widget.content.widgets.forEach(cell => { findHeadingElement(cell); }); }; const onHeadingCollapsed = (_, heading) => { var _a, _b, _c, _d; if (model.configuration.syncCollapseState) { if (heading !== null) { const cell = heading.cellRef; if (cell.headingCollapsed !== ((_a = heading.collapsed) !== null && _a !== void 0 ? _a : false)) { cell.headingCollapsed = (_b = heading.collapsed) !== null && _b !== void 0 ? _b : false; } } else { const collapseState = (_d = (_c = model.headings[0]) === null || _c === void 0 ? void 0 : _c.collapsed) !== null && _d !== void 0 ? _d : false; widget.content.widgets.forEach(cell => { if (cell instanceof MarkdownCell) { if (cell.headingInfo.level >= 0) { cell.headingCollapsed = collapseState; } } }); } } }; const onCellCollapsed = (_, cell) => { if (model.configuration.syncCollapseState) { const h = model.getCellHeadings(cell)[0]; if (h) { model.toggleCollapse({ heading: h, collapsed: cell.headingCollapsed }); } } }; const onCellInViewportChanged = (_, cell) => { if (cell.inViewport) { findHeadingElement(cell); } else { // Needed to remove prefix in cell outputs TableOfContentsUtils.clearNumbering(cell.node); } }; void widget.context.ready.then(() => { onHeadingsChanged(model); model.activeHeadingChanged.connect(onActiveHeadingChanged); model.headingsChanged.connect(onHeadingsChanged); model.collapseChanged.connect(onHeadingCollapsed); widget.content.cellCollapsed.connect(onCellCollapsed); widget.content.cellInViewportChanged.connect(onCellInViewportChanged); widget.disposed.connect(() => { model.activeHeadingChanged.disconnect(onActiveHeadingChanged); model.headingsChanged.disconnect(onHeadingsChanged); model.collapseChanged.disconnect(onHeadingCollapsed); widget.content.cellCollapsed.disconnect(onCellCollapsed); widget.content.cellInViewportChanged.disconnect(onCellInViewportChanged); }); }); return model; } } /** * Get the element id for an heading * @param heading Heading * @param parser The markdownparser * @returns The element id */ export async function getIdForHeading(heading, parser, sanitizer) { let elementId = null; if (heading.type === Cell.HeadingType.Markdown) { elementId = await TableOfContentsUtils.Markdown.getHeadingId(parser, // Type from TableOfContentsUtils.Markdown.IMarkdownHeading heading.raw, heading.level, sanitizer); } else if (heading.type === Cell.HeadingType.HTML) { // Type from TableOfContentsUtils.IHTMLHeading elementId = heading.id; } return elementId; } //# sourceMappingURL=toc.js.map