diff --git a/examples/scripts/example31-isScrolling.js b/examples/scripts/example31-isScrolling.js index 6547f7ac64..c26491f7d5 100644 --- a/examples/scripts/example31-isScrolling.js +++ b/examples/scripts/example31-isScrolling.js @@ -1,6 +1,6 @@ +import React, { useRef, useState } from 'react'; import ReactDataGrid from 'react-data-grid'; import exampleWrapper from '../components/exampleWrapper'; -import React from 'react'; import { AreaChart, Area } from 'Recharts'; const getRandom = (min, max) => { @@ -10,10 +10,17 @@ const getRandom = (min, max) => { }; const ExpensiveFormatter = ({ isScrolling }) => { - if (isScrolling) { + const isReady = useRef(!isScrolling); + const [items] = useState(() => { + return [...Array(1000).keys()].map(i => ({ name: `Page ${i}`, uv: getRandom(0, 4000), pv: getRandom(0, 4000), amt: getRandom(0, 4000) })).slice(0, 50); + }); + + if (isScrolling && !isReady.current) { return
is scrolling
; } - const items = [...Array(1000).keys()].map(i => ({ name: `Page ${i}`, uv: getRandom(0, 4000), pv: getRandom(0, 4000), amt: getRandom(0, 4000) })).slice(0, 50); + + isReady.current = true; + return ( = Pick, +type SharedGridProps = Pick, | 'rowKey' | 'rowGetter' | 'rowsCount' +| 'columnMetrics' | 'selectedRows' -| 'onSelectedRowsChange' +| 'onRowSelectionChange' | 'rowRenderer' | 'cellMetaData' | 'rowHeight' @@ -30,204 +31,294 @@ type SharedViewportProps = Pick, | 'RowsContainer' | 'editorPortalTarget' | 'interactionMasksMetaData' +| 'overscanRowCount' +| 'overscanColumnCount' +| 'enableIsScrolling' +| 'onCanvasKeydown' +| 'onCanvasKeyup' >; -type SharedViewportState = ScrollPosition & HorizontalRangeToRender & VerticalRangeToRender; - -export interface CanvasProps extends SharedViewportProps, SharedViewportState { - columns: CalculatedColumn[]; +export interface CanvasProps extends SharedGridProps { height: number; - width: number; - lastFrozenColumnIndex: number; - isScrolling?: boolean; - onScroll(position: ScrollPosition): void; + onScroll(position: ScrollState): void; } -type RendererProps = Pick, 'columns' | 'cellMetaData' | 'colOverscanEndIdx' | 'colOverscanStartIdx' | 'lastFrozenColumnIndex' | 'isScrolling'> & { +interface RendererProps extends Pick, 'cellMetaData' | 'onRowSelectionChange'> { ref(row: (RowRenderer & React.Component>) | null): void; key: number; idx: number; + columns: CalculatedColumn[]; + lastFrozenColumnIndex: number; row: R; subRowDetails?: SubRowDetails; height: number; isRowSelected: boolean; - onRowSelectionChange(rowIdx: number, row: R, checked: boolean, isShiftClick: boolean): void; scrollLeft: number; -}; + isScrolling: boolean; + colOverscanStartIdx: number; + colOverscanEndIdx: number; +} + +export default function Canvas({ + cellMetaData, + cellNavigationMode, + columnMetrics, + contextMenu, + editorPortalTarget, + enableCellAutoFocus, + enableCellSelect, + enableIsScrolling, + eventBus, + getSubRowDetails, + height, + interactionMasksMetaData, + onCanvasKeydown, + onCanvasKeyup, + onRowSelectionChange, + onScroll, + overscanColumnCount, + overscanRowCount, + rowGetter, + rowGroupRenderer, + rowHeight, + rowKey, + rowRenderer, + RowsContainer = Fragment, + rowsCount, + scrollToRowIndex, + selectedRows +}: CanvasProps) { + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + const [scrollDirection, setScrollDirection] = useState(SCROLL_DIRECTION.NONE); + const [isScrolling, setIsScrolling] = useState(false); + const canvas = useRef(null); + const interactionMasks = useRef>(null); + const prevScrollToRowIndex = useRef(); + const resetScrollStateTimeoutId = useRef(null); + const [rows] = useState(() => new Map & React.Component>>()); + const clientHeight = getClientHeight(); + + const { rowOverscanStartIdx, rowOverscanEndIdx } = useMemo(() => { + return getVerticalRangeToRender({ + height: clientHeight, + rowHeight, + scrollTop, + rowsCount, + scrollDirection, + overscanRowCount + }); + }, [clientHeight, overscanRowCount, rowHeight, rowsCount, scrollDirection, scrollTop]); + + const { colOverscanStartIdx, colOverscanEndIdx, colVisibleStartIdx, colVisibleEndIdx } = useMemo(() => { + return getHorizontalRangeToRender({ + columnMetrics, + scrollLeft, + scrollDirection, + overscanColumnCount + }); + }, [columnMetrics, overscanColumnCount, scrollDirection, scrollLeft]); + + useEffect(() => { + return eventBus.subscribe(EventTypes.SCROLL_TO_COLUMN, idx => scrollToColumn(idx, columnMetrics.columns)); + }, [columnMetrics.columns, eventBus]); + + useEffect(() => { + if (prevScrollToRowIndex.current === scrollToRowIndex) return; + prevScrollToRowIndex.current = scrollToRowIndex; + const { current } = canvas; + if (typeof scrollToRowIndex === 'number' && current) { + current.scrollTop = scrollToRowIndex * rowHeight; + } + }, [rowHeight, scrollToRowIndex]); -export default class Canvas extends React.PureComponent> { - static displayName = 'Canvas'; + function handleScroll(e: React.UIEvent) { + const { scrollLeft: newScrollLeft, scrollTop: newScrollTop } = e.currentTarget; + // Freeze columns on legacy browsers + setComponentsScrollLeft(newScrollLeft); - private readonly canvas = React.createRef(); - private readonly interactionMasks = React.createRef>(); - private readonly rows = new Map & React.Component>>(); - private lastSelectedRowIdx = -1; - private unsubscribeScrollToColumn?(): void; + if (enableIsScrolling) { + setIsScrolling(true); + resetScrollStateAfterDelay(); + } - componentDidMount() { - this.unsubscribeScrollToColumn = this.props.eventBus.subscribe(EventTypes.SCROLL_TO_COLUMN, this.scrollToColumn); + const scrollDirection = getScrollDirection( + { scrollLeft, scrollTop }, + { scrollLeft: newScrollLeft, scrollTop: newScrollTop } + ); + setScrollLeft(newScrollLeft); + setScrollTop(newScrollTop); + setScrollDirection(scrollDirection); + onScroll({ scrollLeft: newScrollLeft, scrollTop: newScrollTop, scrollDirection }); } - componentWillUnmount() { - this.unsubscribeScrollToColumn!(); + function resetScrollStateAfterDelay() { + clearScrollTimer(); + resetScrollStateTimeoutId.current = window.setTimeout( + resetScrollStateAfterDelayCallback, + 150 + ); } - componentDidUpdate(prevProps: CanvasProps) { - const { scrollToRowIndex } = this.props; - if (scrollToRowIndex && prevProps.scrollToRowIndex !== scrollToRowIndex) { - this.scrollToRow(scrollToRowIndex); + function clearScrollTimer() { + if (resetScrollStateTimeoutId.current !== null) { + window.clearTimeout(resetScrollStateTimeoutId.current); + resetScrollStateTimeoutId.current = null; } } - handleScroll = (e: React.UIEvent) => { - const { scrollLeft, scrollTop } = e.currentTarget; - // Freeze columns on legacy browsers - this.setScrollLeft(scrollLeft); - this.props.onScroll({ scrollLeft, scrollTop }); - }; + function resetScrollStateAfterDelayCallback() { + resetScrollStateTimeoutId.current = null; + setIsScrolling(false); + } - onHitBottomCanvas = () => { - const { current } = this.canvas; + function getClientHeight() { + if (canvas.current) return canvas.current.clientHeight; + const scrollbarSize = columnMetrics.totalColumnWidth > columnMetrics.viewportWidth ? getScrollbarSize() : 0; + return height - scrollbarSize; + } + + function onHitBottomCanvas({ rowIdx }: Position) { + const { current } = canvas; if (current) { - current.scrollTop += this.props.rowHeight + this.getClientScrollTopOffset(current); + // We do not need to check for the index being in range, as the scrollTop setter will adequately clamp the value. + current.scrollTop = (rowIdx + 1) * rowHeight - clientHeight; } - }; + } - onHitTopCanvas = () => { - const { current } = this.canvas; + function onHitTopCanvas({ rowIdx }: Position) { + const { current } = canvas; if (current) { - current.scrollTop -= this.props.rowHeight - this.getClientScrollTopOffset(current); + current.scrollTop = rowIdx * rowHeight; } - }; - - handleHitColummBoundary = ({ idx }: Position) => { - this.scrollToColumn(idx); - }; + } - scrollToRow(scrollToRowIndex: number) { - const { current } = this.canvas; - if (!current) return; - const { rowHeight, rowsCount, height } = this.props; - current.scrollTop = Math.min( - scrollToRowIndex * rowHeight, - rowsCount * rowHeight - height - ); + function handleHitColummBoundary({ idx }: Position) { + scrollToColumn(idx, columnMetrics.columns); } - scrollToColumn(idx: number) { - const { current } = this.canvas; + function scrollToColumn(idx: number, columns: CalculatedColumn[]) { + const { current } = canvas; if (!current) return; const { scrollLeft, clientWidth } = current; - const newScrollLeft = getColumnScrollPosition(this.props.columns, idx, scrollLeft, clientWidth); + const newScrollLeft = getColumnScrollPosition(columns, idx, scrollLeft, clientWidth); if (newScrollLeft !== 0) { current.scrollLeft = scrollLeft + newScrollLeft; } } - getRows(rowOverscanStartIdx: number, rowOverscanEndIdx: number) { + function getRows() { const rows = []; - let i = rowOverscanStartIdx; - while (i < rowOverscanEndIdx) { - const row = this.props.rowGetter(i); - let subRowDetails: SubRowDetails | undefined; - if (this.props.getSubRowDetails) { - subRowDetails = this.props.getSubRowDetails(row); - } - rows.push({ row, subRowDetails }); - i++; - } - return rows; - } - getClientScrollTopOffset(node: HTMLDivElement) { - const { rowHeight } = this.props; - const scrollVariation = node.scrollTop % rowHeight; - return scrollVariation > 0 ? rowHeight - scrollVariation : 0; - } + for (let idx = rowOverscanStartIdx; idx <= rowOverscanEndIdx; idx++) { + rows.push(renderRow(idx)); + } - isRowSelected(row: R): boolean { - return this.props.selectedRows !== undefined && this.props.selectedRows.has(row[this.props.rowKey]); + return rows; } - handleRowSelectionChange = (rowIdx: number, row: R, checked: boolean, isShiftClick: boolean) => { - if (!this.props.onSelectedRowsChange) return; - - const { rowKey } = this.props; - const newSelectedRows = new Set(this.props.selectedRows); - - if (checked) { - newSelectedRows.add(row[rowKey]); - const previousRowIdx = this.lastSelectedRowIdx; - this.lastSelectedRowIdx = rowIdx; - if (isShiftClick && previousRowIdx !== -1 && previousRowIdx !== rowIdx) { - const step = Math.sign(rowIdx - previousRowIdx); - for (let i = previousRowIdx + step; i !== rowIdx; i += step) { - newSelectedRows.add(this.props.rowGetter(i)[rowKey]); + function renderRow(idx: number) { + const row = rowGetter(idx); + const rendererProps: RendererProps = { + key: idx, + ref(row) { + if (row) { + rows.set(idx, row); + } else { + rows.delete(idx); } + }, + idx, + row, + height: rowHeight, + columns: columnMetrics.columns, + isRowSelected: isRowSelected(row), + onRowSelectionChange, + cellMetaData, + subRowDetails: getSubRowDetails ? getSubRowDetails(row) : undefined, + colOverscanStartIdx, + colOverscanEndIdx, + lastFrozenColumnIndex: columnMetrics.lastFrozenColumnIndex, + isScrolling, + scrollLeft + }; + const { __metaData } = row as RowData; + + if (__metaData) { + if (__metaData.getRowRenderer) { + return __metaData.getRowRenderer(rendererProps, idx); + } + if (__metaData.isGroup) { + return renderGroupRow(rendererProps); } - } else { - newSelectedRows.delete(row[rowKey]); - this.lastSelectedRowIdx = -1; } - this.props.onSelectedRowsChange(newSelectedRows); - }; + if (rowRenderer) { + return renderCustomRowRenderer(rendererProps); + } + + return {...rendererProps} />; + } + + function isRowSelected(row: R): boolean { + return selectedRows !== undefined && selectedRows.has(row[rowKey]); + } - setScrollLeft(scrollLeft: number) { + function setComponentsScrollLeft(scrollLeft: number) { if (isPositionStickySupported()) return; - const { current } = this.interactionMasks; + const { current } = interactionMasks; if (current) { current.setScrollLeft(scrollLeft); } - this.rows.forEach((r, idx) => { - const row = this.getRowByRef(idx); + rows.forEach((r, idx) => { + const row = getRowByRef(idx); if (row && row.setScrollLeft) { row.setScrollLeft(scrollLeft); } }); } - getRowByRef = (i: number) => { + function getRowByRef(i: number) { // check if wrapped with React DND drop target - if (!this.rows.has(i)) return; + if (!rows.has(i)) return; - const row = this.rows.get(i)!; + const row = rows.get(i)!; const wrappedRow = row.getDecoratedComponentInstance ? row.getDecoratedComponentInstance(i) : null; return wrappedRow ? wrappedRow.row : row; - }; + } - getRowTop = (rowIdx: number) => { - const row = this.getRowByRef(rowIdx); + function getRowTop(rowIdx: number) { + const row = getRowByRef(rowIdx); if (row && row.getRowTop) { return row.getRowTop(); } - return this.props.rowHeight * rowIdx; - }; + return rowHeight * rowIdx; + } - getRowHeight = (rowIdx: number) => { - const row = this.getRowByRef(rowIdx); + function getRowHeight(rowIdx: number) { + const row = getRowByRef(rowIdx); if (row && row.getRowHeight) { return row.getRowHeight(); } - return this.props.rowHeight; - }; + return rowHeight; + } - getRowColumns = (rowIdx: number) => { - const row = this.getRowByRef(rowIdx); - return row && row.props ? row.props.columns : this.props.columns; - }; + function getRowColumns(rowIdx: number) { + const row = getRowByRef(rowIdx); + return row && row.props ? row.props.columns : columnMetrics.columns; + } - renderCustomRowRenderer(props: RendererProps) { - const { ref, ...otherProps } = props; - const CustomRowRenderer = this.props.rowRenderer!; + function renderCustomRowRenderer(rowProps: RendererProps) { + const { ref, ...otherProps } = rowProps; + const CustomRowRenderer = rowRenderer!; const customRowRendererProps = { ...otherProps, renderBaseRow: (p: RowRendererProps) => }; if (isElement(CustomRowRenderer)) { if (CustomRowRenderer.type === Row) { // In the case where Row is specified as the custom render, ensure the correct ref is passed - return {...props} />; + return {...rowProps} />; } return React.cloneElement(CustomRowRenderer, customRowRendererProps); } @@ -235,9 +326,9 @@ export default class Canvas extends React.PureComponent> { return ; } - renderGroupRow(props: RendererProps) { - const { ref, columns, ...rowGroupProps } = props; - const row = props.row as RowData; + function renderGroupRow(groupRowProps: RendererProps) { + const { ref, columns, ...rowGroupProps } = groupRowProps; + const row = groupRowProps.row as RowData; return ( extends React.PureComponent> { {...row.__metaData!} columns={columns as CalculatedColumn[]} name={row.name!} - eventBus={this.props.eventBus} - renderer={this.props.rowGroupRenderer} + eventBus={eventBus} + renderer={rowGroupRenderer} renderBaseRow={(p: RowRendererProps) => } /> ); } - renderRow(props: RendererProps) { - const row = props.row as RowData; - - if (row.__metaData && row.__metaData.getRowRenderer) { - return row.__metaData.getRowRenderer(this.props, props.idx); - } - if (row.__metaData && row.__metaData.isGroup) { - return this.renderGroupRow(props); - } - - if (this.props.rowRenderer) { - return this.renderCustomRowRenderer(props); - } - - return {...props} />; - } - - renderPlaceholder(key: string, height: number) { - return ( -
- ); - } - - render() { - const { rowOverscanStartIdx, rowOverscanEndIdx, cellMetaData, columns, colOverscanStartIdx, colOverscanEndIdx, colVisibleStartIdx, colVisibleEndIdx, lastFrozenColumnIndex, rowHeight, rowsCount, width, height, rowGetter, contextMenu, isScrolling, scrollLeft } = this.props; - const RowsContainer = this.props.RowsContainer || Fragment; - - const rows = this.getRows(rowOverscanStartIdx, rowOverscanEndIdx) - .map(({ row, subRowDetails }, idx) => { - const rowIdx = rowOverscanStartIdx + idx; - return row && this.renderRow({ - key: rowIdx, - ref: (row: (RowRenderer & React.Component>) | null) => { - if (row) { - this.rows.set(rowIdx, row); - } else { - this.rows.delete(rowIdx); - } - }, - idx: rowIdx, - row, - height: rowHeight, - columns, - isRowSelected: this.isRowSelected(row), - onRowSelectionChange: this.handleRowSelectionChange, - cellMetaData, - subRowDetails, - colOverscanStartIdx, - colOverscanEndIdx, - lastFrozenColumnIndex, - isScrolling, - scrollLeft - }); - }); - - if (rowOverscanStartIdx > 0) { - rows.unshift(this.renderPlaceholder('top', rowOverscanStartIdx * rowHeight)); - } - - if (rowsCount - rowOverscanEndIdx > 0) { - rows.push(this.renderPlaceholder('bottom', (rowsCount - rowOverscanEndIdx) * rowHeight)); - } - - return ( -
- - ref={this.interactionMasks} - rowGetter={rowGetter} - rowsCount={rowsCount} - rowHeight={rowHeight} - columns={columns} - rowVisibleStartIdx={this.props.rowVisibleStartIdx} - rowVisibleEndIdx={this.props.rowVisibleEndIdx} - colVisibleStartIdx={colVisibleStartIdx} - colVisibleEndIdx={colVisibleEndIdx} - enableCellSelect={this.props.enableCellSelect} - enableCellAutoFocus={this.props.enableCellAutoFocus} - cellNavigationMode={this.props.cellNavigationMode} - eventBus={this.props.eventBus} - contextMenu={this.props.contextMenu} - onHitBottomBoundary={this.onHitBottomCanvas} - onHitTopBoundary={this.onHitTopCanvas} - onHitLeftBoundary={this.handleHitColummBoundary} - onHitRightBoundary={this.handleHitColummBoundary} - scrollLeft={scrollLeft} - scrollTop={this.props.scrollTop} - getRowHeight={this.getRowHeight} - getRowTop={this.getRowTop} - getRowColumns={this.getRowColumns} - editorPortalTarget={this.props.editorPortalTarget} - {...this.props.interactionMasksMetaData} - /> - -
{rows}
-
-
- ); - } + const paddingTop = rowOverscanStartIdx * rowHeight; + const paddingBottom = (rowsCount - 1 - rowOverscanEndIdx) * rowHeight; + + return ( +
+ + ref={interactionMasks} + rowGetter={rowGetter} + rowsCount={rowsCount} + rowHeight={rowHeight} + columns={columnMetrics.columns} + height={clientHeight} + colVisibleStartIdx={colVisibleStartIdx} + colVisibleEndIdx={colVisibleEndIdx} + enableCellSelect={enableCellSelect} + enableCellAutoFocus={enableCellAutoFocus} + cellNavigationMode={cellNavigationMode} + eventBus={eventBus} + contextMenu={contextMenu} + onHitBottomBoundary={onHitBottomCanvas} + onHitTopBoundary={onHitTopCanvas} + onHitLeftBoundary={handleHitColummBoundary} + onHitRightBoundary={handleHitColummBoundary} + scrollLeft={scrollLeft} + scrollTop={scrollTop} + getRowHeight={getRowHeight} + getRowTop={getRowTop} + getRowColumns={getRowColumns} + editorPortalTarget={editorPortalTarget} + {...interactionMasksMetaData} + /> + +
+ {getRows()} +
+
+
+ ); } diff --git a/packages/react-data-grid/src/Grid.tsx b/packages/react-data-grid/src/Grid.tsx index f013698a84..0df6f93906 100644 --- a/packages/react-data-grid/src/Grid.tsx +++ b/packages/react-data-grid/src/Grid.tsx @@ -2,8 +2,8 @@ import React, { useRef, createElement } from 'react'; import { isValidElementType } from 'react-is'; import Header, { HeaderHandle, HeaderProps } from './Header'; -import Viewport, { ScrollState } from './Viewport'; -import { HeaderRowData, CellMetaData, InteractionMasksMetaData, ColumnMetrics } from './common/types'; +import Canvas from './Canvas'; +import { HeaderRowData, CellMetaData, InteractionMasksMetaData, ColumnMetrics, ScrollState } from './common/types'; import { DEFINE_SORT } from './common/enums'; import { ReactDataGridProps } from './ReactDataGrid'; import { EventBus } from './masks'; @@ -47,18 +47,20 @@ export interface GridProps extends SharedDataGridProps { eventBus: EventBus; interactionMasksMetaData: InteractionMasksMetaData; onSort?(columnKey: keyof R, direction: DEFINE_SORT): void; - onViewportKeydown?(e: React.KeyboardEvent): void; - onViewportKeyup?(e: React.KeyboardEvent): void; + onCanvasKeydown?(e: React.KeyboardEvent): void; + onCanvasKeyup?(e: React.KeyboardEvent): void; onColumnResize(idx: number, width: number): void; - viewportWidth: number; + onRowSelectionChange(rowIdx: number, row: R, checked: boolean, isShiftClick: boolean): void; } export default function Grid({ + rowGetter, rowKey, + rowOffsetHeight, rowsCount, + columnMetrics, emptyRowsView, headerRows, - viewportWidth, selectedRows, ...props }: GridProps) { @@ -84,11 +86,11 @@ export default function Grid({ rowKey, rowsCount, ref: header, - rowGetter: props.rowGetter, - columnMetrics: props.columnMetrics, + rowGetter, + columnMetrics, onColumnResize: props.onColumnResize, headerRows, - rowOffsetHeight: props.rowOffsetHeight, + rowOffsetHeight, sortColumn: props.sortColumn, sortDirection: props.sortDirection, draggableHeaderCell: props.draggableHeaderCell, @@ -105,19 +107,18 @@ export default function Grid({ {createElement(emptyRowsView)}
) : ( - + rowKey={rowKey} rowHeight={props.rowHeight} rowRenderer={props.rowRenderer} - rowGetter={props.rowGetter} + rowGetter={rowGetter} rowsCount={rowsCount} selectedRows={selectedRows} - onSelectedRowsChange={props.onSelectedRowsChange} - columnMetrics={props.columnMetrics} + onRowSelectionChange={props.onRowSelectionChange} + columnMetrics={columnMetrics} onScroll={onScroll} cellMetaData={props.cellMetaData} - rowOffsetHeight={props.rowOffsetHeight} - minHeight={props.minHeight} + height={props.minHeight - rowOffsetHeight} scrollToRowIndex={props.scrollToRowIndex} contextMenu={props.contextMenu} getSubRowDetails={props.getSubRowDetails} @@ -132,9 +133,8 @@ export default function Grid({ overscanRowCount={props.overscanRowCount} overscanColumnCount={props.overscanColumnCount} enableIsScrolling={props.enableIsScrolling} - onViewportKeydown={props.onViewportKeydown} - onViewportKeyup={props.onViewportKeyup} - viewportWidth={viewportWidth} + onCanvasKeydown={props.onCanvasKeydown} + onCanvasKeyup={props.onCanvasKeyup} /> )} diff --git a/packages/react-data-grid/src/ReactDataGrid.tsx b/packages/react-data-grid/src/ReactDataGrid.tsx index 3de5818252..662f03ab83 100644 --- a/packages/react-data-grid/src/ReactDataGrid.tsx +++ b/packages/react-data-grid/src/ReactDataGrid.tsx @@ -11,7 +11,6 @@ import React, { import Grid from './Grid'; import ToolbarContainer, { ToolbarProps } from './ToolbarContainer'; import { getColumnMetrics } from './ColumnMetrics'; -import { ScrollState } from './Viewport'; import { EventBus } from './masks'; import { CellNavigationMode, EventTypes, UpdateActions, HeaderRowType, DEFINE_SORT } from './common/enums'; import { @@ -33,7 +32,8 @@ import { SelectedRange, SubRowDetails, SubRowOptions, - RowRendererProps + RowRendererProps, + ScrollState } from './common/types'; export interface ReactDataGridProps { @@ -196,6 +196,7 @@ const ReactDataGridBase = forwardRef(function ReactDataGrid({ const [eventBus] = useState(() => new EventBus()); const [gridWidth, setGridWidth] = useState(0); const gridRef = useRef(null); + const lastSelectedRowIdx = useRef(-1); const viewportWidth = (width || gridWidth) - 2; // 2 for border width; const columnMetrics = useMemo(() => { @@ -345,6 +346,29 @@ const ReactDataGridBase = forwardRef(function ReactDataGrid({ eventBus.dispatch(EventTypes.SCROLL_TO_COLUMN, colIdx); } + function handleRowSelectionChange(rowIdx: number, row: R, checked: boolean, isShiftClick: boolean) { + if (!onSelectedRowsChange) return; + + const newSelectedRows = new Set(selectedRows); + + if (checked) { + newSelectedRows.add(row[rowKey]); + const previousRowIdx = lastSelectedRowIdx.current; + lastSelectedRowIdx.current = rowIdx; + if (isShiftClick && previousRowIdx !== -1 && previousRowIdx !== rowIdx) { + const step = Math.sign(rowIdx - previousRowIdx); + for (let i = previousRowIdx + step; i !== rowIdx; i += step) { + newSelectedRows.add(rowGetter(i)[rowKey]); + } + } + } else { + newSelectedRows.delete(row[rowKey]); + lastSelectedRowIdx.current = -1; + } + + onSelectedRowsChange(newSelectedRows); + } + useImperativeHandle(ref, () => ({ scrollToColumn, selectCell, @@ -412,14 +436,15 @@ const ReactDataGridBase = forwardRef(function ReactDataGrid({ rowGroupRenderer={props.rowGroupRenderer} cellMetaData={cellMetaData} selectedRows={selectedRows} + onRowSelectionChange={handleRowSelectionChange} onSelectedRowsChange={onSelectedRowsChange} rowOffsetHeight={rowOffsetHeight} sortColumn={props.sortColumn} sortDirection={props.sortDirection} onSort={props.onGridSort} minHeight={minHeight} - onViewportKeydown={props.onGridKeyDown} - onViewportKeyup={props.onGridKeyUp} + onCanvasKeydown={props.onGridKeyDown} + onCanvasKeyup={props.onGridKeyUp} onColumnResize={handleColumnResize} scrollToRowIndex={props.scrollToRowIndex} contextMenu={props.contextMenu} @@ -437,7 +462,6 @@ const ReactDataGridBase = forwardRef(function ReactDataGrid({ overscanRowCount={props.overscanRowCount} overscanColumnCount={props.overscanColumnCount} enableIsScrolling={props.enableIsScrolling} - viewportWidth={viewportWidth} /> )} diff --git a/packages/react-data-grid/src/Viewport.tsx b/packages/react-data-grid/src/Viewport.tsx deleted file mode 100644 index 57cb6e2a56..0000000000 --- a/packages/react-data-grid/src/Viewport.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import React, { useRef, useState, useMemo } from 'react'; - -import Canvas from './Canvas'; -import { getVerticalRangeToRender, getHorizontalRangeToRender, getScrollDirection } from './utils/viewportUtils'; -import { GridProps } from './Grid'; -import { ScrollPosition } from './common/types'; -import { SCROLL_DIRECTION } from './common/enums'; - -export interface ScrollState { - scrollTop: number; - scrollLeft: number; - scrollDirection: SCROLL_DIRECTION; -} - -type SharedGridProps = Pick, -| 'rowKey' -| 'rowHeight' -| 'rowRenderer' -| 'rowGetter' -| 'rowsCount' -| 'selectedRows' -| 'onSelectedRowsChange' -| 'columnMetrics' -| 'cellMetaData' -| 'rowOffsetHeight' -| 'minHeight' -| 'scrollToRowIndex' -| 'contextMenu' -| 'getSubRowDetails' -| 'rowGroupRenderer' -| 'enableCellSelect' -| 'enableCellAutoFocus' -| 'cellNavigationMode' -| 'eventBus' -| 'interactionMasksMetaData' -| 'RowsContainer' -| 'editorPortalTarget' -| 'overscanRowCount' -| 'overscanColumnCount' -| 'enableIsScrolling' -| 'onViewportKeydown' -| 'onViewportKeyup' ->; - -export interface ViewportProps extends SharedGridProps { - onScroll(scrollState: ScrollState): void; - viewportWidth: number; -} - -export default function Viewport({ - minHeight, - rowHeight, - rowOffsetHeight, - rowsCount, - onScroll: handleScroll, - columnMetrics, - overscanRowCount, - overscanColumnCount, - enableIsScrolling, - viewportWidth, - ...props -}: ViewportProps) { - const resetScrollStateTimeoutId = useRef(null); - const [scrollLeft, setScrollLeft] = useState(0); - const [scrollTop, setScrollTop] = useState(0); - const [scrollDirection, setScrollDirection] = useState(SCROLL_DIRECTION.NONE); - const [isScrolling, setIsScrolling] = useState(undefined); - - function clearScrollTimer() { - if (resetScrollStateTimeoutId.current !== null) { - window.clearTimeout(resetScrollStateTimeoutId.current); - resetScrollStateTimeoutId.current = null; - } - } - - function resetScrollStateAfterDelay() { - clearScrollTimer(); - resetScrollStateTimeoutId.current = window.setTimeout( - resetScrollStateAfterDelayCallback, - 150 - ); - } - - function resetScrollStateAfterDelayCallback() { - resetScrollStateTimeoutId.current = null; - setIsScrolling(false); - } - - function onScroll({ scrollLeft: newScrollLeft, scrollTop: newScrollTop }: ScrollPosition) { - if (enableIsScrolling) { - setIsScrolling(true); - resetScrollStateAfterDelay(); - } - - const newScrollDirection = getScrollDirection({ scrollLeft, scrollTop }, { scrollLeft: newScrollLeft, scrollTop: newScrollTop }); - setScrollLeft(newScrollLeft); - setScrollTop(newScrollTop); - setScrollDirection(newScrollDirection); - handleScroll({ - scrollLeft: newScrollLeft, - scrollTop: newScrollTop, - scrollDirection: newScrollDirection - }); - } - - const canvasHeight = minHeight - rowOffsetHeight; - - const verticalRangeToRender = useMemo(() => { - return getVerticalRangeToRender({ - height: canvasHeight, - rowHeight, - scrollTop, - rowsCount, - scrollDirection, - overscanRowCount - }); - }, [canvasHeight, overscanRowCount, rowHeight, rowsCount, scrollDirection, scrollTop]); - - const horizontalRangeToRender = useMemo(() => { - return getHorizontalRangeToRender({ - columnMetrics, - scrollLeft, - viewportWidth, - scrollDirection, - overscanColumnCount - }); - }, [columnMetrics, overscanColumnCount, scrollDirection, scrollLeft, viewportWidth]); - - return ( -
- - {...verticalRangeToRender} - {...horizontalRangeToRender} - rowKey={props.rowKey} - width={columnMetrics.totalColumnWidth} - columns={columnMetrics.columns} - lastFrozenColumnIndex={columnMetrics.lastFrozenColumnIndex} - rowGetter={props.rowGetter} - rowsCount={rowsCount} - selectedRows={props.selectedRows} - onSelectedRowsChange={props.onSelectedRowsChange} - rowRenderer={props.rowRenderer} - scrollTop={scrollTop} - scrollLeft={scrollLeft} - cellMetaData={props.cellMetaData} - height={canvasHeight} - rowHeight={rowHeight} - onScroll={onScroll} - scrollToRowIndex={props.scrollToRowIndex} - contextMenu={props.contextMenu} - getSubRowDetails={props.getSubRowDetails} - rowGroupRenderer={props.rowGroupRenderer} - isScrolling={isScrolling} - enableCellSelect={props.enableCellSelect} - enableCellAutoFocus={props.enableCellAutoFocus} - cellNavigationMode={props.cellNavigationMode} - eventBus={props.eventBus} - RowsContainer={props.RowsContainer} - editorPortalTarget={props.editorPortalTarget} - interactionMasksMetaData={props.interactionMasksMetaData} - /> -
- ); -} diff --git a/packages/react-data-grid/src/__tests__/Canvas.spec.tsx b/packages/react-data-grid/src/__tests__/Canvas.spec.tsx index c9df2be97a..82458159f9 100644 --- a/packages/react-data-grid/src/__tests__/Canvas.spec.tsx +++ b/packages/react-data-grid/src/__tests__/Canvas.spec.tsx @@ -20,16 +20,7 @@ const testProps: CanvasProps = { rowKey: 'row', rowHeight: 25, height: 200, - rowOverscanStartIdx: 1, - rowOverscanEndIdx: 10, - rowVisibleStartIdx: 0, - rowVisibleEndIdx: 10, - colVisibleStartIdx: 0, - colVisibleEndIdx: 100, - colOverscanStartIdx: 0, - colOverscanEndIdx: 100, rowsCount: 1, - columns: [], rowGetter() { return {}; }, cellMetaData: { rowKey: 'row', @@ -45,51 +36,38 @@ const testProps: CanvasProps = { onGridRowsUpdated: noop, onDragHandleDoubleClick: noop }, - isScrolling: false, + onRowSelectionChange() {}, enableCellSelect: true, enableCellAutoFocus: false, cellNavigationMode: CellNavigationMode.NONE, eventBus: new EventBus(), editorPortalTarget: document.body, - width: 1000, onScroll() {}, - lastFrozenColumnIndex: 0, - scrollTop: 0, - scrollLeft: 0 + columnMetrics: { + columns: [{ key: 'id', name: 'ID', idx: 0, width: 100, left: 100 }], + columnWidths: new Map(), + lastFrozenColumnIndex: -1, + minColumnWidth: 80, + totalColumnWidth: 0, + viewportWidth: 1000 + }, + onCanvasKeydown() {}, + onCanvasKeyup() {} }; function renderComponent(extraProps?: Partial>) { - return shallow>( {...testProps} {...extraProps} />); + return shallow( {...testProps} {...extraProps} />); } describe('Canvas Tests', () => { - it('Should not call setScroll on render', () => { - const wrapper = renderComponent(); - const testElementNode = wrapper.instance(); - - jest.spyOn(testElementNode, 'setScrollLeft').mockImplementation(() => { }); - expect(testElementNode.setScrollLeft).not.toHaveBeenCalled(); - }); - - it('Should not call setScroll on update', () => { - const wrapper = renderComponent(); - const testElementNode = wrapper.instance(); - - jest.spyOn(testElementNode, 'setScrollLeft').mockImplementation(() => { }); - testElementNode.componentDidUpdate(testProps); - expect(testElementNode.setScrollLeft).not.toHaveBeenCalled(); - }); - it('Should render the InteractionMasks component', () => { const wrapper = renderComponent(); expect(wrapper.find(InteractionMasks).props()).toMatchObject({ rowHeight: 25, rowsCount: 1, - rowVisibleStartIdx: 0, - rowVisibleEndIdx: 10, colVisibleStartIdx: 0, - colVisibleEndIdx: 100 + colVisibleEndIdx: 1 }); }); @@ -98,9 +76,6 @@ describe('Canvas Tests', () => { const rowGetter = () => ({ id: 1 }); const wrapper = renderComponent({ - rowOverscanStartIdx: 0, - rowOverscanEndIdx: 1, - columns: [{ key: 'id', name: 'ID', idx: 0, width: 100, left: 100 }], rowGetter, rowsCount: 1, rowKey: 'id', diff --git a/packages/react-data-grid/src/common/types.ts b/packages/react-data-grid/src/common/types.ts index e636b2beff..0dacb3a1f7 100644 --- a/packages/react-data-grid/src/common/types.ts +++ b/packages/react-data-grid/src/common/types.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { KeyboardEvent, ReactNode } from 'react'; import { List } from 'immutable'; -import { HeaderRowType, UpdateActions } from './enums'; +import { HeaderRowType, UpdateActions, SCROLL_DIRECTION } from './enums'; export type Omit = Pick>; @@ -252,6 +252,12 @@ export interface RowRenderer { getDecoratedComponentInstance?(idx: number): { row: RowRenderer & React.Component> } | undefined; } +export interface ScrollState { + scrollTop: number; + scrollLeft: number; + scrollDirection: SCROLL_DIRECTION; +} + export interface ScrollPosition { scrollLeft: number; scrollTop: number; diff --git a/packages/react-data-grid/src/masks/InteractionMasks.tsx b/packages/react-data-grid/src/masks/InteractionMasks.tsx index f5114e93e4..50dcaa4d0d 100644 --- a/packages/react-data-grid/src/masks/InteractionMasks.tsx +++ b/packages/react-data-grid/src/masks/InteractionMasks.tsx @@ -27,11 +27,9 @@ import keyCodes from '../KeyCodes'; // Types import { UpdateActions, CellNavigationMode, EventTypes } from '../common/enums'; -import { CalculatedColumn, Position, SelectedRange, Dimension, InteractionMasksMetaData, CommitEvent } from '../common/types'; +import { CalculatedColumn, Position, SelectedRange, Dimension, InteractionMasksMetaData, CommitEvent, ColumnMetrics } from '../common/types'; import { CanvasProps } from '../Canvas'; -const SCROLL_CELL_BUFFER = 2; - interface NavAction { getNext(current: Position): Position; isCellAtBoundary(cell: Position): boolean; @@ -39,32 +37,30 @@ interface NavAction { } type SharedCanvasProps = Pick, -'rowGetter' +| 'rowGetter' | 'rowsCount' | 'rowHeight' -| 'columns' -| 'rowVisibleStartIdx' -| 'rowVisibleEndIdx' -| 'colVisibleStartIdx' -| 'colVisibleEndIdx' | 'enableCellSelect' | 'enableCellAutoFocus' | 'cellNavigationMode' | 'eventBus' | 'contextMenu' | 'editorPortalTarget' ->; +> & Pick, 'columns'>; export interface InteractionMasksProps extends SharedCanvasProps, InteractionMasksMetaData { - onHitTopBoundary(): void; - onHitBottomBoundary(): void; + onHitTopBoundary(position: Position): void; + onHitBottomBoundary(position: Position): void; onHitLeftBoundary(position: Position): void; onHitRightBoundary(position: Position): void; + height: number; scrollLeft: number; scrollTop: number; getRowHeight(rowIdx: number): number; getRowTop(rowIdx: number): number; getRowColumns(rowIdx: number): CalculatedColumn[]; + colVisibleStartIdx: number; + colVisibleEndIdx: number; } export interface InteractionMasksState { @@ -331,9 +327,13 @@ export default class InteractionMasks extends React.Component): NavAction | null { - const { rowVisibleEndIdx, rowVisibleStartIdx, colVisibleEndIdx, colVisibleStartIdx, onHitBottomBoundary, onHitRightBoundary, onHitLeftBoundary, onHitTopBoundary } = this.props; - const isCellAtBottomBoundary = (cell: Position): boolean => cell.rowIdx >= rowVisibleEndIdx - SCROLL_CELL_BUFFER; - const isCellAtTopBoundary = (cell: Position): boolean => cell.rowIdx !== 0 && cell.rowIdx <= rowVisibleStartIdx - 1; + const { colVisibleEndIdx, colVisibleStartIdx, onHitBottomBoundary, onHitRightBoundary, onHitLeftBoundary, onHitTopBoundary } = this.props; + const isCellAtBottomBoundary = (cell: Position): boolean => { + return (cell.rowIdx + 1) * this.props.rowHeight > this.props.scrollTop + this.props.height; + }; + const isCellAtTopBoundary = (cell: Position): boolean => { + return cell.rowIdx * this.props.rowHeight < this.props.scrollTop; + }; const isCellAtRightBoundary = (cell: Position): boolean => cell.idx !== 0 && cell.idx >= colVisibleEndIdx - 1; const isCellAtLeftBoundary = (cell: Position): boolean => cell.idx !== 0 && cell.idx <= colVisibleStartIdx + 1; @@ -354,7 +354,7 @@ export default class InteractionMasks extends React.Component extends React.Component', () => { function setup(overrideProps?: Partial>, initialState?: Pick, isMount = false) { const onCellSelected = jest.fn(); const props: InteractionMasksProps = { - rowVisibleStartIdx: 0, - rowVisibleEndIdx: 10, + height: 100, colVisibleStartIdx: 0, colVisibleEndIdx: 10, columns, diff --git a/packages/react-data-grid/src/utils/__tests__/viewportUtils.spec.ts b/packages/react-data-grid/src/utils/__tests__/viewportUtils.spec.ts index ac1fd2dbf0..9534309e7b 100644 --- a/packages/react-data-grid/src/utils/__tests__/viewportUtils.spec.ts +++ b/packages/react-data-grid/src/utils/__tests__/viewportUtils.spec.ts @@ -27,8 +27,6 @@ describe('viewportUtils', () => { it('should use rowHeight to calculate the range', () => { expect(getRange({ rowHeight: 50 })).toEqual({ - rowVisibleStartIdx: 4, - rowVisibleEndIdx: 14, rowOverscanStartIdx: 2, rowOverscanEndIdx: 16 }); @@ -36,8 +34,6 @@ describe('viewportUtils', () => { it('should use height to calculate the range', () => { expect(getRange({ height: 250 })).toEqual({ - rowVisibleStartIdx: 4, - rowVisibleEndIdx: 9, rowOverscanStartIdx: 2, rowOverscanEndIdx: 11 }); @@ -45,8 +41,6 @@ describe('viewportUtils', () => { it('should use scrollTop to calculate the range', () => { expect(getRange({ scrollTop: 500 })).toEqual({ - rowVisibleStartIdx: 10, - rowVisibleEndIdx: 20, rowOverscanStartIdx: 8, rowOverscanEndIdx: 22 }); @@ -54,17 +48,13 @@ describe('viewportUtils', () => { it('should use rowsCount to calculate the range', () => { expect(getRange({ rowsCount: 5, scrollTop: 0 })).toEqual({ - rowVisibleStartIdx: 0, - rowVisibleEndIdx: 5, rowOverscanStartIdx: 0, - rowOverscanEndIdx: 5 + rowOverscanEndIdx: 4 }); }); it('should use overscanRowCount to calculate the range', () => { expect(getRange({ overscanRowCount: 10 })).toEqual({ - rowVisibleStartIdx: 4, - rowVisibleEndIdx: 14, rowOverscanStartIdx: 2, rowOverscanEndIdx: 24 }); @@ -72,8 +62,6 @@ describe('viewportUtils', () => { it('should use scrollDirection to calculate the range', () => { expect(getRange({ overscanRowCount: 10, scrollDirection: SCROLL_DIRECTION.UP })).toEqual({ - rowVisibleStartIdx: 4, - rowVisibleEndIdx: 14, rowOverscanStartIdx: 0, rowOverscanEndIdx: 16 }); @@ -85,7 +73,7 @@ describe('viewportUtils', () => { const columns = [...Array(500).keys()].map(i => ({ idx: i, key: `col${i}`, name: `col${i}`, width: 100, left: i * 100 })); return { columns, - viewportWidth: 0, + viewportWidth: 1000, totalColumnWidth: 200, columnWidths: new Map(), minColumnWidth: 80, @@ -97,7 +85,6 @@ describe('viewportUtils', () => { return getHorizontalRangeToRender({ columnMetrics: getColumnMetrics(), scrollLeft: 200, - viewportWidth: 1000, scrollDirection: SCROLL_DIRECTION.RIGHT, ...overrides }); @@ -124,7 +111,13 @@ describe('viewportUtils', () => { }); it('should use viewportWidth to calculate the range', () => { - expect(getRange({ viewportWidth: 500 })).toEqual({ + const columnMetrics = getColumnMetrics(); + columnMetrics.viewportWidth = 500; + expect(getHorizontalRangeToRender({ + columnMetrics, + scrollLeft: 200, + scrollDirection: SCROLL_DIRECTION.RIGHT + })).toEqual({ colVisibleStartIdx: 2, colVisibleEndIdx: 7, colOverscanStartIdx: 0, diff --git a/packages/react-data-grid/src/utils/viewportUtils.ts b/packages/react-data-grid/src/utils/viewportUtils.ts index 3f93d20fb1..ff72f0fe9a 100644 --- a/packages/react-data-grid/src/utils/viewportUtils.ts +++ b/packages/react-data-grid/src/utils/viewportUtils.ts @@ -35,8 +35,6 @@ export interface VerticalRangeToRenderParams { } export interface VerticalRangeToRender { - rowVisibleStartIdx: number; - rowVisibleEndIdx: number; rowOverscanStartIdx: number; rowOverscanEndIdx: number; } @@ -49,13 +47,12 @@ export function getVerticalRangeToRender({ scrollDirection, overscanRowCount = 2 }: VerticalRangeToRenderParams): VerticalRangeToRender { - const renderedRowsCount = Math.ceil(height / rowHeight); - const rowVisibleStartIdx = Math.max(0, Math.round(scrollTop / rowHeight)); - const rowVisibleEndIdx = Math.min(rowsCount, rowVisibleStartIdx + renderedRowsCount); + const rowVisibleStartIdx = Math.floor(scrollTop / rowHeight); + const rowVisibleEndIdx = Math.min(rowsCount - 1, Math.floor((scrollTop + height) / rowHeight)); const rowOverscanStartIdx = Math.max(0, rowVisibleStartIdx - (scrollDirection === SCROLL_DIRECTION.UP ? overscanRowCount : 2)); - const rowOverscanEndIdx = Math.min(rowsCount, rowVisibleEndIdx + (scrollDirection === SCROLL_DIRECTION.DOWN ? overscanRowCount : 2)); + const rowOverscanEndIdx = Math.min(rowsCount - 1, rowVisibleEndIdx + (scrollDirection === SCROLL_DIRECTION.DOWN ? overscanRowCount : 2)); - return { rowVisibleStartIdx, rowVisibleEndIdx, rowOverscanStartIdx, rowOverscanEndIdx }; + return { rowOverscanStartIdx, rowOverscanEndIdx }; } export interface HorizontalRangeToRender { @@ -67,7 +64,6 @@ export interface HorizontalRangeToRender { export interface HorizontalRangeToRenderParams { columnMetrics: ColumnMetrics; - viewportWidth: number; scrollLeft: number; scrollDirection: SCROLL_DIRECTION; overscanColumnCount?: number; @@ -76,11 +72,11 @@ export interface HorizontalRangeToRenderParams { export function getHorizontalRangeToRender({ columnMetrics, scrollLeft, - viewportWidth, scrollDirection, overscanColumnCount = 2 }: HorizontalRangeToRenderParams): HorizontalRangeToRender { const { columns, totalColumnWidth, lastFrozenColumnIndex } = columnMetrics; + let { viewportWidth } = columnMetrics; let remainingScroll = scrollLeft; let columnIndex = lastFrozenColumnIndex;