import React, { useEffect, useMemo } from 'react' import { useGridContext, useGridSelector } from '../../context/grid-context' import { createStructuredSelector } from 'reselect' import { createAutoScroller } from './auto-scroller' import { positionFactory } from './position' import { healSpannedRange, isRangeEqual } from '../../utils/range' export type GridRangeSelectionProps = { scroller: HTMLDivElement | null } const GridRangeSelectionImpl = ({ scroller }: GridRangeSelectionProps) => { const grid = useGridContext() const combinedSelector = useMemo( () => createStructuredSelector({ flatIds: grid.selectors.selectRowIds, rowHeight: grid.selectors.selectRowHeight, columnsWidths: grid.selectors.selectColumnWidths, columnIds: grid.selectors.selectColumnIds, headerHeight: grid.selectors.selectHeaderHeight, }), [grid] ) const positioningState = useGridSelector(combinedSelector) const { getColumnFromX, getRowFromY } = useMemo( () => positionFactory(positioningState), [positioningState] ) const stickyPosition = useGridSelector( grid.selectors.selectStickyColumnDetails ) const totalWidth = useMemo( () => positioningState.columnsWidths.reduce( (acc, width) => acc + width, 0 ), [positioningState.columnsWidths] ) const supportsRangeSelection = useGridSelector( grid.selectors.selectIsSpreadsheet ) useEffect(() => { if (!scroller || !supportsRangeSelection) { return } const updateRangeSelection = ({ x1, y1, x2, y2, }: { x1: number x2: number y1: number y2: number }) => { const startingColumn = getColumnFromX(x1).columnId const startingRow = getRowFromY(y1).rowId const endingColumn = getColumnFromX(x2).columnId const endingRow = getRowFromY(y2).rowId const state = grid.getState() const existingRange = grid.selectors.selectRangeSelectionPreview(state) if ( startingColumn != null && startingRow != null && endingColumn != null && endingRow != null ) { const newRange = healSpannedRange( { from: { columnId: startingColumn, rowId: startingRow, }, to: { columnId: endingColumn, rowId: endingRow, }, }, grid ) if (isRangeEqual(existingRange, newRange)) { return } grid.dispatch({ type: 'updateRangeSelectionPreview', payload: newRange, }) } else { if (!existingRange) { return } grid.dispatch({ type: 'updateRangeSelectionPreview', payload: null, }) } } const scrollContainer = scroller function handlePointerDown(e: PointerEvent) { if (!(e.target instanceof Element) || !supportsRangeSelection) { return } if (e.target instanceof HTMLInputElement) { grid.dispatch({ type: 'updateRangeSelectionPreview', payload: null, }) // Ignore clicks on input elements return } const scrollLeft = scrollContainer.scrollLeft const scrollTop = scrollContainer.scrollTop const scrollBounds = scrollContainer.getBoundingClientRect() const x = e.clientX const y = e.clientY let gridX = x - scrollBounds.left const gridY = y - scrollBounds.top + scrollTop const dragStartedOnLeftStickyColumn = stickyPosition.left.width && gridX < stickyPosition.left.width let autoScrollComplete = false let dragStartedOnRightStickyColumn = false if (totalWidth > scrollBounds.width && stickyPosition.right.width) { if (gridX > scrollBounds.width - stickyPosition.right.width) { dragStartedOnRightStickyColumn = true } } if (dragStartedOnRightStickyColumn) { gridX += totalWidth - scrollBounds.width } else if (!dragStartedOnLeftStickyColumn) { gridX += scrollLeft } const column = getColumnFromX(gridX) const row = getRowFromY(gridY) if (column.outOfBounds || row.outOfBounds) { grid.dispatch({ type: 'updateRangeSelectionPreview', payload: null, }) return } e.stopPropagation() const state = grid.getState() const footerEnabled = grid.selectors.selectAggregationEnabled(state) const rowHeight = grid.selectors.selectRowHeight(state) // TODO how does this work when synchronizing with another grid? const autoScroller = createAutoScroller({ scrollContainer, scrollThreshold: { top: 0, left: stickyPosition.left.width || 0, right: stickyPosition.right.width || 0, bottom: footerEnabled ? rowHeight : 0, // TODO: footer? }, scrollSpeed: 20, initialX: x, }) updateRangeSelection({ x1: gridX, x2: gridX, y1: gridY, y2: gridY, }) function handlePointerMove(moveEvent: PointerEvent) { const newScrollLeft = scrollContainer.scrollLeft const newScrollTop = scrollContainer.scrollTop const newScrollBounds = scrollContainer.getBoundingClientRect() const newX = moveEvent.clientX const newY = moveEvent.clientY let newGridX = newX - newScrollBounds.left const newGridY = newY - newScrollBounds.top + newScrollTop if (dragStartedOnLeftStickyColumn) { if ( !autoScrollComplete && newGridX > stickyPosition.left.width ) { /* Fix scroll one time when dragging from left sticky toward right side of grid */ scrollContainer.scrollLeft = 0 autoScrollComplete = true } if (autoScrollComplete) { newGridX += newScrollLeft } } else if (dragStartedOnRightStickyColumn) { if ( !autoScrollComplete && newGridX < newScrollBounds.width - stickyPosition.right.width ) { /* Fix scroll one time when dragging from right sticky toward left side of grid */ scrollContainer.scrollLeft = totalWidth autoScrollComplete = true } if (autoScrollComplete) { newGridX += newScrollLeft } else { newGridX += Math.max( totalWidth - newScrollBounds.width, 0 ) } } else { newGridX += newScrollLeft } updateRangeSelection({ x1: gridX, x2: newGridX, y1: gridY, y2: newGridY, }) autoScroller.updateScroll(moveEvent.clientX, moveEvent.clientY) } function cleanup() { window.removeEventListener('pointermove', handlePointerMove) window.removeEventListener('pointerup', handlePointerUp) window.removeEventListener('pointercancel', handlePointerCancel) autoScroller.stop() } function handlePointerCancel() { cleanup() } function handlePointerUp() { const rangeSelection = grid.selectors.selectRangeSelectionPreview(grid.getState()) if (rangeSelection) { grid.api.rangeSelection.select(rangeSelection) } grid.dispatch({ type: 'updateRangeSelectionPreview', payload: null, }) cleanup() } window.addEventListener('pointermove', handlePointerMove) window.addEventListener('pointerup', handlePointerUp) window.addEventListener('pointercancel', handlePointerCancel) } scrollContainer.addEventListener('pointerdown', handlePointerDown) const handlePreventSelection = (e: Event) => { e.preventDefault() } scrollContainer.addEventListener('selectstart', handlePreventSelection) return () => { scrollContainer.removeEventListener( 'pointerdown', handlePointerDown ) scrollContainer.removeEventListener( 'selectstart', handlePreventSelection ) } }, [ grid, supportsRangeSelection, scroller, getColumnFromX, getRowFromY, positioningState.columnIds, stickyPosition, totalWidth, ]) return null } export const GridRangeSelection = React.memo(GridRangeSelectionImpl) GridRangeSelection.displayName = 'GridRangeSelection'