// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /*----------------------------------------------------------------------------- | Copyright (c) 2014-2019, PhosphorJS Contributors | | Distributed under the terms of the BSD 3-Clause License. | | The full license is in the file LICENSE, distributed with this software. |----------------------------------------------------------------------------*/ import { IDisposable } from '@lumino/disposable'; import { Platform } from '@lumino/domutils'; import { Drag } from '@lumino/dragdrop'; import { DataGrid } from './datagrid'; import { HyperlinkRenderer } from './hyperlinkrenderer'; import { DataModel } from './datamodel'; import { SelectionModel } from './selectionmodel'; import { CellEditor } from './celleditor'; import { CellGroup } from './cellgroup'; import { CellRenderer } from './cellrenderer'; import { TextRenderer } from './textrenderer'; /** * A basic implementation of a data grid mouse handler. * * #### Notes * This class may be subclassed and customized as needed. */ export class BasicMouseHandler implements DataGrid.IMouseHandler { /** * Dispose of the resources held by the mouse handler. */ dispose(): void { // Bail early if the handler is already disposed. if (this._disposed) { return; } // Release any held resources. this.release(); // Mark the handler as disposed. this._disposed = true; } /** * Whether the mouse handler is disposed. */ get isDisposed(): boolean { return this._disposed; } /** * Release the resources held by the handler. */ release(): void { // Bail early if the is no press data. if (!this._pressData) { return; } // Clear the autoselect timeout. if (this._pressData.type === 'select') { this._pressData.timeout = -1; } // Clear the press data. this._pressData.override.dispose(); this._pressData = null; } /** * Handle the mouse hover event for the data grid. * * @param grid - The data grid of interest. * * @param event - The mouse hover event of interest. */ onMouseHover(grid: DataGrid, event: MouseEvent): void { // Hit test the grid. let hit = grid.hitTest(event.clientX, event.clientY); // Get the resize handle for the hit test. let handle = Private.resizeHandleForHitTest(hit); // Fetch the cursor for the handle. let cursor = this.cursorForHandle(handle); // Hyperlink logic. const config = Private.createCellConfigObject(grid, hit); if (config) { // Retrieve renderer for hovered cell. const renderer = grid.cellRenderers.get(config); if (renderer instanceof HyperlinkRenderer) { cursor = this.cursorForHandle('hyperlink'); } } // Update the viewport cursor based on the part. grid.viewport.node.style.cursor = cursor; // TODO support user-defined hover items } /** * Handle the mouse leave event for the data grid. * * @param grid - The data grid of interest. * * @param event - The mouse hover event of interest. */ onMouseLeave(grid: DataGrid, event: MouseEvent): void { // TODO support user-defined hover popups. // Clear the viewport cursor. grid.viewport.node.style.cursor = ''; } /** * Handle the mouse down event for the data grid. * * @param grid - The data grid of interest. * * @param event - The mouse down event of interest. */ onMouseDown(grid: DataGrid, event: MouseEvent): void { // Unpack the event. let { clientX, clientY } = event; // Hit test the grid. let hit = grid.hitTest(clientX, clientY); // Unpack the hit test. const { region, row, column } = hit; // Bail if the hit test is on an uninteresting region. if (region === 'void') { return; } // Fetch the modifier flags. let shift = event.shiftKey; let accel = Platform.accelKey(event); // Hyperlink logic. if (grid) { // Create cell config object. const config = Private.createCellConfigObject(grid, hit); // Retrieve cell renderer. let renderer = grid.cellRenderers.get(config!); // Only process hyperlink renderers. if (renderer instanceof HyperlinkRenderer) { // Use the url param if it exists. let url = CellRenderer.resolveOption(renderer.url, config!); // Otherwise assume cell value is the URL. if (!url) { const format = TextRenderer.formatGeneric(); url = format(config!); } // Open the hyperlink only if user hit Ctrl+Click. if (accel) { window.open(url); // Reset cursor default after clicking const cursor = this.cursorForHandle('none'); grid.viewport.node.style.cursor = cursor; // Not applying selections if navigating away. return; } } } // If the hit test is the body region, the only option is select. if (region === 'body') { // Fetch the selection model. let model = grid.selectionModel; // Bail early if there is no selection model. if (!model) { return; } // Override the document cursor. let override = Drag.overrideCursor('default'); // Set up the press data. this._pressData = { type: 'select', region, row, column, override, localX: -1, localY: -1, timeout: -1 }; // Set up the selection variables. let r1: number; let c1: number; let r2: number; let c2: number; let cursorRow: number; let cursorColumn: number; let clear: SelectionModel.ClearMode; // Accel == new selection, keep old selections. if (accel) { r1 = row; r2 = row; c1 = column; c2 = column; cursorRow = row; cursorColumn = column; clear = 'none'; } else if (shift) { r1 = model.cursorRow; r2 = row; c1 = model.cursorColumn; c2 = column; cursorRow = model.cursorRow; cursorColumn = model.cursorColumn; clear = 'current'; } else { r1 = row; r2 = row; c1 = column; c2 = column; cursorRow = row; cursorColumn = column; clear = 'all'; } // Make the selection. model.select({ r1, c1, r2, c2, cursorRow, cursorColumn, clear }); // Done. return; } // Otherwise, the hit test is on a header region. // Convert the hit test into a part. let handle = Private.resizeHandleForHitTest(hit); // Fetch the cursor for the handle. let cursor = this.cursorForHandle(handle); // Handle horizontal resize. if (handle === 'left' || handle === 'right') { // Set up the resize data type. const type = 'column-resize'; // Determine the column region. let rgn: DataModel.ColumnRegion = region === 'column-header' ? 'body' : 'row-header'; // Determine the section index. let index = handle === 'left' ? column - 1 : column; // Fetch the section size. let size = grid.columnSize(rgn, index); // Override the document cursor. let override = Drag.overrideCursor(cursor); // Create the temporary press data. this._pressData = { type, region: rgn, index, size, clientX, override }; // Done. return; } // Handle vertical resize if (handle === 'top' || handle === 'bottom') { // Set up the resize data type. const type = 'row-resize'; // Determine the row region. let rgn: DataModel.RowRegion = region === 'row-header' ? 'body' : 'column-header'; // Determine the section index. let index = handle === 'top' ? row - 1 : row; // Fetch the section size. let size = grid.rowSize(rgn, index); // Override the document cursor. let override = Drag.overrideCursor(cursor); // Create the temporary press data. this._pressData = { type, region: rgn, index, size, clientY, override }; // Done. return; } // Otherwise, the only option is select. // Fetch the selection model. let model = grid.selectionModel; // Bail if there is no selection model. if (!model) { return; } // Override the document cursor. let override = Drag.overrideCursor('default'); // Set up the press data. this._pressData = { type: 'select', region, row, column, override, localX: -1, localY: -1, timeout: -1 }; // Set up the selection variables. let r1: number; let c1: number; let r2: number; let c2: number; let cursorRow: number; let cursorColumn: number; let clear: SelectionModel.ClearMode; // Compute the selection based on the pressed region. if (region === 'corner-header') { r1 = 0; r2 = Infinity; c1 = 0; c2 = Infinity; cursorRow = accel ? 0 : shift ? model.cursorRow : 0; cursorColumn = accel ? 0 : shift ? model.cursorColumn : 0; clear = accel ? 'none' : shift ? 'current' : 'all'; } else if (region === 'row-header') { r1 = accel ? row : shift ? model.cursorRow : row; r2 = row; const selectionGroup: CellGroup = { r1: r1, c1: 0, r2: r2, c2: 0 }; const joinedGroup = CellGroup.joinCellGroupsIntersectingAtAxis( grid.dataModel!, ['row-header', 'body'], 'row', selectionGroup ); // Check if there are any merges if (joinedGroup.r1 != Number.MAX_VALUE) { r1 = joinedGroup.r1; r2 = joinedGroup.r2; } c1 = 0; c2 = Infinity; cursorRow = accel ? row : shift ? model.cursorRow : row; cursorColumn = accel ? 0 : shift ? model.cursorColumn : 0; clear = accel ? 'none' : shift ? 'current' : 'all'; } else if (region === 'column-header') { r1 = 0; r2 = Infinity; c1 = accel ? column : shift ? model.cursorColumn : column; c2 = column; const selectionGroup: CellGroup = { r1: 0, c1: c1, r2: 0, c2: c2 }; const joinedGroup = CellGroup.joinCellGroupsIntersectingAtAxis( grid.dataModel!, ['column-header', 'body'], 'column', selectionGroup ); // Check if there are any merges if (joinedGroup.c1 != Number.MAX_VALUE) { c1 = joinedGroup.c1; c2 = joinedGroup.c2; } cursorRow = accel ? 0 : shift ? model.cursorRow : 0; cursorColumn = accel ? column : shift ? model.cursorColumn : column; clear = accel ? 'none' : shift ? 'current' : 'all'; } else { r1 = accel ? row : shift ? model.cursorRow : row; r2 = row; c1 = accel ? column : shift ? model.cursorColumn : column; c2 = column; cursorRow = accel ? row : shift ? model.cursorRow : row; cursorColumn = accel ? column : shift ? model.cursorColumn : column; clear = accel ? 'none' : shift ? 'current' : 'all'; } // Make the selection. model.select({ r1, c1, r2, c2, cursorRow, cursorColumn, clear }); } /** * Handle the mouse move event for the data grid. * * @param grid - The data grid of interest. * * @param event - The mouse move event of interest. */ onMouseMove(grid: DataGrid, event: MouseEvent): void { // Fetch the press data. const data = this._pressData; // Bail early if there is no press data. if (!data) { return; } // Handle a row resize. if (data.type === 'row-resize') { let dy = event.clientY - data.clientY; grid.resizeRow(data.region, data.index, data.size + dy); return; } // Handle a column resize. if (data.type === 'column-resize') { let dx = event.clientX - data.clientX; grid.resizeColumn(data.region, data.index, data.size + dx); return; } // Otherwise, it's a select. // Mouse moves during a corner header press are a no-op. if (data.region === 'corner-header') { return; } // Fetch the selection model. let model = grid.selectionModel; // Bail early if the selection model was removed. if (!model) { return; } // Map to local coordinates. let { lx, ly } = grid.mapToLocal(event.clientX, event.clientY); // Update the local mouse coordinates in the press data. data.localX = lx; data.localY = ly; // Fetch the grid geometry. let hw = grid.headerWidth; let hh = grid.headerHeight; let vpw = grid.viewportWidth; let vph = grid.viewportHeight; let sx = grid.scrollX; let sy = grid.scrollY; let msx = grid.maxScrollY; let msy = grid.maxScrollY; // Fetch the selection mode. let mode = model.selectionMode; // Set up the timeout variable. let timeout = -1; // Compute the timemout based on hit region and mouse position. if (data.region === 'row-header' || mode === 'row') { if (ly < hh && sy > 0) { timeout = Private.computeTimeout(hh - ly); } else if (ly >= vph && sy < msy) { timeout = Private.computeTimeout(ly - vph); } } else if (data.region === 'column-header' || mode === 'column') { if (lx < hw && sx > 0) { timeout = Private.computeTimeout(hw - lx); } else if (lx >= vpw && sx < msx) { timeout = Private.computeTimeout(lx - vpw); } } else { if (lx < hw && sx > 0) { timeout = Private.computeTimeout(hw - lx); } else if (lx >= vpw && sx < msx) { timeout = Private.computeTimeout(lx - vpw); } else if (ly < hh && sy > 0) { timeout = Private.computeTimeout(hh - ly); } else if (ly >= vph && sy < msy) { timeout = Private.computeTimeout(ly - vph); } } // Update or initiate the autoselect if needed. if (timeout >= 0) { if (data.timeout < 0) { data.timeout = timeout; setTimeout(() => { Private.autoselect(grid, data); }, timeout); } else { data.timeout = timeout; } return; } // Otherwise, clear the autoselect timeout. data.timeout = -1; // Map the position to virtual coordinates. let { vx, vy } = grid.mapToVirtual(event.clientX, event.clientY); // Clamp the coordinates to the limits. vx = Math.max(0, Math.min(vx, grid.bodyWidth - 1)); vy = Math.max(0, Math.min(vy, grid.bodyHeight - 1)); // Set up the selection variables. let r1: number; let c1: number; let r2: number; let c2: number; let cursorRow = model.cursorRow; let cursorColumn = model.cursorColumn; let clear: SelectionModel.ClearMode = 'current'; // Compute the selection based pressed region. if (data.region === 'row-header' || mode === 'row') { r1 = data.row; r2 = grid.rowAt('body', vy); const selectionGroup: CellGroup = { r1: r1, c1: 0, r2: r2, c2: 0 }; const joinedGroup = CellGroup.joinCellGroupsIntersectingAtAxis( grid.dataModel!, ['row-header', 'body'], 'row', selectionGroup ); // Check if there are any merges if (joinedGroup.r1 != Number.MAX_VALUE) { r1 = Math.min(r1, joinedGroup.r1); r2 = Math.max(r2, joinedGroup.r2); } c1 = 0; c2 = Infinity; } else if (data.region === 'column-header' || mode === 'column') { r1 = 0; r2 = Infinity; c1 = data.column; c2 = grid.columnAt('body', vx); const selectionGroup: CellGroup = { r1: 0, c1: c1, r2: 0, c2: c2 }; const joinedGroup = CellGroup.joinCellGroupsIntersectingAtAxis( grid.dataModel!, ['column-header', 'body'], 'column', selectionGroup ); // Check if there are any merges if (joinedGroup.c1 != Number.MAX_VALUE) { c1 = joinedGroup.c1; c2 = joinedGroup.c2; } } else { r1 = cursorRow; r2 = grid.rowAt('body', vy); c1 = cursorColumn; c2 = grid.columnAt('body', vx); } // Make the selection. model.select({ r1, c1, r2, c2, cursorRow, cursorColumn, clear }); } /** * Handle the mouse up event for the data grid. * * @param grid - The data grid of interest. * * @param event - The mouse up event of interest. */ onMouseUp(grid: DataGrid, event: MouseEvent): void { this.release(); } /** * Handle the mouse double click event for the data grid. * * @param grid - The data grid of interest. * * @param event - The mouse up event of interest. */ onMouseDoubleClick(grid: DataGrid, event: MouseEvent): void { if (!grid.dataModel) { this.release(); return; } // Unpack the event. let { clientX, clientY } = event; // Hit test the grid. let hit = grid.hitTest(clientX, clientY); // Unpack the hit test. let { region, row, column } = hit; if (region === 'void') { this.release(); return; } if (region === 'column-header' || region === 'corner-header') { // Convert the hit test into a part. const handle = Private.resizeHandleForHitTest(hit); if (handle === 'left' || handle === 'right') { let colIndex = handle === 'left' ? column - 1 : column; let colRegion: DataModel.ColumnRegion = region === 'column-header' ? 'body' : 'row-header'; if (colIndex < 0) { if (region === 'column-header') { // If the column is -1, it means we are in the corner header colIndex = grid.dataModel.columnCount('row-header') - 1; colRegion = 'row-header'; } else { // If we are on the left edge of the row header, do nothing return; } } const cs = grid.selectionModel?.currentSelection(); const cv = grid.currentViewport; const rowCount = grid.selectionModel?.dataModel.rowCount('body') ?? 0; if ( colRegion == 'body' && cs != null && cv != null && cs.r1 == 0 && cs.r2 == rowCount - 1 ) { // One or more columns are selected let c1 = Math.max(Math.min(cs.c1, cs.c2), cv.firstColumn); let c2 = Math.min(Math.max(cs.c1, cs.c2), cv.lastColumn); if (c1 <= colIndex && colIndex <= c2) { // When we double-click one of the selected column headers, resize all visible selected columns. for (let ci = c1; ci <= c2; ci++) { grid.resizeColumn(colRegion, ci, null); } } else { // When we double-click the column header outside the selection, resize only the clicked column. grid.resizeColumn(colRegion, colIndex, null); } } else { // When no columns are selected, resize only the clicked column. grid.resizeColumn(colRegion, colIndex, null); } } } if (region === 'body') { if (grid.editable) { const cell: CellEditor.CellConfig = { grid: grid, row: row, column: column }; grid.editorController!.edit(cell); } } this.release(); } /** * Handle the context menu event for the data grid. * * @param grid - The data grid of interest. * * @param event - The context menu event of interest. */ onContextMenu(grid: DataGrid, event: MouseEvent): void { // TODO support user-defined context menus } /** * Handle the wheel event for the data grid. * * @param grid - The data grid of interest. * * @param event - The wheel event of interest. */ onWheel(grid: DataGrid, event: WheelEvent): void { // Bail if a mouse press is in progress. if (this._pressData) { return; } // Extract the delta X and Y movement. let dx = event.deltaX; let dy = event.deltaY; // Convert the delta values to pixel values. switch (event.deltaMode) { case 0: // DOM_DELTA_PIXEL break; case 1: { // DOM_DELTA_LINE let ds = grid.defaultSizes; dx *= ds.columnWidth; dy *= ds.rowHeight; break; } case 2: // DOM_DELTA_PAGE dx *= grid.pageWidth; dy *= grid.pageHeight; break; default: throw 'unreachable'; } // Only scroll and stop the event propagation if needed. if ( // Scrolling left and not reached min already (dx < 0 && grid.scrollX !== 0) || // Scrolling right and not reached max already (dx > 0 && grid.scrollX !== grid.maxScrollX) || // Scrolling top and not reached min already (dy < 0 && grid.scrollY !== 0) || // Scrolling down and not reached max already (dy > 0 && grid.scrollY !== grid.maxScrollY) ) { event.preventDefault(); event.stopPropagation(); // Scroll by the desired amount. grid.scrollBy(dx, dy); } } /** * Convert a resize handle into a cursor. */ cursorForHandle(handle: ResizeHandle): string { return Private.cursorMap[handle]; } /** * Get the current pressData */ get pressData(): PressData.PressData | null { return this._pressData; } private _disposed = false; protected _pressData: PressData.PressData | null = null; } /** * A type alias for the resize handle types. */ export type ResizeHandle = | 'top' | 'left' | 'right' | 'bottom' | 'none' | 'hyperlink'; /** * The namespace for the pressdata. */ export namespace PressData { /** * A type alias for the row resize data. */ export type RowResizeData = { /** * The descriminated type for the data. */ readonly type: 'row-resize'; /** * The row region which holds the section being resized. */ readonly region: DataModel.RowRegion; /** * The index of the section being resized. */ readonly index: number; /** * The original size of the section. */ readonly size: number; /** * The original client Y position of the mouse. */ readonly clientY: number; /** * The disposable to clear the cursor override. */ readonly override: IDisposable; }; /** * A type alias for the column resize data. */ export type ColumnResizeData = { /** * The descriminated type for the data. */ readonly type: 'column-resize'; /** * The column region which holds the section being resized. */ readonly region: DataModel.ColumnRegion; /** * The index of the section being resized. */ readonly index: number; /** * The original size of the section. */ readonly size: number; /** * The original client X position of the mouse. */ readonly clientX: number; /** * The disposable to clear the cursor override. */ readonly override: IDisposable; }; /** * A type alias for the select data. */ export type SelectData = { /** * The descriminated type for the data. */ readonly type: 'select'; /** * The original region for the mouse press. */ readonly region: DataModel.CellRegion; /** * The original row that was selected. */ readonly row: number; /** * The original column that was selected. */ readonly column: number; /** * The disposable to clear the cursor override. */ readonly override: IDisposable; /** * The current local X position of the mouse. */ localX: number; /** * The current local Y position of the mouse. */ localY: number; /** * The timeout delay for the autoselect loop. */ timeout: number; }; /** * A type alias for the resize handler press data. */ export type PressData = RowResizeData | ColumnResizeData | SelectData; } /** * The namespace for the module implementation details. */ namespace Private { /** * Creates a CellConfig object from a hit region. */ export function createCellConfigObject( grid: DataGrid, hit: DataGrid.HitTestResult ): CellRenderer.CellConfig | undefined { const { region, row, column } = hit; // Terminate call if region is void. if (region === 'void') { return undefined; } // Augment hit region params with value and metadata. const value = grid.dataModel!.data(region, row, column); const metadata = grid.dataModel!.metadata(region, row, column); // Create cell config object to retrieve cell renderer. const config = { ...hit, value: value, metadata: metadata } as CellRenderer.CellConfig; return config; } /** * Get the resize handle for a grid hit test. */ export function resizeHandleForHitTest( hit: DataGrid.HitTestResult ): ResizeHandle { // Fetch the row and column. let r = hit.row; let c = hit.column; // Fetch the leading and trailing sizes. let lw = hit.x; let lh = hit.y; let tw = hit.width - hit.x; let th = hit.height - hit.y; // Set up the result variable. let result: ResizeHandle; // Dispatch based on hit test region. switch (hit.region) { case 'corner-header': if (c > 0 && lw <= 5) { result = 'left'; } else if (tw <= 6) { result = 'right'; } else if (r > 0 && lh <= 5) { result = 'top'; } else if (th <= 6) { result = 'bottom'; } else { result = 'none'; } break; case 'column-header': if (c > 0 && lw <= 5) { result = 'left'; } else if (tw <= 6) { result = 'right'; } else if (r > 0 && lh <= 5) { result = 'top'; } else if (th <= 6) { result = 'bottom'; } else { result = 'none'; } break; case 'row-header': if (c > 0 && lw <= 5) { result = 'left'; } else if (tw <= 6) { result = 'right'; } else if (r > 0 && lh <= 5) { result = 'top'; } else if (th <= 6) { result = 'bottom'; } else { result = 'none'; } break; case 'body': result = 'none'; break; case 'void': result = 'none'; break; default: throw 'unreachable'; } // Return the result. return result; } /** * A timer callback for the autoselect loop. * * @param grid - The datagrid of interest. * * @param data - The select data of interest. */ export function autoselect(grid: DataGrid, data: PressData.SelectData): void { // Bail early if the timeout has been reset. if (data.timeout < 0) { return; } // Fetch the selection model. let model = grid.selectionModel; // Bail early if the selection model has been removed. if (!model) { return; } // Fetch the current selection. let cs = model.currentSelection(); // Bail early if there is no current selection. if (!cs) { return; } // Fetch local X and Y coordinates of the mouse. let lx = data.localX; let ly = data.localY; // Set up the selection variables. let r1 = cs.r1; let c1 = cs.c1; let r2 = cs.r2; let c2 = cs.c2; let cursorRow = model.cursorRow; let cursorColumn = model.cursorColumn; let clear: SelectionModel.ClearMode = 'current'; // Fetch the grid geometry. let hw = grid.headerWidth; let hh = grid.headerHeight; let vpw = grid.viewportWidth; let vph = grid.viewportHeight; // Fetch the selection mode. let mode = model.selectionMode; // Update the selection based on the hit region. if (data.region === 'row-header' || mode === 'row') { r2 += ly <= hh ? -1 : ly >= vph ? 1 : 0; } else if (data.region === 'column-header' || mode === 'column') { c2 += lx <= hw ? -1 : lx >= vpw ? 1 : 0; } else { r2 += ly <= hh ? -1 : ly >= vph ? 1 : 0; c2 += lx <= hw ? -1 : lx >= vpw ? 1 : 0; } // Update the current selection. model.select({ r1, c1, r2, c2, cursorRow, cursorColumn, clear }); // Re-fetch the current selection. cs = model.currentSelection(); // Bail if there is no selection. if (!cs) { return; } // Scroll the grid based on the hit region. if (data.region === 'row-header' || mode === 'row') { grid.scrollToRow(cs.r2); } else if (data.region === 'column-header' || mode == 'column') { grid.scrollToColumn(cs.c2); } else if (mode === 'cell') { grid.scrollToCell(cs.r2, cs.c2); } // Schedule the next call with the current timeout. setTimeout(() => { autoselect(grid, data); }, data.timeout); } /** * Compute the scroll timeout for the given delta distance. * * @param delta - The delta pixels from the origin. * * @returns The scaled timeout in milliseconds. */ export function computeTimeout(delta: number): number { return 5 + 120 * (1 - Math.min(128, Math.abs(delta)) / 128); } /** * A mapping of resize handle to cursor. */ export const cursorMap = { top: 'ns-resize', left: 'ew-resize', right: 'ew-resize', bottom: 'ns-resize', hyperlink: 'pointer', none: 'default' }; }