react-konva-grid
Version:
Declarative React Canvas Grid primitive for Data table, Pivot table, Excel Worksheets
723 lines (652 loc) • 17.9 kB
text/typescript
// 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;
};