UNPKG

fixed-react-data-grid-custom

Version:

Excel-like grid component built with React, with editors, keyboard navigation, copy & paste, and the like

755 lines (676 loc) 25.9 kB
import React, { isValidElement, cloneElement } from 'react'; import PropTypes from 'prop-types'; import SelectionMask from './SelectionMask'; import SelectionRangeMask from './SelectionRangeMask'; import CopyMask from './CopyMask'; import DragMask from './DragMask'; import DragHandle from './DragHandle'; import EditorContainer from 'common/editors/EditorContainer'; import EditorPortal from 'common/editors/EditorPortal'; import { UpdateActions } from 'common/constants'; import { isKeyPrintable, isCtrlKeyHeldDown } from 'common/utils/keyboardUtils'; import { getSelectedDimensions, getSelectedCellValue, getSelectedRow, getSelectedColumn, getNextSelectedCellPosition, canExitGrid, isSelectedCellEditable, selectedRangeIsSingleCell } from '../utils/SelectedCellUtils'; import { isFunction } from 'common/utils'; import { getSize, getColumn, isFrozen } from '../ColumnUtils'; import * as keyCodes from '../KeyCodes'; import { CellNavigationMode, EventTypes } from 'common/constants'; require('../../../../themes/interaction-masks.css'); const SCROLL_CELL_BUFFER = 2; class InteractionMasks extends React.Component { static dispplayName = 'InteractionMasks'; static propTypes = { colVisibleStartIdx: PropTypes.number.isRequired, colVisibleEndIdx: PropTypes.number.isRequired, rowVisibleStartIdx: PropTypes.number.isRequired, rowVisibleEndIdx: PropTypes.number.isRequired, rowOverscanStartIdx: PropTypes.number.isRequired, columns: PropTypes.array, width: PropTypes.number, rowHeight: PropTypes.number.isRequired, rowGetter: PropTypes.func.isRequired, rowsCount: PropTypes.number.isRequired, enableCellSelect: PropTypes.bool.isRequired, enableCellAutoFocus: PropTypes.bool.isRequired, cellNavigationMode: PropTypes.oneOf([ CellNavigationMode.NONE, CellNavigationMode.LOOP_OVER_ROW, CellNavigationMode.CHANGE_ROW ]).isRequired, eventBus: PropTypes.object.isRequired, contextMenu: PropTypes.element, onCheckCellIsEditable: PropTypes.func, onCellCopyPaste: PropTypes.func, onGridRowsUpdated: PropTypes.func.isRequired, onHitBottomBoundary: PropTypes.func.isRequired, onHitTopBoundary: PropTypes.func.isRequired, onHitRightBoundary: PropTypes.func.isRequired, onHitLeftBoundary: PropTypes.func.isRequired, onCommit: PropTypes.func.isRequired, onCommitCancel: PropTypes.func, onCellSelected: PropTypes.func, onCellDeSelected: PropTypes.func, onCellRangeSelectionStarted: PropTypes.func, onCellRangeSelectionUpdated: PropTypes.func, onCellRangeSelectionCompleted: PropTypes.func, onCellsDragged: PropTypes.func, onDragHandleDoubleClick: PropTypes.func.isRequired, scrollLeft: PropTypes.number.isRequired, scrollTop: PropTypes.number.isRequired, rows: PropTypes.array.isRequired, getRowHeight: PropTypes.func.isRequired, getRowTop: PropTypes.func.isRequired, getRowColumns: PropTypes.func.isRequired, editorPortalTarget: PropTypes.instanceOf(Element).isRequired }; state = { selectedPosition: { idx: -1, rowIdx: -1 }, selectedRange: { topLeft: { idx: -1, rowIdx: -1 }, bottomRight: { idx: -1, rowIdx: -1 } }, copiedPosition: null, draggedPosition: null, isEditorEnabled: false, firstEditorKeyPress: null }; componentDidUpdate(prevProps, prevState) { const { selectedPosition, isEditorEnabled } = this.state; const { selectedPosition: prevSelectedPosition, isEditorEnabled: prevIsEditorEnabled } = prevState; const isSelectedPositionChanged = selectedPosition !== prevSelectedPosition && (selectedPosition.rowIdx !== prevSelectedPosition.rowIdx || selectedPosition.idx !== prevSelectedPosition.idx); const isEditorClosed = isEditorEnabled !== prevIsEditorEnabled && !isEditorEnabled; if (isSelectedPositionChanged) { // Call event handlers if selected cell has changed const { onCellSelected, onCellDeSelected } = this.props; if (isFunction(onCellDeSelected) && this.isCellWithinBounds(prevSelectedPosition)) { onCellDeSelected({ ...prevSelectedPosition }); } if (isFunction(onCellSelected) && this.isCellWithinBounds(selectedPosition)) { onCellSelected({ ...selectedPosition }); } } if ((isSelectedPositionChanged && this.isCellWithinBounds(selectedPosition)) || isEditorClosed) { this.focus(); } } componentDidMount() { const { eventBus, enableCellAutoFocus } = this.props; this.unsubscribeSelectCell = eventBus.subscribe(EventTypes.SELECT_CELL, this.selectCell); this.unsubscribeSelectStart = eventBus.subscribe(EventTypes.SELECT_START, this.onSelectCellRangeStarted); this.unsubscribeSelectUpdate = eventBus.subscribe(EventTypes.SELECT_UPDATE, this.onSelectCellRangeUpdated); this.unsubscribeSelectEnd = eventBus.subscribe(EventTypes.SELECT_END, this.onSelectCellRangeEnded); this.unsubscribeDragEnter = eventBus.subscribe(EventTypes.DRAG_ENTER, this.handleDragEnter); if (enableCellAutoFocus && this.isFocusedOnBody()) { this.selectFirstCell(); } } componentWillUnmount() { this.unsubscribeSelectCell(); this.unsubscribeSelectStart(); this.unsubscribeSelectUpdate(); this.unsubscribeSelectEnd(); this.unsubscribeDragEnter(); } getEditorPosition = () => { if (this.selectionMask) { const { editorPortalTarget } = this.props; const { left: selectionMaskLeft, top: selectionMaskTop } = this.selectionMask.getBoundingClientRect(); if (editorPortalTarget === document.body) { const { scrollLeft, scrollTop } = document.scrollingElement || document.documentElement; return { left: selectionMaskLeft + scrollLeft, top: selectionMaskTop + scrollTop }; } const { left: portalTargetLeft, top: portalTargetTop } = editorPortalTarget.getBoundingClientRect(); const { scrollLeft, scrollTop } = editorPortalTarget; return { left: selectionMaskLeft - portalTargetLeft + scrollLeft, top: selectionMaskTop - portalTargetTop + scrollTop }; } }; setMaskScollLeft = (mask, position, scrollLeft) => { if (mask) { const { idx, rowIdx } = position; if (idx >= 0 && rowIdx >= 0) { const { columns, getRowTop } = this.props; const column = getColumn(columns, idx); const frozen = isFrozen(column); if (frozen) { const top = getRowTop(rowIdx); const left = scrollLeft + column.left; const transform = `translate(${left}px, ${top}px)`; if (mask.style.transform !== transform) { mask.style.transform = transform; } } } } }; /** * Sets the position of SelectionMask and CopyMask components when the canvas is scrolled * This is only required on the frozen columns * @param scrollLeft number */ setScrollLeft = (scrollLeft) => { const { selectionMask, copyMask, state: { selectedPosition, copiedPosition } } = this; this.setMaskScollLeft(selectionMask, selectedPosition, scrollLeft); this.setMaskScollLeft(copyMask, copiedPosition, scrollLeft); }; onKeyDown = e => { if (isCtrlKeyHeldDown(e)) { this.onPressKeyWithCtrl(e); } else if (e.keyCode === keyCodes.Escape) { this.onPressEscape(e); } else if (e.keyCode === keyCodes.Tab) { this.onPressTab(e); } else if (this.isKeyboardNavigationEvent(e)) { this.changeCellFromEvent(e); } else if (isKeyPrintable(e.keyCode) || [keyCodes.Backspace, keyCodes.Delete, keyCodes.Enter].indexOf(e.keyCode) !== -1) { this.openEditor(e); } }; isSelectedCellEditable = () => { const { enableCellSelect, columns, rowGetter, onCheckCellIsEditable } = this.props; const { selectedPosition } = this.state; return isSelectedCellEditable({ enableCellSelect, columns, rowGetter, selectedPosition, onCheckCellIsEditable }); }; openEditor = ({ key } = {}) => { if (this.isSelectedCellEditable() && !this.state.isEditorEnabled) { this.setState({ isEditorEnabled: true, firstEditorKeyPress: key, editorPosition: this.getEditorPosition() }); } }; closeEditor = () => { this.setState({ isEditorEnabled: false, firstEditorKeyPress: null, editorPosition: null }); }; onPressKeyWithCtrl = ({ keyCode }) => { if (this.copyPasteEnabled()) { if (keyCode === keyCodes.c) { const { columns, rowGetter } = this.props; const { selectedPosition } = this.state; const value = getSelectedCellValue({ selectedPosition, columns, rowGetter }); this.handleCopy({ value }); } else if (keyCode === keyCodes.v) { this.handlePaste(); } } }; onFocus = (e) => { const shift = e.shiftKey === true; const { selectedPosition: { idx, rowIdx } } = this.state; if (idx === -1 && rowIdx === -1) { if (shift) { // FIXME: How to check if shift was pressed? this.selectLastCell(); } else { this.selectFirstCell(); } } }; onPressTab = (e) => { const { cellNavigationMode, columns, rowsCount } = this.props; const { selectedPosition, isEditorEnabled } = this.state; // When there are no rows in the grid, pressing tab needs to allow the browser to handle it if (rowsCount === 0) { return; } // If we are in a position to leave the grid, stop editing but stay in that cell if (canExitGrid(e, { cellNavigationMode, columns, rowsCount, selectedPosition })) { if (isEditorEnabled) { this.closeEditor(); return; } // Reset the selected position before exiting this.setState({ selectedPosition: { idx: -1, rowIdx: -1 } }); return; } this.changeCellFromEvent(e); }; onPressEscape = () => { if (this.copyPasteEnabled()) { this.handleCancelCopy(); this.closeEditor(); } }; copyPasteEnabled = () => { return this.props.onCellCopyPaste !== null && this.isSelectedCellEditable(); }; handleCopy = ({ value }) => { const { rowIdx, idx } = this.state.selectedPosition; this.setState({ copiedPosition: { rowIdx, idx, value } }); }; handleCancelCopy = () => { this.setState({ copiedPosition: null }); }; handlePaste = () => { const { columns, onCellCopyPaste, onGridRowsUpdated } = this.props; const { selectedPosition, copiedPosition } = this.state; const { rowIdx: toRow } = selectedPosition; if (copiedPosition == null) { return; } const { key: cellKey } = getSelectedColumn({ selectedPosition, columns }); const { rowIdx: fromRow, value: textToCopy } = copiedPosition; if (isFunction(onCellCopyPaste)) { onCellCopyPaste({ cellKey, rowIdx: toRow, fromRow, toRow, value: textToCopy }); } onGridRowsUpdated(cellKey, toRow, toRow, { [cellKey]: textToCopy }, UpdateActions.COPY_PASTE, fromRow); }; isKeyboardNavigationEvent(e) { return this.getKeyNavActionFromEvent(e) != null; } isGroupedRowSelected() { const { rowGetter } = this.props; const { selectedPosition } = this.state; const rowData = getSelectedRow({ selectedPosition, rowGetter }); return rowData && rowData.__metaData ? rowData.__metaData.isGroup : false; } getKeyNavActionFromEvent(e) { const { rowVisibleEndIdx, rowVisibleStartIdx, colVisibleEndIdx, colVisibleStartIdx, onHitBottomBoundary, onHitRightBoundary, onHitLeftBoundary, onHitTopBoundary } = this.props; const isCellAtBottomBoundary = cell => cell.rowIdx >= rowVisibleEndIdx - SCROLL_CELL_BUFFER; const isCellAtTopBoundary = cell => cell.rowIdx !== 0 && cell.rowIdx <= rowVisibleStartIdx - 1; const isCellAtRightBoundary = cell => cell.idx !== 0 && cell.idx >= colVisibleEndIdx - 1; const isCellAtLeftBoundary = cell => cell.idx !== 0 && cell.idx <= colVisibleStartIdx + 1; const keyNavActions = { ArrowDown: { getNext: current => ({ ...current, rowIdx: current.rowIdx + 1 }), isCellAtBoundary: isCellAtBottomBoundary, onHitBoundary: onHitBottomBoundary }, ArrowUp: { getNext: current => ({ ...current, rowIdx: current.rowIdx - 1 }), isCellAtBoundary: isCellAtTopBoundary, onHitBoundary: onHitTopBoundary }, ArrowRight: { getNext: current => ({ ...current, idx: current.idx + 1 }), isCellAtBoundary: isCellAtRightBoundary, onHitBoundary: (next) => { onHitRightBoundary(next); // Selected cell can hit the bottom boundary when the cellNavigationMode is 'changeRow' if (isCellAtBottomBoundary(next)) { onHitBottomBoundary(next); } } }, ArrowLeft: { getNext: current => ({ ...current, idx: current.idx - 1 }), isCellAtBoundary: isCellAtLeftBoundary, onHitBoundary: (next) => { onHitLeftBoundary(next); // Selected cell can hit the top boundary when the cellNavigationMode is 'changeRow' if (isCellAtTopBoundary(next)) { onHitTopBoundary(next); } } } }; if (e.keyCode === keyCodes.Tab) { return e.shiftKey === true ? keyNavActions.ArrowLeft : keyNavActions.ArrowRight; } return keyNavActions[e.key]; } changeCellFromEvent(e) { e.preventDefault(); const isTab = e.keyCode === keyCodes.Tab; const isShift = e.shiftKey; if (isTab) { const cellNavigationMode = this.props.cellNavigationMode === CellNavigationMode.NONE ? CellNavigationMode.CHANGE_ROW : this.props.cellNavigationMode; this.changeCellFromKeyAction(e, cellNavigationMode); } else if (isShift) { this.changeSelectedRangeFromArrowKeyAction(e); } else { this.changeCellFromKeyAction(e, this.props.cellNavigationMode); } } changeCellFromKeyAction(e, cellNavigationMode) { const currentPosition = this.state.selectedPosition; const keyNavAction = this.getKeyNavActionFromEvent(e); const next = this.getNextSelectedCellPositionForKeyNavAction(keyNavAction, currentPosition, cellNavigationMode); this.checkIsAtGridBoundary(keyNavAction, next); this.selectCell({ ...next }); } changeSelectedRangeFromArrowKeyAction(e) { const { cellNavigationMode } = this.props; const currentPosition = this.state.selectedRange.cursorCell || this.state.selectedPosition; const keyNavAction = this.getKeyNavActionFromEvent(e); const next = this.getNextSelectedCellPositionForKeyNavAction(keyNavAction, currentPosition, cellNavigationMode); this.checkIsAtGridBoundary(keyNavAction, next); this.onSelectCellRangeUpdated({ ...next }, true, () => { this.onSelectCellRangeEnded(); }); } getNextSelectedCellPositionForKeyNavAction(keyNavAction, currentPosition, cellNavigationMode) { const { getNext } = keyNavAction; const nextPosition = getNext(currentPosition); const { columns, rowsCount } = this.props; return getNextSelectedCellPosition({ columns, rowsCount, cellNavigationMode }, nextPosition); } checkIsAtGridBoundary(keyNavAction, next) { const { isCellAtBoundary, onHitBoundary } = keyNavAction; const { changeRowOrColumn, ...nextPos } = next; if (isCellAtBoundary(nextPos) || changeRowOrColumn) { onHitBoundary(nextPos); } } isCellWithinBounds = ({ idx, rowIdx }) => { const { columns, rowsCount } = this.props; return rowIdx >= 0 && rowIdx < rowsCount && idx >= 0 && idx < getSize(columns); }; isGridSelected = () => { return this.isCellWithinBounds(this.state.selectedPosition); }; isFocused = () => { return document.activeElement === this.selectionMask; }; isFocusedOnBody = () => { return document.activeElement === document.body; }; focus = () => { if (this.selectionMask && !this.isFocused()) { this.selectionMask.focus(); } }; selectFirstCell = () => { this.selectCell({ rowIdx: 0, idx: 0 }); }; selectLastCell = () => { const { rowsCount, columns } = this.props; this.selectCell({ rowIdx: rowsCount - 1, idx: getSize(columns) - 1 }); }; selectCell = (cell, openEditor) => { const callback = openEditor ? this.openEditor : () => null; // Close the editor to commit any pending changes if (this.state.isEditorEnabled) { this.closeEditor(); } this.setState(prevState => { const next = { ...prevState.selectedPosition, ...cell }; if (this.isCellWithinBounds(next)) { return { selectedPosition: next, selectedRange: { topLeft: next, bottomRight: next, startCell: next, cursorCell: next, isDragging: false } }; } return prevState; }, callback); }; createSingleCellSelectedRange(cellPosition, isDragging) { return { topLeft: cellPosition, bottomRight: cellPosition, startCell: cellPosition, cursorCell: cellPosition, isDragging }; } onSelectCellRangeStarted = (selectedPosition) => { this.setState( {isEditorEnabled: false}, () => { this.setState({ selectedRange: this.createSingleCellSelectedRange(selectedPosition, true), selectedPosition }, () => { if (isFunction(this.props.onCellRangeSelectionStarted)) { this.props.onCellRangeSelectionStarted(this.state.selectedRange); } }); }); }; onSelectCellRangeUpdated = (cellPosition, isFromKeyboard, callback) => { if (!this.state.selectedRange.isDragging && !isFromKeyboard) { return; } if (!this.isCellWithinBounds(cellPosition)) { return; } const startCell = this.state.selectedRange.startCell || this.state.selectedPosition; const colIdxs = [startCell.idx, cellPosition.idx].sort((a, b) => a - b); const rowIdxs = [startCell.rowIdx, cellPosition.rowIdx].sort((a, b) => a - b); const topLeft = { idx: colIdxs[0], rowIdx: rowIdxs[0] }; const bottomRight = { idx: colIdxs[1], rowIdx: rowIdxs[1] }; const selectedRange = { // default the startCell to the selected cell, in case we've just started via keyboard startCell: this.state.selectedPosition, // assign the previous state (which will override the startCell if we already have one) ...this.state.selectedRange, // assign the new state - the bounds of the range, and the new cursor cell topLeft, bottomRight, cursorCell: cellPosition }; this.setState({ selectedRange }, () => { if (isFunction(this.props.onCellRangeSelectionUpdated)) { this.props.onCellRangeSelectionUpdated(this.state.selectedRange); } if (isFunction(callback)) { callback(); } }); }; onSelectCellRangeEnded = () => { const selectedRange = { ...this.state.selectedRange, isDragging: false }; this.setState({ selectedRange }, () => { if (isFunction(this.props.onCellRangeSelectionCompleted)) { this.props.onCellRangeSelectionCompleted(this.state.selectedRange); } // Focus the InteractionMasks, so it can receive keyboard events this.focus(); }); }; isDragEnabled = () => { const { onGridRowsUpdated, onCellsDragged } = this.props; return this.isSelectedCellEditable() && (isFunction(onGridRowsUpdated) || isFunction(onCellsDragged)); }; handleDragStart = (e) => { const { selectedPosition: { idx, rowIdx } } = this.state; // To prevent dragging down/up when reordering rows. (TODO: is this required) const isViewportDragging = e && e.target && e.target.className; if (idx > -1 && isViewportDragging) { e.dataTransfer.effectAllowed = 'copy'; // Setting data is required to make an element draggable in FF const transferData = JSON.stringify({ idx, rowIdx }); try { e.dataTransfer.setData('text/plain', transferData); } catch (ex) { // IE only supports 'text' and 'URL' for the 'type' argument e.dataTransfer.setData('text', transferData); } this.setState({ draggedPosition: { idx, rowIdx } }); } }; handleDragEnter = ({ overRowIdx }) => { if (this.state.draggedPosition != null) { this.setState(({ draggedPosition }) => ({ draggedPosition: { ...draggedPosition, overRowIdx } })); } }; handleDragEnd = () => { const { draggedPosition } = this.state; if (draggedPosition != null) { const { rowIdx, overRowIdx } = draggedPosition; if (overRowIdx != null) { const { columns, onCellsDragged, onGridRowsUpdated, rowGetter } = this.props; const column = getSelectedColumn({ selectedPosition: draggedPosition, columns }); const value = getSelectedCellValue({ selectedPosition: draggedPosition, columns, rowGetter }); const cellKey = column.key; const fromRow = rowIdx < overRowIdx ? rowIdx : overRowIdx; const toRow = rowIdx > overRowIdx ? rowIdx : overRowIdx; if (isFunction(onCellsDragged)) { onCellsDragged({ cellKey, fromRow, toRow, value }); } if (isFunction(onGridRowsUpdated)) { onGridRowsUpdated(cellKey, fromRow, toRow, { [cellKey]: value }, UpdateActions.CELL_DRAG); } } this.setState({ draggedPosition: null }); } }; onDragHandleDoubleClick = () => { const { onDragHandleDoubleClick, rowGetter } = this.props; const { selectedPosition } = this.state; const { idx, rowIdx } = selectedPosition; const rowData = getSelectedRow({ selectedPosition, rowGetter }); onDragHandleDoubleClick({ idx, rowIdx, rowData }); }; onCommit = (...args) => { this.props.onCommit(...args); this.closeEditor(); }; onCommitCancel = () => { this.closeEditor(); }; setSelectionMaskRef = (node) => { this.selectionMask = node; }; setCopyMaskRef = (node) => { this.copyMask = node; }; getSelectedDimensions = (selectedPosition, useGridColumns) => { const { scrollLeft, getRowHeight, getRowTop, getRowColumns, columns: gridColumns } = this.props; const columns = useGridColumns ? gridColumns : getRowColumns(selectedPosition.rowIdx); const top = getRowTop(selectedPosition.rowIdx); const rowHeight = getRowHeight(selectedPosition.rowIdx); return { ...getSelectedDimensions({ selectedPosition, columns, scrollLeft, rowHeight }), top }; }; renderSingleCellSelectView = () => { const { selectedPosition } = this.state; return ( !this.state.isEditorEnabled && this.isGridSelected() && ( <SelectionMask selectedPosition={selectedPosition} innerRef={this.setSelectionMaskRef} getSelectedDimensions={this.getSelectedDimensions} > {this.isDragEnabled() && ( <DragHandle onDragStart={this.handleDragStart} onDragEnd={this.handleDragEnd} onDoubleClick={this.onDragHandleDoubleClick} /> )} </SelectionMask> ) ); }; renderCellRangeSelectView = () => { const { columns, rowHeight } = this.props; return [ <SelectionRangeMask key="range-mask" selectedRange={this.state.selectedRange} columns={columns} rowHeight={rowHeight} />, <SelectionMask key="selection-mask" selectedPosition={this.state.selectedRange.startCell} innerRef={this.setSelectionMaskRef} getSelectedDimensions={this.getSelectedDimensions} /> ]; }; render() { const { rowGetter, contextMenu, getRowColumns, scrollLeft, scrollTop } = this.props; const { isEditorEnabled, firstEditorKeyPress, selectedPosition, draggedPosition, copiedPosition } = this.state; const rowData = getSelectedRow({ selectedPosition, rowGetter }); const columns = getRowColumns(selectedPosition.rowIdx); return ( <div onKeyDown={this.onKeyDown} onFocus={this.onFocus} > {copiedPosition && ( <CopyMask copiedPosition={copiedPosition} innerRef={this.setCopyMaskRef} getSelectedDimensions={this.getSelectedDimensions} /> )} {draggedPosition && ( <DragMask draggedPosition={draggedPosition} getSelectedDimensions={this.getSelectedDimensions} /> )} {selectedRangeIsSingleCell(this.state.selectedRange) ? this.renderSingleCellSelectView() : this.renderCellRangeSelectView() } {isEditorEnabled && ( <EditorPortal target={this.props.editorPortalTarget}> <EditorContainer firstEditorKeyPress={firstEditorKeyPress} onCommit={this.onCommit} onCommitCancel={this.onCommitCancel} rowIdx={selectedPosition.rowIdx} value={getSelectedCellValue({ selectedPosition, columns, rowGetter })} rowData={rowData} column={getSelectedColumn({ selectedPosition, columns })} scrollLeft={scrollLeft} scrollTop={scrollTop} editorPortalTarget={this.props.editorPortalTarget} {...{ ...this.getSelectedDimensions(selectedPosition), ...this.state.editorPosition }} /> </EditorPortal> )} {isValidElement(contextMenu) && cloneElement(contextMenu, { ...selectedPosition })} </div> ); } } export default InteractionMasks;