react-delightful-scroller
Version:
Delightful, virtualized modern infinite scroller 🎉
736 lines (634 loc) • 21.4 kB
JavaScript
import React, { useState, useRef, useEffect, memo } from 'react';
import useWindowSize from '@rehooks/window-size';
import useComponentSize from '@rehooks/component-size';
import PropTypes from 'prop-types';
import Measure from 'react-measure';
import throttle from 'lodash.throttle';
function _extends() {
_extends = Object.assign || function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}
function _slicedToArray(arr, i) {
return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest();
}
function _toConsumableArray(arr) {
return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _nonIterableSpread();
}
function _arrayWithoutHoles(arr) {
if (Array.isArray(arr)) {
for (var i = 0, arr2 = new Array(arr.length); i < arr.length; i++) arr2[i] = arr[i];
return arr2;
}
}
function _arrayWithHoles(arr) {
if (Array.isArray(arr)) return arr;
}
function _iterableToArray(iter) {
if (Symbol.iterator in Object(iter) || Object.prototype.toString.call(iter) === "[object Arguments]") return Array.from(iter);
}
function _iterableToArrayLimit(arr, i) {
var _arr = [];
var _n = true;
var _d = false;
var _e = undefined;
try {
for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) {
_arr.push(_s.value);
if (i && _arr.length === i) break;
}
} catch (err) {
_d = true;
_e = err;
} finally {
try {
if (!_n && _i["return"] != null) _i["return"]();
} finally {
if (_d) throw _e;
}
}
return _arr;
}
function _nonIterableSpread() {
throw new TypeError("Invalid attempt to spread non-iterable instance");
}
function _nonIterableRest() {
throw new TypeError("Invalid attempt to destructure non-iterable instance");
}
var getBatchedItems = function getBatchedItems(items) {
var batchSize = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1;
var batched = []; // Faster that clone and splice
for (var index = 0; index < items.length; index += batchSize) {
var chunk = items.slice(index, index + batchSize); // Do something if you want with the group
batched.push(chunk);
}
return batched;
};
var Wrapper = React.forwardRef(function (_ref, ref) {
var _ref$as = _ref.as,
as = _ref$as === void 0 ? "div" : _ref$as,
style = _ref.style,
children = _ref.children;
return React.createElement(as, {
ref: ref,
style: style
}, children);
});
Wrapper.displayName = "Wrapper";
// node won't update when other props on Render item changes
// Might create memory leak/closure issues in react hooks
var RenderItemWrapper = React.memo(function (_ref) {
var item = _ref.item,
index = _ref.index,
RenderItem = _ref.RenderItem;
return React.createElement(RenderItem, {
item: item,
index: index
});
});
RenderItemWrapper.displayName = "RenderItemWrapper";
var BatchRenderer = React.memo(function (_ref) {
var batch = _ref.batch,
index = _ref.index,
getItemKey = _ref.getItemKey,
batchSize = _ref.batchSize,
wrapperElement = _ref.wrapperElement,
removeFromDOM = _ref.removeFromDOM,
dimensions = _ref.dimensions,
setDimension = _ref.setDimension,
RenderItem = _ref.RenderItem,
visible = _ref.visible,
itemHeight = _ref.itemHeight;
var hasFixedHeightItems = !!itemHeight;
var batchWrapper = null;
if (visible || !removeFromDOM) {
var items = batch.map(function (item, idx) {
var actualIndex = batchSize * index + idx;
var key = getItemKey(item, actualIndex);
return React.createElement(RenderItemWrapper, {
key: key,
item: item,
index: actualIndex,
RenderItem: RenderItem
});
});
var itemsBatch = React.createElement(Wrapper, {
"data-iscroller-batch": index,
as: wrapperElement,
style: !removeFromDOM ? {
visibility: visible ? "visible" : "hidden"
} : {}
}, items);
batchWrapper = hasFixedHeightItems ? // No need to add resize observer to batch of fixed height items
itemsBatch : // Add resize observer to batch of dynamic items
React.createElement(Measure // ScrollHeight is actual height of batch including content margins
, {
scroll: true,
onResize: function onResize(contentRect) {
setDimension(index, contentRect);
}
}, function (_ref2) {
var measureRef = _ref2.measureRef;
return React.cloneElement(itemsBatch, {
ref: measureRef
});
});
} else {
batchWrapper = React.createElement("div", {
style: {
height: dimensions.height
}
});
}
return batchWrapper;
} // Don't put equality check for batch items here!
// prev batch items changes are reverted if next batch items are changed
// Might create memory leak/closure issues in react hooks
);
BatchRenderer.displayName = "BatchRenderer";
var NoRemoveFromDOMBatcher = React.memo(function (props) {
return React.createElement(BatchRenderer, props);
}, function (_ref3, _ref4) {
var prevBatch = _ref3.batch,
prevVisible = _ref3.visible;
var batch = _ref4.batch,
visible = _ref4.visible,
removeFromDOM = _ref4.removeFromDOM;
if (!removeFromDOM) {
var batchItemsHaveSameRef = prevBatch.length === batch.length && prevBatch.every(function (e, i) {
return e === batch[i];
});
return batchItemsHaveSameRef && prevVisible === visible && prevVisible === false && visible === false;
}
return true;
});
NoRemoveFromDOMBatcher.displayName = "NoRemoveFromDOMBatcher";
var useDimensions = function useDimensions() {
var initialValue = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
var _useState = useState(initialValue),
_useState2 = _slicedToArray(_useState, 2),
dimensions = _useState2[0],
setDimension = _useState2[1]; // Set state is async, we need a ref to store intermediate value
var intermediate = useRef(null);
var wrappedSetDimensions = function wrappedSetDimensions(index, dimension) {
var newDimensions = _toConsumableArray(intermediate && intermediate.current || dimensions);
var _dimension$scroll = dimension.scroll,
width = _dimension$scroll.width,
height = _dimension$scroll.height;
newDimensions[index] = {
width: width,
height: height
};
intermediate.current = newDimensions;
setDimension(newDimensions);
};
return [dimensions, wrappedSetDimensions];
};
function initializeDimensions(_ref) {
var axis = _ref.axis,
itemHeight = _ref.itemHeight,
averageItemHeight = _ref.averageItemHeight,
batchSize = _ref.batchSize,
itemsCount = _ref.itemsCount;
return function () {
var totalBatches = Math.ceil(itemsCount / batchSize);
var estimatedEmptyBatchHeight = axis === "y" ? Math.ceil((itemHeight || averageItemHeight) * batchSize) : // TODO - handle other directions
0;
var initial = [];
for (var i = 0; i < totalBatches; i += 1) {
initial[i] = {
height: estimatedEmptyBatchHeight,
width: null
};
}
return initial;
};
}
function useVisibility() {
var initial = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
var _useState = useState(initial),
_useState2 = _slicedToArray(_useState, 2),
visibility = _useState2[0],
setVisibility = _useState2[1];
var wrappedSetVisibility = function wrappedSetVisibility(newVisibility) {
setVisibility(newVisibility);
};
return [visibility, wrappedSetVisibility];
}
function initializeInitialVisibility(_ref) {
var axis = _ref.axis,
containerHeight = _ref.containerHeight,
itemHeight = _ref.itemHeight,
averageItemHeight = _ref.averageItemHeight,
batchSize = _ref.batchSize,
itemsCount = _ref.itemsCount;
return function () {
var totalBatches = Math.ceil(itemsCount / batchSize);
var estimatedInitialBatches = axis === "y" ? Math.ceil(containerHeight / ((itemHeight || averageItemHeight) * batchSize)) : // TODO - handle other directions
0;
var initial = [];
for (var i = 0; i < totalBatches; i += 1) {
initial[i] = i < estimatedInitialBatches;
}
return initial;
};
}
var getScrollOffset = function getScrollOffset(element, axis) {
var scrollTop = element.scrollTop,
scrollY = element.scrollY;
if (axis === "y") {
if (scrollTop !== undefined) {
return scrollTop;
}
return scrollY;
}
return 0;
};
var useScroll = function useScroll(_ref) {
var root = _ref.root,
axis = _ref.axis;
var timeout = useRef(null);
var _useState = useState(0),
_useState2 = _slicedToArray(_useState, 2),
scrollOffset = _useState2[0],
setScrollOffset = _useState2[1];
useEffect(function () {
var element = root ? root() : window;
var handler = throttle(function () {
// If there's a timer, cancel it
if (timeout.current) {
window.cancelAnimationFrame(timeout.current);
} // Setup the requestAnimationFrame
timeout.current = window.requestAnimationFrame(function () {
return setScrollOffset(getScrollOffset(element, axis));
});
}, 100);
element.addEventListener("scroll", handler, {
capture: false,
passive: true
});
return function () {
if (handler.cancel) {
handler.cancel();
}
window.cancelAnimationFrame(timeout.current);
element.removeEventListener("scroll", handler);
};
}, [axis, root]);
return scrollOffset;
};
// B starts after A starts but before A finishes.
// B starts before A starts and finishes after A starts.
function areOverlapping(A, B) {
if (B[0] < A[0]) {
return B[1] > A[0];
}
return B[0] < A[1];
}
var useVisibilityAndDimension = function useVisibilityAndDimension(_ref) {
var root = _ref.root,
axis = _ref.axis,
containerHeight = _ref.containerHeight,
itemsCount = _ref.itemsCount,
itemHeight = _ref.itemHeight,
averageItemHeight = _ref.averageItemHeight,
batchSize = _ref.batchSize,
batchBufferDistance = _ref.batchBufferDistance;
var _useDimensions = useDimensions(initializeDimensions({
itemsCount: itemsCount,
axis: axis,
itemHeight: itemHeight,
averageItemHeight: averageItemHeight,
batchSize: batchSize
})),
_useDimensions2 = _slicedToArray(_useDimensions, 2),
dimensions = _useDimensions2[0],
setDimension = _useDimensions2[1];
var _useVisibility = useVisibility(initializeInitialVisibility({
itemsCount: itemsCount,
axis: axis,
containerHeight: containerHeight,
itemHeight: itemHeight,
averageItemHeight: averageItemHeight,
batchSize: batchSize
})),
_useVisibility2 = _slicedToArray(_useVisibility, 2),
visibility = _useVisibility2[0],
setVisibility = _useVisibility2[1];
var scrollOffset = useScroll({
root: root,
axis: axis
});
useEffect(function () {
var renderWindow = [scrollOffset - batchBufferDistance, scrollOffset + containerHeight + batchBufferDistance];
var totalBatches = Math.ceil(itemsCount / batchSize);
var nextTotal = 0;
var nextVisibility = [];
for (var i = 0; i < totalBatches; i += 1) {
var currentHeight = nextTotal;
var nextHeight = nextTotal + dimensions[i].height;
nextVisibility[i] = areOverlapping(renderWindow, [currentHeight, nextHeight]);
nextTotal = nextHeight;
}
var visibilityChanged = nextVisibility.some(function (e, i) {
return e !== visibility[i];
});
if (visibilityChanged) {
setVisibility(nextVisibility);
}
}, [batchSize, containerHeight, setVisibility, dimensions, itemsCount, scrollOffset, visibility, batchBufferDistance]);
return [dimensions, visibility, setDimension];
};
var getVisibleIndexes = function getVisibleIndexes(visibility) {
var start;
var end;
for (var i = 0; i < visibility.length; i += 1) {
var v = visibility[i];
if (start !== undefined && end !== undefined && !v) {
break;
}
if (start === undefined && v) {
start = i;
}
if (start !== undefined && v) {
end = i;
}
}
return [start, end];
};
var Sentinel = function Sentinel(_ref) {
var fetchMoreBufferDistance = _ref.fetchMoreBufferDistance,
onFetchMore = _ref.onFetchMore,
RenderLoader = _ref.RenderLoader,
wrapperElement = _ref.wrapperElement,
items = _ref.items,
itemsCount = _ref.itemsCount,
batchSize = _ref.batchSize,
root = _ref.root,
axis = _ref.axis;
var ref = useRef(null);
useEffect(function () {
var targetNode = ref.current;
var options = {
root: root ? root() : null,
rootMargin: axis === "y" ? "0px 0px ".concat(fetchMoreBufferDistance, "px 0px") : "0px",
threshold: 0
};
var callback = function callback(_ref2) {
var _ref3 = _slicedToArray(_ref2, 1),
isIntersecting = _ref3[0].isIntersecting;
if (isIntersecting) {
onFetchMore({
size: items.length,
itemsCount: itemsCount,
batchSize: batchSize
});
}
};
var observer = new IntersectionObserver(callback, options);
if (targetNode) {
observer.observe(targetNode);
}
return function () {
// Stop watching all of its target elements for visibility changes
observer.disconnect();
};
}, [axis, fetchMoreBufferDistance, batchSize, items, onFetchMore, root, itemsCount]);
return React.createElement(wrapperElement, {
ref: ref
}, React.createElement(RenderLoader, {
size: items.length,
itemsCount: itemsCount,
batchSize: batchSize
}));
};
var DefaultRenderContainer = function DefaultRenderContainer(_ref) {
var children = _ref.children,
forwardRef = _ref.forwardRef;
return React.createElement("div", {
ref: forwardRef
}, children);
};
DefaultRenderContainer.displayName = "DefaultRenderContainer"; // eslint-disable-next-line no-unused-vars
var DefaultRenderItem = function DefaultRenderItem(_ref2) {
var item = _ref2.item,
index = _ref2.index;
return item;
};
DefaultRenderItem.displayName = "DefaultRenderItem"; // eslint-disable-next-line no-unused-vars
var DefaultRenderLoader = function DefaultRenderLoader(_ref3) {
var items = _ref3.items,
itemsCount = _ref3.itemsCount,
batchSize = _ref3.batchSize;
return null;
};
DefaultRenderLoader.displayName = "DefaultRenderLoader";
var BaseRenderer = function BaseRenderer(_ref) {
var containerHeight = _ref.containerHeight,
items = _ref.items,
RenderItem = _ref.RenderItem,
getItemKey = _ref.getItemKey,
wrapperElement = _ref.wrapperElement,
forwardRef = _ref.forwardRef,
RenderContainer = _ref.RenderContainer,
removeFromDOM = _ref.removeFromDOM,
root = _ref.root,
batch = _ref.batch,
batchSize = _ref.batchSize,
axis = _ref.axis,
averageItemHeight = _ref.averageItemHeight,
itemHeight = _ref.itemHeight,
itemsCount = _ref.itemsCount,
batchBufferDistance = _ref.batchBufferDistance,
onFetchMore = _ref.onFetchMore,
RenderLoader = _ref.RenderLoader,
fetchMoreBufferDistance = _ref.fetchMoreBufferDistance;
var _useVisibilityAndDime = useVisibilityAndDimension({
root: root,
axis: axis,
containerHeight: containerHeight,
itemsCount: itemsCount,
itemHeight: itemHeight,
averageItemHeight: averageItemHeight,
batchSize: batchSize,
batchBufferDistance: batchBufferDistance
}),
_useVisibilityAndDime2 = _slicedToArray(_useVisibilityAndDime, 3),
dimensions = _useVisibilityAndDime2[0],
visibility = _useVisibilityAndDime2[1],
setDimension = _useVisibilityAndDime2[2];
var batchedItems = getBatchedItems(items, batchSize);
var Batcher = removeFromDOM ? BatchRenderer : NoRemoveFromDOMBatcher;
var current = batchedItems;
var previous = [];
var next = [];
var prevHeight;
var nextHeight;
if (removeFromDOM && batch) {
var _getVisibleIndexes = getVisibleIndexes(visibility),
_getVisibleIndexes2 = _slicedToArray(_getVisibleIndexes, 2),
startIndex = _getVisibleIndexes2[0],
endIndex = _getVisibleIndexes2[1];
previous = batchedItems.slice(0, startIndex);
current = batchedItems.slice(startIndex, endIndex + 1);
next = batchedItems.slice(endIndex + 1, batchedItems.length);
prevHeight = previous.reduce(function (p, c, i) {
var index = i;
var dimension = dimensions[index];
return p + dimension.height;
}, 0);
nextHeight = next.reduce(function (p, c, i) {
var index = previous.length + current.length + i;
var dimension = dimensions[index];
return p + dimension.height;
}, 0);
}
var batchedElements = current.map(function (currentBatch, i) {
var index = previous.length + i;
return React.createElement(Batcher, {
key: "".concat(index),
batch: currentBatch,
index: index,
getItemKey: getItemKey,
batchSize: batchSize,
wrapperElement: wrapperElement,
removeFromDOM: removeFromDOM,
setDimension: setDimension,
RenderItem: RenderItem,
dimensions: dimensions[index],
visible: visibility[index],
itemHeight: itemHeight
});
});
var Container = React.createElement(RenderContainer, {
forwardRef: forwardRef
}, prevHeight ? React.createElement("div", {
style: {
height: prevHeight,
visibility: "hidden"
}
}) : null, batchedElements, nextHeight ? React.createElement("div", {
style: {
height: nextHeight,
visibility: "hidden"
}
}) : null, axis === "y" && items.length < itemsCount ? React.createElement(Sentinel, {
onFetchMore: onFetchMore,
fetchMoreBufferDistance: fetchMoreBufferDistance,
RenderLoader: RenderLoader,
wrapperElement: wrapperElement,
items: items,
itemsCount: itemsCount,
batchSize: batchSize,
root: root,
axis: axis
}) : null);
return Container;
};
BaseRenderer.displayName = "BaseRenderer";
var WindowContainer = function WindowContainer(props) {
var _useWindowSize = useWindowSize(),
innerWidth = _useWindowSize.innerWidth,
innerHeight = _useWindowSize.innerHeight;
return React.createElement(BaseRenderer, _extends({}, props, {
containerWidth: innerWidth,
containerHeight: innerHeight
}));
};
var CustomScrollContainer = function CustomScrollContainer(props) {
var root = props.root;
var _useComponentSize = useComponentSize({
current: root()
}),
width = _useComponentSize.width,
height = _useComponentSize.height;
return React.createElement(BaseRenderer, _extends({}, props, {
containerWidth: width,
containerHeight: height
}));
};
var Entry = function Entry(props, ref) {
var _useState = useState(!props.root),
_useState2 = _slicedToArray(_useState, 2),
render = _useState2[0],
setRender = _useState2[1];
/**
* Mount custom container after the first render cycle
* to make sure the parent scroll node is available
*/
useEffect(function () {
if (!render) {
setRender(true);
}
}, []);
if (render) {
// Window scroll
if (!props.root) {
return React.createElement(WindowContainer, _extends({}, props, {
forwardRef: ref
}));
} // Custom container scroll
return React.createElement(CustomScrollContainer, _extends({}, props, {
forwardRef: ref
}));
}
return null;
};
var DelightfulScroller = memo(React.forwardRef(Entry));
DelightfulScroller.defaultProps = {
items: [],
itemsCount: 0,
RenderItem: DefaultRenderItem,
getItemKey: function getItemKey(item, index) {
return typeof item === "string" ? item : index;
},
wrapperElement: "div",
RenderContainer: DefaultRenderContainer,
removeFromDOM: true,
root: null,
averageItemHeight: 10,
itemHeight: null,
axis: "y",
batch: true,
batchSize: 10,
batchBufferDistance: 500,
fetchMoreBufferDistance: 500,
RenderLoader: DefaultRenderLoader,
// eslint-disable-next-line no-unused-vars
onFetchMore: function onFetchMore(_ref2) {
var items = _ref2.items,
itemsCount = _ref2.itemsCount,
batchSize = _ref2.batchSize;
}
};
DelightfulScroller.propTypes = {
items: PropTypes.arrayOf(PropTypes.any),
itemsCount: PropTypes.number,
RenderItem: PropTypes.elementType,
getItemKey: PropTypes.func,
wrapperElement: PropTypes.string,
RenderContainer: PropTypes.elementType,
removeFromDOM: PropTypes.bool,
root: PropTypes.func,
averageItemHeight: PropTypes.number,
itemHeight: PropTypes.number,
axis: PropTypes.oneOf(["y"]),
batch: PropTypes.bool,
batchSize: PropTypes.number,
batchBufferDistance: PropTypes.number,
fetchMoreBufferDistance: PropTypes.number,
RenderLoader: PropTypes.elementType,
onFetchMore: PropTypes.func
};
DelightfulScroller.displayName = "DelightfulScroller";
export default DelightfulScroller;
//# sourceMappingURL=react-delightful-scroller.es.js.map