diff --git a/examples/notebook/package.json b/examples/notebook/package.json index 994b21b2783d..6e1229e52e10 100644 --- a/examples/notebook/package.json +++ b/examples/notebook/package.json @@ -12,11 +12,11 @@ "@jupyterlab/completer": "^0.19.1", "@jupyterlab/docmanager": "^0.19.1", "@jupyterlab/docregistry": "^0.19.1", + "@jupyterlab/mathjax2": "^0.7.1", "@jupyterlab/notebook": "^0.19.2", "@jupyterlab/rendermime": "^0.19.1", "@jupyterlab/services": "^3.2.1", "@jupyterlab/theme-light-extension": "^0.19.1", - "@jupyterlab/mathjax2": "^0.7.1", "@phosphor/commands": "^1.6.1", "@phosphor/widgets": "^1.6.0", "es6-promise": "~4.1.1" diff --git a/packages/cells/package.json b/packages/cells/package.json index 39d92dab4e13..1ea9789bb6ba 100644 --- a/packages/cells/package.json +++ b/packages/cells/package.json @@ -40,9 +40,11 @@ "@jupyterlab/outputarea": "^0.19.1", "@jupyterlab/rendermime": "^0.19.1", "@jupyterlab/services": "^3.2.1", + "@phosphor/algorithm": "^1.1.2", "@phosphor/coreutils": "^1.3.0", "@phosphor/messaging": "^1.2.2", "@phosphor/signaling": "^1.2.2", + "@phosphor/virtualdom": "^1.1.2", "@phosphor/widgets": "^1.6.0", "react": "~16.4.2" }, diff --git a/packages/cells/src/celldragutils.ts b/packages/cells/src/celldragutils.ts new file mode 100644 index 000000000000..d67bd588df2f --- /dev/null +++ b/packages/cells/src/celldragutils.ts @@ -0,0 +1,210 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +/** + * This module contains some utility functions to operate on cells. This + * could be shared by widgets that contain cells, like the CodeConsole or + * Notebook widgets. + */ + +import { each, IterableOrArrayLike } from '@phosphor/algorithm'; +import { ICodeCellModel } from './model'; +import { Cell } from './widget'; +import { h, VirtualDOM } from '@phosphor/virtualdom'; +import { nbformat } from '@jupyterlab/coreutils'; + +/** + * Constants for drag + */ + +/** + * The threshold in pixels to start a drag event. + */ +const DRAG_THRESHOLD = 5; +/** + * The class name added to drag images. + */ +const DRAG_IMAGE_CLASS = 'jp-dragImage'; + +/** + * The class name added to singular drag images + */ +const SINGLE_DRAG_IMAGE_CLASS = 'jp-dragImage-singlePrompt'; + +/** + * The class name added to the drag image cell content. + */ +const CELL_DRAG_CONTENT_CLASS = 'jp-dragImage-content'; + +/** + * The class name added to the drag image cell content. + */ +const CELL_DRAG_PROMPT_CLASS = 'jp-dragImage-prompt'; + +/** + * The class name added to the drag image cell content. + */ +const CELL_DRAG_MULTIPLE_BACK = 'jp-dragImage-multipleBack'; + +export namespace CellDragUtils { + export type ICellTargetArea = 'input' | 'prompt' | 'cell' | 'unknown'; + + /** + * Find the cell index containing the target html element. + * This function traces up the DOM hierarchy to find the root cell + * node. Then find the corresponding child and select it. + * + * @param node - the cell node or a child of the cell node. + * @param cells - an iterable of Cells + * @param isCellNode - a function that takes in a node and checks if + * it is a cell node. + * + * @returns index of the cell we're looking for. Returns -1 if + * the cell is not founds + */ + export function findCell( + node: HTMLElement, + cells: IterableOrArrayLike, + isCellNode: (node: HTMLElement) => boolean + ): number { + let cellIndex: number = -1; + while (node && node.parentElement) { + if (isCellNode(node)) { + each(cells, (cell, index) => { + if (cell.node === node) { + cellIndex = index; + return false; + } + }); + break; + } + node = node.parentElement; + } + return cellIndex; + } + + /** + * Detect which part of the cell triggered the MouseEvent + * + * @param cell - The cell which contains the MouseEvent's target + * @param target - The DOM node which triggered the MouseEvent + */ + export function detectTargetArea( + cell: Cell, + target: HTMLElement + ): ICellTargetArea { + let targetArea: ICellTargetArea = null; + if (cell) { + if (cell.editorWidget.node.contains(target)) { + targetArea = 'input'; + } else if (cell.promptNode.contains(target)) { + targetArea = 'prompt'; + } else { + targetArea = 'cell'; + } + } else { + targetArea = 'unknown'; + } + return targetArea; + } + + /** + * Detect if a drag event should be started. This is down if the + * mouse is moved beyond a certain distance (DRAG_THRESHOLD). + * + * @param prevX - X Coordinate of the mouse pointer during the mousedown event + * @param prevY - Y Coordinate of the mouse pointer during the mousedown event + * @param nextX - Current X Coordinate of the mouse pointer + * @param nextY - Current Y Coordinate of the mouse pointer + */ + export function shouldStartDrag( + prevX: number, + prevY: number, + nextX: number, + nextY: number + ): boolean { + let dx = Math.abs(nextX - prevX); + let dy = Math.abs(nextY - prevY); + return dx >= DRAG_THRESHOLD || dy >= DRAG_THRESHOLD; + } + + /** + * Create an image for the cell(s) to be dragged + * + * @param activeCell - The cell from where the drag event is triggered + * @param selectedCells - The cells to be dragged + */ + export function createCellDragImage( + activeCell: Cell, + selectedCells: nbformat.ICell[] + ): HTMLElement { + const count = selectedCells.length; + let promptNumber: string; + if (activeCell.model.type === 'code') { + let executionCount = (activeCell.model as ICodeCellModel).executionCount; + promptNumber = ' '; + if (executionCount) { + promptNumber = executionCount.toString(); + } + } else { + promptNumber = ''; + } + + const cellContent = activeCell.model.value.text.split('\n')[0].slice(0, 26); + if (count > 1) { + if (promptNumber !== '') { + return VirtualDOM.realize( + h.div( + h.div( + { className: DRAG_IMAGE_CLASS }, + h.span( + { className: CELL_DRAG_PROMPT_CLASS }, + '[' + promptNumber + ']:' + ), + h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent) + ), + h.div({ className: CELL_DRAG_MULTIPLE_BACK }, '') + ) + ); + } else { + return VirtualDOM.realize( + h.div( + h.div( + { className: DRAG_IMAGE_CLASS }, + h.span({ className: CELL_DRAG_PROMPT_CLASS }), + h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent) + ), + h.div({ className: CELL_DRAG_MULTIPLE_BACK }, '') + ) + ); + } + } else { + if (promptNumber !== '') { + return VirtualDOM.realize( + h.div( + h.div( + { className: `${DRAG_IMAGE_CLASS} ${SINGLE_DRAG_IMAGE_CLASS}` }, + h.span( + { className: CELL_DRAG_PROMPT_CLASS }, + '[' + promptNumber + ']:' + ), + h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent) + ) + ) + ); + } else { + return VirtualDOM.realize( + h.div( + h.div( + { className: `${DRAG_IMAGE_CLASS} ${SINGLE_DRAG_IMAGE_CLASS}` }, + h.span({ className: CELL_DRAG_PROMPT_CLASS }), + h.span({ className: CELL_DRAG_CONTENT_CLASS }, cellContent) + ) + ) + ); + } + } + } +} diff --git a/packages/cells/src/index.ts b/packages/cells/src/index.ts index dab1b3e03e3a..bfd0e0cb30a9 100644 --- a/packages/cells/src/index.ts +++ b/packages/cells/src/index.ts @@ -5,6 +5,7 @@ import '../style/index.css'; +export * from './celldragutils'; export * from './collapser'; export * from './headerfooter'; export * from './inputarea'; diff --git a/packages/console/package.json b/packages/console/package.json index 1685f5a75246..1e69f6976441 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -41,6 +41,7 @@ "@phosphor/algorithm": "^1.1.2", "@phosphor/coreutils": "^1.3.0", "@phosphor/disposable": "^1.1.2", + "@phosphor/dragdrop": "^1.3.0", "@phosphor/messaging": "^1.2.2", "@phosphor/signaling": "^1.2.2", "@phosphor/widgets": "^1.6.0" diff --git a/packages/console/src/widget.ts b/packages/console/src/widget.ts index 538973c0d1da..0e4a2c8b4bb3 100644 --- a/packages/console/src/widget.ts +++ b/packages/console/src/widget.ts @@ -5,6 +5,7 @@ import { IClientSession } from '@jupyterlab/apputils'; import { Cell, + CellDragUtils, CellModel, CodeCell, CodeCellModel, @@ -27,6 +28,10 @@ import { KernelMessage } from '@jupyterlab/services'; import { each } from '@phosphor/algorithm'; +import { MimeData } from '@phosphor/coreutils'; + +import { Drag } from '@phosphor/dragdrop'; + import { Message } from '@phosphor/messaging'; import { ISignal, Signal } from '@phosphor/signaling'; @@ -50,6 +55,11 @@ const CODE_RUNNER = 'jpCodeRunner'; */ const CONSOLE_CLASS = 'jp-CodeConsole'; +/** + * The class added to console cells + */ +const CONSOLE_CELL_CLASS = 'jp-Console-cell'; + /** * The class name added to the console banner. */ @@ -75,6 +85,11 @@ const INPUT_CLASS = 'jp-CodeConsole-input'; */ const EXECUTION_TIMEOUT = 250; +/** + * The mimetype used for Jupyter cell data. + */ +const JUPYTER_CELL_MIME = 'application/vnd.jupyter.cells'; + /** * A widget containing a Jupyter console. * @@ -196,6 +211,7 @@ export class CodeConsole extends Widget { * the execution message id). */ addCell(cell: CodeCell, msgId?: string) { + cell.addClass(CONSOLE_CELL_CLASS); this._content.addWidget(cell); this._cells.push(cell); if (msgId) { @@ -373,10 +389,122 @@ export class CodeConsole extends Widget { return cells; } + /** + * Handle `mousedown` events for the widget. + */ + private _evtMouseDown(event: MouseEvent): void { + const { button, shiftKey } = event; + + // We only handle main or secondary button actions. + if ( + !(button === 0 || button === 2) || + // Shift right-click gives the browser default behavior. + (shiftKey && button === 2) + ) { + return; + } + + let target = event.target as HTMLElement; + let cellFilter = (node: HTMLElement) => + node.classList.contains(CONSOLE_CELL_CLASS); + let cellIndex = CellDragUtils.findCell(target, this._cells, cellFilter); + + if (cellIndex === -1) { + // `event.target` sometimes gives an orphaned node in + // Firefox 57, which can have `null` anywhere in its parent line. If we fail + // to find a cell using `event.target`, try again using a target + // reconstructed from the position of the click event. + target = document.elementFromPoint( + event.clientX, + event.clientY + ) as HTMLElement; + cellIndex = CellDragUtils.findCell(target, this._cells, cellFilter); + } + + if (cellIndex === -1) { + return; + } + + const cell = this._cells.get(cellIndex); + + let targetArea: CellDragUtils.ICellTargetArea = CellDragUtils.detectTargetArea( + cell, + event.target as HTMLElement + ); + + if (targetArea === 'prompt') { + this._dragData = { + pressX: event.clientX, + pressY: event.clientY, + index: cellIndex + }; + + this._focusedCell = cell; + + document.addEventListener('mouseup', this, true); + document.addEventListener('mousemove', this, true); + event.preventDefault(); + } + } + + /** + * Handle `mousemove` event of widget + */ + private _evtMouseMove(event: MouseEvent) { + const data = this._dragData; + if ( + CellDragUtils.shouldStartDrag( + data.pressX, + data.pressY, + event.clientX, + event.clientY + ) + ) { + this._startDrag(data.index, event.clientX, event.clientY); + } + } + + /** + * Start a drag event + */ + private _startDrag(index: number, clientX: number, clientY: number) { + const cellModel = this._focusedCell.model as ICodeCellModel; + let selected: nbformat.ICell[] = [cellModel.toJSON()]; + + const dragImage = CellDragUtils.createCellDragImage( + this._focusedCell, + selected + ); + + this._drag = new Drag({ + mimeData: new MimeData(), + dragImage, + proposedAction: 'copy', + supportedActions: 'copy', + source: this + }); + + this._drag.mimeData.setData(JUPYTER_CELL_MIME, selected); + const textContent = cellModel.value.text; + this._drag.mimeData.setData('text/plain', textContent); + + this._focusedCell = null; + + document.removeEventListener('mousemove', this, true); + document.removeEventListener('mouseup', this, true); + this._drag.start(clientX, clientY).then(() => { + if (this.isDisposed) { + return; + } + this._drag = null; + this._dragData = null; + }); + } + /** * Handle the DOM events for the widget. * - * @param event - The DOM event sent to the widget. + * @param event -The DOM event sent to the widget. * * #### Notes * This method implements the DOM `EventListener` interface and is @@ -388,8 +516,14 @@ export class CodeConsole extends Widget { case 'keydown': this._evtKeyDown(event as KeyboardEvent); break; - case 'click': - this._evtClick(event as MouseEvent); + case 'mousedown': + this._evtMouseDown(event as MouseEvent); + break; + case 'mousemove': + this._evtMouseMove(event as MouseEvent); + break; + case 'mouseup': + this._evtMouseUp(event as MouseEvent); break; default: break; @@ -403,6 +537,7 @@ export class CodeConsole extends Widget { let node = this.node; node.addEventListener('keydown', this, true); node.addEventListener('click', this); + node.addEventListener('mousedown', this); // Create a prompt if necessary. if (!this.promptCell) { this.newPromptCell(); @@ -487,9 +622,9 @@ export class CodeConsole extends Widget { } /** - * Handle the `'click'` event for the widget. + * Handle the `'mouseup'` event for the widget. */ - private _evtClick(event: MouseEvent): void { + private _evtMouseUp(event: MouseEvent): void { if ( this.promptCell && this.promptCell.node.contains(event.target as HTMLElement) @@ -684,6 +819,9 @@ export class CodeConsole extends Widget { private _msgIds = new Map(); private _msgIdCells = new Map(); private _promptCellCreated = new Signal(this); + private _dragData: { pressX: number; pressY: number; index: number } = null; + private _drag: Drag = null; + private _focusedCell: Cell = null; } /** diff --git a/packages/console/style/index.css b/packages/console/style/index.css index a44027a781d4..220a7684c07a 100644 --- a/packages/console/style/index.css +++ b/packages/console/style/index.css @@ -47,6 +47,7 @@ .jp-CodeConsole-content .jp-Cell:not(.jp-mod-active) .jp-InputPrompt { opacity: var(--jp-cell-prompt-not-active-opacity); color: var(--jp-cell-inprompt-font-color); + cursor: move; } .jp-CodeConsole-content .jp-Cell:not(.jp-mod-active) .jp-OutputPrompt {