Browse Source

Add 'merge cell above/below' commands with shortcuts (#10076)

* Add 'merge cell above/below' commands with shortcuts

* Add tests for mergeCells with mergeAbove = true

* Lint

* Update after #10094
Karolina Krassowska 4 years ago
parent
commit
765ee24f89

+ 10 - 0
packages/notebook-extension/schema/tracker.json

@@ -122,6 +122,16 @@
       "keys": ["Shift M"],
       "selector": ".jp-Notebook:focus"
     },
+    {
+      "command": "notebook:merge-cell-above",
+      "keys": ["Ctrl Backspace"],
+      "selector": ".jp-Notebook:focus"
+    },
+    {
+      "command": "notebook:merge-cell-below",
+      "keys": ["Ctrl Shift M"],
+      "selector": ".jp-Notebook:focus"
+    },
     {
       "command": "notebook:move-cursor-down",
       "keys": ["ArrowDown"],

+ 44 - 1
packages/notebook-extension/src/index.ts

@@ -191,6 +191,10 @@ namespace CommandIDs {
 
   export const merge = 'notebook:merge-cells';
 
+  export const mergeAbove = 'notebook:merge-cell-above';
+
+  export const mergeBelow = 'notebook:merge-cell-below';
+
   export const split = 'notebook:split-cell-at-cursor';
 
   export const commandMode = 'notebook:enter-command-mode';
@@ -1191,6 +1195,16 @@ function activateNotebookHandler(
     selector: '.jp-Notebook .jp-Cell',
     rank: 8
   });
+  app.contextMenu.addItem({
+    command: CommandIDs.mergeAbove,
+    selector: '.jp-Notebook .jp-Cell',
+    rank: 8
+  });
+  app.contextMenu.addItem({
+    command: CommandIDs.mergeBelow,
+    selector: '.jp-Notebook .jp-Cell',
+    rank: 8
+  });
   app.contextMenu.addItem({
     type: 'separator',
     selector: '.jp-Notebook .jp-Cell',
@@ -1680,6 +1694,28 @@ function addCommands(
     },
     isEnabled
   });
+  commands.addCommand(CommandIDs.mergeAbove, {
+    label: trans.__('Merge Cell Above'),
+    execute: args => {
+      const current = getCurrent(tracker, shell, args);
+
+      if (current) {
+        return NotebookActions.mergeCells(current.content, true);
+      }
+    },
+    isEnabled
+  });
+  commands.addCommand(CommandIDs.mergeBelow, {
+    label: trans.__('Merge Cell Below'),
+    execute: args => {
+      const current = getCurrent(tracker, shell, args);
+
+      if (current) {
+        return NotebookActions.mergeCells(current.content, false);
+      }
+    },
+    isEnabled
+  });
   commands.addCommand(CommandIDs.insertAbove, {
     label: trans.__('Insert Cell Above'),
     execute: args => {
@@ -2157,6 +2193,8 @@ function populatePalette(
     CommandIDs.deleteCell,
     CommandIDs.split,
     CommandIDs.merge,
+    CommandIDs.mergeAbove,
+    CommandIDs.mergeBelow,
     CommandIDs.insertAbove,
     CommandIDs.insertBelow,
     CommandIDs.selectAbove,
@@ -2412,7 +2450,12 @@ function populateMenus(
     }
   );
 
-  const splitMergeGroup = [CommandIDs.split, CommandIDs.merge].map(command => {
+  const splitMergeGroup = [
+    CommandIDs.split,
+    CommandIDs.merge,
+    CommandIDs.mergeAbove,
+    CommandIDs.mergeBelow
+  ].map(command => {
     return { command };
   });
 

+ 30 - 11
packages/notebook/src/actions.tsx

@@ -165,15 +165,22 @@ export namespace NotebookActions {
    *
    * @param notebook - The target notebook widget.
    *
+   * @param mergeAbove - If only one cell is selected, indicates whether to merge it
+   *    with the cell above (true) or below (false, default).
+   *
    * #### Notes
    * The widget mode will be preserved.
-   * If only one cell is selected, the next cell will be selected.
+   * If only one cell is selected and `mergeAbove` is true, the above cell will be selected.
+   * If only one cell is selected and `mergeAbove` is false, the below cell will be selected.
    * If the active cell is a code cell, its outputs will be cleared.
    * This action can be undone.
    * The final cell will have the same type as the active cell.
    * If the active cell is a markdown cell, it will be unrendered.
    */
-  export function mergeCells(notebook: Notebook): void {
+  export function mergeCells(
+    notebook: Notebook,
+    mergeAbove: boolean = false
+  ): void {
     if (!notebook.model || !notebook.activeCell) {
       return;
     }
@@ -206,16 +213,28 @@ export namespace NotebookActions {
 
     // Check for only a single cell selected.
     if (toMerge.length === 1) {
-      // Bail if it is the last cell.
-      if (active === cells.length - 1) {
-        return;
-      }
-
-      // Otherwise merge with the next cell.
-      const cellModel = cells.get(active + 1);
+      // Merge with the cell above when mergeAbove is true
+      if (mergeAbove === true) {
+        // Bail if it is the first cell.
+        if (active === 0) {
+          return;
+        }
+        // Otherwise merge with the previous cell.
+        const cellModel = cells.get(active - 1);
+
+        toMerge.unshift(cellModel.value.text);
+        toDelete.push(cellModel);
+      } else if (mergeAbove === false) {
+        // Bail if it is the last cell.
+        if (active === cells.length - 1) {
+          return;
+        }
+        // Otherwise merge with the next cell.
+        const cellModel = cells.get(active + 1);
 
-      toMerge.push(cellModel.value.text);
-      toDelete.push(cellModel);
+        toMerge.push(cellModel.value.text);
+        toDelete.push(cellModel);
+      }
     }
 
     notebook.deselectAll();

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

@@ -226,6 +226,23 @@ describe('@jupyterlab/notebook', () => {
         expect(widget.activeCell!.model.value.text).toBe(source);
       });
 
+      it('should select the previous cell if there is only one cell selected and mergeAbove is true', () => {
+        widget.activeCellIndex = 1;
+        let source = widget.activeCell!.model.value.text;
+        const previous = widget.widgets[0];
+        source = previous.model.value.text + '\n\n' + source;
+        NotebookActions.mergeCells(widget, true);
+        expect(widget.activeCell!.model.value.text).toBe(source);
+      });
+
+      it('should do nothing if first cell selected and mergeAbove is true', () => {
+        let source = widget.activeCell!.model.value.text;
+        const cellNumber = widget.widgets.length;
+        NotebookActions.mergeCells(widget, true);
+        expect(widget.widgets.length).toBe(cellNumber);
+        expect(widget.activeCell!.model.value.text).toBe(source);
+      });
+
       it('should clear the outputs of a code cell', () => {
         NotebookActions.mergeCells(widget);
         const cell = widget.activeCell as CodeCell;