Просмотр исходного кода

Merge pull request #1170 from blink1073/cell-drag-drop

Notebook Cell Drag Drop
Afshin Darian 8 лет назад
Родитель
Сommit
a4cdbfef27

+ 1 - 7
src/notebook/notebook/actions.ts

@@ -27,16 +27,10 @@ import {
 } from './model';
 
 import {
-  Notebook
+  Notebook, JUPYTER_CELL_MIME
 } from './widget';
 
 
-/**
- * The mimetype used for Jupyter cell data.
- */
-export
-const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells';
-
 /**
  * A namespace for handling actions on a notebook.
  *

+ 43 - 2
src/notebook/notebook/index.css

@@ -24,7 +24,7 @@
 }
 
 
-.jp-Notebook.jp-mod-commandMode .jp-Notebook-cell.jp-mod-active.jp-mod-selected {
+.jp-Notebook.jp-mod-commandMode .jp-Notebook-cell.jp-mod-active.jp-mod-selected{
   border-color: #ABABAB;
   border-left-width: var(--jp-border-width);
   background: linear-gradient(to right, #42A5F5 -40px, #42A5F5 5px, transparent 5px, transparent 100%);
@@ -55,4 +55,45 @@
 
 .jp-Notebook {
   flex: 1 1 auto;
-}
+}
+
+
+.jp-Notebook-cell.jp-mod-dropSource {
+  opacity: 0.5;
+}
+
+
+.jp-Notebook-cell.jp-mod-dropTarget, 
+.jp-Notebook.jp-mod-commandMode .jp-Notebook-cell.jp-mod-active.jp-mod-selected.jp-mod-dropTarget {
+  border-top-color: #42A5F5;
+}
+
+
+.jp-Notebook-cell .jp-Cell-prompt {
+  cursor: move;
+}
+
+.jp-dragImage {
+  position: absolute;
+  width: 50px;
+  height: 50px;
+  border: 1px solid #ABABAB;
+  background: linear-gradient(to right, #42A5F5 -40px, #42A5F5 5px, white 5px, white 100%);
+}
+
+
+.jp-dragImage .jp-filledCircle {
+    position: absolute;
+    left: 10px;
+    top: 10px;
+    border-radius: 50%;
+
+    width: 30px;
+    height: 30px;
+
+    background: #66BB6A;
+    border: 1px solid #ABABAB;
+    color: white;
+    text-align: center;
+    line-height: 30px;
+}

+ 306 - 32
src/notebook/notebook/widget.ts

@@ -6,11 +6,11 @@ import {
 } from '@jupyterlab/services';
 
 import {
-  each
+  each, enumerate
 } from 'phosphor/lib/algorithm/iteration';
 
 import {
-  find
+  find, findIndex, indexOf
 } from 'phosphor/lib/algorithm/searching';
 
 import {
@@ -21,6 +21,10 @@ import {
   Message
 } from 'phosphor/lib/core/messaging';
 
+import {
+  MimeData
+} from 'phosphor/lib/core/mimedata';
+
 import {
   AttachedProperty
 } from 'phosphor/lib/core/properties';
@@ -29,6 +33,10 @@ import {
   defineSignal, ISignal
 } from 'phosphor/lib/core/signaling';
 
+import {
+  Drag, IDragEvent
+} from 'phosphor/lib/dom/dragdrop';
+
 import {
   scrollIntoViewIfNeeded
 } from 'phosphor/lib/dom/query';
@@ -113,6 +121,36 @@ const OTHER_SELECTED_CLASS = 'jp-mod-multiSelected';
  */
 const UNCONFINED_CLASS = 'jp-mod-unconfined';
 
+/**
+ * The class name added to a drop target.
+ */
+const DROP_TARGET_CLASS = 'jp-mod-dropTarget';
+
+/**
+ * The class name added to a drop source.
+ */
+const DROP_SOURCE_CLASS = 'jp-mod-dropSource';
+
+/**
+ * The class name added to drag images.
+ */
+const DRAG_IMAGE_CLASS = 'jp-dragImage';
+
+/**
+ * The class name added to a filled circle.
+ */
+const FILLED_CIRCLE_CLASS = 'jp-filledCircle';
+
+/**
+ * The mimetype used for Jupyter cell data.
+ */
+export
+const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells';
+
+/**
+ * The threshold in pixels to start a drag event.
+ */
+const DRAG_THRESHOLD = 5;
 
 /**
  * The interactivity modes for the notebook.
@@ -304,9 +342,9 @@ class StaticNotebook extends Widget {
     }
     this._updateMimetype();
     let cells = newValue.cells;
-    for (let i = 0; i < cells.length; i++) {
-      this._insertCell(i, cells.at(i));
-    }
+    each(enumerate(cells), ([i, cell]) => {
+      this._insertCell(i, cell);
+    });
     cells.changed.connect(this._onCellsChanged, this);
     newValue.contentChanged.connect(this.onModelContentChanged, this);
     newValue.metadataChanged.connect(this.onMetadataChanged, this);
@@ -392,10 +430,9 @@ class StaticNotebook extends Widget {
    * Update the cell widgets.
    */
   private _updateCells(): void {
-    let layout = this.layout as PanelLayout;
-    for (let i = 0; i < layout.widgets.length; i++) {
+    each(enumerate(this.widgets), ([i, widget]) => {
       this._updateCell(i);
-    }
+    });
   }
 
   /**
@@ -595,11 +632,7 @@ class Notebook extends StaticNotebook {
     }
     // Edit mode deselects all cells.
     if (newValue === 'edit') {
-      let layout = this.layout as PanelLayout;
-      for (let i = 0; i < layout.widgets.length; i++) {
-        let widget = layout.widgets.at(i) as BaseCellWidget;
-        this.deselect(widget);
-      }
+      each(this.widgets, widget => { this.deselect(widget); });
       if (activeCell instanceof MarkdownCellWidget) {
         activeCell.rendered = false;
       }
@@ -750,6 +783,12 @@ class Notebook extends StaticNotebook {
     case 'mousedown':
       this._evtMouseDown(event as MouseEvent);
       break;
+    case 'mouseup':
+      this._evtMouseup(event as MouseEvent);
+      break;
+    case 'mousemove':
+      this._evtMousemove(event as MouseEvent);
+      break;
     case 'dblclick':
       this._evtDblClick(event as MouseEvent);
       break;
@@ -759,6 +798,18 @@ class Notebook extends StaticNotebook {
     case 'blur':
       this._evtBlur(event as MouseEvent);
       break;
+    case 'p-dragenter':
+      this._evtDragEnter(event as IDragEvent);
+      break;
+    case 'p-dragleave':
+      this._evtDragLeave(event as IDragEvent);
+      break;
+    case 'p-dragover':
+      this._evtDragOver(event as IDragEvent);
+      break;
+    case 'p-drop':
+      this._evtDrop(event as IDragEvent);
+      break;
     default:
       break;
     }
@@ -769,20 +820,32 @@ class Notebook extends StaticNotebook {
    */
   protected onAfterAttach(msg: Message): void {
     super.onAfterAttach(msg);
-    this.node.addEventListener('mousedown', this);
-    this.node.addEventListener('dblclick', this);
-    this.node.addEventListener('focus', this, true);
-    this.node.addEventListener('blur', this, true);
+    let node = this.node;
+    node.addEventListener('mousedown', this);
+    node.addEventListener('dblclick', this);
+    node.addEventListener('focus', this, true);
+    node.addEventListener('blur', this, true);
+    node.addEventListener('p-dragenter', this);
+    node.addEventListener('p-dragleave', this);
+    node.addEventListener('p-dragover', this);
+    node.addEventListener('p-drop', this);
   }
 
   /**
    * Handle `before_detach` messages for the widget.
    */
   protected onBeforeDetach(msg: Message): void {
-    this.node.removeEventListener('mousedown', this);
-    this.node.removeEventListener('dblclick', this);
-    this.node.removeEventListener('focus', this, true);
-    this.node.removeEventListener('blur', this, true);
+    let node = this.node;
+    node.removeEventListener('mousedown', this);
+    node.removeEventListener('dblclick', this);
+    node.removeEventListener('focus', this, true);
+    node.removeEventListener('blur', this, true);
+    node.removeEventListener('p-dragenter', this);
+    node.removeEventListener('p-dragleave', this);
+    node.removeEventListener('p-dragover', this);
+    node.removeEventListener('p-drop', this);
+    document.removeEventListener('mousemove', this, true);
+    document.removeEventListener('mouseup', this, true);
   }
 
   /**
@@ -826,10 +889,8 @@ class Notebook extends StaticNotebook {
     }
 
     let count = 0;
-    let layout = this.layout as PanelLayout;
-    for (let i = 0; i < layout.widgets.length; i++) {
-      let widget = layout.widgets.at(i) as BaseCellWidget;
-      if (i !== this.activeCellIndex) {
+    each(this.widgets, widget => {
+      if (widget !== activeCell) {
         widget.removeClass(ACTIVE_CLASS);
       }
       widget.removeClass(OTHER_SELECTED_CLASS);
@@ -839,7 +900,7 @@ class Notebook extends StaticNotebook {
       } else {
         widget.removeClass(SELECTED_CLASS);
       }
-    }
+    });
     if (count > 1) {
       activeCell.addClass(OTHER_SELECTED_CLASS);
     }
@@ -913,13 +974,11 @@ class Notebook extends StaticNotebook {
   private _findCell(node: HTMLElement): number {
     // Trace up the DOM hierarchy to find the root cell node.
     // Then find the corresponding child and select it.
-    let layout = this.layout as PanelLayout;
     while (node && node !== this.node) {
       if (node.classList.contains(NB_CELL_CLASS)) {
-        for (let i = 0; i < layout.widgets.length; i++) {
-          if (layout.widgets.at(i).node === node) {
-            return i;
-          }
+        let i = findIndex(this.widgets, widget => widget.node === node);
+        if (i !== -1) {
+          return i;
         }
         break;
       }
@@ -934,26 +993,225 @@ class Notebook extends StaticNotebook {
   private _evtMouseDown(event: MouseEvent): void {
     let target = event.target as HTMLElement;
     let i = this._findCell(target);
+    let shouldDrag = false;
 
     if (i !== -1) {
       let widget = this.widgets.at(i);
       // Event is on a cell but not in its editor, switch to command mode.
       if (!widget.editor.node.contains(target)) {
         this.mode = 'command';
+        shouldDrag = true;
       }
       if (event.shiftKey) {
+        shouldDrag = false;
         this._extendSelectionTo(i);
 
         // Prevent text select behavior.
         event.preventDefault();
         event.stopPropagation();
       } else {
-        this.deselectAll();
+        if (!this.isSelected(widget)) {
+          this.deselectAll();
+        }
       }
       // Set the cell as the active one.
       // This must be done *after* setting the mode above.
       this.activeCellIndex = i;
     }
+
+    // Left mouse press for drag start.
+    if (event.button === 0 && shouldDrag) {
+      this._dragData = { pressX: event.clientX, pressY: event.clientY, index: i};
+      document.addEventListener('mouseup', this, true);
+      document.addEventListener('mousemove', this, true);
+    }
+  }
+
+
+  /**
+   * Handle the `'mouseup'` event for the widget.
+   */
+  private _evtMouseup(event: MouseEvent): void {
+    if (event.button !== 0 || !this._drag) {
+      document.removeEventListener('mousemove', this, true);
+      document.removeEventListener('mouseup', this, true);
+      return;
+    }
+    event.preventDefault();
+    event.stopPropagation();
+  }
+
+  /**
+   * Handle the `'mousemove'` event for the widget.
+   */
+  private _evtMousemove(event: MouseEvent): void {
+    event.preventDefault();
+    event.stopPropagation();
+
+    // Bail if we are the one dragging.
+    if (this._drag) {
+      return;
+    }
+
+    // Check for a drag initialization.
+    let data = this._dragData;
+    let dx = Math.abs(event.clientX - data.pressX);
+    let dy = Math.abs(event.clientY - data.pressY);
+    if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) {
+      return;
+    }
+
+    this._startDrag(data.index, event.clientX, event.clientY);
+  }
+
+  /**
+   * Handle the `'p-dragenter'` event for the widget.
+   */
+  private _evtDragEnter(event: IDragEvent): void {
+    if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
+      return;
+    }
+    event.preventDefault();
+    event.stopPropagation();
+    let index = this._findCell(event.target as HTMLElement);
+    if (index === -1) {
+      return;
+    }
+    let target = (this.layout as PanelLayout).widgets.at(index);
+    target.node.classList.add(DROP_TARGET_CLASS);
+  }
+
+  /**
+   * Handle the `'p-dragleave'` event for the widget.
+   */
+  private _evtDragLeave(event: IDragEvent): void {
+    if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
+      return;
+    }
+    event.preventDefault();
+    event.stopPropagation();
+    let elements = this.node.getElementsByClassName(DROP_TARGET_CLASS);
+    if (elements.length) {
+      (elements[0] as HTMLElement).classList.remove(DROP_TARGET_CLASS);
+    }
+  }
+
+  /**
+   * Handle the `'p-dragover'` event for the widget.
+   */
+  private _evtDragOver(event: IDragEvent): void {
+    if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
+      return;
+    }
+    event.preventDefault();
+    event.stopPropagation();
+    event.dropAction = event.proposedAction;
+    let elements = this.node.getElementsByClassName(DROP_TARGET_CLASS);
+    if (elements.length) {
+      (elements[0] as HTMLElement).classList.remove(DROP_TARGET_CLASS);
+    }
+    let index = this._findCell(event.target as HTMLElement);
+    if (index === -1) {
+      return;
+    }
+    let target = (this.layout as PanelLayout).widgets.at(index);
+    target.node.classList.add(DROP_TARGET_CLASS);
+  }
+
+  /**
+   * Handle the `'p-drop'` event for the widget.
+   */
+  private _evtDrop(event: IDragEvent): void {
+    if (!event.mimeData.hasData(JUPYTER_CELL_MIME)) {
+      return;
+    }
+    event.preventDefault();
+    event.stopPropagation();
+    if (event.proposedAction === 'none') {
+      event.dropAction = 'none';
+      return;
+    }
+    event.dropAction = event.proposedAction;
+
+    let target = event.target as HTMLElement;
+    while (target && target.parentElement) {
+      if (target.classList.contains(DROP_TARGET_CLASS)) {
+        target.classList.remove(DROP_TARGET_CLASS);
+        break;
+      }
+      target = target.parentElement;
+    }
+
+    // Find the target cell and insert the copied cells.
+    let index = this._findCell(target);
+    let model = this.model;
+    let values = event.mimeData.getData(JUPYTER_CELL_MIME);
+
+    // Insert the copies of the original cells.
+    each(values, (value: nbformat.ICell) => {
+      let cell: ICellModel;
+      switch (value.cell_type) {
+      case 'code':
+        cell = model.factory.createCodeCell(value);
+        break;
+      case 'markdown':
+        cell = model.factory.createMarkdownCell(value);
+        break;
+      default:
+        cell = model.factory.createRawCell(value);
+        break;
+      }
+      model.cells.insert(index, cell);
+    });
+    // Activate the last cell.
+    this.activeCellIndex = index + values.length - 1;
+  }
+
+  /**
+   * Start a drag event.
+   */
+  private _startDrag(index: number, clientX: number, clientY: number): void {
+    let cells = this.model.cells;
+    let selected: nbformat.ICell[] = [];
+    let toremove: BaseCellWidget[] = [];
+
+    each(enumerate(this.widgets), ([i, widget]) => {
+      let cell = cells.at(i);
+      if (this.isSelected(widget)) {
+        widget.addClass(DROP_SOURCE_CLASS);
+        selected.push(cell.toJSON());
+        toremove.push(widget);
+      }
+    });
+
+    // Create the drag image.
+    let dragImage = Private.createDragImage(selected.length);
+
+    // Set up the drag event.
+    this._drag = new Drag({
+      mimeData: new MimeData(),
+      dragImage,
+      supportedActions: 'move',
+      proposedAction: 'move'
+    });
+    this._drag.mimeData.setData(JUPYTER_CELL_MIME, selected);
+
+    // Remove mousemove and mouseup listeners and start the drag.
+    document.removeEventListener('mousemove', this, true);
+    document.removeEventListener('mouseup', this, true);
+    this._drag.start(clientX, clientY).then(action => {
+      this._drag = null;
+      each(toremove, widget => { widget.removeClass(DROP_SOURCE_CLASS); });
+      if (action === 'none') {
+        return;
+      }
+      let activeCell = cells.at(this.activeCellIndex);
+      each(toremove, widget => {
+        this.model.cells.remove(widget.model);
+      });
+      this.activeCellIndex = indexOf(cells, activeCell);
+    });
+
   }
 
   /**
@@ -1040,6 +1298,8 @@ class Notebook extends StaticNotebook {
   private _activeCell: BaseCellWidget = null;
   private _inspectionHandler: InspectionHandler = null;
   private _mode: NotebookMode = 'command';
+  private _drag: Drag = null;
+  private _dragData: { pressX: number, pressY: number, index: number } = null;
 }
 
 
@@ -1098,4 +1358,18 @@ namespace Private {
       // This is a no-op.
     }
   }
+
+  /**
+   * Create a cell drag image.
+   */
+  export
+  function createDragImage(count: number): HTMLElement {
+    let node = document.createElement('div');
+    let span = document.createElement('span');
+    span.textContent = `${count}`;
+    span.className = FILLED_CIRCLE_CLASS;
+    node.appendChild(span);
+    node.className = DRAG_IMAGE_CLASS;
+    return node;
+  }
 }

+ 2 - 2
test/src/notebook/notebook/actions.spec.ts

@@ -24,11 +24,11 @@ import {
 } from '../../../../lib/notebook/notebook/model';
 
 import {
-  NotebookActions, JUPYTER_CELL_MIME
+  NotebookActions
 } from '../../../../lib/notebook/notebook/actions';
 
 import {
-  Notebook
+  Notebook, JUPYTER_CELL_MIME
 } from '../../../../lib/notebook/notebook/widget';
 
 import {

+ 5 - 1
test/src/notebook/notebook/default-toolbar.spec.ts

@@ -28,7 +28,7 @@ import {
 } from '../../../../lib/notebook/cells/widget';
 
 import {
-  JUPYTER_CELL_MIME, NotebookActions
+  NotebookActions
 } from '../../../../lib/notebook/notebook/actions';
 
 import {
@@ -43,6 +43,10 @@ import {
  INotebookModel
 } from '../../../../lib/notebook/notebook/model';
 
+import {
+  JUPYTER_CELL_MIME
+} from '../../../../lib/notebook/notebook/widget';
+
 import {
  NotebookPanel
 } from '../../../../lib/notebook/notebook/panel';