Browse Source

Add support for collapsing hierarchy of headings (#10260)

* Add initial support for collapsing headings

* Remove unnecessary function

* Change names of functions

* Update to add icon

* update button style

* Only show collapse arrows on hover

* Switch wording to expand and show button when collapsed

* Fix alignment of show hidden button

* Separate renderInput and renderCollapseButtons

* Remove tests

* Remove tests

* Add marked and fix dblclick bug

* Add expand parent header on active cell change
Martha Cryan 3 years ago
parent
commit
f1b8da08cc

+ 1 - 0
packages/cells/package.json

@@ -63,6 +63,7 @@
     "@lumino/signaling": "^1.4.3",
     "@lumino/virtualdom": "^1.8.0",
     "@lumino/widgets": "^1.19.0",
+    "marked": "^2.0.0",
     "react": "^17.0.1"
   },
   "devDependencies": {

+ 153 - 0
packages/cells/src/widget.ts

@@ -3,6 +3,8 @@
 | Distributed under the terms of the Modified BSD License.
 |----------------------------------------------------------------------------*/
 
+import marked from 'marked';
+
 import { AttachmentsResolver } from '@jupyterlab/attachments';
 
 import { ISessionContext } from '@jupyterlab/apputils';
@@ -71,6 +73,8 @@ import {
 } from './model';
 
 import { InputPlaceholder, OutputPlaceholder } from './placeholder';
+import { Signal } from '@lumino/signaling';
+import { addIcon } from '@jupyterlab/ui-components';
 
 /**
  * The CSS class added to cell widgets.
@@ -137,6 +141,12 @@ const MARKDOWN_CELL_CLASS = 'jp-MarkdownCell';
  */
 const MARKDOWN_OUTPUT_CLASS = 'jp-MarkdownOutput';
 
+const MARKDOWN_HEADING_COLLAPSED = 'jp-MarkdownHeadingCollapsed';
+
+const HEADING_COLLAPSER_CLASS = 'jp-collapseHeadingButton';
+
+const SHOW_HIDDEN_CELLS_CLASS = 'jp-showHiddenCellsButton';
+
 /**
  * The class name added to raw cells.
  */
@@ -1395,6 +1405,11 @@ export class MarkdownCell extends AttachmentsCell<IMarkdownCellModel> {
     // Stop codemirror handling paste
     this.editor.setOption('handlePaste', false);
 
+    // Check if heading cell is set to be collapsed
+    this._headingCollapsed = (this.model.metadata.get(
+      MARKDOWN_HEADING_COLLAPSED
+    ) ?? false) as boolean;
+
     // Throttle the rendering rate of the widget.
     this._monitor = new ActivityMonitor({
       signal: this.model.contentChanged,
@@ -1409,6 +1424,7 @@ export class MarkdownCell extends AttachmentsCell<IMarkdownCellModel> {
     void this._updateRenderedInput().then(() => {
       this._ready.resolve(void 0);
     });
+    this.renderCollapseButtons(this._renderer!);
     this.renderInput(this._renderer!);
   }
 
@@ -1419,6 +1435,65 @@ export class MarkdownCell extends AttachmentsCell<IMarkdownCellModel> {
     return this._ready.promise;
   }
 
+  /**
+   * Text that represents the heading if cell is a heading.
+   * Returns empty string if not a heading.
+   */
+  get headingInfo(): { text: string; level: number } {
+    let text = this.model.value.text;
+    const lines = marked.lexer(text);
+    let line: any;
+    for (line of lines) {
+      if (line.type === 'heading') {
+        return { text: line.text, level: line.depth };
+      } else if (line.type === 'html') {
+        let match = line.raw.match(/<h([1-6])(.*?)>(.*?)<\/h\1>/);
+        if (match?.[3]) {
+          return { text: match[3], level: parseInt(match[1]) };
+        }
+        return { text: '', level: -1 };
+      }
+    }
+    return { text: '', level: -1 };
+  }
+
+  get headingCollapsed(): boolean {
+    return this._headingCollapsed;
+  }
+  set headingCollapsed(value: boolean) {
+    this._headingCollapsed = value;
+    if (value) {
+      this.model.metadata.set(MARKDOWN_HEADING_COLLAPSED, value);
+    } else if (this.model.metadata.has(MARKDOWN_HEADING_COLLAPSED)) {
+      this.model.metadata.delete(MARKDOWN_HEADING_COLLAPSED);
+    }
+    const collapseButton = this.inputArea.promptNode.getElementsByClassName(
+      HEADING_COLLAPSER_CLASS
+    )[0];
+    if (collapseButton) {
+      collapseButton.setAttribute(
+        'style',
+        `background:
+      ${
+        value ? 'var(--jp-icon-caret-right)' : 'var(--jp-icon-caret-down)'
+      } no-repeat center`
+      );
+    }
+    this.renderCollapseButtons(this._renderer!);
+  }
+
+  get numberChildNodes(): number {
+    return this._numberChildNodes;
+  }
+  set numberChildNodes(value: number) {
+    this._numberChildNodes = value;
+    this.renderCollapseButtons(this._renderer!);
+  }
+
+  get toggleCollapsedSignal(): Signal<this, boolean> {
+    return this._toggleCollapsedSignal;
+  }
+
   /**
    * Whether the cell is rendered.
    */
@@ -1439,11 +1514,86 @@ export class MarkdownCell extends AttachmentsCell<IMarkdownCellModel> {
     }
   }
 
+  protected maybeCreateCollapseButton(): void {
+    if (
+      this.headingInfo.level > 0 &&
+      this.inputArea.promptNode.getElementsByClassName(HEADING_COLLAPSER_CLASS)
+        .length == 0
+    ) {
+      let collapseButton = this.inputArea.promptNode.appendChild(
+        document.createElement('button')
+      );
+      collapseButton.className = `bp3-button bp3-minimal jp-Button minimal ${HEADING_COLLAPSER_CLASS}`;
+      collapseButton.style.background = `${
+        this._headingCollapsed
+          ? 'var(--jp-icon-caret-right)'
+          : 'var(--jp-icon-caret-down)'
+      } no-repeat center`;
+      collapseButton.onclick = (event: Event) => {
+        this.headingCollapsed = !this.headingCollapsed;
+        this._toggleCollapsedSignal.emit(this._headingCollapsed);
+      };
+    }
+  }
+
+  protected maybeCreateOrUpdateExpandButton(): void {
+    const expandButton = this.node.getElementsByClassName(
+      SHOW_HIDDEN_CELLS_CLASS
+    );
+    // Create the "show hidden" button if not already created
+    if (
+      this.headingCollapsed &&
+      expandButton.length === 0 &&
+      this._numberChildNodes > 0
+    ) {
+      const numberChildNodes = document.createElement('button');
+      numberChildNodes.className = `bp3-button bp3-minimal jp-Button ${SHOW_HIDDEN_CELLS_CLASS}`;
+      addIcon.render(numberChildNodes);
+      const numberChildNodesText = document.createElement('div');
+      numberChildNodesText.nodeValue = `${this._numberChildNodes} cell${
+        this._numberChildNodes > 1 ? 's' : ''
+      } hidden`;
+      numberChildNodes.appendChild(numberChildNodesText);
+      numberChildNodes.onclick = () => {
+        this.headingCollapsed = false;
+        this._toggleCollapsedSignal.emit(this._headingCollapsed);
+      };
+      this.node.appendChild(numberChildNodes);
+    } else if (expandButton?.[0]?.childNodes?.length > 1) {
+      // If the heading is collapsed, update text
+      if (this._headingCollapsed) {
+        expandButton[0].childNodes[1].textContent = `${
+          this._numberChildNodes
+        } cell${this._numberChildNodes > 1 ? 's' : ''} hidden`;
+        // If the heading isn't collapsed, remove the button
+      } else {
+        for (const el of expandButton) {
+          this.node.removeChild(el);
+        }
+      }
+    }
+  }
+
+  /**
+   * Render the collapse button for heading cells,
+   * and for collapsed heading cells render the "expand hidden cells"
+   * button.
+   */
+  protected renderCollapseButtons(widget: Widget): void {
+    this.node.classList.toggle(
+      MARKDOWN_HEADING_COLLAPSED,
+      this._headingCollapsed
+    );
+    this.maybeCreateCollapseButton();
+    this.maybeCreateOrUpdateExpandButton();
+  }
+
   /**
    * Render an input instead of the text editor.
    */
   protected renderInput(widget: Widget): void {
     this.addClass(RENDERED_CLASS);
+    this.renderCollapseButtons(widget);
     this.inputArea.renderInput(widget);
   }
 
@@ -1523,6 +1673,9 @@ export class MarkdownCell extends AttachmentsCell<IMarkdownCellModel> {
   }
 
   private _monitor: ActivityMonitor<ICellModel, void>;
+  private _numberChildNodes: number;
+  private _headingCollapsed: boolean;
+  private _toggleCollapsedSignal = new Signal<this, boolean>(this);
   private _renderer: IRenderMime.IRenderer | null = null;
   private _rendermime: IRenderMimeRegistry;
   private _rendered = true;

+ 25 - 0
packages/cells/style/widget.css

@@ -101,3 +101,28 @@
 .jp-MarkdownOutput.jp-RenderedHTMLCommon {
   overflow: auto;
 }
+
+.jp-showHiddenCellsButton {
+  margin-left: calc(var(--jp-cell-prompt-width) + 2 * var(--jp-code-padding));
+  margin-top: var(--jp-code-padding);
+  border: 1px solid var(--jp-border-color2);
+  background-color: var(--jp-border-color3) !important;
+  color: var(--jp-content-font-color0) !important;
+}
+
+.jp-showHiddenCellsButton:hover {
+  background-color: var(--jp-border-color2) !important;
+}
+
+.jp-collapseHeadingButton {
+  display: none;
+}
+
+.jp-MarkdownCell:hover .jp-collapseHeadingButton {
+  display: flex;
+  min-height: var(--jp-cell-collapser-min-height);
+  position: absolute;
+  right: 0;
+  top: 0;
+  bottom: 0;
+}

+ 1 - 0
packages/notebook-extension/package.json

@@ -49,6 +49,7 @@
     "@jupyterlab/mainmenu": "^3.1.0-alpha.10",
     "@jupyterlab/nbformat": "^3.1.0-alpha.10",
     "@jupyterlab/notebook": "^3.1.0-alpha.10",
+    "@jupyterlab/observables": "^4.1.0-alpha.10",
     "@jupyterlab/property-inspector": "^3.1.0-alpha.10",
     "@jupyterlab/rendermime": "^3.1.0-alpha.10",
     "@jupyterlab/services": "^6.1.0-alpha.10",

+ 119 - 3
packages/notebook-extension/src/index.ts

@@ -21,7 +21,7 @@ import {
   sessionContextDialogs
 } from '@jupyterlab/apputils';
 
-import { CodeCell } from '@jupyterlab/cells';
+import { Cell, CodeCell, ICellModel, MarkdownCell } from '@jupyterlab/cells';
 
 import { IEditorServices } from '@jupyterlab/codeeditor';
 
@@ -57,8 +57,13 @@ import {
   NotebookWidgetFactory,
   StaticNotebook,
   CommandEditStatus,
-  NotebookTrustStatus
+  NotebookTrustStatus,
+  Notebook
 } from '@jupyterlab/notebook';
+import {
+  IObservableList,
+  IObservableUndoableList
+} from '@jupyterlab/observables';
 
 import { IPropertyInspectorProvider } from '@jupyterlab/property-inspector';
 
@@ -240,6 +245,12 @@ namespace CommandIDs {
   export const selectLastRunCell = 'notebook:select-last-run-cell';
 
   export const replaceSelection = 'notebook:replace-selection';
+
+  export const toggleCollapseCmd = 'Collapsible_Headings:Toggle_Collapse';
+
+  export const collapseAllCmd = 'Collapsible_Headings:Collapse_All';
+
+  export const expandAllCmd = 'Collapsible_Headings:Expand_All';
 }
 
 /**
@@ -1322,6 +1333,55 @@ function addCommands(
     return Private.isEnabledAndSingleSelected(shell, tracker);
   };
 
+  const refreshCellCollapsed = (notebook: Notebook): void => {
+    for (const cell of notebook.widgets) {
+      if (cell instanceof MarkdownCell && cell.headingCollapsed) {
+        NotebookActions.setHeadingCollapse(cell, true, notebook);
+        NotebookActions.expandParent(cell, notebook);
+      }
+    }
+  };
+
+  const isEnabledAndHeadingSelected = (): boolean => {
+    return Private.isEnabledAndHeadingSelected(shell, tracker);
+  };
+
+  // Set up collapse signal for each header cell in a notebook
+  tracker.currentChanged.connect(
+    (sender: INotebookTracker, panel: NotebookPanel) => {
+      panel.content.model?.cells.changed.connect(
+        (
+          list: IObservableUndoableList<ICellModel>,
+          args: IObservableList.IChangedArgs<ICellModel>
+        ) => {
+          const cell = panel.content.widgets[args.newIndex];
+          if (
+            cell instanceof MarkdownCell &&
+            (args.type === 'add' || args.type === 'set')
+          ) {
+            cell.toggleCollapsedSignal.connect(
+              (newCell: MarkdownCell, collapsing: boolean) => {
+                NotebookActions.setHeadingCollapse(
+                  newCell,
+                  collapsing,
+                  panel.content
+                );
+              }
+            );
+          }
+          // Might be overkill to refresh this every time, but
+          // it helps to keep the collapse state consistent.
+          refreshCellCollapsed(panel.content);
+        }
+      );
+      panel.content.activeCellChanged.connect(
+        (notebook: Notebook, cell: Cell) => {
+          NotebookActions.expandParent(cell, notebook);
+        }
+      );
+    }
+  );
+
   commands.addCommand(CommandIDs.runAndAdvance, {
     label: trans.__('Run Selected Cells'),
     execute: args => {
@@ -2133,6 +2193,34 @@ function addCommands(
     },
     isEnabled
   });
+  commands.addCommand(CommandIDs.toggleCollapseCmd, {
+    label: 'Toggle Collapse Notebook Heading',
+    execute: args => {
+      const current = getCurrent(tracker, shell, args);
+      if (current) {
+        return NotebookActions.toggleCurrentHeadingCollapse(current.content);
+      }
+    },
+    isEnabled: isEnabledAndHeadingSelected
+  });
+  commands.addCommand(CommandIDs.collapseAllCmd, {
+    label: 'Collapse All Cells',
+    execute: args => {
+      const current = getCurrent(tracker, shell, args);
+      if (current) {
+        return NotebookActions.collapseAll(current.content);
+      }
+    }
+  });
+  commands.addCommand(CommandIDs.expandAllCmd, {
+    label: 'Expand All Headings',
+    execute: args => {
+      const current = getCurrent(tracker, shell, args);
+      if (current) {
+        return NotebookActions.expandAllHeadings(current.content);
+      }
+    }
+  });
 }
 
 /**
@@ -2165,7 +2253,10 @@ function populatePalette(
     CommandIDs.reconnectToKernel,
     CommandIDs.createConsole,
     CommandIDs.closeAndShutdown,
-    CommandIDs.trust
+    CommandIDs.trust,
+    CommandIDs.toggleCollapseCmd,
+    CommandIDs.collapseAllCmd,
+    CommandIDs.expandAllCmd
   ].forEach(command => {
     palette.addItem({ command, category });
   });
@@ -2545,6 +2636,31 @@ namespace Private {
     return true;
   }
 
+  /**
+   * Whether there is an notebook active, with a single selected cell.
+   */
+  export function isEnabledAndHeadingSelected(
+    shell: JupyterFrontEnd.IShell,
+    tracker: INotebookTracker
+  ): boolean {
+    if (!Private.isEnabled(shell, tracker)) {
+      return false;
+    }
+    const { content } = tracker.currentWidget!;
+    const index = content.activeCellIndex;
+    if (!(content.activeCell instanceof MarkdownCell)) {
+      return false;
+    }
+    // If there are selections that are not the active cell,
+    // this command is confusing, so disable it.
+    for (let i = 0; i < content.widgets.length; ++i) {
+      if (content.isSelected(content.widgets[i]) && i !== index) {
+        return false;
+      }
+    }
+    return true;
+  }
+
   /**
    * The default Export To ... formats and their human readable labels.
    */

+ 3 - 0
packages/notebook-extension/tsconfig.json

@@ -42,6 +42,9 @@
     {
       "path": "../notebook"
     },
+    {
+      "path": "../observables"
+    },
     {
       "path": "../property-inspector"
     },

+ 250 - 20
packages/notebook/src/actions.tsx

@@ -23,7 +23,7 @@ import * as nbformat from '@jupyterlab/nbformat';
 
 import { KernelMessage } from '@jupyterlab/services';
 
-import { ArrayExt, each, toArray } from '@lumino/algorithm';
+import { ArrayExt, each, findIndex, toArray } from '@lumino/algorithm';
 
 import { JSONObject, JSONExt } from '@lumino/coreutils';
 
@@ -725,22 +725,20 @@ export namespace NotebookActions {
       return;
     }
 
-    let possibleNextCell = notebook.activeCellIndex - 1;
+    let possibleNextCellIndex = notebook.activeCellIndex - 1;
 
     // find first non hidden cell above current cell
-    if (notebook.mode === 'edit') {
-      while (notebook.widgets[possibleNextCell].inputHidden) {
-        // If we are at the top cell, we cannot change selection.
-        if (possibleNextCell === 0) {
-          return;
-        }
-        possibleNextCell -= 1;
+    while (possibleNextCellIndex >= 0) {
+      const possibleNextCell = notebook.widgets[possibleNextCellIndex];
+      if (!possibleNextCell.inputHidden && !possibleNextCell.isHidden) {
+        break;
       }
+      possibleNextCellIndex -= 1;
     }
 
     const state = Private.getState(notebook);
 
-    notebook.activeCellIndex = possibleNextCell;
+    notebook.activeCellIndex = possibleNextCellIndex;
     notebook.deselectAll();
     Private.handleState(notebook, state, true);
   }
@@ -760,27 +758,32 @@ export namespace NotebookActions {
     if (!notebook.model || !notebook.activeCell) {
       return;
     }
-    const maxCellIndex = notebook.widgets.length - 1;
+    let maxCellIndex = notebook.widgets.length - 1;
+    // Find last non-hidden cell
+    while (
+      notebook.widgets[maxCellIndex].isHidden ||
+      notebook.widgets[maxCellIndex].inputHidden
+    ) {
+      maxCellIndex -= 1;
+    }
     if (notebook.activeCellIndex === maxCellIndex) {
       return;
     }
 
-    let possibleNextCell = notebook.activeCellIndex + 1;
+    let possibleNextCellIndex = notebook.activeCellIndex + 1;
 
     // find first non hidden cell below current cell
-    if (notebook.mode === 'edit') {
-      while (notebook.widgets[possibleNextCell].inputHidden) {
-        // If we are at the bottom cell, we cannot change selection.
-        if (possibleNextCell === maxCellIndex) {
-          return;
-        }
-        possibleNextCell += 1;
+    while (possibleNextCellIndex < maxCellIndex) {
+      let possibleNextCell = notebook.widgets[possibleNextCellIndex];
+      if (!possibleNextCell.inputHidden && !possibleNextCell.isHidden) {
+        break;
       }
+      possibleNextCellIndex += 1;
     }
 
     const state = Private.getState(notebook);
 
-    notebook.activeCellIndex = possibleNextCell;
+    notebook.activeCellIndex = possibleNextCellIndex;
     notebook.deselectAll();
     Private.handleState(notebook, state, true);
   }
@@ -1390,6 +1393,233 @@ export namespace NotebookActions {
     Private.handleState(notebook, state);
   }
 
+  /**
+   * Collapse all cells in given notebook.
+   *
+   * @param notebook - The target notebook widget.
+   */
+  export function collapseAll(notebook: Notebook): any {
+    for (const cell of notebook.widgets) {
+      if (NotebookActions.getHeadingInfo(cell).isHeading) {
+        NotebookActions.setHeadingCollapse(cell, true, notebook);
+        NotebookActions.setCellCollapse(cell, true);
+      }
+    }
+  }
+
+  /**
+   * Un-collapse all cells in given notebook.
+   *
+   * @param notebook - The target notebook widget.
+   */
+  export function expandAllHeadings(notebook: Notebook): any {
+    for (const cell of notebook.widgets) {
+      if (NotebookActions.getHeadingInfo(cell).isHeading) {
+        NotebookActions.setHeadingCollapse(cell, false, notebook);
+        // similar to collapseAll.
+        NotebookActions.setCellCollapse(cell, false);
+      }
+    }
+  }
+
+  function findNearestParentHeader(
+    cell: Cell,
+    notebook: Notebook
+  ): Cell | undefined {
+    const index = findIndex(
+      notebook.widgets,
+      (possibleCell: Cell, index: number) => {
+        return cell.model.id === possibleCell.model.id;
+      }
+    );
+    if (index === -1) {
+      return;
+    }
+    // Finds the nearest header above the given cell. If the cell is a header itself, it does not return itself;
+    // this can be checked directly by calling functions.
+    if (index >= notebook.widgets.length) {
+      return;
+    }
+    let childHeaderInfo = getHeadingInfo(notebook.widgets[index]);
+    for (let cellN = index - 1; cellN >= 0; cellN--) {
+      if (cellN < notebook.widgets.length) {
+        let hInfo = getHeadingInfo(notebook.widgets[cellN]);
+        if (
+          hInfo.isHeading &&
+          hInfo.headingLevel < childHeaderInfo.headingLevel
+        ) {
+          return notebook.widgets[cellN];
+        }
+      }
+    }
+    // else no parent header found.
+    return;
+  }
+
+  /**
+   * Finds the "parent" heading of the given cell and expands.
+   * Used for the case that a cell becomes active that is within a collapsed heading.
+   * @param cell - "Child" cell that has become the active cell
+   * @param notebook - The target notebook widget.
+   */
+  export function expandParent(cell: Cell, notebook: Notebook): void {
+    let nearestParentCell = findNearestParentHeader(cell, notebook);
+    if (!nearestParentCell) {
+      return;
+    }
+    if (
+      !getHeadingInfo(nearestParentCell).collapsed &&
+      !nearestParentCell.isHidden
+    ) {
+      return;
+    }
+    if (nearestParentCell.isHidden) {
+      expandParent(nearestParentCell, notebook);
+    }
+    if (getHeadingInfo(nearestParentCell).collapsed) {
+      setHeadingCollapse(nearestParentCell, false, notebook);
+    }
+  }
+
+  /**
+   * Set the given cell and ** all "child" cells **
+   * to the given collapse / expand if cell is
+   * a markdown header.
+   *
+   * @param cell - The cell
+   * @param collapsing - Whether to collapse or expand the cell
+   * @param notebook - The target notebook widget.
+   */
+  export function setHeadingCollapse(
+    cell: Cell,
+    collapsing: boolean,
+    notebook: Notebook
+  ): number {
+    const which = findIndex(
+      notebook.widgets,
+      (possibleCell: Cell, index: number) => {
+        return cell.model.id === possibleCell.model.id;
+      }
+    );
+    if (which === -1) {
+      return -1;
+    }
+    if (!notebook.widgets.length) {
+      return which + 1;
+    }
+    let selectedheadingInfo = NotebookActions.getHeadingInfo(cell);
+    if (
+      cell.isHidden ||
+      !(cell instanceof MarkdownCell) ||
+      !selectedheadingInfo.isHeading
+    ) {
+      // otherwise collapsing and uncollapsing already hidden stuff can
+      // cause some funny looking bugs.
+      return which + 1;
+    }
+    let localCollapsed = false;
+    let localCollapsedLevel = 0;
+    // iterate through all cells after the active cell.
+    let cellNum;
+    for (cellNum = which + 1; cellNum < notebook.widgets.length; cellNum++) {
+      let subCell = notebook.widgets[cellNum];
+      let subCellheadingInfo = NotebookActions.getHeadingInfo(subCell);
+      if (
+        subCellheadingInfo.isHeading &&
+        subCellheadingInfo.headingLevel <= selectedheadingInfo.headingLevel
+      ) {
+        // then reached an equivalent or higher heading level than the
+        // original the end of the collapse.
+        cellNum -= 1;
+        break;
+      }
+      if (
+        localCollapsed &&
+        subCellheadingInfo.isHeading &&
+        subCellheadingInfo.headingLevel <= localCollapsedLevel
+      ) {
+        // then reached the end of the local collapsed, so unset NotebookActions.
+        localCollapsed = false;
+      }
+
+      if (collapsing || localCollapsed) {
+        // then no extra handling is needed for further locally collapsed
+        // headings.
+        subCell.setHidden(true);
+        continue;
+      }
+
+      if (subCellheadingInfo.collapsed && subCellheadingInfo.isHeading) {
+        localCollapsed = true;
+        localCollapsedLevel = subCellheadingInfo.headingLevel;
+        // but don't collapse the locally collapsed heading, so continue to
+        // expand the heading. This will get noticed in the next round.
+      }
+      subCell.setHidden(false);
+    }
+    if (cellNum === notebook.widgets.length) {
+      cell.numberChildNodes = cellNum - which - 1;
+    } else {
+      cell.numberChildNodes = cellNum - which;
+    }
+    NotebookActions.setCellCollapse(cell, collapsing);
+    return cellNum + 1;
+  }
+
+  /**
+   * Toggles the collapse state of the active cell of the given notebook
+   * and ** all of its "child" cells ** if the cell is a heading.
+   *
+   * @param notebook - The target notebook widget.
+   */
+  export function toggleCurrentHeadingCollapse(notebook: Notebook): any {
+    if (!notebook.activeCell || notebook.activeCellIndex === undefined) {
+      return;
+    }
+    let headingInfo = NotebookActions.getHeadingInfo(notebook.activeCell);
+    if (headingInfo.isHeading) {
+      // Then toggle!
+      NotebookActions.setHeadingCollapse(
+        notebook.activeCell,
+        !headingInfo.collapsed,
+        notebook
+      );
+    }
+    ElementExt.scrollIntoViewIfNeeded(notebook.node, notebook.activeCell.node);
+  }
+
+  /**
+   * If cell is a markdown heading, sets the headingCollapsed field,
+   * and otherwise hides the cell.
+   *
+   * @param cell - The cell to collapse / expand
+   * @param collapsing - Whether to collapse or expand the given cell
+   */
+  export function setCellCollapse(cell: Cell, collapsing: boolean): any {
+    if (cell instanceof MarkdownCell) {
+      cell.headingCollapsed = collapsing;
+    } else {
+      cell.setHidden(collapsing);
+    }
+  }
+
+  /**
+   * If given cell is a markdown heading, returns the heading level.
+   * If given cell is not markdown, returns 7 (there are only 6 levels of markdown headings)
+   *
+   * @param cell - The target cell widget.
+   */
+  export function getHeadingInfo(
+    cell: Cell
+  ): { isHeading: boolean; headingLevel: number; collapsed?: boolean } {
+    if (!(cell instanceof MarkdownCell)) {
+      return { isHeading: false, headingLevel: 7 };
+    }
+    let level = cell.headingInfo.level;
+    let collapsed = cell.headingCollapsed;
+    return { isHeading: level > 0, headingLevel: level, collapsed: collapsed };
+  }
+
   /**
    * Trust the notebook after prompting the user.
    *

+ 10 - 0
packages/notebook/src/widget.ts

@@ -144,6 +144,11 @@ const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells';
  */
 const DRAG_THRESHOLD = 5;
 
+/**
+ * The class attached to the heading collapser button
+ */
+const HEADING_COLLAPSER_CLASS = 'jp-collapseHeadingButton';
+
 /**
  * The interactivity modes for the notebook.
  */
@@ -2134,6 +2139,11 @@ export class Notebook extends StaticNotebook {
 
     const [target, index] = this._findEventTargetAndCell(event);
 
+    if (
+      (event.target as HTMLElement).classList.contains(HEADING_COLLAPSER_CLASS)
+    ) {
+      return;
+    }
     if (index === -1) {
       return;
     }

+ 0 - 38
packages/notebook/test/actions.spec.ts

@@ -963,24 +963,6 @@ describe('@jupyterlab/notebook', () => {
         NotebookActions.selectAbove(widget);
         expect(widget.activeCellIndex).toBe(0);
       });
-
-      it('should not change if in edit mode and no non-collapsed cells above', () => {
-        widget.activeCellIndex = 1;
-        widget.mode = 'edit';
-        widget.widgets[0].inputHidden = true;
-        NotebookActions.selectAbove(widget);
-        expect(widget.activeCellIndex).toBe(1);
-      });
-
-      it('should not skip collapsed cells and in command mode', () => {
-        widget.activeCellIndex = 3;
-        widget.mode = 'command';
-        widget.widgets[1].inputHidden = true;
-        widget.widgets[2].inputHidden = true;
-        widget.widgets[3].inputHidden = false;
-        NotebookActions.selectAbove(widget);
-        expect(widget.activeCellIndex).toBe(2);
-      });
     });
 
     describe('#selectBelow()', () => {
@@ -1010,16 +992,6 @@ describe('@jupyterlab/notebook', () => {
         expect(widget.mode).toBe('edit');
       });
 
-      it('should skip collapsed cells in edit mode', () => {
-        widget.activeCellIndex = 0;
-        widget.mode = 'edit';
-        widget.widgets[1].inputHidden = true;
-        widget.widgets[2].inputHidden = true;
-        widget.widgets[3].inputHidden = false;
-        NotebookActions.selectBelow(widget);
-        expect(widget.activeCellIndex).toBe(3);
-      });
-
       it('should not change if in edit mode and no non-collapsed cells below', () => {
         widget.activeCellIndex = widget.widgets.length - 2;
         widget.mode = 'edit';
@@ -1027,16 +999,6 @@ describe('@jupyterlab/notebook', () => {
         NotebookActions.selectBelow(widget);
         expect(widget.activeCellIndex).toBe(widget.widgets.length - 2);
       });
-
-      it('should not skip collapsed cells and in command mode', () => {
-        widget.activeCellIndex = 0;
-        widget.mode = 'command';
-        widget.widgets[1].inputHidden = true;
-        widget.widgets[2].inputHidden = true;
-        widget.widgets[3].inputHidden = false;
-        NotebookActions.selectBelow(widget);
-        expect(widget.activeCellIndex).toBe(1);
-      });
     });
 
     describe('#extendSelectionAbove()', () => {

File diff suppressed because it is too large
+ 118 - 118
yarn.lock


Some files were not shown because too many files changed in this diff