diff --git a/web/package.json b/web/package.json index 635eaee0..492d52ac 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/DynamicVirtualizedList/DynamicSizeList.tsx b/web/src/components/DynamicVirtualizedList/DynamicSizeList.tsx new file mode 100644 index 00000000..abf1d2e6 --- /dev/null +++ b/web/src/components/DynamicVirtualizedList/DynamicSizeList.tsx @@ -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; + 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.'); + } + } +}; diff --git a/web/src/components/DynamicVirtualizedList/ItemMeasurer.tsx b/web/src/components/DynamicVirtualizedList/ItemMeasurer.tsx new file mode 100644 index 00000000..035c739b --- /dev/null +++ b/web/src/components/DynamicVirtualizedList/ItemMeasurer.tsx @@ -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 { + _node: Element | Text | null = null; + _resizeSensorExpand = React.createRef(); + _resizeSensorShrink = React.createRef(); + _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 = ( +
+ {item} +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + 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); + } + } + }; +} diff --git a/web/src/components/DynamicVirtualizedList/README.md b/web/src/components/DynamicVirtualizedList/README.md new file mode 100644 index 00000000..e20f2832 --- /dev/null +++ b/web/src/components/DynamicVirtualizedList/README.md @@ -0,0 +1 @@ +Fork from https://github.com/mattermost/dynamic-virtualized-list diff --git a/yarn.lock b/yarn.lock index f72f8c95..3ae2d9cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"