UNPKG

@mui/x-virtualizer

Version:

MUI virtualization library

834 lines (811 loc) 34.2 kB
"use strict"; 'use client'; var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; Object.defineProperty(exports, "__esModule", { value: true }); exports.Virtualization = exports.EMPTY_RENDER_CONTEXT = void 0; exports.areRenderContextsEqual = areRenderContextsEqual; exports.computeOffsetLeft = computeOffsetLeft; exports.roundToDecimalPlaces = roundToDecimalPlaces; var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends")); var React = _interopRequireWildcard(require("react")); var ReactDOM = _interopRequireWildcard(require("react-dom")); var _useLazyRef = _interopRequireDefault(require("@mui/utils/useLazyRef")); var _useTimeout = _interopRequireDefault(require("@mui/utils/useTimeout")); var _useEventCallback = _interopRequireDefault(require("@mui/utils/useEventCallback")); var _useEnhancedEffect = _interopRequireDefault(require("@mui/utils/useEnhancedEffect")); var platform = _interopRequireWildcard(require("@mui/x-internals/platform")); var _useRunOnce = require("@mui/x-internals/useRunOnce"); var _useFirstRender = require("@mui/x-internals/useFirstRender"); var _store = require("@mui/x-internals/store"); var _core = require("../models/core"); var _dimensions = require("./dimensions"); var _models = require("../models"); /* eslint-disable import/export, @typescript-eslint/no-redeclare */ const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const MINIMUM_COLUMN_WIDTH = 50; const EMPTY_SCROLL_POSITION = { top: 0, left: 0 }; const EMPTY_DETAIL_PANELS = Object.freeze(new Map()); const EMPTY_RENDER_CONTEXT = exports.EMPTY_RENDER_CONTEXT = { firstRowIndex: 0, lastRowIndex: 0, firstColumnIndex: 0, lastColumnIndex: 0 }; const selectors = { renderContext: (0, _store.createSelector)(state => state.virtualization.renderContext), enabledForRows: (0, _store.createSelector)(state => state.virtualization.enabledForRows), enabledForColumns: (0, _store.createSelector)(state => state.virtualization.enabledForColumns) }; const Virtualization = exports.Virtualization = { initialize: initializeState, use: useVirtualization, selectors }; function initializeState(params) { const state = { virtualization: (0, _extends2.default)({ enabled: !platform.isJSDOM, enabledForRows: !platform.isJSDOM, enabledForColumns: !platform.isJSDOM, renderContext: EMPTY_RENDER_CONTEXT }, params.initialState?.virtualization), // FIXME: refactor once the state shape is settled getters: null }; return state; } /** APIs to override for colspan/rowspan */ function useVirtualization(store, params, api) { const { refs, dimensions: { rowHeight, columnsTotalWidth }, virtualization: { isRtl = false, rowBufferPx = 150, columnBufferPx = 150 }, colspan, initialState, rows, range, columns, pinnedRows = _core.PinnedRows.EMPTY, pinnedColumns = _core.PinnedColumns.EMPTY, minimalContentHeight, autoHeight, onWheel, onTouchMove, onRenderContextChange, onScrollChange, scrollReset, renderRow, renderInfiniteLoadingTrigger } = params; const needsHorizontalScrollbar = (0, _store.useStore)(store, _dimensions.Dimensions.selectors.needsHorizontalScrollbar); const hasBottomPinnedRows = pinnedRows.bottom.length > 0; const [panels, setPanels] = React.useState(EMPTY_DETAIL_PANELS); const [, setRefTick] = React.useState(0); const isRenderContextReady = React.useRef(false); const renderContext = (0, _store.useStore)(store, selectors.renderContext); const enabledForRows = (0, _store.useStore)(store, selectors.enabledForRows); const enabledForColumns = (0, _store.useStore)(store, selectors.enabledForColumns); const contentHeight = (0, _store.useStore)(store, _dimensions.Dimensions.selectors.contentHeight); /* * Scroll context logic * ==================== * We only render the cells contained in the `renderContext`. However, when the user starts scrolling the grid * in a direction, we want to render as many cells as possible in that direction, as to avoid presenting white * areas if the user scrolls too fast/far and the viewport ends up in a region we haven't rendered yet. To render * more cells, we store some offsets to add to the viewport in `scrollCache.buffer`. Those offsets make the render * context wider in the direction the user is going, but also makes the buffer around the viewport `0` for the * dimension (horizontal or vertical) in which the user is not scrolling. So if the normal viewport is 8 columns * wide, with a 1 column buffer (10 columns total), then we want it to be exactly 8 columns wide during vertical * scroll. * However, we don't want the rows in the old context to re-render from e.g. 10 columns to 8 columns, because that's * work that's not necessary. Thus we store the context at the start of the scroll in `frozenContext`, and the rows * that are part of this old context will keep their same render context as to avoid re-rendering. */ const scrollPosition = React.useRef(initialState?.scroll ?? EMPTY_SCROLL_POSITION); const ignoreNextScrollEvent = React.useRef(false); const previousContextScrollPosition = React.useRef(EMPTY_SCROLL_POSITION); const previousRowContext = React.useRef(EMPTY_RENDER_CONTEXT); const scrollTimeout = (0, _useTimeout.default)(); const frozenContext = React.useRef(undefined); const scrollCache = (0, _useLazyRef.default)(() => createScrollCache(isRtl, rowBufferPx, columnBufferPx, rowHeight * 15, MINIMUM_COLUMN_WIDTH * 6)).current; const updateRenderContext = React.useCallback(nextRenderContext => { if (!areRenderContextsEqual(nextRenderContext, store.state.virtualization.renderContext)) { store.set('virtualization', (0, _extends2.default)({}, store.state.virtualization, { renderContext: nextRenderContext })); } // The lazy-loading hook is listening to `renderedRowsIntervalChange`, // but only does something if we already have a render context, because // otherwise we would call an update directly on mount const isReady = _dimensions.Dimensions.selectors.dimensions(store.state).isReady; const didRowsIntervalChange = nextRenderContext.firstRowIndex !== previousRowContext.current.firstRowIndex || nextRenderContext.lastRowIndex !== previousRowContext.current.lastRowIndex; if (isReady && didRowsIntervalChange) { previousRowContext.current = nextRenderContext; onRenderContextChange?.(nextRenderContext); } previousContextScrollPosition.current = scrollPosition.current; }, [store, onRenderContextChange]); const triggerUpdateRenderContext = (0, _useEventCallback.default)(() => { const scroller = refs.scroller.current; if (!scroller) { return undefined; } const dimensions = _dimensions.Dimensions.selectors.dimensions(store.state); const maxScrollTop = Math.ceil(dimensions.minimumSize.height - dimensions.viewportOuterSize.height); const maxScrollLeft = Math.ceil(dimensions.minimumSize.width - dimensions.viewportInnerSize.width); // Clamp the scroll position to the viewport to avoid re-calculating the render context for scroll bounce const newScroll = { top: clamp(scroller.scrollTop, 0, maxScrollTop), left: isRtl ? clamp(scroller.scrollLeft, -maxScrollLeft, 0) : clamp(scroller.scrollLeft, 0, maxScrollLeft) }; const dx = newScroll.left - scrollPosition.current.left; const dy = newScroll.top - scrollPosition.current.top; const isScrolling = dx !== 0 || dy !== 0; scrollPosition.current = newScroll; const direction = isScrolling ? _models.ScrollDirection.forDelta(dx, dy) : _models.ScrollDirection.NONE; // Since previous render, we have scrolled... const rowScroll = Math.abs(scrollPosition.current.top - previousContextScrollPosition.current.top); const columnScroll = Math.abs(scrollPosition.current.left - previousContextScrollPosition.current.left); // PERF: use the computed minimum column width instead of a static one const didCrossThreshold = rowScroll >= rowHeight || columnScroll >= MINIMUM_COLUMN_WIDTH; const didChangeDirection = scrollCache.direction !== direction; const shouldUpdate = didCrossThreshold || didChangeDirection; if (!shouldUpdate) { return renderContext; } // Render a new context if (didChangeDirection) { switch (direction) { case _models.ScrollDirection.NONE: case _models.ScrollDirection.LEFT: case _models.ScrollDirection.RIGHT: frozenContext.current = undefined; break; default: frozenContext.current = renderContext; break; } } scrollCache.direction = direction; scrollCache.buffer = bufferForDirection(isRtl, direction, rowBufferPx, columnBufferPx, rowHeight * 15, MINIMUM_COLUMN_WIDTH * 6); const inputs = inputsSelector(store, params, api, enabledForRows, enabledForColumns); const nextRenderContext = computeRenderContext(inputs, scrollPosition.current, scrollCache); if (!areRenderContextsEqual(nextRenderContext, renderContext)) { // Prevents batching render context changes ReactDOM.flushSync(() => { updateRenderContext(nextRenderContext); }); scrollTimeout.start(1000, triggerUpdateRenderContext); } return nextRenderContext; }); const forceUpdateRenderContext = (0, _useEventCallback.default)(() => { // skip update if dimensions are not ready and virtualization is enabled if (!_dimensions.Dimensions.selectors.dimensions(store.state).isReady && (enabledForRows || enabledForColumns)) { return; } const inputs = inputsSelector(store, params, api, enabledForRows, enabledForColumns); const nextRenderContext = computeRenderContext(inputs, scrollPosition.current, scrollCache); // Reset the frozen context when the render context changes, see the illustration in https://github.com/mui/mui-x/pull/12353 frozenContext.current = undefined; updateRenderContext(nextRenderContext); }); const handleScroll = (0, _useEventCallback.default)(() => { if (ignoreNextScrollEvent.current) { ignoreNextScrollEvent.current = false; return; } const nextRenderContext = triggerUpdateRenderContext(); if (nextRenderContext) { onScrollChange?.(scrollPosition.current, nextRenderContext); } }); /** * HACK: unstable_rowTree fixes the issue described below, but does it by tightly coupling this * section of code to the DataGrid's rowTree model. The `unstable_rowTree` param is a temporary * solution to decouple the code. */ const getRows = (rowParams = {}, unstable_rowTree) => { if (!rowParams.rows && !range) { return []; } let baseRenderContext = renderContext; if (rowParams.renderContext) { baseRenderContext = rowParams.renderContext; baseRenderContext.firstColumnIndex = renderContext.firstColumnIndex; baseRenderContext.lastColumnIndex = renderContext.lastColumnIndex; } const isLastSection = !hasBottomPinnedRows && rowParams.position === undefined || hasBottomPinnedRows && rowParams.position === 'bottom'; const isPinnedSection = rowParams.position !== undefined; let rowIndexOffset; switch (rowParams.position) { case 'top': rowIndexOffset = 0; break; case 'bottom': rowIndexOffset = pinnedRows.top.length + rows.length; break; case undefined: default: rowIndexOffset = pinnedRows.top.length; break; } const rowModels = rowParams.rows ?? rows; const firstRowToRender = baseRenderContext.firstRowIndex; const lastRowToRender = Math.min(baseRenderContext.lastRowIndex, rowModels.length); const rowIndexes = rowParams.rows ? createRange(0, rowParams.rows.length) : createRange(firstRowToRender, lastRowToRender); let virtualRowIndex = -1; const focusedVirtualCell = params.focusedVirtualCell?.(); if (!isPinnedSection && focusedVirtualCell) { if (focusedVirtualCell.rowIndex < firstRowToRender) { rowIndexes.unshift(focusedVirtualCell.rowIndex); virtualRowIndex = focusedVirtualCell.rowIndex; } if (focusedVirtualCell.rowIndex > lastRowToRender) { rowIndexes.push(focusedVirtualCell.rowIndex); virtualRowIndex = focusedVirtualCell.rowIndex; } } const rowElements = []; const columnPositions = _dimensions.Dimensions.selectors.columnPositions(store.state, columns); rowIndexes.forEach(rowIndexInPage => { const { id, model } = rowModels[rowIndexInPage]; // In certain cases, the state might already be updated and `params.rows` (which sets `rowModels`) // contains stale data. // In that case, skip any further row processing. // See: // - https://github.com/mui/mui-x/issues/16638 // - https://github.com/mui/mui-x/issues/17022 if (unstable_rowTree && !unstable_rowTree[id]) { return; } const rowIndex = (range?.firstRowIndex || 0) + rowIndexOffset + rowIndexInPage; // NOTE: This is an expensive feature, the colSpan code could be optimized. if (colspan?.enabled) { const minFirstColumn = pinnedColumns.left.length; const maxLastColumn = columns.length - pinnedColumns.right.length; api.calculateColSpan(id, minFirstColumn, maxLastColumn, columns); if (pinnedColumns.left.length > 0) { api.calculateColSpan(id, 0, pinnedColumns.left.length, columns); } if (pinnedColumns.right.length > 0) { api.calculateColSpan(id, columns.length - pinnedColumns.right.length, columns.length, columns); } } const baseRowHeight = !api.rowsMeta.rowHasAutoHeight(id) ? api.rowsMeta.getRowHeight(id) : 'auto'; let isFirstVisible = false; if (rowParams.position === undefined) { isFirstVisible = rowIndexInPage === 0; } let isLastVisible = false; const isLastVisibleInSection = rowIndexInPage === rowModels.length - 1; if (isLastSection) { if (!isPinnedSection) { const lastIndex = rows.length - 1; const isLastVisibleRowIndex = rowIndexInPage === lastIndex; if (isLastVisibleRowIndex) { isLastVisible = true; } } else { isLastVisible = isLastVisibleInSection; } } let currentRenderContext = baseRenderContext; if (frozenContext.current && rowIndexInPage >= frozenContext.current.firstRowIndex && rowIndexInPage < frozenContext.current.lastRowIndex) { currentRenderContext = frozenContext.current; } const isVirtualFocusRow = rowIndexInPage === virtualRowIndex; const isVirtualFocusColumn = focusedVirtualCell?.rowIndex === rowIndex; const offsetLeft = computeOffsetLeft(columnPositions, currentRenderContext, pinnedColumns.left.length); const showBottomBorder = isLastVisibleInSection && rowParams.position === 'top'; const firstColumnIndex = currentRenderContext.firstColumnIndex; const lastColumnIndex = currentRenderContext.lastColumnIndex; rowElements.push(renderRow({ id, model, rowIndex, offsetLeft, columnsTotalWidth, baseRowHeight, firstColumnIndex, lastColumnIndex, focusedColumnIndex: isVirtualFocusColumn ? focusedVirtualCell.columnIndex : undefined, isFirstVisible, isLastVisible, isVirtualFocusRow, showBottomBorder })); if (isVirtualFocusRow) { return; } const panel = panels.get(id); if (panel) { rowElements.push(panel); } if (rowParams.position === undefined && isLastVisibleInSection) { rowElements.push(renderInfiniteLoadingTrigger(id)); } }); return rowElements; }; const scrollerStyle = React.useMemo(() => ({ overflowX: !needsHorizontalScrollbar ? 'hidden' : undefined, overflowY: autoHeight ? 'hidden' : undefined }), [needsHorizontalScrollbar, autoHeight]); const contentSize = React.useMemo(() => { const size = { width: needsHorizontalScrollbar ? columnsTotalWidth : 'auto', flexBasis: contentHeight, flexShrink: 0 }; if (size.flexBasis === 0) { size.flexBasis = minimalContentHeight; // Give room to show the overlay when there no rows. } return size; }, [columnsTotalWidth, contentHeight, needsHorizontalScrollbar, minimalContentHeight]); const scrollRestoreCallback = React.useRef(null); const contentNodeRef = React.useCallback(node => { if (!node) { return; } scrollRestoreCallback.current?.(columnsTotalWidth, contentHeight); }, [columnsTotalWidth, contentHeight]); (0, _useEnhancedEffect.default)(() => { if (!isRenderContextReady.current) { return; } forceUpdateRenderContext(); }, [enabledForColumns, enabledForRows, forceUpdateRenderContext]); (0, _useEnhancedEffect.default)(() => { if (refs.scroller.current) { refs.scroller.current.scrollLeft = 0; } }, [refs.scroller, scrollReset]); (0, _useRunOnce.useRunOnce)(renderContext !== EMPTY_RENDER_CONTEXT, () => { onScrollChange?.(scrollPosition.current, renderContext); isRenderContextReady.current = true; if (initialState?.scroll && refs.scroller.current) { const scroller = refs.scroller.current; const { top, left } = initialState.scroll; const isScrollRestored = { top: !(top > 0), left: !(left > 0) }; if (!isScrollRestored.left && columnsTotalWidth) { scroller.scrollLeft = left; isScrollRestored.left = true; ignoreNextScrollEvent.current = true; } // To restore the vertical scroll, we need to wait until the rows are available in the DOM (otherwise // there's nowhere to scroll). We still set the scrollTop to the initial value at this point in case // there already are rows rendered in the DOM, but we only confirm `isScrollRestored.top = true` in the // asynchronous callback below. if (!isScrollRestored.top && contentHeight) { scroller.scrollTop = top; ignoreNextScrollEvent.current = true; } if (!isScrollRestored.top || !isScrollRestored.left) { scrollRestoreCallback.current = (columnsTotalWidthCurrent, contentHeightCurrent) => { if (!isScrollRestored.left && columnsTotalWidthCurrent) { scroller.scrollLeft = left; isScrollRestored.left = true; ignoreNextScrollEvent.current = true; } if (!isScrollRestored.top && contentHeightCurrent) { scroller.scrollTop = top; isScrollRestored.top = true; ignoreNextScrollEvent.current = true; } if (isScrollRestored.left && isScrollRestored.top) { scrollRestoreCallback.current = null; } }; } } }); (0, _store.useStoreEffect)(store, _dimensions.Dimensions.selectors.dimensions, forceUpdateRenderContext); const refSetter = name => node => { if (node && refs[name].current !== node) { refs[name].current = node; setRefTick(tick => tick + 1); } }; const getters = { setPanels, getRows, getContainerProps: () => ({ ref: refSetter('container') }), getScrollerProps: () => ({ ref: refSetter('scroller'), onScroll: handleScroll, onWheel, onTouchMove, style: scrollerStyle, role: 'presentation', // `tabIndex` shouldn't be used along role=presentation, but it fixes a Firefox bug // https://github.com/mui/mui-x/pull/13891#discussion_r1683416024 tabIndex: platform.isFirefox ? -1 : undefined }), getContentProps: () => ({ ref: contentNodeRef, style: contentSize, role: 'presentation' }), getScrollbarVerticalProps: () => ({ ref: refSetter('scrollbarVertical'), scrollPosition }), getScrollbarHorizontalProps: () => ({ ref: refSetter('scrollbarHorizontal'), scrollPosition }), getScrollAreaProps: () => ({ scrollPosition }) }; (0, _useFirstRender.useFirstRender)(() => { store.state = (0, _extends2.default)({}, store.state, { getters }); }); React.useEffect(() => { store.update({ getters }); // eslint-disable-next-line react-hooks/exhaustive-deps }, Object.values(getters)); /* Placeholder API functions for colspan & rowspan to re-implement */ const getCellColSpanInfo = () => { throw new Error('Unimplemented: colspan feature is required'); }; const calculateColSpan = () => { throw new Error('Unimplemented: colspan feature is required'); }; const getHiddenCellsOrigin = () => { throw new Error('Unimplemented: rowspan feature is required'); }; return { getters, useVirtualization: () => (0, _store.useStore)(store, state => state), setPanels, forceUpdateRenderContext, getCellColSpanInfo, calculateColSpan, getHiddenCellsOrigin }; } function inputsSelector(store, params, api, enabledForRows, enabledForColumns) { const dimensions = _dimensions.Dimensions.selectors.dimensions(store.state); const rows = params.rows; const range = params.range; const columns = params.columns; const hiddenCellsOriginMap = api.getHiddenCellsOrigin(); const lastRowId = params.rows.at(-1)?.id; const lastColumn = columns.at(-1); return { api, enabledForRows, enabledForColumns, autoHeight: params.autoHeight, rowBufferPx: params.virtualization.rowBufferPx, columnBufferPx: params.virtualization.columnBufferPx, leftPinnedWidth: dimensions.leftPinnedWidth, columnsTotalWidth: dimensions.columnsTotalWidth, viewportInnerWidth: dimensions.viewportInnerSize.width, viewportInnerHeight: dimensions.viewportInnerSize.height, lastRowHeight: lastRowId !== undefined ? api.rowsMeta.getRowHeight(lastRowId) : 0, lastColumnWidth: lastColumn?.computedWidth ?? 0, rowsMeta: _dimensions.Dimensions.selectors.rowsMeta(store.state), columnPositions: _dimensions.Dimensions.selectors.columnPositions(store.state, params.columns), rows, range, pinnedColumns: params.pinnedColumns, columns, hiddenCellsOriginMap, virtualizeColumnsWithAutoRowHeight: params.virtualizeColumnsWithAutoRowHeight }; } function computeRenderContext(inputs, scrollPosition, scrollCache) { const renderContext = { firstRowIndex: 0, lastRowIndex: inputs.rows.length, firstColumnIndex: 0, lastColumnIndex: inputs.columns.length }; const { top, left } = scrollPosition; const realLeft = Math.abs(left) + inputs.leftPinnedWidth; if (inputs.enabledForRows) { // Clamp the value because the search may return an index out of bounds. // In the last index, this is not needed because Array.slice doesn't include it. let firstRowIndex = Math.min(getNearestIndexToRender(inputs, top, { atStart: true, lastPosition: inputs.rowsMeta.positions[inputs.rowsMeta.positions.length - 1] + inputs.lastRowHeight }), inputs.rowsMeta.positions.length - 1); // If any of the cells in the `firstRowIndex` is hidden due to an extended row span, // Make sure the row from where the rowSpan is originated is visible. const rowSpanHiddenCellOrigin = inputs.hiddenCellsOriginMap[firstRowIndex]; if (rowSpanHiddenCellOrigin) { const minSpannedRowIndex = Math.min(...Object.values(rowSpanHiddenCellOrigin)); firstRowIndex = Math.min(firstRowIndex, minSpannedRowIndex); } const lastRowIndex = inputs.autoHeight ? firstRowIndex + inputs.rows.length : getNearestIndexToRender(inputs, top + inputs.viewportInnerHeight); renderContext.firstRowIndex = firstRowIndex; renderContext.lastRowIndex = lastRowIndex; } // XXX // if (inputs.listView) { // return { // ...renderContext, // lastColumnIndex: 1, // }; // } if (inputs.enabledForColumns) { let firstColumnIndex = 0; let lastColumnIndex = inputs.columnPositions.length; let hasRowWithAutoHeight = false; const [firstRowToRender, lastRowToRender] = getIndexesToRender({ firstIndex: renderContext.firstRowIndex, lastIndex: renderContext.lastRowIndex, minFirstIndex: 0, maxLastIndex: inputs.rows.length, bufferBefore: scrollCache.buffer.rowBefore, bufferAfter: scrollCache.buffer.rowAfter, positions: inputs.rowsMeta.positions, lastSize: inputs.lastRowHeight }); if (!inputs.virtualizeColumnsWithAutoRowHeight) { for (let i = firstRowToRender; i < lastRowToRender && !hasRowWithAutoHeight; i += 1) { const row = inputs.rows[i]; hasRowWithAutoHeight = inputs.api.rowsMeta.rowHasAutoHeight(row.id); } } if (!hasRowWithAutoHeight || inputs.virtualizeColumnsWithAutoRowHeight) { firstColumnIndex = binarySearch(realLeft, inputs.columnPositions, { atStart: true, lastPosition: inputs.columnsTotalWidth }); lastColumnIndex = binarySearch(realLeft + inputs.viewportInnerWidth, inputs.columnPositions); } renderContext.firstColumnIndex = firstColumnIndex; renderContext.lastColumnIndex = lastColumnIndex; } const actualRenderContext = deriveRenderContext(inputs, renderContext, scrollCache); return actualRenderContext; } function getNearestIndexToRender(inputs, offset, options) { const lastMeasuredIndexRelativeToAllRows = inputs.api.rowsMeta.getLastMeasuredRowIndex(); let allRowsMeasured = lastMeasuredIndexRelativeToAllRows === Infinity; if (inputs.range?.lastRowIndex && !allRowsMeasured) { // Check if all rows in this page are already measured allRowsMeasured = lastMeasuredIndexRelativeToAllRows >= inputs.range.lastRowIndex; } const lastMeasuredIndexRelativeToCurrentPage = clamp(lastMeasuredIndexRelativeToAllRows - (inputs.range?.firstRowIndex || 0), 0, inputs.rowsMeta.positions.length); if (allRowsMeasured || inputs.rowsMeta.positions[lastMeasuredIndexRelativeToCurrentPage] >= offset) { // If all rows were measured (when no row has "auto" as height) or all rows before the offset // were measured, then use a binary search because it's faster. return binarySearch(offset, inputs.rowsMeta.positions, options); } // Otherwise, use an exponential search. // If rows have "auto" as height, their positions will be based on estimated heights. // In this case, we can skip several steps until we find a position higher than the offset. // Inspired by https://github.com/bvaughn/react-virtualized/blob/master/source/Grid/utils/CellSizeAndPositionManager.js return exponentialSearch(offset, inputs.rowsMeta.positions, lastMeasuredIndexRelativeToCurrentPage, options); } /** * Accepts as input a raw render context (the area visible in the viewport) and adds * computes the actual render context based on pinned elements, buffer dimensions and * spanning. */ function deriveRenderContext(inputs, nextRenderContext, scrollCache) { const [firstRowToRender, lastRowToRender] = getIndexesToRender({ firstIndex: nextRenderContext.firstRowIndex, lastIndex: nextRenderContext.lastRowIndex, minFirstIndex: 0, maxLastIndex: inputs.rows.length, bufferBefore: scrollCache.buffer.rowBefore, bufferAfter: scrollCache.buffer.rowAfter, positions: inputs.rowsMeta.positions, lastSize: inputs.lastRowHeight }); const [initialFirstColumnToRender, lastColumnToRender] = getIndexesToRender({ firstIndex: nextRenderContext.firstColumnIndex, lastIndex: nextRenderContext.lastColumnIndex, minFirstIndex: inputs.pinnedColumns?.left.length ?? 0, maxLastIndex: inputs.columns.length - (inputs.pinnedColumns?.right.length ?? 0), bufferBefore: scrollCache.buffer.columnBefore, bufferAfter: scrollCache.buffer.columnAfter, positions: inputs.columnPositions, lastSize: inputs.lastColumnWidth }); const firstColumnToRender = getFirstNonSpannedColumnToRender({ api: inputs.api, firstColumnToRender: initialFirstColumnToRender, firstRowToRender, lastRowToRender, visibleRows: inputs.rows }); return { firstRowIndex: firstRowToRender, lastRowIndex: lastRowToRender, firstColumnIndex: firstColumnToRender, lastColumnIndex: lastColumnToRender }; } /** * Use binary search to avoid looping through all possible positions. * The `options.atStart` provides the possibility to match for the first element that * intersects the screen, even if said element's start position is before `offset`. In * other words, we search for `offset + width`. */ function binarySearch(offset, positions, options = undefined, sliceStart = 0, sliceEnd = positions.length) { if (positions.length <= 0) { return -1; } if (sliceStart >= sliceEnd) { return sliceStart; } const pivot = sliceStart + Math.floor((sliceEnd - sliceStart) / 2); const position = positions[pivot]; let isBefore; if (options?.atStart) { const width = (pivot === positions.length - 1 ? options.lastPosition : positions[pivot + 1]) - position; isBefore = offset - width < position; } else { isBefore = offset <= position; } return isBefore ? binarySearch(offset, positions, options, sliceStart, pivot) : binarySearch(offset, positions, options, pivot + 1, sliceEnd); } function exponentialSearch(offset, positions, index, options = undefined) { let interval = 1; while (index < positions.length && Math.abs(positions[index]) < offset) { index += interval; interval *= 2; } return binarySearch(offset, positions, options, Math.floor(index / 2), Math.min(index, positions.length)); } function getIndexesToRender({ firstIndex, lastIndex, bufferBefore, bufferAfter, minFirstIndex, maxLastIndex, positions, lastSize }) { const firstPosition = positions[firstIndex] - bufferBefore; const lastPosition = positions[lastIndex] + bufferAfter; const firstIndexPadded = binarySearch(firstPosition, positions, { atStart: true, lastPosition: positions[positions.length - 1] + lastSize }); const lastIndexPadded = binarySearch(lastPosition, positions); return [clamp(firstIndexPadded, minFirstIndex, maxLastIndex), clamp(lastIndexPadded, minFirstIndex, maxLastIndex)]; } function areRenderContextsEqual(context1, context2) { if (context1 === context2) { return true; } return context1.firstRowIndex === context2.firstRowIndex && context1.lastRowIndex === context2.lastRowIndex && context1.firstColumnIndex === context2.firstColumnIndex && context1.lastColumnIndex === context2.lastColumnIndex; } function computeOffsetLeft(columnPositions, renderContext, pinnedLeftLength) { const left = (columnPositions[renderContext.firstColumnIndex] ?? 0) - (columnPositions[pinnedLeftLength] ?? 0); return Math.abs(left); } function bufferForDirection(isRtl, direction, rowBufferPx, columnBufferPx, verticalBuffer, horizontalBuffer) { if (isRtl) { switch (direction) { case _models.ScrollDirection.LEFT: direction = _models.ScrollDirection.RIGHT; break; case _models.ScrollDirection.RIGHT: direction = _models.ScrollDirection.LEFT; break; default: } } switch (direction) { case _models.ScrollDirection.NONE: return { rowAfter: rowBufferPx, rowBefore: rowBufferPx, columnAfter: columnBufferPx, columnBefore: columnBufferPx }; case _models.ScrollDirection.LEFT: return { rowAfter: 0, rowBefore: 0, columnAfter: 0, columnBefore: horizontalBuffer }; case _models.ScrollDirection.RIGHT: return { rowAfter: 0, rowBefore: 0, columnAfter: horizontalBuffer, columnBefore: 0 }; case _models.ScrollDirection.UP: return { rowAfter: 0, rowBefore: verticalBuffer, columnAfter: 0, columnBefore: 0 }; case _models.ScrollDirection.DOWN: return { rowAfter: verticalBuffer, rowBefore: 0, columnAfter: 0, columnBefore: 0 }; default: // eslint unable to figure out enum exhaustiveness throw new Error('unreachable'); } } function createScrollCache(isRtl, rowBufferPx, columnBufferPx, verticalBuffer, horizontalBuffer) { return { direction: _models.ScrollDirection.NONE, buffer: bufferForDirection(isRtl, _models.ScrollDirection.NONE, rowBufferPx, columnBufferPx, verticalBuffer, horizontalBuffer) }; } function createRange(from, to) { return Array.from({ length: to - from }).map((_, i) => from + i); } function getFirstNonSpannedColumnToRender({ api, firstColumnToRender, firstRowToRender, lastRowToRender, visibleRows }) { let firstNonSpannedColumnToRender = firstColumnToRender; let foundStableColumn = false; // Keep checking columns until we find one that's not spanned in any visible row while (!foundStableColumn && firstNonSpannedColumnToRender >= 0) { foundStableColumn = true; for (let i = firstRowToRender; i < lastRowToRender; i += 1) { const row = visibleRows[i]; if (row) { const rowId = visibleRows[i].id; const cellColSpanInfo = api.getCellColSpanInfo(rowId, firstNonSpannedColumnToRender); if (cellColSpanInfo && cellColSpanInfo.spannedByColSpan && cellColSpanInfo.leftVisibleCellIndex < firstNonSpannedColumnToRender) { firstNonSpannedColumnToRender = cellColSpanInfo.leftVisibleCellIndex; foundStableColumn = false; break; // Check the new column index against the visible rows, because it might be spanned } } } } return firstNonSpannedColumnToRender; } function roundToDecimalPlaces(value, decimals) { return Math.round(value * 10 ** decimals) / 10 ** decimals; }