feat: add DynamicSizeList

pull/13/head
moonrailgun 3 years ago
parent ce0d2f08c5
commit 25c20b8f8d

@ -26,6 +26,7 @@
"is-hotkey": "^0.2.0",
"jsonschema": "^1.4.0",
"jwt-decode": "^3.1.2",
"memoize-one": "^6.0.0",
"mini-star": "^1.0.0",
"p-min-delay": "^4.0.0",
"react": "^17.0.2",
@ -37,6 +38,7 @@
"react-router-dom": "^5.2.0",
"react-transition-group": "^4.4.2",
"react-use-gesture": "^9.1.3",
"react-virtualized-auto-sizer": "^1.0.6",
"socket.io-client": "^4.1.2",
"str2int": "^1.1.0",
"tailchat-shared": "*",
@ -59,6 +61,7 @@
"@types/react-router": "^5.1.15",
"@types/react-router-dom": "^5.1.7",
"@types/react-transition-group": "^4.4.2",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/webpack": "^5.28.0",
"@types/webpack-dev-server": "^4.3.1",
"@types/workbox-webpack-plugin": "^5.1.8",

@ -0,0 +1,948 @@
import memoizeOne from 'memoize-one';
import { createElement, PureComponent } from 'react';
import { ItemMeasurer } from './ItemMeasurer';
type ScrollDirection = any;
const getItemMetadata = (props: any, index: any, listMetaData: any) => {
const { itemOffsetMap, itemSizeMap } = listMetaData;
const { itemData } = props;
// If the specified item has not yet been measured,
// Just return an estimated size for now.
if (!itemSizeMap[itemData[index]]) {
return {
offset: 0,
size: 0,
};
}
const offset = itemOffsetMap[itemData[index]] || 0;
const size = itemSizeMap[itemData[index]] || 0;
return { offset, size };
};
const getItemOffset = (props: any, index: any, listMetaData: any) =>
getItemMetadata(props, index, listMetaData).offset;
const getOffsetForIndexAndAlignment = (
props: any,
index: any,
align: any,
scrollOffset: any,
listMetaData: any
) => {
const { height } = props;
const itemMetadata = getItemMetadata(props, index, listMetaData);
// Get estimated total size after ItemMetadata is computed,
// To ensure it reflects actual measurements instead of just estimates.
const estimatedTotalSize = listMetaData.totalMeasuredSize;
const maxOffset = Math.max(
0,
itemMetadata.offset + itemMetadata.size - height
);
const minOffset = Math.max(0, itemMetadata.offset);
switch (align) {
case 'start':
return minOffset;
case 'end':
return maxOffset;
case 'center':
return Math.round(minOffset - height / 2 + itemMetadata.size / 2);
case 'auto':
default:
if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
return estimatedTotalSize - (scrollOffset + height);
} else if (scrollOffset - minOffset < maxOffset - scrollOffset) {
return minOffset;
} else {
return maxOffset;
}
}
};
const findNearestItem = (
props: any,
listMetaData: any,
high: any,
low: any,
scrollOffset: any
) => {
let index = low;
while (low <= high) {
const currentOffset = getItemMetadata(props, low, listMetaData).offset;
if (scrollOffset - currentOffset <= 0) {
index = low;
}
low++;
}
return index;
};
const getStartIndexForOffset = (props: any, offset: any, listMetaData: any) => {
const { totalMeasuredSize } = listMetaData;
const { itemData } = props;
// If we've already positioned and measured past this point,
// Use a binary search to find the closets cell.
if (offset <= totalMeasuredSize) {
return findNearestItem(props, listMetaData, itemData.length, 0, offset);
}
// Otherwise render a new batch of items starting from where 0.
return 0;
};
const getStopIndexForStartIndex = (
props: any,
startIndex: any,
scrollOffset: any,
listMetaData: any
) => {
const { itemData } = props;
let stopIndex = startIndex;
const maxOffset = scrollOffset + props.height;
const itemMetadata = getItemMetadata(props, stopIndex, listMetaData);
let offset = itemMetadata.offset + (itemMetadata.size || 0);
const closestOffsetIndex = 0;
while (stopIndex > 0 && offset <= maxOffset) {
const itemMetadata = getItemMetadata(props, stopIndex, listMetaData);
offset = itemMetadata.offset + itemMetadata.size;
stopIndex--;
}
if (stopIndex >= itemData.length) {
return closestOffsetIndex;
}
return stopIndex;
};
const getItemSize = (props: any, index: any, listMetaData: any) => {
// Do not hard-code item dimensions.
// We don't know them initially.
// Even once we do, changes in item content or list size should reflow.
return getItemMetadata(props, index, listMetaData).size;
};
type OnScrollArgs = {
scrollDirection: 'backward' | 'forward';
scrollOffset: number;
scrollUpdateWasRequested: boolean;
clientHeight: number;
scrollHeight: number;
};
interface DynamicSizeListProps {
canLoadMorePosts: (id?: string) => void;
children: (info: { data: any; itemId: any; style?: any }) => JSX.Element;
height: number;
initRangeToRender: number[];
initScrollToIndex: () => any;
initialScrollOffset?: number;
innerRef: React.RefObject<any>;
innerTagName?: string;
outerTagName?: string;
itemData: string[];
onItemsRendered: (args: any) => void;
onScroll: (scrollArgs: OnScrollArgs) => void;
overscanCountBackward: number;
overscanCountForward: number;
style: React.CSSProperties;
width: number;
outerRef?: any;
className?: string;
correctScrollToBottom?: boolean;
innerListStyle?: React.CSSProperties;
loaderId?: string;
scrollToFailed?: (index: number) => void;
}
interface DynamicSizeListState {
scrollDirection: 'backward' | 'forward';
scrollOffset: number;
scrollUpdateWasRequested: boolean;
scrollDelta: number;
scrollHeight: number;
localOlderPostsToRender: any[];
scrolledToInitIndex?: boolean;
}
export default class DynamicSizeList extends PureComponent<
DynamicSizeListProps,
DynamicSizeListState
> {
_listMetaData = {
itemOffsetMap: {} as any,
itemSizeMap: {} as any,
totalMeasuredSize: 0,
atBottom: true,
};
_itemStyleCache: any = {};
_outerRef: any;
_scrollCorrectionInProgress = false;
_scrollByCorrection: number | null = null;
_keepScrollPosition = false;
_keepScrollToBottom = false;
_mountingCorrections = 0;
_correctedInstances = 0;
innerRefWidth = 0;
static defaultProps = {
innerTagName: 'div',
itemData: undefined,
outerTagName: 'div',
overscanCountForward: 30,
overscanCountBackward: 10,
};
state: DynamicSizeListState = {
scrollDirection: 'backward',
scrollOffset:
typeof this.props.initialScrollOffset === 'number'
? this.props.initialScrollOffset
: 0,
scrollUpdateWasRequested: false,
scrollDelta: 0,
scrollHeight: 0,
localOlderPostsToRender: [],
};
// Always use explicit constructor for React components.
// It produces less code after transpilation. (#26)
// eslint-disable-next-line no-useless-constructor
constructor(props: any) {
super(props);
}
static getDerivedStateFromProps(props: any, state: any) {
validateProps(props);
return null;
}
scrollBy = (scrollOffset: number, scrollBy?: number) => () => {
const element = this._outerRef;
if (typeof element.scrollBy === 'function' && scrollBy) {
element.scrollBy(0, scrollBy);
} else if (scrollOffset) {
element.scrollTop = scrollOffset;
}
this._scrollCorrectionInProgress = false;
};
scrollTo(
scrollOffset: any,
scrollByValue?: number,
useAnimationFrame = false
) {
this._scrollCorrectionInProgress = true;
this.setState((prevState) => ({
scrollOffset,
scrollUpdateWasRequested: true,
}));
this.setState(
(prevState) => ({
scrollDirection:
prevState.scrollOffset >= scrollOffset ? 'backward' : 'forward',
scrollOffset: scrollOffset,
scrollUpdateWasRequested: true,
}),
() => {
if (useAnimationFrame) {
this._scrollByCorrection = window.requestAnimationFrame(
this.scrollBy(this.state.scrollOffset, scrollByValue)
);
} else {
this.scrollBy(this.state.scrollOffset, scrollByValue)();
}
}
);
this.forceUpdate();
}
scrollToItem(index: number, align = 'auto', offset = 0) {
const { scrollOffset } = this.state;
//Ideally the below scrollTo works fine but firefox has 6px issue and stays 6px from bottom when corrected
//so manually keeping scroll position bottom for now
const element = this._outerRef;
if (index === 0 && align === 'end') {
this.scrollTo(element.scrollHeight - this.props.height);
return;
}
const offsetOfItem = getOffsetForIndexAndAlignment(
this.props,
index,
align,
scrollOffset,
this._listMetaData
);
if (!offsetOfItem) {
const itemSize = getItemSize(this.props, index, this._listMetaData);
if (!itemSize && this.props.scrollToFailed) {
if (this.state.scrolledToInitIndex) {
this.props.scrollToFailed(index);
} else {
console.warn(
'Failed to do initial scroll correction',
this.props.initRangeToRender,
index
);
}
}
}
this.scrollTo(offsetOfItem + offset);
}
componentDidMount() {
const { initialScrollOffset } = this.props;
if (typeof initialScrollOffset === 'number' && this._outerRef !== null) {
const element = this._outerRef;
element.scrollTop = initialScrollOffset;
}
this._commitHook();
}
getSnapshotBeforeUpdate(
prevProps: DynamicSizeListProps,
prevState: DynamicSizeListState
) {
if (
prevState.localOlderPostsToRender[0] !==
this.state.localOlderPostsToRender[0] ||
prevState.localOlderPostsToRender[1] !==
this.state.localOlderPostsToRender[1]
) {
const element = this._outerRef;
const previousScrollTop = element.scrollTop;
const previousScrollHeight = element.scrollHeight;
return {
previousScrollTop,
previousScrollHeight,
};
}
return null;
}
componentDidUpdate(
prevProps: DynamicSizeListProps,
prevState: DynamicSizeListState,
snapshot: any
) {
if (this.state.scrolledToInitIndex) {
const {
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
scrollHeight,
} = this.state;
const {
scrollDirection: prevScrollDirection,
scrollOffset: prevScrollOffset,
scrollUpdateWasRequested: prevScrollUpdateWasRequested,
scrollHeight: previousScrollHeight,
} = prevState;
if (
scrollDirection !== prevScrollDirection ||
scrollOffset !== prevScrollOffset ||
scrollUpdateWasRequested !== prevScrollUpdateWasRequested ||
scrollHeight !== previousScrollHeight
) {
this._callPropsCallbacks();
}
if (!prevState.scrolledToInitIndex) {
this._keepScrollPosition = false;
this._keepScrollToBottom = false;
}
}
this._commitHook();
if (prevProps.itemData !== this.props.itemData) {
this._dataChange();
}
if (prevProps.height !== this.props.height) {
this._heightChange(prevProps.height, prevState.scrollOffset);
}
if (prevState.scrolledToInitIndex !== this.state.scrolledToInitIndex) {
this._dataChange(); // though this is not data change we are checking for first load change
}
if (prevProps.width !== this.props.width && !!this.props.innerRef) {
this.innerRefWidth = this.props.innerRef.current.clientWidth;
this._widthChange(prevProps.height, prevState.scrollOffset);
}
if (
prevState.localOlderPostsToRender[0] !==
this.state.localOlderPostsToRender[0] ||
prevState.localOlderPostsToRender[1] !==
this.state.localOlderPostsToRender[1]
) {
const postlistScrollHeight = this._outerRef.scrollHeight;
const scrollValue =
snapshot.previousScrollTop +
(postlistScrollHeight - snapshot.previousScrollHeight);
this.scrollTo(
scrollValue,
scrollValue - snapshot.previousScrollTop,
true
);
}
}
componentWillUnmount() {
if (this._scrollByCorrection) {
window.cancelAnimationFrame(this._scrollByCorrection);
}
}
render() {
const {
className,
innerRef,
innerTagName,
outerTagName,
style,
innerListStyle,
} = this.props;
const onScroll = this._onScrollVertical;
const items = this._renderItems();
return createElement(
outerTagName!,
{
className,
onScroll,
ref: this._outerRefSetter,
style: {
WebkitOverflowScrolling: 'touch',
overflowY: 'auto',
overflowAnchor: 'none',
willChange: 'transform',
width: '100%',
...style,
},
},
createElement(innerTagName!, {
children: items,
ref: innerRef,
style: innerListStyle,
})
);
}
_callOnItemsRendered = memoizeOne(
(
overscanStartIndex: number,
overscanStopIndex: number,
visibleStartIndex: number,
visibleStopIndex: number
) =>
this.props.onItemsRendered({
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
})
);
_callOnScroll = memoizeOne(
(
scrollDirection: ScrollDirection,
scrollOffset: number,
scrollUpdateWasRequested: boolean,
scrollHeight: number,
clientHeight: number
) =>
this.props.onScroll({
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
scrollHeight,
clientHeight,
})
);
_callPropsCallbacks() {
const { itemData, height } = this.props;
const {
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
scrollHeight,
} = this.state;
const itemCount = itemData.length;
if (typeof this.props.onItemsRendered === 'function') {
if (itemCount > 0) {
const [
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
] = this._getRangeToRender();
this._callOnItemsRendered(
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex
);
if (
scrollDirection === 'backward' &&
scrollOffset < 1000 &&
overscanStopIndex !== itemCount - 1
) {
const sizeOfNextElement = getItemSize(
this.props,
overscanStopIndex + 1,
this._listMetaData
).size;
if (!sizeOfNextElement && this.state.scrolledToInitIndex) {
this.setState((prevState) => {
if (
prevState.localOlderPostsToRender[0] !==
overscanStopIndex + 1
) {
return {
localOlderPostsToRender: [
overscanStopIndex + 1,
overscanStopIndex + 50,
],
};
}
return null;
});
}
}
}
}
if (typeof this.props.onScroll === 'function') {
this._callOnScroll(
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
scrollHeight,
height
);
}
}
// This method is called after mount and update.
// List implementations can override this method to be notified.
_commitHook = () => {
if (
!this.state.scrolledToInitIndex &&
Object.keys(this._listMetaData.itemOffsetMap).length
) {
const { index, position, offset } = this.props.initScrollToIndex();
this.scrollToItem(index, position, offset);
this.setState({
scrolledToInitIndex: true,
});
if (index === 0) {
this._keepScrollToBottom = true;
} else {
this._keepScrollPosition = true;
}
}
};
// This method is called when data changes
// List implementations can override this method to be notified.
_dataChange = () => {
if (this._listMetaData.totalMeasuredSize < this.props.height) {
this.props.canLoadMorePosts();
}
};
_heightChange = (prevHeight: number, prevOffset: number) => {
if (prevOffset + prevHeight >= this._listMetaData.totalMeasuredSize - 10) {
this.scrollToItem(0, 'end');
return;
}
};
_widthChange = (prevHeight: number, prevOffset: number) => {
if (prevOffset + prevHeight >= this._listMetaData.totalMeasuredSize - 10) {
this.scrollToItem(0, 'end');
return;
}
};
// Lazily create and cache item styles while scrolling,
// So that pure component sCU will prevent re-renders.
// We maintain this cache, and pass a style prop rather than index,
// So that List can clear cached styles and force item re-render if necessary.
_getItemStyle = (index: number) => {
const { itemData } = this.props;
const itemStyleCache = this._itemStyleCache;
let style;
if (itemStyleCache.hasOwnProperty(itemData[index])) {
style = itemStyleCache[itemData[index]];
} else {
itemStyleCache[itemData[index]] = style = {
left: 0,
top: getItemOffset(this.props, index, this._listMetaData),
height: getItemSize(this.props, index, this._listMetaData),
width: '100%',
};
}
return style;
};
_getRangeToRender(scrollTop = 0): number[] {
const { itemData, overscanCountForward, overscanCountBackward } =
this.props;
const { scrollDirection, scrollOffset } = this.state;
const itemCount = itemData.length;
if (itemCount === 0) {
return [0, 0, 0, 0];
}
const scrollOffsetValue = scrollTop >= 0 ? scrollTop : scrollOffset;
const startIndex = getStartIndexForOffset(
this.props,
scrollOffsetValue,
this._listMetaData
);
const stopIndex = getStopIndexForStartIndex(
this.props,
startIndex,
scrollOffsetValue,
this._listMetaData
);
// Overscan by one item in each direction so that tab/focus works.
// If there isn't at least one extra item, tab loops back around.
const overscanBackward =
scrollDirection === 'backward'
? overscanCountBackward
: Math.max(1, overscanCountForward);
const overscanForward =
scrollDirection === 'forward'
? overscanCountBackward
: Math.max(1, overscanCountForward);
const minValue = Math.max(0, stopIndex - overscanBackward);
let maxValue = Math.max(
0,
Math.min(itemCount - 1, startIndex + overscanForward)
);
while (
!getItemSize(this.props, maxValue, this._listMetaData) &&
maxValue > 0 &&
this._listMetaData.totalMeasuredSize > this.props.height
) {
maxValue--;
}
if (
!this.state.scrolledToInitIndex &&
this.props.initRangeToRender.length
) {
return this.props.initRangeToRender;
}
return [minValue, maxValue, startIndex, stopIndex];
}
_correctScroll = () => {
const { scrollOffset } = this.state;
const element = this._outerRef;
if (element) {
element.scrollTop = scrollOffset;
this._scrollCorrectionInProgress = false;
this._correctedInstances = 0;
this._mountingCorrections = 0;
}
};
_generateOffsetMeasurements = () => {
const { itemOffsetMap, itemSizeMap } = this._listMetaData;
const { itemData } = this.props;
this._listMetaData.totalMeasuredSize = 0;
for (let i = itemData.length - 1; i >= 0; i--) {
const prevOffset = itemOffsetMap[itemData[i + 1]] || 0;
// In some browsers (e.g. Firefox) fast scrolling may skip rows.
// In this case, our assumptions about last measured indices may be incorrect.
// Handle this edge case to prevent NaN values from breaking styles.
// Slow scrolling back over these skipped rows will adjust their sizes.
const prevSize = itemSizeMap[itemData[i + 1]] || 0;
itemOffsetMap[itemData[i]] = prevOffset + prevSize;
this._listMetaData.totalMeasuredSize += itemSizeMap[itemData[i]] || 0;
// Reset cached style to clear stale position.
delete this._itemStyleCache[itemData[i]];
}
};
_handleNewMeasurements = (
key: any,
newSize: any,
forceScrollCorrection: any
) => {
const { itemSizeMap } = this._listMetaData;
const { itemData } = this.props;
const index = itemData.findIndex((item) => item === key);
// In some browsers (e.g. Firefox) fast scrolling may skip rows.
// In this case, our assumptions about last measured indices may be incorrect.
// Handle this edge case to prevent NaN values from breaking styles.
// Slow scrolling back over these skipped rows will adjust their sizes.
const oldSize = itemSizeMap[key] || 0;
if (oldSize === newSize) {
return;
}
itemSizeMap[key] = newSize;
if (!this.state.scrolledToInitIndex) {
this._generateOffsetMeasurements();
return;
}
const element = this._outerRef;
const wasAtBottom =
this.props.height + element.scrollTop >=
this._listMetaData.totalMeasuredSize - 10;
if (
(wasAtBottom || this._keepScrollToBottom) &&
this.props.correctScrollToBottom
) {
this._generateOffsetMeasurements();
this.scrollToItem(0, 'end');
this.forceUpdate();
return;
}
if (forceScrollCorrection || this._keepScrollPosition) {
const delta = newSize - oldSize;
const [, , visibleStartIndex] = this._getRangeToRender(
this.state.scrollOffset
);
this._generateOffsetMeasurements();
if (index < visibleStartIndex + 1) {
return;
}
this._scrollCorrectionInProgress = true;
this.setState(
(prevState) => {
let deltaValue;
if (this._mountingCorrections === 0) {
deltaValue = delta;
} else {
deltaValue = prevState.scrollDelta + delta;
}
this._mountingCorrections++;
const newOffset = prevState.scrollOffset + delta;
return {
scrollOffset: newOffset,
scrollDelta: deltaValue,
};
},
() => {
// $FlowFixMe Property scrollBy is missing in HTMLDivElement
this._correctedInstances++;
if (this._mountingCorrections === this._correctedInstances) {
this._correctScroll();
}
}
);
return;
}
this._generateOffsetMeasurements();
};
_onItemRowUnmount = (itemId: string, index: number) => {
const { props } = this;
if (props.itemData[index] === itemId) {
return;
}
const doesItemExist = props.itemData.includes(itemId);
if (!doesItemExist) {
delete this._listMetaData.itemSizeMap[itemId];
delete this._listMetaData.itemOffsetMap[itemId];
const element = this._outerRef;
const atBottom =
element.offsetHeight + element.scrollTop >=
this._listMetaData.totalMeasuredSize - 10;
this._generateOffsetMeasurements();
if (atBottom) {
this.scrollToItem(0, 'end');
}
this.forceUpdate();
}
};
_renderItems = () => {
const { children, itemData, loaderId } = this.props;
const width = this.innerRefWidth;
const [startIndex, stopIndex] = this._getRangeToRender();
const itemCount = itemData.length;
const items = [];
if (itemCount > 0) {
for (let index = itemCount - 1; index >= 0; index--) {
const { size } = getItemMetadata(this.props, index, this._listMetaData);
const [
localOlderPostsToRenderStartIndex,
localOlderPostsToRenderStopIndex,
] = this.state.localOlderPostsToRender;
const isItemInLocalPosts =
index >= localOlderPostsToRenderStartIndex &&
index < localOlderPostsToRenderStopIndex + 1 &&
localOlderPostsToRenderStartIndex === stopIndex + 1;
const isLoader = itemData[index] === loaderId;
const itemId = itemData[index];
// It's important to read style after fetching item metadata.
// getItemMetadata() will clear stale styles.
const style = this._getItemStyle(index);
if (
(index >= startIndex && index < stopIndex + 1) ||
isItemInLocalPosts ||
isLoader
) {
const item = createElement(children, {
data: itemData,
itemId,
});
// Always wrap children in a ItemMeasurer to detect changes in size.
items.push(
createElement(ItemMeasurer, {
handleNewMeasurements: this._handleNewMeasurements,
index,
item,
key: itemId,
size,
itemId,
width,
onUnmount: this._onItemRowUnmount,
})
);
} else {
items.push(
createElement('div', {
key: itemId,
style,
})
);
}
}
}
return items;
};
_onScrollVertical = (event: any) => {
if (!this.state.scrolledToInitIndex) {
return;
}
const { scrollTop, scrollHeight } = event.currentTarget;
if (this._scrollCorrectionInProgress) {
if (this.state.scrollUpdateWasRequested) {
this.setState(() => ({
scrollUpdateWasRequested: false,
}));
}
return;
}
if (scrollHeight !== this.state.scrollHeight) {
this.setState({
scrollHeight,
});
}
this.setState((prevState: DynamicSizeListState) => {
if (prevState.scrollOffset === scrollTop) {
// Scroll position may have been updated by cDM/cDU,
// In which case we don't need to trigger another render,
return null;
}
return {
...prevState,
scrollDirection:
prevState.scrollOffset < scrollTop ? 'forward' : 'backward',
scrollOffset: scrollTop,
scrollUpdateWasRequested: false,
scrollHeight,
scrollTop,
scrollDelta: 0,
};
});
};
_outerRefSetter = (ref: any) => {
const { outerRef } = this.props;
this.innerRefWidth = this.props.innerRef.current.clientWidth;
this._outerRef = ref;
if (typeof outerRef === 'function') {
outerRef(ref);
} else if (
outerRef != null &&
typeof outerRef === 'object' &&
outerRef.hasOwnProperty('current')
) {
outerRef.current = ref;
}
};
}
// NOTE: I considered further wrapping individual items with a pure ListItem component.
// This would avoid ever calling the render function for the same index more than once,
// But it would also add the overhead of a lot of components/fibers.
// I assume people already do this (render function returning a class component),
// So my doing it would just unnecessarily double the wrappers.
const validateProps = ({ children, itemSize }: any) => {
if (process.env.NODE_ENV !== 'production') {
if (children == null) {
throw Error(
'An invalid "children" prop has been specified. ' +
'Value should be a React component. ' +
`"${children === null ? 'null' : typeof children}" was specified.`
);
}
if (itemSize !== undefined) {
throw Error('An unexpected "itemSize" prop has been provided.');
}
}
};

@ -0,0 +1,216 @@
import React, { Component } from 'react';
import { findDOMNode } from 'react-dom';
function isBrowserSafari() {
const userAgent = window.navigator.userAgent;
return (
userAgent.indexOf('Safari') !== -1 && userAgent.indexOf('Chrome') === -1
);
}
const isSafari = isBrowserSafari();
const scrollBarWidth = 8;
const scrollableContainerStyles: React.CSSProperties = {
display: 'inline',
width: '0px',
height: '0px',
zIndex: -1,
overflow: 'hidden',
margin: '0px',
padding: '0px',
};
const scrollableWrapperStyle: React.CSSProperties = {
position: 'absolute',
flex: '0 0 auto',
overflow: 'hidden',
visibility: 'hidden',
zIndex: -1,
width: '100%',
height: '100%',
left: '0px',
top: '0px',
};
const expandShrinkContainerStyles: React.CSSProperties = {
flex: '0 0 auto',
overflow: 'hidden',
zIndex: -1,
visibility: 'hidden',
left: `-${scrollBarWidth + 1}px`, //8px(scrollbar width) + 1px
bottom: `-${scrollBarWidth}px`, //8px because of scrollbar width
right: `-${scrollBarWidth}px`, //8px because of scrollbar width
top: `-${scrollBarWidth + 1}px`, //8px(scrollbar width) + 1px
};
const expandShrinkStyles: React.CSSProperties = {
position: 'absolute',
flex: '0 0 auto',
visibility: 'hidden',
overflow: 'scroll',
zIndex: -1,
width: '100%',
height: '100%',
};
const shrinkChildStyle: React.CSSProperties = {
position: 'absolute',
height: '200%',
width: '200%',
};
//values below need to be changed when scrollbar width changes
//TODO: change these to be dynamic
const shrinkScrollDelta = 2 * scrollBarWidth + 1; // 17 = 2* scrollbar width(8px) + 1px as buffer
// 27 = 2* scrollbar width(8px) + 1px as buffer + 10px(this value is based of off lib(Link below). Probably not needed but doesnt hurt to leave)
//https://github.com/wnr/element-resize-detector/blob/27983e59dce9d8f1296d8f555dc2340840fb0804/src/detection-strategy/scroll.js#L246
const expandScrollDelta = shrinkScrollDelta + 10;
interface ItemMeasurerProps {
size: number;
handleNewMeasurements: any;
itemId: any;
item: any;
width: number;
onUnmount: any;
index: any;
}
export class ItemMeasurer extends Component<ItemMeasurerProps> {
_node: Element | Text | null = null;
_resizeSensorExpand = React.createRef<any>();
_resizeSensorShrink = React.createRef<any>();
_positionScrollbarsRef: number | null = null;
_measureItemAnimFrame: number | null = null;
componentDidMount() {
// eslint-disable-next-line react/no-find-dom-node
this._node = findDOMNode(this);
// Force sync measure for the initial mount.
// This is necessary to support the DynamicSizeList layout logic.
if (isSafari && this.props.size) {
this._measureItemAnimFrame = window.requestAnimationFrame(() => {
this._measureItem(false);
});
} else {
this._measureItem(false);
}
if (this.props.size) {
// Don't wait for positioning scrollbars when we have size
// This is needed triggering an event for remounting a post
this.positionScrollBars();
}
}
componentDidUpdate(prevProps: ItemMeasurerProps) {
if (
(prevProps.size === 0 && this.props.size !== 0) ||
prevProps.size !== this.props.size
) {
this.positionScrollBars();
}
}
positionScrollBars = (height = this.props.size, width = this.props.width) => {
//we are position these hiiden div scroll bars to the end so they can emit
//scroll event when height in the div changes
//Heavily inspired from https://github.com/marcj/css-element-queries/blob/master/src/ResizeSensor.js
//and https://github.com/wnr/element-resize-detector/blob/master/src/detection-strategy/scroll.js
//For more info http://www.backalleycoder.com/2013/03/18/cross-browser-event-based-element-resize-detection/#comment-244
if (this._positionScrollbarsRef) {
window.cancelAnimationFrame(this._positionScrollbarsRef);
}
this._positionScrollbarsRef = window.requestAnimationFrame(() => {
this._resizeSensorExpand.current.scrollTop = height + expandScrollDelta;
this._resizeSensorShrink.current.scrollTop =
2 * height + shrinkScrollDelta;
});
};
componentWillUnmount() {
if (this._positionScrollbarsRef) {
window.cancelAnimationFrame(this._positionScrollbarsRef);
}
if (this._measureItemAnimFrame) {
window.cancelAnimationFrame(this._measureItemAnimFrame);
}
const { onUnmount, itemId, index } = this.props;
if (onUnmount) {
onUnmount(itemId, index);
}
}
scrollingDiv = (event: any) => {
if (event.target.offsetHeight !== this.props.size) {
this._measureItem(event.target.offsetWidth !== this.props.width);
}
};
renderItems = () => {
const item = this.props.item;
const expandChildStyle: React.CSSProperties = {
position: 'absolute',
left: '0',
top: '0',
height: `${this.props.size + expandScrollDelta}px`,
width: '100%',
};
const renderItem = (
<div style={{ position: 'relative' }}>
{item}
<div style={scrollableContainerStyles}>
<div dir="ltr" style={scrollableWrapperStyle}>
<div style={expandShrinkContainerStyles}>
<div
style={expandShrinkStyles}
ref={this._resizeSensorExpand}
onScroll={this.scrollingDiv}
>
<div style={expandChildStyle} />
</div>
<div
style={expandShrinkStyles}
ref={this._resizeSensorShrink}
onScroll={this.scrollingDiv}
>
<div style={shrinkChildStyle} />
</div>
</div>
</div>
</div>
</div>
);
return renderItem;
};
render() {
return this.renderItems();
}
_measureItem = (forceScrollCorrection: any) => {
const { handleNewMeasurements, size: oldSize, itemId } = this.props;
const node = this._node;
if (
node &&
node.ownerDocument &&
node.ownerDocument.defaultView &&
node instanceof node.ownerDocument.defaultView.HTMLElement
) {
const newSize = Math.ceil(node.offsetHeight);
if (oldSize !== newSize) {
handleNewMeasurements(itemId, newSize, forceScrollCorrection);
}
}
};
}

@ -0,0 +1 @@
Fork from https://github.com/mattermost/dynamic-virtualized-list

@ -2113,6 +2113,13 @@
dependencies:
"@types/react" "*"
"@types/react-virtualized-auto-sizer@^1.0.1":
version "1.0.1"
resolved "https://registry.nlark.com/@types/react-virtualized-auto-sizer/download/@types/react-virtualized-auto-sizer-1.0.1.tgz#b3187dae1dfc4c15880c9cfc5b45f2719ea6ebd4"
integrity sha1-sxh9rh38TBWIDJz8W0XycZ6m69Q=
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^17.0.11":
version "17.0.11"
resolved "https://registry.npmjs.org/@types/react/-/react-17.0.11.tgz#67fcd0ddbf5a0b083a0f94e926c7d63f3b836451"
@ -7242,6 +7249,11 @@ memfs@^3.2.2:
dependencies:
fs-monkey "1.0.3"
memoize-one@^6.0.0:
version "6.0.0"
resolved "https://registry.npmmirror.com/memoize-one/download/memoize-one-6.0.0.tgz?cache=0&sync_timestamp=1634697208428&other_urls=https%3A%2F%2Fregistry.npmmirror.com%2Fmemoize-one%2Fdownload%2Fmemoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045"
integrity sha1-slkbhx7YKUiu5HJ9xqvO7qyMEEU=
meow@^8.0.0:
version "8.1.2"
resolved "https://registry.yarnpkg.com/meow/-/meow-8.1.2.tgz#bcbe45bda0ee1729d350c03cffc8395a36c4e897"
@ -9167,6 +9179,11 @@ react-use-gesture@^9.1.3:
resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-9.1.3.tgz#92bd143e4f58e69bd424514a5bfccba2a1d62ec0"
integrity sha512-CdqA2SmS/fj3kkS2W8ZU8wjTbVBAIwDWaRprX7OKaj7HlGwBasGEFggmk5qNklknqk9zK/h8D355bEJFTpqEMg==
react-virtualized-auto-sizer@^1.0.6:
version "1.0.6"
resolved "https://registry.nlark.com/react-virtualized-auto-sizer/download/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca"
integrity sha1-ZsWxySeAZMXvFpntQKKcEVGPl8o=
react@^17.0.2:
version "17.0.2"
resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"

Loading…
Cancel
Save