import React, { isValidElement, cloneElement } from 'react'; import PropTypes from 'prop-types'; import deepCopy from 'deep-copy'; import toaster from '../../../toast'; import EditorPortal from '../../editors/editor-portal'; import EditorContainer from '../../editors/editor-container'; import DragHandler from '../drag-handler'; import DragMask from '../drag-mask'; import SelectionRangeMask from '../selection-range-mask'; import SelectionMask from '../selection-mask'; import { gettext } from '../../../../utils/constants'; import { Utils } from '../../../../utils/utils'; import { isEmptyObject } from '../../../../utils/object'; import { KeyCodes } from '../../../../constants'; import { GROUP_ROW_TYPE } from '../../constants/group'; import TRANSFER_TYPES from '../../constants/transfer-types'; import { GRID_HEADER_DOUBLE_HEIGHT, GRID_HEADER_DEFAULT_HEIGHT, HEADER_HEIGHT_TYPE, PASTE_SOURCE, EDITOR_TYPE } from '../../constants/grid'; import { getNewSelectedRange, getSelectedDimensions, selectedRangeIsSingleCell, getSelectedRangeDimensions, getSelectedRow, getSelectedColumn, getRecordsFromSelectedRange, getSelectedCellValue, checkIsSelectedCellEditable, } from '../../utils/selected-cell-utils'; import RecordMetrics from '../../utils/record-metrics'; import setEventTransfer from '../../utils/set-event-transfer'; import getEventTransfer from '../../utils/get-event-transfer'; import { getGroupRecordByIndex } from '../../utils/group-metrics'; import { isSpace } from '../../../../utils/hotkey'; import EventBus from '../../../common/event-bus'; import { EVENT_BUS_TYPE } from '../../constants/event-bus-type'; import { isCtrlKeyHeldDown, isKeyPrintable } from '../../../../utils/keyboard-utils'; import { checkIsColumnSupportPreview, getColumnIndexByKey } from '../../utils/column'; import './index.css'; class InteractionMasks extends React.Component { static defaultProps = { enableCellSelect: true, isGroupView: false, groupOffsetLeft: 0, }; throttle = null; constructor(props) { super(props); const initPosition = { idx: -1, rowIdx: -1, groupRecordIndex: -1 }; this.state = { selectedPosition: initPosition, selectedRange: { topLeft: initPosition, bottomRight: initPosition, startCell: null, cursorCell: null, isDragging: false, }, draggedRange: null, isEditorEnabled: false, openEditorMode: '', }; this.eventBus = EventBus.getInstance(); this.pasteSource = PASTE_SOURCE.COPY; this.cutPosition = null; this.selectionMask = null; } componentDidMount() { this.unsubscribeSelectColumn = this.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_COLUMN, this.onColumnSelect); this.unsubscribeDragEnter = this.eventBus.subscribe(EVENT_BUS_TYPE.DRAG_ENTER, this.handleDragEnter); this.unsubscribeSelectCell = this.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_CELL, this.onSelectCell); this.unsubscribeSelectNone = this.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_NONE, this.selectNone); this.unsubscribeSelectStart = this.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_START, this.onSelectCellRangeStarted); this.unsubscribeSelectUpdate = this.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_UPDATE, this.onSelectCellRangeUpdated); this.unsubscribeSelectEnd = this.eventBus.subscribe(EVENT_BUS_TYPE.SELECT_END, this.onSelectCellRangeEnded); this.unsubscribeOpenEditorEvent = this.eventBus.subscribe(EVENT_BUS_TYPE.OPEN_EDITOR, this.onOpenEditorEvent); this.unsubscribeCloseEditorEvent = this.eventBus.subscribe(EVENT_BUS_TYPE.CLOSE_EDITOR, this.onCloseEditorEvent); this.unsubscribeCopy = this.eventBus.subscribe(EVENT_BUS_TYPE.COPY_CELLS, this.onCopy); this.unsubscribePaste = this.eventBus.subscribe(EVENT_BUS_TYPE.PASTE_CELLS, this.onPaste); this.unsubscribeCut = this.eventBus.subscribe(EVENT_BUS_TYPE.CUT_CELLS, this.onCut); } componentDidUpdate(prevProps, prevState) { const { selectedRange, isEditorEnabled } = this.state; const { selectedRange: prevSelectedRange, isEditorEnabled: prevIsEditorEnabled } = prevState; const isEditorClosed = isEditorEnabled !== prevIsEditorEnabled && !isEditorEnabled; const isSelectedRangeChanged = selectedRange !== prevSelectedRange && (selectedRange.topLeft !== prevSelectedRange.topLeft || selectedRange.bottomRight !== prevSelectedRange.bottomRight); if (isSelectedRangeChanged || isEditorClosed) { this.focus(); } } componentWillUnmount() { this.unsubscribeSelectColumn(); this.unsubscribeSelectCell(); this.unsubscribeSelectStart(); this.unsubscribeSelectUpdate(); this.unsubscribeSelectEnd(); this.unsubscribeOpenEditorEvent(); this.unsubscribeCloseEditorEvent(); this.unsubscribeCopy(); this.unsubscribePaste(); this.unsubscribeCut(); this.setState = (state, callback) => { return; }; } onColumnSelect = (column) => { const { columns, isGroupView, recordsCount } = this.props; if (isGroupView) return; const selectColumnIndex = getColumnIndexByKey(column.key, columns); this.setState({ selectedPosition: { ...this.state.selectedPosition, idx: selectColumnIndex, rowIdx: 0 }, selectedRange: { startCell: { idx: selectColumnIndex, rowIdx: 0 }, topLeft: { idx: selectColumnIndex, rowIdx: 0 }, bottomRight: { idx: selectColumnIndex, rowIdx: recordsCount - 1 }, isDragging: false, } }); }; onOpenEditorEvent = (mode) => { this.setState({ openEditorMode: mode }, () => { this.openEditor(null); }); }; onCloseEditorEvent = () => { if (this.state.isEditorEnabled) { this.closeEditor(); } }; onSelectCell = (cell, openEditor) => { const { selectedPosition, isEditorEnabled } = this.state; const callback = openEditor ? this.openEditor : () => null; if (isEditorEnabled) { this.closeEditor(); } this.setState((prevState) => { const next = { ...selectedPosition, ...cell }; if (this.isCellWithinBounds(next)) { return { selectedPosition: next, selectedRange: { topLeft: next, bottomRight: next, startCell: next, cursorCell: next, isDragging: false, } }; } return prevState; }, callback); }; selectNone = () => { const initPosition = { idx: -1, rowIdx: -1, groupRecordIndex: -1 }; this.setState({ selectedPosition: initPosition, selectedRange: { topLeft: initPosition, bottomRight: initPosition, startCell: null, cursorCell: null, }, }); this.props.selectNone(); }; getSelectedPosition = () => { const { topLeft, bottomRight } = this.state.selectedRange; return { top: topLeft.rowIdx, bottom: bottomRight.rowIdx, left: topLeft.idx, right: bottomRight.idx, }; }; getSelectedRange = () => { return this.state.selectedRange; }; selectCell = (groupRecordIndex, rowIdx, idx) => { const selectedPosition = { idx, groupRecordIndex, rowIdx }; this.setState({ selectedPosition, selectedRange: { topLeft: selectedPosition, bottomRight: selectedPosition, startCell: selectedPosition, cursorCell: selectedPosition, }, }); }; // onCellSelect || onKeyDown openEditor = (event = null) => { const { key } = event || {}; const { isEditorEnabled, selectedPosition, openEditorMode } = this.state; const { columns } = this.props; const selectedColumn = getSelectedColumn({ selectedPosition, columns }); // how to open editors? // 1. editor is closed // 2. record-cell is editable or open editor with preview mode if (!isEditorEnabled && (this.checkIsSelectedCellEditable() || (openEditorMode === EDITOR_TYPE.PREVIEWER && checkIsColumnSupportPreview(selectedColumn)))) { this.setState({ isEditorEnabled: true, firstEditorKeyDown: key, editorPosition: this.getEditorPosition() }); } }; closeEditor = () => { this.setState({ isEditorEnabled: false, firstEditorKeyDown: null, editorPosition: null, openEditorMode: '' }); }; onSelectCellRangeStarted = (selectedPosition) => { if (!this.isCellWithinBounds(selectedPosition)) return; const selectedRange = this.createSingleCellSelectedRange(selectedPosition, true); this.setState({ selectedRange }, () => { if (Utils.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 { topLeft, bottomRight } = getNewSelectedRange(startCell, cellPosition); 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 (Utils.isFunction(this.props.onCellRangeSelectionUpdated)) { this.props.onCellRangeSelectionUpdated(this.state.selectedRange); } if (Utils.isFunction(callback)) { callback(this.state.selectedRange); } }); }; onSelectCellRangeEnded = () => { const selectedRange = { ...this.state.selectedRange, isDragging: false }; this.setState({ selectedRange }, () => { if (Utils.isFunction(this.props.onCellRangeSelectionCompleted)) { this.props.onCellRangeSelectionCompleted(this.state.selectedRange); } }); }; createSingleCellSelectedRange(cellPosition, isDragging) { return { topLeft: cellPosition, bottomRight: cellPosition, startCell: cellPosition, cursorCell: cellPosition, isDragging }; } focus = () => { if (this.selectionMask && !this.isFocused()) { this.selectionMask.focus(); } }; isFocused = () => { return document.activeElement === this.selectionMask; }; checkIsCellSelected = () => { const { selectedPosition } = this.state; return selectedPosition.idx !== -1 && selectedPosition.rowIdx !== -1; }; isCellWithinBounds = ({ idx, rowIdx }) => { const { columns, recordsCount } = this.props; const maxRowIdx = recordsCount; return rowIdx >= 0 && rowIdx < maxRowIdx && idx >= 0 && idx < columns.length; }; checkIsSelectedCellEditable = () => { const { enableCellSelect, columns, isGroupView, recordGetterByIndex, checkCanModifyRecord } = this.props; const { selectedPosition } = this.state; return checkIsSelectedCellEditable({ enableCellSelect, columns, isGroupView, selectedPosition, recordGetterByIndex, checkCanModifyRecord }); }; isGridSelected = () => { return this.isCellWithinBounds(this.state.selectedPosition); }; getSelectedDimensions = (selectedPosition) => { const { columns, rowHeight, isGroupView, groupOffsetLeft, getRowTop: getRecordTopFromRecordsBody } = this.props; const scrollLeft = this.props.getScrollLeft(); return { ...getSelectedDimensions({ selectedPosition, columns, scrollLeft, rowHeight, isGroupView, groupOffsetLeft, getRecordTopFromRecordsBody }) }; }; getSelectedRangeDimensions = (selectedRange) => { const { columns, rowHeight, isGroupView, groups, groupMetrics, groupOffsetLeft, getRowTop: getRecordTopFromRecordsBody } = this.props; return { ...getSelectedRangeDimensions({ selectedRange, columns, rowHeight, isGroupView, groups, groupMetrics, groupOffsetLeft, getRecordTopFromRecordsBody, }) }; }; setScrollLeft = (scrollLeft, scrollTop) => { const { selectionMask, state: { selectedPosition } } = this; this.setMaskScrollLeft(selectionMask, selectedPosition, scrollLeft, scrollTop); }; geHeaderHeight = () => { // const { table } = this.props; const settings = {}; // table.header_settings || {}; const heightMode = isEmptyObject(settings) ? HEADER_HEIGHT_TYPE.DEFAULT : settings.header_height; const containerHeight = heightMode === HEADER_HEIGHT_TYPE.DOUBLE ? GRID_HEADER_DOUBLE_HEIGHT : GRID_HEADER_DEFAULT_HEIGHT; // 1: header border-bottom return containerHeight + 1; }; setMaskScrollLeft = (mask, position, scrollLeft, scrollTop) => { const headerHeight = this.geHeaderHeight(); if (mask) { const { idx, rowIdx, groupRecordIndex } = position; if (idx >= 0 && rowIdx >= 0) { const { columns, getRowTop, isGroupView, groupOffsetLeft } = this.props; const column = columns[idx]; const frozen = !!column.frozen; if (frozen) { // use fixed let top = -scrollTop + getRowTop(isGroupView ? groupRecordIndex : rowIdx) + headerHeight; let left = column.left; if (isGroupView) { top += 1; left += groupOffsetLeft; } mask.style.position = 'fixed'; mask.style.top = top + 'px'; mask.style.left = left + 'px'; mask.style.transform = 'none'; } } } }; cancelSetScrollLeft = () => { if (this.selectionMask) { this.cancelSetMaskScrollLeft(this.selectionMask, this.state.selectedPosition); } }; cancelSetMaskScrollLeft = (mask, position) => { const { left, top } = this.getSelectedDimensions(position); mask.style.position = 'absolute'; mask.style.top = 0; mask.style.left = 0; mask.style.transform = `translate(${left}px, ${top}px)`; }; 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 }; } }; onCommit = (updated, closeEditor = true) => { if (this.props.modifyRecord) { this.props.modifyRecord(updated); } if (closeEditor) { this.closeEditor(); } }; onCommitCancel = () => { this.closeEditor(); }; handleSpaceKeyDown = (e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); const { selectedPosition } = this.state; const { isGroupView, recordGetterByIndex } = this.props; const record = getSelectedRow({ selectedPosition, isGroupView, recordGetterByIndex }); if (this.props.handleSpaceKeyDown) { this.props.handleSpaceKeyDown(record); } }; onKeyDown = (e) => { const keyCode = e.keyCode; if (isCtrlKeyHeldDown(e)) { this.onPressKeyWithCtrl(e); } else if (keyCode === KeyCodes.Escape) { this.onPressEscape(e); } else if (keyCode === KeyCodes.Tab) { this.onPressTab(e); } else if (this.isKeyboardNavigationEvent(e)) { this.changeCellFromEvent(e); } else if (isSpace(e)) { this.handleSpaceKeyDown(e); } else if (isKeyPrintable(keyCode) || keyCode === KeyCodes.Enter) { this.openEditor(e); } else if (keyCode === KeyCodes.Backspace || keyCode === KeyCodes.Delete) { const name = e.target.className; if (name === 'rdg-selected') { e.preventDefault(); this.handleSelectCellsDelete(); } } }; handleSelectCellsDelete = () => { if (this.props.handleSelectCellsDelete) { const { isGroupView, recordGetterByIndex, columns } = this.props; const { selectedRange } = this.state; const { topLeft, bottomRight } = selectedRange; const recordsFromSelectedRange = getRecordsFromSelectedRange({ selectedRange, isGroupView, recordGetterByIndex }); if (recordsFromSelectedRange.length === 0) { return; } const { idx: startColumnIdx } = topLeft; const { idx: endColumnIdx } = bottomRight; const columnsFromSelectedRange = columns.slice(startColumnIdx, endColumnIdx); if (columnsFromSelectedRange.length === 0) { return; } this.props.handleSelectCellsDelete(recordsFromSelectedRange, columnsFromSelectedRange); } }; onCopySelected = () => { this.onCopyCells(); }; onCopy = (e) => { e.preventDefault(); const { recordMetrics } = this.props; // select the records to copy const selectedRecordIds = RecordMetrics.getSelectedIds(recordMetrics); if (selectedRecordIds.length > 0) { this.copyRows(e, selectedRecordIds); return; } // window.getSelection() doesn't work on the content of in FireFox, Edge and IE. // The selectionStart and selectionEnd properties could be used to work around this. let selectTxt = window.getSelection().toString(); if (!selectTxt && e.target.value) { const { selectionStart, selectionEnd } = e.target; selectTxt = e.target.value.substring(selectionStart, selectionEnd); } if (selectTxt) { this.copyText(e, selectTxt); return; } // when activeElement is not cellMask, can't copy cell if (!this.isCellMaskActive()) { return; } this.onCopyCells(e); }; onPaste = (e) => { // when activeElement is not cellMask or has no permission, can't paste cell if (!this.isCellMaskActive() || !this.props.canModifyRecords) return; const { columns, isGroupView } = this.props; const { selectedPosition, selectedRange } = this.state; const { idx, rowIdx } = selectedPosition; if (idx === -1 || rowIdx === -1) return; // prevent paste when no cell selected const cliperData = getEventTransfer(e); if (!cliperData) return; const cliperDataType = cliperData.type; const copied = cliperData[TRANSFER_TYPES.METADATA_FRAGMENT]; let copiedRecordsCount = 0; let copiedColumnsCount = 0; if (cliperDataType === TRANSFER_TYPES.METADATA_FRAGMENT) { const { selectedRecordIds, copiedRange } = copied; if (Array.isArray(selectedRecordIds) && selectedRecordIds.length > 0) { // copy from selected records copiedRecordsCount = selectedRecordIds.length; copiedColumnsCount = columns.length; } else { // copy from selected range const { topLeft: copiedTopLeft, bottomRight: copiedBottomRight } = copiedRange; const { idx: startCopiedColumnIndex, rowIdx: startCopiedRecordIndex } = copiedTopLeft; const { idx: endCopiedColumnIndex, rowIdx: endCopiedRecordIndex } = copiedBottomRight; copiedRecordsCount = endCopiedRecordIndex - startCopiedRecordIndex + 1; copiedColumnsCount = endCopiedColumnIndex - startCopiedColumnIndex + 1; } } else { const { copiedRecords, copiedColumns } = copied; copiedRecordsCount = copiedRecords.length; copiedColumnsCount = copiedColumns.length; } const multiplePaste = this.isMultiplePaste(copiedRecordsCount, copiedColumnsCount); if (this.props.paste) { this.props.paste({ copied, multiplePaste, type: cliperDataType, pasteRange: selectedRange, columns, isGroupView, pasteSource: this.pasteSource, cutPosition: this.cutPosition, }); if (!multiplePaste) { this.setPasteRange(copiedRecordsCount, copiedColumnsCount); } } }; onCut = (event) => { // when activeElement is not cellMask or has no permission, can't paste cell if (!this.isCellMaskActive() || !this.props.canModifyRecords) return; const { selectedPosition, selectedRange } = this.state; const { idx, rowIdx } = selectedPosition; if (idx === -1 || rowIdx === -1) return; // prevent paste when no cell selected event.preventDefault(); const { tableId: copiedTableId, columns, isGroupView, recordGetterByIndex, getCopiedRecordsAndColumnsFromRange, getClientCellValueDisplayString } = this.props; if (rowIdx < 0 || idx < 0) { return; // can not copy when no cell select } const { topLeft, bottomRight } = selectedRange; const copiedCellsCount = (bottomRight.rowIdx - topLeft.rowIdx + 1) * (bottomRight.idx - topLeft.idx + 1); const type = copiedCellsCount <= 0 ? 'text' : TRANSFER_TYPES.METADATA_FRAGMENT; const tip = copiedCellsCount > 1 ? gettext('xxx cells cut').replace('xxx', copiedCellsCount) : gettext('1 cell cut'); toaster.success(tip); const copied = { copiedRange: selectedRange }; const { copiedRecords, copiedColumns } = getCopiedRecordsAndColumnsFromRange({ type, copied, columns, isGroupView }); setEventTransfer({ type, event, copiedRange: { ...selectedRange }, copiedRecords, copiedColumns, copiedTableId, tableData: { columns, }, isGroupView, recordGetterByIndex, getClientCellValueDisplayString, }); if (copiedCellsCount > 0) { this.pasteSource = PASTE_SOURCE.CUT; this.cutPosition = { ...selectedPosition }; } }; copyText = (event, copiedText) => { const type = 'text'; setEventTransfer({ type, event, copiedText, }); }; copyRows = (event, selectedRecordIds) => { const { tableId: copiedTableId, columns, recordGetterById, isGroupView, getCopiedRecordsAndColumnsFromRange, getClientCellValueDisplayString } = this.props; const copiedRowsCount = selectedRecordIds.length; toaster.success( copiedRowsCount > 1 ? gettext('xxx rows are copied.').replace('xxx', copiedRowsCount) : gettext('1 row is copied.') ); const type = TRANSFER_TYPES.METADATA_FRAGMENT; const copied = { selectedRecordIds }; const { copiedRecords, copiedColumns } = getCopiedRecordsAndColumnsFromRange({ type, copied, columns, isGroupView }); setEventTransfer({ type, event, selectedRecordIds, copiedRecords, copiedColumns, copiedTableId, tableData: { columns, }, recordGetterById, getClientCellValueDisplayString, }); }; onCopyCells = (event) => { const { tableId: copiedTableId, columns, isGroupView, recordGetterByIndex, getCopiedRecordsAndColumnsFromRange, getClientCellValueDisplayString } = this.props; const { selectedPosition, selectedRange } = this.state; const { rowIdx, idx } = selectedPosition; if (rowIdx < 0 || idx < 0) { return; // can not copy when no cell select } const { topLeft, bottomRight } = selectedRange; const type = TRANSFER_TYPES.METADATA_FRAGMENT; const copiedCellsCount = (bottomRight.rowIdx - topLeft.rowIdx + 1) * (bottomRight.idx - topLeft.idx + 1); toaster.success( copiedCellsCount > 1 ? gettext('xxx cells copied').replace('xxx', copiedCellsCount) : gettext('1 cell copied') ); const copied = { copiedRange: selectedRange }; const { copiedRecords, copiedColumns } = getCopiedRecordsAndColumnsFromRange({ type, copied, columns, isGroupView }); setEventTransfer({ type, event, copiedRange: { ...selectedRange }, copiedRecords, copiedColumns, copiedTableId, tableData: { columns, }, isGroupView, recordGetterByIndex, getClientCellValueDisplayString, }); this.cutPosition = null; this.pasteSource = PASTE_SOURCE.COPY; }; isMultiplePaste = (copiedRecordsCount, copiedColumnsCount) => { const { selectedRange } = this.state; const { topLeft, bottomRight } = selectedRange; const { idx: startColumnIndex, rowIdx: startRecordIndex } = topLeft; const { idx: endColumnIndex, rowIdx: endRecordIndex } = bottomRight; return Number.isInteger((endColumnIndex - startColumnIndex + 1) / copiedColumnsCount) && Number.isInteger((endRecordIndex - startRecordIndex + 1) / copiedRecordsCount); }; setPasteRange = (copiedRecordsCount, copiedColumnsCount) => { const { recordsCount, columns } = this.props; const { selectedPosition, selectedRange } = this.state; const { topLeft } = selectedRange; const { idx, rowIdx } = topLeft; const columnsLen = columns.length; const groupRecordIndex = selectedPosition.groupRecordIndex; let nextColumnIndex = idx + copiedColumnsCount - 1; let nextRecordIndex = rowIdx + copiedRecordsCount - 1; if (nextColumnIndex >= columnsLen) { nextColumnIndex = columnsLen - 1; } if (nextRecordIndex >= recordsCount) { nextRecordIndex = recordsCount - 1; } const nextSelectedRange = { topLeft, startCell: selectedPosition, bottomRight: { idx: nextColumnIndex, rowIdx: nextRecordIndex, groupRecordIndex, }, cursorCell: { idx: selectedPosition.idx, rowIdx: selectedPosition.rowIdx, groupRecordIndex, } }; this.setState({ selectedRange: { ...selectedRange, ...nextSelectedRange } }, () => { this.focus(); }); return nextSelectedRange; }; onPressKeyWithCtrl = () => { }; onPressEscape = () => { }; onPressTab = (e) => { this.changeCellFromEvent(e); }; getLeftInterval = () => { const { isGroupView, columns, groupOffsetLeft, frozenColumnsWidth } = this.props; const firstColumnFrozen = columns[0] ? columns[0].frozen : false; let leftInterval = 0; if (firstColumnFrozen) { leftInterval = groupOffsetLeft + frozenColumnsWidth; if (isGroupView) { leftInterval += groupOffsetLeft; } } else { leftInterval = 0; } return leftInterval; }; handleVerticalArrowAction = (current, actionType) => { const { isGroupView, groupMetrics, rowHeight } = this.props; const step = actionType === 'ArrowDown' ? 1 : -1; if (isGroupView) { const groupRows = groupMetrics.groupRows || []; const groupRowsLen = groupRows.length; const { groupRecordIndex: currentGroupRowIndex } = current; let nextGroupRowIndex = currentGroupRowIndex + step; let nextGroupRow; while (nextGroupRowIndex > 0 && nextGroupRowIndex < groupRowsLen) { nextGroupRow = getGroupRecordByIndex(nextGroupRowIndex, groupMetrics); if (nextGroupRow.type === GROUP_ROW_TYPE.ROW) { break; } nextGroupRowIndex += step; } if (!nextGroupRow || nextGroupRow.type !== GROUP_ROW_TYPE.ROW) { return; } const currentScrollTop = this.props.getGroupCanvasScrollTop() || 0; const { rowIdx: nextRowIdx, top: nextRowTop } = nextGroupRow; let newScrollTop; // 32: footerHeight; 16: preview of next row. const HEADER_HEIGHT = 150; if (nextRowTop <= currentScrollTop + 16) { newScrollTop = nextRowTop - 16; } else if (nextRowTop + HEADER_HEIGHT - currentScrollTop >= window.innerHeight - 32 - 16) { newScrollTop = nextRowTop + HEADER_HEIGHT - window.innerHeight + 32 + rowHeight + 16; } if (newScrollTop !== undefined) { this.props.setGroupCanvasScrollTop(newScrollTop); } return { ...current, rowIdx: nextRowIdx, groupRecordIndex: nextGroupRowIndex }; } else { return { ...current, rowIdx: current.rowIdx + step }; } }; handleLeftArrowAction = (current) => { let cellContainer = this.selectionMask; if (!cellContainer) return; const { columns } = this.props; const rect = cellContainer.getBoundingClientRect(); const leftInterval = this.getLeftInterval(); const nextColumnWidth = columns[current.idx - 1] ? columns[current.idx - 1].width : 0; const { left: tableContentLeft, right } = this.props.getTableContentRect(); const viewLeft = tableContentLeft + 130; // selectMask is outside the viewport, scroll to next column if (rect.x < 0 || rect.x > right) { this.props.scrollToColumn(current.idx - 1); } else if (nextColumnWidth > rect.x - leftInterval - viewLeft) { // selectMask is part of the viewport, newScrollLeft = columnWidth - visibleWidth const newScrollLeft = nextColumnWidth - (rect.x - leftInterval - viewLeft); this.props.setRecordsScrollLeft(this.props.getScrollLeft() - newScrollLeft); } return ({ ...current, idx: current.idx === 0 ? 0 : current.idx - 1 }); }; handleRightArrowAction = (current) => { let cellContainer = this.selectionMask; if (!cellContainer) return; const { columns } = this.props; const rect = cellContainer.getBoundingClientRect(); const columnIdx = current.idx; const column = columns[columnIdx]; if (columnIdx === 1 && column.frozen === true) { this.props.scrollToColumn(1); } else { const { right: tableContentRight } = this.props.getTableContentRect(); const nextColumnWidth = columns[columnIdx + 1] ? columns[columnIdx + 1].width : 0; // selectMask is outside the viewport, scroll to next column if (rect.x < 0 || rect.x > tableContentRight) { this.props.scrollToColumn(columnIdx + 1); } else if (rect.x + rect.width + nextColumnWidth > tableContentRight) { // selectMask is part of the viewport, newScrollLeft = columnWidth - visibleWidth const newScrollLeft = nextColumnWidth - (tableContentRight - rect.x - rect.width); this.props.setRecordsScrollLeft(this.props.getScrollLeft() + newScrollLeft); } } return ({ ...current, idx: current.idx + 1 }); }; isKeyboardNavigationEvent(e) { return this.getKeyNavActionFromEvent(e) != null; } getKeyNavActionFromEvent = (e) => { const { getVisibleIndex, onHitBottomBoundary, onHitTopBoundary } = this.props; const { rowVisibleStartIdx, rowVisibleEndIdx } = getVisibleIndex(); const isCellAtBottomBoundary = cell => cell.rowIdx >= rowVisibleEndIdx - 1; const isCellAtTopBoundary = cell => cell.rowIdx !== 0 && cell.rowIdx <= rowVisibleStartIdx; const keyNavActions = { ArrowDown: { getNext: (current) => { return this.handleVerticalArrowAction(current, 'ArrowDown'); }, isCellAtBoundary: isCellAtBottomBoundary, onHitBoundary: onHitBottomBoundary }, ArrowUp: { getNext: (current) => { return this.handleVerticalArrowAction(current, 'ArrowUp'); }, isCellAtBoundary: isCellAtTopBoundary, onHitBoundary: onHitTopBoundary }, ArrowRight: { getNext: (current) => { return this.handleRightArrowAction(current); }, isCellAtBoundary: () => { return false; } }, ArrowLeft: { getNext: (current) => { return this.handleLeftArrowAction(current); }, isCellAtBoundary: () => { return false; } } }; if (e.keyCode === KeyCodes.Tab) { return e.shiftKey === true ? keyNavActions.ArrowLeft : keyNavActions.ArrowRight; } return keyNavActions[e.key]; }; changeCellFromEvent = (e) => { e.preventDefault(); if (e.keyCode === KeyCodes.ChineseInputMethod && this.state.isEditorEnabled) { return; } if (this.throttle) return; const currentPosition = this.state.selectedPosition; const keyNavAction = this.getKeyNavActionFromEvent(e); const next = keyNavAction.getNext(currentPosition); if (!next) return; this.checkIsAtGridBoundary(keyNavAction, next); this.props.onCellClick(next); this.onSelectCell({ ...next }); this.throttle = true; setTimeout(() => { this.throttle = false; }, 30); }; checkIsAtGridBoundary(keyNavAction, next) { const { isCellAtBoundary, onHitBoundary } = keyNavAction; if (isCellAtBoundary(next)) { onHitBoundary(next); } } onFocus = () => { }; onScroll = (e) => { e.stopPropagation(); }; setSelectionMaskRef = (ref) => { this.selectionMask = ref; }; setSelectionRangeMaskRef = (ref) => { this.selectedRangeMask = ref; }; setContainerRef = (ref) => { this.container = ref; }; isCellMaskActive = () => { const activeElement = document.activeElement; return (activeElement && (activeElement.getAttribute('data-test') === 'cell-mask' || activeElement.getAttribute('data-test') === 'active-editor') ); }; handleDragCopy = (draggedRange) => { const { columns, groupMetrics } = this.props; // compute the new records const newRecords = this.props.getUpdateDraggedRecords(draggedRange, columns, groupMetrics); if (this.props.modifyRecords) { this.props.modifyRecords({ ...newRecords, isCopyPaste: true }); } }; handleDragStart = (e) => { const { selectedRange: { topLeft, bottomRight, startCell, cursorCell } } = this.state; // To prevent dragging down/up when reordering rows. (TODO: is this required) const isViewportDragging = e && e.target && e.target.className; if (topLeft.idx > -1 && isViewportDragging) { try { e.dataTransfer.setData('text/plain', ''); } catch (ex) { // IE only supports 'text' and 'URL' for the 'type' argument e.dataTransfer.setData('text', ''); } this.setState({ draggedRange: { topLeft, bottomRight, startCell, cursorCell } }); } }; handleDragEnter = ({ overRecordIdx, overGroupRecordIndex }) => { if (this.state.draggedRange != null) { this.setState(({ draggedRange }) => ({ draggedRange: { ...draggedRange, overRecordIdx, overGroupRecordIndex } })); } }; handleDragEnd = () => { const { draggedRange, selectedRange } = this.state; let newSelectedRange = deepCopy(selectedRange); if (draggedRange !== null) { const { overRecordIdx, overGroupRecordIndex, bottomRight } = draggedRange; if (overRecordIdx !== null && bottomRight.rowIdx < overRecordIdx) { this.handleDragCopy(draggedRange); newSelectedRange.bottomRight.rowIdx = overRecordIdx; newSelectedRange.cursorCell.rowIdx = overRecordIdx; newSelectedRange.bottomRight.groupRecordIndex = overGroupRecordIndex; newSelectedRange.cursorCell.groupRecordIndex = overGroupRecordIndex; } this.setState({ draggedRange: null, selectedRange: newSelectedRange }); } }; renderSingleCellSelectView = () => { const { isEditorEnabled, selectedPosition } = this.state; const isDragEnabled = this.checkIsSelectedCellEditable(); const showDragHandle = (isDragEnabled && this.props.canModifyRecords); if (isEditorEnabled) { return null; } if (!this.isGridSelected()) return null; const props = { innerRef: this.setSelectionMaskRef, selectedPosition, getSelectedDimensions: this.getSelectedDimensions, }; return ( {showDragHandle ? : null} ); }; renderCellRangeSelectView = () => { const { selectedRange } = this.state; const { columns, rowHeight } = this.props; const isDragEnabled = this.checkIsSelectedCellEditable(); const showDragHandle = (isDragEnabled && this.props.canModifyRecords); return [ {showDragHandle ? : null} , ]; }; render() { const { selectedRange, isEditorEnabled, draggedRange, selectedPosition, firstEditorKeyDown, openEditorMode, editorPosition } = this.state; const { columns, isGroupView, recordGetterByIndex, scrollTop, getScrollLeft, editorPortalTarget, contextMenu } = this.props; const isSelectedSingleCell = selectedRangeIsSingleCell(selectedRange); return (
{draggedRange && ( )} {isSelectedSingleCell ? this.renderSingleCellSelectView() : this.renderCellRangeSelectView()} {isEditorEnabled && ( )} {isValidElement(contextMenu) && cloneElement(contextMenu, { selectedPosition: isSelectedSingleCell ? selectedPosition : null, selectedRange: !isSelectedSingleCell ? selectedRange : null, onClearSelected: this.handleSelectCellsDelete, onCopySelected: this.onCopySelected, getTableContentRect: this.props.getTableContentRect, getTableCanvasContainerRect: this.props.getTableCanvasContainerRect, })}
); } } InteractionMasks.propTypes = { contextmenu: PropTypes.element, tableId: PropTypes.string, columns: PropTypes.array, canAddRow: PropTypes.bool, isGroupView: PropTypes.bool, recordsCount: PropTypes.number, recordMetrics: PropTypes.object, groups: PropTypes.array, groupMetrics: PropTypes.object, rowHeight: PropTypes.number, groupOffsetLeft: PropTypes.number, frozenColumnsWidth: PropTypes.number, enableCellSelect: PropTypes.bool, canModifyRecords: PropTypes.bool, getRowTop: PropTypes.func, scrollTop: PropTypes.number, getScrollLeft: PropTypes.func, getTableContentRect: PropTypes.func, getMobileFloatIconStyle: PropTypes.func, onToggleMobileMoreOperations: PropTypes.func, onToggleInsertRecordDialog: PropTypes.func, onCellRangeSelectionStarted: PropTypes.func, onCellRangeSelectionUpdated: PropTypes.func, onCellRangeSelectionCompleted: PropTypes.func, selectNone: PropTypes.func, checkCanModifyRecord: PropTypes.func, editorPortalTarget: PropTypes.instanceOf(Element).isRequired, modifyRecord: PropTypes.func, modifyColumnData: PropTypes.func, recordGetterByIndex: PropTypes.func, recordGetterById: PropTypes.func, modifyRecords: PropTypes.func, deleteRecordsLinks: PropTypes.func, paste: PropTypes.func, editMobileCell: PropTypes.func, getVisibleIndex: PropTypes.func, onHitBottomBoundary: PropTypes.func, onHitTopBoundary: PropTypes.func, onCellClick: PropTypes.func, scrollToColumn: PropTypes.func, setRecordsScrollLeft: PropTypes.func, getGroupCanvasScrollTop: PropTypes.func, setGroupCanvasScrollTop: PropTypes.func, appPage: PropTypes.object, onFillingDragRows: PropTypes.func, onCellsDragged: PropTypes.func, getUpdateDraggedRecords: PropTypes.func, getCopiedRecordsAndColumnsFromRange: PropTypes.func, onCommit: PropTypes.func, getTableCanvasContainerRect: PropTypes.func, handleSpaceKeyDown: PropTypes.func, }; export default InteractionMasks;