浏览代码

Add support for collapsing notebook cells via ToC

kgryte 5 年之前
父节点
当前提交
75a88a8e91

+ 4 - 4
packages/toc/src/generators/notebookgenerator/index.ts

@@ -51,7 +51,7 @@ export function createNotebookGenerator(
       return notebookGeneratorToolbar(options, tracker);
     },
     itemRenderer: (item: INotebookHeading) => {
-      return notebookItemRenderer(options, item);
+      return notebookItemRenderer(options, tracker, item);
     },
     generate: panel => {
       let headings: INotebookHeading[] = [];
@@ -364,7 +364,7 @@ namespace Private {
    */
   export function getCodeCells(
     text: string,
-    onClickFactory: (line: number) => (() => void),
+    onClickFactory: (line: number) => () => void,
     executionCount: string,
     lastLevel: number,
     cellRef: Cell
@@ -398,7 +398,7 @@ namespace Private {
    */
   export function getMarkdownHeading(
     text: string,
-    onClickFactory: (line: number) => (() => void),
+    onClickFactory: (line: number) => () => void,
     numberingDict: any,
     lastLevel: number,
     cellRef: Cell
@@ -474,7 +474,7 @@ namespace Private {
    */
   export function getRenderedHTMLHeading(
     node: HTMLElement,
-    onClickFactory: (el: Element) => (() => void),
+    onClickFactory: (el: Element) => () => void,
     sanitizer: ISanitizer,
     numberingDict: { [level: number]: number },
     lastLevel: number,

+ 193 - 27
packages/toc/src/generators/notebookgenerator/itemrenderer.tsx

@@ -5,6 +5,8 @@ import { CodeComponent } from './codemirror';
 
 import { Cell } from '@jupyterlab/cells';
 
+import { INotebookTracker } from '@jupyterlab/notebook';
+
 import { NotebookGeneratorOptionsManager } from './optionsmanager';
 
 import { INotebookHeading } from './heading';
@@ -13,8 +15,161 @@ import { sanitizerOptions } from '../shared';
 
 import * as React from 'react';
 
+/**
+ * Returns the header level for a provided cell.
+ *
+ * ## Notes
+ *
+ * -   If a cell does not contain a header, the function returns the sentinel value `-1`.
+ *
+ * -   Cell header examples:
+ *
+ *     -   Markdown header:
+ *
+ *         ```
+ *         # Foo
+ *         ```
+ *
+ *     -   Markdown header:
+ *
+ *         ```
+ *         Foo
+ *         ===
+ *         ```
+ *
+ *         ```
+ *         Foo
+ *         ---
+ *         ```
+ *
+ *     -   HTML heading:
+ *
+ *         ```
+ *         <h3>Foo</h3>
+ *         ```
+ *
+ * @private
+ * @param cell - notebook cell
+ * @returns header level
+ */
+function headerLevel(cell: Cell): number {
+  if (cell.constructor.name !== 'MarkdownCell') {
+    return -1;
+  }
+  const lines = cell.model.value.text.split('\n');
+
+  // Case: Markdown header
+  let match = lines[0].match(/^([#]{1,6}) (.*)/);
+  if (match) {
+    return match[1].length;
+  }
+  // Case: Markdown header
+  if (lines.length > 1) {
+    match = lines[1].match(/^([=]{2,}|[-]{2,})/);
+    if (match) {
+      return match[1][0] === '=' ? 1 : 2;
+    }
+  }
+  // Case: HTML heading (WARNING: this is not particularly robust, as HTML headings can span multiple lines)
+  match = lines[0].match(/<h([1-6])>(.*)<\/h\1>/i);
+  if (match) {
+    return parseInt(match[1], 10);
+  }
+  return -1;
+}
+
+/**
+ * Collapses a notebook cell by hiding its section-defined sub-cells (i.e., cells which have lower precedence).
+ *
+ * @private
+ * @param tracker - notebook tracker
+ * @param cell - notebook cell
+ */
+function collapseCell(tracker: any, cell: Cell): void {
+  // Guard against attempting to collapse already hidden cells...
+  if (cell.isHidden) {
+    return;
+  }
+  const level: number = headerLevel(cell);
+
+  // Guard against attempting to collapse cells which are not "collapsible" (i.e., do not define sections)...
+  if (level === -1) {
+    return;
+  }
+  const widgets = tracker.currentWidget.content.widgets;
+  const len = widgets.length;
+  const idx = widgets.indexOf(cell);
+
+  // Guard against an unrecognized "cell" argument...
+  if (idx === -1) {
+    return;
+  }
+  // Search for notebook cells which are semantically defined as sub-cells...
+  for (let i = idx + 1; i < len; i++) {
+    let w = widgets[i];
+    let l: number = headerLevel(w);
+
+    // Check if a widget is at the same or higher level...
+    if (l >= 0 && l <= level) {
+      // We've reached the end of the section...
+      break;
+    }
+    // Collapse a sub-cell by setting its `hidden` state:
+    w.setHidden(true);
+  }
+  // Set a meta-data flag to indicate that we've collapsed notebook sections:
+  cell.model.metadata.set('toc-nb-collapsed', true);
+}
+
+/**
+ * Expands a notebook cell by displaying its section-defined sub-cells (i.e., cells which have lower precedence).
+ *
+ * @private
+ * @param tracker - notebook tracker
+ * @param cell - notebook cell
+ */
+function uncollapseCell(tracker: any, cell: Cell): void {
+  // Guard against attempting to un-collapse cells which we did not collapse or are already un-collapsed...
+  if (
+    cell.model.metadata.has('toc-nb-collapsed') === false ||
+    cell.model.metadata.get('toc-nb-collapsed') === false
+  ) {
+    return;
+  }
+  const level: number = headerLevel(cell);
+
+  // Guard against attempting to un-collapse cells which are not "collapsible" (i.e., do not define sections)...
+  if (level === -1) {
+    return;
+  }
+  const widgets = tracker.currentWidget.content.widgets;
+  const len = widgets.length;
+  const idx = widgets.indexOf(cell);
+
+  // Guard against an unrecognized "cell" argument...
+  if (idx === -1) {
+    return;
+  }
+  // Search for notebook cells which are semantically defined as sub-cells...
+  for (let i = idx + 1; i < len; i++) {
+    let w = widgets[i];
+    let l: number = headerLevel(w);
+
+    // Check if a widget is at the same or higher level...
+    if (l >= 0 && l <= level) {
+      // We've reached the end of the section...
+      break;
+    }
+    // Un-collapse a sub-cell by setting its `hidden` state:
+    w.setHidden(false);
+  }
+  // Set a meta-data flag to indicate that we've un-collapsed notebook sections:
+  cell.model.metadata.set('toc-nb-collapsed', false);
+}
+
 export function notebookItemRenderer(
   options: NotebookGeneratorOptionsManager,
+  tracker: INotebookTracker,
   item: INotebookHeading
 ) {
   let jsx;
@@ -23,8 +178,15 @@ export function notebookItemRenderer(
       let collapsed = cellRef!.model.metadata.get(
         'toc-hr-collapsed'
       ) as boolean;
-      collapsed = collapsed != undefined ? collapsed : false;
+      collapsed = collapsed !== undefined ? collapsed : false;
       cellRef!.model.metadata.set('toc-hr-collapsed', !collapsed);
+      if (cellRef && tracker) {
+        if (collapsed) {
+          uncollapseCell(tracker as any, cellRef);
+        } else {
+          collapseCell(tracker as any, cellRef);
+        }
+      }
       options.updateWidget();
     };
     let fontSizeClass = 'toc-level-size-default';
@@ -51,18 +213,7 @@ export function notebookItemRenderer(
         collapsed = collapsed != undefined ? collapsed : false;
 
         // Render the twist button
-        let twistButton = (
-          <div
-            className="toc-collapse-button"
-            onClick={event => {
-              event.stopPropagation();
-              collapseOnClick(item.cellRef);
-            }}
-          >
-            <div className="toc-twist-placeholder">placeholder</div>
-            <div className="toc-downarrow-img toc-arrow-img" />
-          </div>
-        );
+        let twistButton;
         if (collapsed) {
           twistButton = (
             <div
@@ -76,11 +227,24 @@ export function notebookItemRenderer(
               <div className="toc-rightarrow-img toc-arrow-img" />
             </div>
           );
+        } else {
+          twistButton = (
+            <div
+              className="toc-collapse-button"
+              onClick={event => {
+                event.stopPropagation();
+                collapseOnClick(item.cellRef);
+              }}
+            >
+              <div className="toc-twist-placeholder">placeholder</div>
+              <div className="toc-downarrow-img toc-arrow-img" />
+            </div>
+          );
         }
         // Render the header item
         jsx = (
           <div className="toc-entry-holder">
-            {item.hasChild && twistButton}
+            {twistButton}
             {jsx}
           </div>
         );
@@ -97,18 +261,7 @@ export function notebookItemRenderer(
           'toc-hr-collapsed'
         ) as boolean;
         collapsed = collapsed != undefined ? collapsed : false;
-        let twistButton = (
-          <div
-            className="toc-collapse-button"
-            onClick={event => {
-              event.stopPropagation();
-              collapseOnClick(item.cellRef);
-            }}
-          >
-            <div className="toc-twist-placeholder">placeholder</div>
-            <div className="toc-downarrow-img toc-arrow-img" />
-          </div>
-        );
+        let twistButton;
         if (collapsed) {
           twistButton = (
             <div
@@ -122,10 +275,23 @@ export function notebookItemRenderer(
               <div className="toc-rightarrow-img toc-arrow-img" />
             </div>
           );
+        } else {
+          twistButton = (
+            <div
+              className="toc-collapse-button"
+              onClick={event => {
+                event.stopPropagation();
+                collapseOnClick(item.cellRef);
+              }}
+            >
+              <div className="toc-twist-placeholder">placeholder</div>
+              <div className="toc-downarrow-img toc-arrow-img" />
+            </div>
+          );
         }
         jsx = (
           <div className="toc-entry-holder">
-            {item.hasChild && twistButton}
+            {twistButton}
             {jsx}
           </div>
         );