UNPKG

react-konva-grid

Version:

Declarative React Canvas Grid primitive for Data table, Pivot table, Excel Worksheets

723 lines (652 loc) 17.9 kB
// Utilities extracted from https://github.com/bvaughn/react-window import { ItemSizer, InstanceInterface, AreaProps, CellInterface, CellMetaData, SelectionArea, } from "./Grid"; import { Movement } from "./types"; enum Align { start = "start", end = "end", center = "center", auto = "auto", smart = "smart", } enum ItemType { row = "row", column = "column", } export interface IItemMetaData { itemType: ItemType; offset: number; index: number; rowCount: number; columnCount: number; rowHeight: ItemSizer; columnWidth: ItemSizer; instanceProps: InstanceInterface; } export const getRowStartIndexForOffset = ({ rowHeight, columnWidth, rowCount, columnCount, instanceProps, offset, }: Omit<IItemMetaData, "index" | "itemType">): number => { return findNearestItem({ itemType: ItemType.row, rowHeight, columnWidth, rowCount, columnCount, instanceProps, offset, }); }; interface IRowStopIndex extends Omit<IItemMetaData, "itemType" | "index" | "offset" | "columnCount"> { startIndex: number; containerHeight: number; scrollTop: number; } export const getRowStopIndexForStartIndex = ({ startIndex, rowCount, rowHeight, columnWidth, scrollTop, containerHeight, instanceProps, }: IRowStopIndex): number => { const itemMetadata = getItemMetadata({ itemType: ItemType.row, rowHeight, columnWidth, index: startIndex, instanceProps, }); const maxOffset = scrollTop + containerHeight; let offset = itemMetadata.offset + itemMetadata.size; let stopIndex = startIndex; while (stopIndex < rowCount - 1 && offset < maxOffset) { stopIndex++; offset += getItemMetadata({ itemType: ItemType.row, rowHeight, columnWidth, index: stopIndex, instanceProps, }).size; } return stopIndex; }; export const getColumnStartIndexForOffset = ({ rowHeight, columnWidth, rowCount, columnCount, instanceProps, offset, }: Omit<IItemMetaData, "index" | "itemType">): number => { return findNearestItem({ itemType: ItemType.column, rowHeight, columnWidth, rowCount, columnCount, instanceProps, offset, }); }; interface IColumnStopIndex extends Omit<IItemMetaData, "itemType" | "index" | "offset" | "rowCount"> { startIndex: number; containerWidth: number; scrollLeft: number; } export const getColumnStopIndexForStartIndex = ({ startIndex, rowHeight, columnWidth, instanceProps, containerWidth, scrollLeft, columnCount, }: IColumnStopIndex): number => { const itemMetadata = getItemMetadata({ itemType: ItemType.column, index: startIndex, rowHeight, columnWidth, instanceProps, }); const maxOffset = scrollLeft + containerWidth; let offset = itemMetadata.offset + itemMetadata.size; let stopIndex = startIndex; while (stopIndex < columnCount - 1 && offset < maxOffset) { stopIndex++; offset += getItemMetadata({ itemType: ItemType.column, rowHeight, columnWidth, index: stopIndex, instanceProps, }).size; } return stopIndex; }; export const getBoundedCells = (area: AreaProps | null | undefined) => { const cells = new Set(); if (!area) return cells; const { top, bottom, left, right } = area; for (let i = top; i <= bottom; i++) { for (let j = left; j <= right; j++) { cells.add(cellIndentifier(i, j)); } } return cells; }; export const itemKey = ({ rowIndex, columnIndex }: CellInterface) => `${rowIndex}:${columnIndex}`; export const getRowOffset = ({ index, rowHeight, columnWidth, instanceProps, }: Omit<IGetItemMetadata, "itemType">): number => { return getItemMetadata({ itemType: ItemType.row, index, rowHeight, columnWidth, instanceProps, }).offset; }; export const getColumnOffset = ({ index, rowHeight, columnWidth, instanceProps, }: Omit<IGetItemMetadata, "itemType">): number => { return getItemMetadata({ itemType: ItemType.column, index, rowHeight, columnWidth, instanceProps, }).offset; }; export const getRowHeight = ( index: number, instanceProps: InstanceInterface ) => { return instanceProps.rowMetadataMap[index].size; }; export const getColumnWidth = ( index: number, instanceProps: InstanceInterface ) => { return instanceProps.columnMetadataMap[index].size; }; interface IGetItemMetadata extends Pick< IItemMetaData, "itemType" | "index" | "rowHeight" | "columnWidth" | "instanceProps" > {} export const getItemMetadata = ({ itemType, index, rowHeight, columnWidth, instanceProps, }: IGetItemMetadata): CellMetaData => { let itemMetadataMap, itemSize, lastMeasuredIndex, recalcIndexes: number[]; if (itemType === "column") { itemMetadataMap = instanceProps.columnMetadataMap; itemSize = columnWidth; lastMeasuredIndex = instanceProps.lastMeasuredColumnIndex; recalcIndexes = instanceProps.recalcColumnIndexes; } else { itemMetadataMap = instanceProps.rowMetadataMap; itemSize = rowHeight; lastMeasuredIndex = instanceProps.lastMeasuredRowIndex; recalcIndexes = instanceProps.recalcRowIndexes; } const recalcWithinBoundsOnly = recalcIndexes.length > 0; if (index > lastMeasuredIndex) { let offset = 0; if (lastMeasuredIndex >= 0) { const itemMetadata = itemMetadataMap[lastMeasuredIndex]; offset = itemMetadata.offset + itemMetadata.size; } for (let i = lastMeasuredIndex + 1; i <= index; i++) { // Only recalculates specified columns let size = recalcWithinBoundsOnly ? recalcIndexes.includes(i) ? itemSize(i) : itemMetadataMap[i]?.size || itemSize(i) : itemSize(i); itemMetadataMap[i] = { offset, size, }; offset += size; } if (itemType === "column") { instanceProps.lastMeasuredColumnIndex = index; } else { instanceProps.lastMeasuredRowIndex = index; } } return itemMetadataMap[index]; }; const findNearestItem = ({ itemType, rowHeight, columnWidth, rowCount, columnCount, instanceProps, offset, }: Omit<IItemMetaData, "index">): number => { let itemMetadataMap, lastMeasuredIndex; if (itemType === "column") { itemMetadataMap = instanceProps.columnMetadataMap; lastMeasuredIndex = instanceProps.lastMeasuredColumnIndex; } else { itemMetadataMap = instanceProps.rowMetadataMap; lastMeasuredIndex = instanceProps.lastMeasuredRowIndex; } const lastMeasuredItemOffset = lastMeasuredIndex > 0 ? itemMetadataMap[lastMeasuredIndex].offset : 0; if (lastMeasuredItemOffset >= offset) { // If we've already measured items within this range just use a binary search as it's faster. return findNearestItemBinarySearch({ itemType, rowHeight, columnWidth, instanceProps, high: lastMeasuredIndex, low: 0, offset, }); } else { // If we haven't yet measured this high, fallback to an exponential search with an inner binary search. // The exponential search avoids pre-computing sizes for the full set of items as a binary search would. // The overall complexity for this approach is O(log n). return findNearestItemExponentialSearch({ itemType, rowHeight, rowCount, columnCount, columnWidth, instanceProps, index: Math.max(0, lastMeasuredIndex), offset, }); } }; interface IBinarySearchArgs extends Omit<IItemMetaData, "index" | "rowCount" | "columnCount"> { high: number; low: number; } const findNearestItemBinarySearch = ({ itemType, rowHeight, columnWidth, instanceProps, high, low, offset, }: IBinarySearchArgs): number => { while (low <= high) { const middle = low + Math.floor((high - low) / 2); const currentOffset = getItemMetadata({ itemType, rowHeight, columnWidth, index: middle, instanceProps, }).offset; if (currentOffset === offset) { return middle; } else if (currentOffset < offset) { low = middle + 1; } else if (currentOffset > offset) { high = middle - 1; } } if (low > 0) { return low - 1; } else { return 0; } }; const findNearestItemExponentialSearch = ({ itemType, rowHeight, columnWidth, rowCount, columnCount, instanceProps, index, offset, }: IItemMetaData) => { const itemCount = itemType === "column" ? columnCount : rowCount; let interval = 1; while ( index < itemCount && getItemMetadata({ itemType, rowHeight, columnWidth, index, instanceProps, }).offset < offset ) { index += interval; interval *= 2; } return findNearestItemBinarySearch({ itemType, rowHeight, columnWidth, instanceProps, high: Math.min(index, itemCount - 1), low: Math.floor(index / 2), offset, }); }; export const getEstimatedTotalHeight = ( rowCount: number, instanceProps: InstanceInterface ) => { const { estimatedRowHeight } = instanceProps; let totalSizeOfMeasuredRows = 0; let { lastMeasuredRowIndex, rowMetadataMap } = instanceProps; // Edge case check for when the number of items decreases while a scroll is in progress. // https://github.com/bvaughn/react-window/pull/138 if (lastMeasuredRowIndex >= rowCount) { lastMeasuredRowIndex = rowCount - 1; } if (lastMeasuredRowIndex >= 0) { const itemMetadata = rowMetadataMap[lastMeasuredRowIndex]; totalSizeOfMeasuredRows = itemMetadata.offset + itemMetadata.size; } const numUnmeasuredItems = rowCount - lastMeasuredRowIndex - 1; const totalSizeOfUnmeasuredItems = numUnmeasuredItems * estimatedRowHeight; return totalSizeOfMeasuredRows + totalSizeOfUnmeasuredItems; }; export const getEstimatedTotalWidth = ( columnCount: number, instanceProps: InstanceInterface ) => { const { estimatedColumnWidth } = instanceProps; let totalSizeOfMeasuredRows = 0; let { lastMeasuredColumnIndex, columnMetadataMap } = instanceProps; // Edge case check for when the number of items decreases while a scroll is in progress. // https://github.com/bvaughn/react-window/pull/138 if (lastMeasuredColumnIndex >= columnCount) { lastMeasuredColumnIndex = columnCount - 1; } if (lastMeasuredColumnIndex >= 0) { const itemMetadata = columnMetadataMap[lastMeasuredColumnIndex]; totalSizeOfMeasuredRows = itemMetadata.offset + itemMetadata.size; } const numUnmeasuredItems = columnCount - lastMeasuredColumnIndex - 1; const totalSizeOfUnmeasuredItems = numUnmeasuredItems * estimatedColumnWidth; return totalSizeOfMeasuredRows + totalSizeOfUnmeasuredItems; }; /* Create a stringified cell identifier */ export const cellIndentifier = ( rowIndex: number, columnIndex: number ): string => [rowIndex, columnIndex].toString(); /** * @desc Throttle fn * @param func function * @param limit Delay in milliseconds */ export function throttle(func: Function, limit: number): Function { let inThrottle: boolean; return function (this: any): any { const args = arguments; const context = this; if (!inThrottle) { inThrottle = true; func.apply(context, args); setTimeout(() => (inThrottle = false), limit); } }; } export function debounce<T extends Function>(cb: T, wait = 20) { let h = 0; let callable = (...args: any) => { clearTimeout(h); h = window.setTimeout(() => cb(...args), wait); }; return <T>(<any>callable); } export function rafThrottle(callback: Function) { var active = false; // a simple flag var evt: any; // to keep track of the last event var handler = function () { // fired only when screen has refreshed active = false; // release our flag callback(evt); }; return function handleEvent(e: any) { // the actual event handler evt = e; // save our event at each call evt && evt.persist(); if (!active) { // only if we weren't already doing it active = true; // raise the flag requestAnimationFrame(handler); // wait for next screen refresh } }; } export interface AlignmentProps extends Omit<IItemMetaData, "offset"> { containerHeight: number; containerWidth: number; align?: Align; scrollOffset: number; scrollbarSize: number; frozenOffset: number; } export const getOffsetForIndexAndAlignment = ({ itemType, containerHeight, containerWidth, rowHeight, columnWidth, columnCount, rowCount, index, align = Align.smart, scrollOffset, instanceProps, scrollbarSize, frozenOffset = 0, }: AlignmentProps): number => { const size = itemType === "column" ? containerWidth : containerHeight; const itemMetadata = getItemMetadata({ itemType, rowHeight, columnWidth, index, instanceProps, }); // Get estimated total size after ItemMetadata is computed, // To ensure it reflects actual measurements instead of just estimates. const estimatedTotalSize = itemType === "column" ? getEstimatedTotalWidth(columnCount, instanceProps) : getEstimatedTotalHeight(rowCount, instanceProps); const maxOffset = Math.max( 0, Math.min(estimatedTotalSize - size, itemMetadata.offset - frozenOffset) ); const minOffset = Math.max( 0, itemMetadata.offset - size + scrollbarSize + itemMetadata.size ); if (align === Align.smart) { if (scrollOffset >= minOffset - size && scrollOffset <= maxOffset + size) { align = Align.auto; } else { align = Align.center; } } switch (align) { case Align.start: return maxOffset; case Align.end: return minOffset; case Align.center: return Math.round(minOffset + (maxOffset - minOffset) / 2); case Align.auto: default: if (scrollOffset >= minOffset && scrollOffset <= maxOffset) { return scrollOffset; } else if (minOffset > maxOffset) { // Because we only take into account the scrollbar size when calculating minOffset // this value can be larger than maxOffset when at the end of the list return minOffset; } else if (scrollOffset < minOffset) { return minOffset; } else { return maxOffset; } } }; export const getOffsetForColumnAndAlignment = ( props: Omit<AlignmentProps, "itemType"> ) => { return getOffsetForIndexAndAlignment({ itemType: ItemType.column, ...props, }); }; export const getOffsetForRowAndAlignment = ( props: Omit<AlignmentProps, "itemType"> ) => { return getOffsetForIndexAndAlignment({ itemType: ItemType.row, ...props, }); }; // Animation frame based implementation of setTimeout. // Inspired by Joe Lambert, https://gist.github.com/joelambert/1002116#file-requesttimeout-js const hasNativePerformanceNow = typeof performance === "object" && typeof performance.now === "function"; const now = hasNativePerformanceNow ? () => performance.now() : () => Date.now(); export type TimeoutID = { id: number; }; export function cancelTimeout(timeoutID: TimeoutID) { cancelAnimationFrame(timeoutID.id); } export function requestTimeout(callback: Function, delay: number): TimeoutID { const start = now(); function tick() { if (now() - start >= delay) { callback.call(null); } else { timeoutID.id = requestAnimationFrame(tick); } } const timeoutID: TimeoutID = { id: requestAnimationFrame(tick), }; return timeoutID; } export const selectionFromActiveCell = ( activeCell: CellInterface | null ): SelectionArea[] => { if (!activeCell) return []; return [ { bounds: { top: activeCell.rowIndex, left: activeCell.columnIndex, bottom: activeCell.rowIndex, right: activeCell.columnIndex, }, }, ]; }; export const prepareClipboardData = (rows: string[][]): [string, string] => { const html = ["<table>"]; const csv: string[] = []; rows.forEach((row) => { html.push("<tr>"); const csvRow: string[] = []; row.forEach((cell) => { html.push(`<td>${cell}</td>`); csvRow.push(`"${cell.replace(/"/g, '""')}"`); }); csv.push(csvRow.join(",")); html.push("</tr>"); }); html.push("</table>"); return [html.join(""), csv.join("\n")]; }; /** * Cycles active cell within selecton bounds * @param activeCellBounds * @param selectionBounds * @param direction */ export const findNextCellWithinBounds = ( activeCellBounds: AreaProps, selectionBounds: AreaProps, direction: string = Movement.forwards ): CellInterface | null => { let rowIndex, columnIndex; let nextActiveCell: CellInterface | null = null; if (direction === Movement.forwards) { rowIndex = activeCellBounds.top; columnIndex = activeCellBounds.left + 1; if (columnIndex > selectionBounds.right) { rowIndex = rowIndex + 1; columnIndex = selectionBounds.left; if (rowIndex > selectionBounds.bottom) { rowIndex = selectionBounds.top; } } nextActiveCell = { rowIndex, columnIndex }; } if (direction === Movement.backwards) { rowIndex = activeCellBounds.bottom; columnIndex = activeCellBounds.left - 1; if (columnIndex < selectionBounds.left) { rowIndex = rowIndex - 1; columnIndex = selectionBounds.right; if (rowIndex < selectionBounds.top) { rowIndex = selectionBounds.bottom; } } nextActiveCell = { rowIndex, columnIndex }; } if (direction === Movement.downwards) { rowIndex = activeCellBounds.bottom + 1; columnIndex = activeCellBounds.left; if (rowIndex > selectionBounds.bottom) { columnIndex = activeCellBounds.left + 1; rowIndex = selectionBounds.top; if (columnIndex > selectionBounds.right) { columnIndex = selectionBounds.left; } } nextActiveCell = { rowIndex, columnIndex }; } return nextActiveCell; };