masonic
Version:
<hr> <div align="center"> <h1 align="center"> 🧱 masonic </h1>
368 lines (340 loc) • 11.2 kB
text/typescript
import * as React from "react";
import { createIntervalTree } from "./interval-tree";
/**
* This hook creates the grid cell positioner and cache required by `useMasonry()`. This is
* the meat of the grid's layout algorithm, determining which cells to render at a given scroll
* position, as well as where to place new items in the grid.
*
* @param options - Properties that determine the number of columns in the grid, as well
* as their widths.
* @param options.columnWidth
* @param options.width
* @param deps - This hook will create a new positioner, clearing all existing cached positions,
* whenever the dependencies in this list change.
* @param options.columnGutter
* @param options.rowGutter
* @param options.columnCount
* @param options.maxColumnCount
*/
export function usePositioner(
{
width,
columnWidth = 200,
columnGutter = 0,
rowGutter,
columnCount,
maxColumnCount,
}: UsePositionerOptions,
deps: React.DependencyList = emptyArr
): Positioner {
const initPositioner = (): Positioner => {
const [computedColumnWidth, computedColumnCount] = getColumns(
width,
columnWidth,
columnGutter,
columnCount,
maxColumnCount
);
return createPositioner(
computedColumnCount,
computedColumnWidth,
columnGutter,
rowGutter ?? columnGutter
);
};
const positionerRef = React.useRef<Positioner>();
if (positionerRef.current === undefined)
positionerRef.current = initPositioner();
const prevDeps = React.useRef(deps);
const opts = [
width,
columnWidth,
columnGutter,
rowGutter,
columnCount,
maxColumnCount,
];
const prevOpts = React.useRef(opts);
const optsChanged = !opts.every((item, i) => prevOpts.current[i] === item);
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
if (deps.length !== prevDeps.current.length) {
throw new Error(
"usePositioner(): The length of your dependencies array changed."
);
}
}
// Create a new positioner when the dependencies or sizes change
// Thanks to https://github.com/khmm12 for pointing this out
// https://github.com/jaredLunde/masonic/pull/41
if (optsChanged || !deps.every((item, i) => prevDeps.current[i] === item)) {
const prevPositioner = positionerRef.current;
const positioner = initPositioner();
prevDeps.current = deps;
prevOpts.current = opts;
if (optsChanged) {
const cacheSize = prevPositioner.size();
for (let index = 0; index < cacheSize; index++) {
const pos = prevPositioner.get(index);
positioner.set(index, pos !== void 0 ? pos.height : 0);
}
}
positionerRef.current = positioner;
}
return positionerRef.current;
}
export interface UsePositionerOptions {
/**
* The width of the container you're rendering the grid within, i.e. the container
* element's `element.offsetWidth`
*/
width: number;
/**
* The minimum column width. The `usePositioner()` hook will automatically size the
* columns to fill their container based upon the `columnWidth` and `columnGutter` values.
* It will never render anything smaller than this width unless its container itself is
* smaller than its value. This property is optional if you're using a static `columnCount`.
*
* @default 200
*/
columnWidth?: number;
/**
* This sets the horizontal space between grid columns in pixels. If `rowGutter` is not set, this
* also sets the vertical space between cells within a column in pixels.
*
* @default 0
*/
columnGutter?: number;
/**
* This sets the vertical space between cells within a column in pixels. If not set, the value of
* `columnGutter` is used instead.
*/
rowGutter?: number;
/**
* By default, `usePositioner()` derives the column count from the `columnWidth`, `columnGutter`,
* and `width` props. However, in some situations it is nice to be able to override that behavior
* (e.g. creating a `List` component).
*/
columnCount?: number;
/**
* The upper bound of column count. This property won't work if `columnCount` is set.
*/
maxColumnCount?: number;
}
/**
* Creates a cell positioner for the `useMasonry()` hook. The `usePositioner()` hook uses
* this utility under the hood.
*
* @param columnCount - The number of columns in the grid
* @param columnWidth - The width of each column in the grid
* @param columnGutter - The amount of horizontal space between columns in pixels.
* @param rowGutter - The amount of vertical space between cells within a column in pixels (falls back
* to `columnGutter`).
*/
export const createPositioner = (
columnCount: number,
columnWidth: number,
columnGutter = 0,
rowGutter = columnGutter
): Positioner => {
// O(log(n)) lookup of cells to render for a given viewport size
// Store tops and bottoms of each cell for fast intersection lookup.
const intervalTree = createIntervalTree();
// Track the height of each column.
// Layout algorithm below always inserts into the shortest column.
const columnHeights: number[] = new Array(columnCount);
// Used for O(1) item access
const items: PositionerItem[] = [];
// Tracks the item indexes within an individual column
const columnItems: number[][] = new Array(columnCount);
for (let i = 0; i < columnCount; i++) {
columnHeights[i] = 0;
columnItems[i] = [];
}
return {
columnCount,
columnWidth,
set: (index, height = 0) => {
let column = 0;
// finds the shortest column and uses it
for (let i = 1; i < columnHeights.length; i++) {
if (columnHeights[i] < columnHeights[column]) column = i;
}
const top = columnHeights[column] || 0;
columnHeights[column] = top + height + rowGutter;
columnItems[column].push(index);
items[index] = {
left: column * (columnWidth + columnGutter),
top,
height,
column,
};
intervalTree.insert(top, top + height, index);
},
get: (index) => items[index],
// This only updates items in the specific columns that have changed, on and after the
// specific items that have changed
update: (updates) => {
const columns: number[] = new Array(columnCount);
let i = 0,
j = 0;
// determines which columns have items that changed, as well as the minimum index
// changed in that column, as all items after that index will have their positions
// affected by the change
for (; i < updates.length - 1; i++) {
const index = updates[i];
const item = items[index];
item.height = updates[++i];
intervalTree.remove(index);
intervalTree.insert(item.top, item.top + item.height, index);
columns[item.column] =
columns[item.column] === void 0
? index
: Math.min(index, columns[item.column]);
}
for (i = 0; i < columns.length; i++) {
// bails out if the column didn't change
if (columns[i] === void 0) continue;
const itemsInColumn = columnItems[i];
// the index order is sorted with certainty so binary search is a great solution
// here as opposed to Array.indexOf()
const startIndex = binarySearch(itemsInColumn, columns[i]);
const index = columnItems[i][startIndex];
const startItem = items[index];
columnHeights[i] = startItem.top + startItem.height + rowGutter;
for (j = startIndex + 1; j < itemsInColumn.length; j++) {
const index = itemsInColumn[j];
const item = items[index];
item.top = columnHeights[i];
columnHeights[i] = item.top + item.height + rowGutter;
intervalTree.remove(index);
intervalTree.insert(item.top, item.top + item.height, index);
}
}
},
// Render all cells visible within the viewport range defined.
range: (lo, hi, renderCallback) =>
intervalTree.search(lo, hi, (index, top) =>
renderCallback(index, items[index].left, top)
),
estimateHeight: (itemCount, defaultItemHeight): number => {
const tallestColumn = Math.max(0, Math.max.apply(null, columnHeights));
return itemCount === intervalTree.size
? tallestColumn
: tallestColumn +
Math.ceil((itemCount - intervalTree.size) / columnCount) *
defaultItemHeight;
},
shortestColumn: () => {
if (columnHeights.length > 1) return Math.min.apply(null, columnHeights);
return columnHeights[0] || 0;
},
size(): number {
return intervalTree.size;
},
all(): PositionerItem[] {
return items;
},
};
};
export interface Positioner {
/**
* The number of columns in the grid
*/
columnCount: number;
/**
* The width of each column in the grid
*/
columnWidth: number;
/**
* Sets the position for the cell at `index` based upon the cell's height
*/
set: (index: number, height: number) => void;
/**
* Gets the `PositionerItem` for the cell at `index`
*/
get: (index: number) => PositionerItem | undefined;
/**
* Updates cells based on their indexes and heights
* positioner.update([index, height, index, height, index, height...])
*/
update: (updates: number[]) => void;
/**
* Searches the interval tree for grid cells with a `top` value in
* betwen `lo` and `hi` and invokes the callback for each item that
* is discovered
*/
range: (
lo: number,
hi: number,
renderCallback: (index: number, left: number, top: number) => void
) => void;
/**
* Returns the number of grid cells in the cache
*/
size: () => number;
/**
* Estimates the total height of the grid
*/
estimateHeight: (itemCount: number, defaultItemHeight: number) => number;
/**
* Returns the height of the shortest column in the grid
*/
shortestColumn: () => number;
/**
* Returns all `PositionerItem` items
*/
all: () => PositionerItem[];
}
export interface PositionerItem {
/**
* This is how far from the top edge of the grid container in pixels the
* item is placed
*/
top: number;
/**
* This is how far from the left edge of the grid container in pixels the
* item is placed
*/
left: number;
/**
* This is the height of the grid cell
*/
height: number;
/**
* This is the column number containing the grid cell
*/
column: number;
}
/* istanbul ignore next */
const binarySearch = (a: number[], y: number): number => {
let l = 0;
let h = a.length - 1;
while (l <= h) {
const m = (l + h) >>> 1;
const x = a[m];
if (x === y) return m;
else if (x <= y) l = m + 1;
else h = m - 1;
}
return -1;
};
const getColumns = (
width = 0,
minimumWidth = 0,
gutter = 8,
columnCount?: number,
maxColumnCount?: number
): [number, number] => {
columnCount =
columnCount ||
Math.min(
Math.floor((width + gutter) / (minimumWidth + gutter)),
maxColumnCount || Infinity
) ||
1;
const columnWidth = Math.floor(
(width - gutter * (columnCount - 1)) / columnCount
);
return [columnWidth, columnCount];
};
const emptyArr: [] = [];