import * as classNames from "classnames"; import * as _ from "lodash"; import * as React from "react"; import { AutoSizer, defaultCellRangeRenderer, Grid, InfiniteLoader, WindowScroller } from "react-virtualized"; import { ImageLoadTracker, IImageLoadTrackerProps, ImageLoadTrackerType } from "../ImageLoadTracker"; import { Image, IImageProps } from "../Image"; import { provideBounce } from "../../utilities/scrolling"; export interface IGalleryProps { /** * The list of images that will be virtualized. Eventually, if this is empty * we will use the emptyContent property in ReactVirtualized */ images: IImageProps[]; /** * If provided, the will scroll the view such * that the active image is visible. */ activeImageIndex?: number; /** * Use this for paginating a */ loadMoreRows?: ({ startIndex, stopIndex }: { startIndex: number, stopIndex: number }) => Promise; /** * If using paginated loading, use this to tell the how many total images it wil eventually need * to fetch */ totalImageCount?: number; /** * By default, the IImageProp from the images array is spread to an * component. However, if this property is provided it will be called instead. * It is expected that it returns an component * * @param remainder - If `maxRowCount` is set, there could be items that are cut off. The number of * cut off items is the "remainder". The remainder will only be passed to the last * element rendered before elements are cut off. */ renderImage?: (image: IImageProps, index: number, remainder?: number) => JSX.Element; /** * If provided, a load tracker will be displayed */ imageLoadTracker?: IImageLoadTrackerProps; /** * If provided, each item will maintain a certain width. This will make * the number of items in a row dynamic */ itemWidth?: number; itemHeight?: number; /** * If provided, the row count will never exceed this number. Calculations for * the row count will still be done as normal */ maxRowCount?: number; /** * If true, whenever the size of the is recomputed, the parent * element's height will be set to the total height of the gallery. */ snapParentHeightToGalleryHeight?: boolean; onSnapParentHeightToGalleryHeight?: () => void; snapParentWidthToGalleryWidth?: boolean; onSnapParentWidthToGalleryWidth?: () => void; /** * Disable the virtualization of elements above the current scroll. Essentially, * prevent unmounting and remount of elements that have already been revealed */ preventBackwardsVirtualization?: boolean; className?: string; windowScrolling?: boolean; } interface IGalleryState { /** * The `imageLoadTracker` is of type ImageLoadTrackerType.Detailed. Therefore, it * can be on an "open" or "closed" state given that its underlying element is an * . We need to account for both states when calculating the s top * offsets */ imageLoadTrackerIsDetailOpen: boolean; } const defaultState: IGalleryState = { imageLoadTrackerIsDetailOpen: false, } export class Gallery extends React.Component { public ref: React.RefObject; public state: IGalleryState = defaultState; constructor(props) { super(props); this.ref = React.createRef(); this.infiniteLoaderChildFunction = this.infiniteLoaderChildFunction.bind(this); } public shouldComponentUpdate(nextProps: IGalleryProps, nextState: IGalleryState) { if (!_.isEqual(this.props.images, nextProps.images)) return true; if (!_.isEqual(this.props.imageLoadTracker, nextProps.imageLoadTracker)) return true; if (this.props.activeImageIndex !== nextProps.activeImageIndex) return true; if (this.props.totalImageCount !== nextProps.totalImageCount) return true; if (this.props.className !== nextProps.className) return true; if (this.state.imageLoadTrackerIsDetailOpen !== nextState.imageLoadTrackerIsDetailOpen) return true; return false; } public componentDidMount() { this.withVirtualGridNode((virtualGridNode) => { provideBounce(virtualGridNode); }); // If there are images to possible "snap"... if (this.props.images.length > 0) { if (this.props.snapParentHeightToGalleryHeight) { this.snapParentHeightToGalleryHeight(); } if (this.props.snapParentWidthToGalleryWidth) { this.snapParentWidthToGalleryWidth(); } } } public componentDidUpdate(prevProps: IGalleryProps, prevState: IGalleryState) { // We want to ignore changes to mount as that's covered // by onEnter and onExit const imageLoadTrackerUpdatedAndShouldTriggerRecompute = ((() => { // Guard against nil if (_.isNil(this.props.imageLoadTracker) || _.isNil(prevProps.imageLoadTracker)) return false if ( !_.isEqual(this.props.imageLoadTracker.images, prevProps.imageLoadTracker.images) || !_.isEqual(this.props.imageLoadTracker.title, prevProps.imageLoadTracker.title) || !_.isEqual(this.props.imageLoadTracker.subtitle, prevProps.imageLoadTracker.subtitle) ) { return true; } return false; })()) // React Virtualized doesn't support updating the underlying data // automatically. To trigger an update, we need to call a `recomputeGridSize` // method that calls 's prototype method if ( this.props.images.length !== prevProps.images.length || imageLoadTrackerUpdatedAndShouldTriggerRecompute || this.state.imageLoadTrackerIsDetailOpen !== prevState.imageLoadTrackerIsDetailOpen ) { this.recomputeGridSize(); if (this.props.snapParentHeightToGalleryHeight) { this.snapParentHeightToGalleryHeight(); } if (this.props.snapParentWidthToGalleryWidth) { this.snapParentWidthToGalleryWidth(); } } } public render () { return (
{}} rowCount={this.getTotalRowCount()} > {this.infiniteLoaderChildFunction.bind(this)}
); } private cellRangeRenderer = (props: any) => { const children = defaultCellRangeRenderer(props); children.push(
this.setState({ imageLoadTrackerIsDetailOpen: true }), onDetailClosed: () => this.setState({ imageLoadTrackerIsDetailOpen: false }), }} onEnter={this.recomputeGridSize} onExit={this.recomputeGridSize} onResize={this.recomputeGridSize} />
); return children; } private infiniteLoaderChildFunction({ onRowsRendered, registerChild }) { this.onRowsRendered = onRowsRendered; if (this.props.windowScrolling) { return ( {this.renderAutosizedGrid} ); } return this.renderAutosizedGrid({ registerChild }); } private renderAutosizedGrid = ({ registerChild, isScrolling, providedHeight }: { registerChild: (ref: any) => void, isScrolling?: boolean, providedHeight?: number }) => { return ( { if (this.props.snapParentHeightToGalleryHeight) { this.snapParentHeightToGalleryHeight() } if (this.props.snapParentWidthToGalleryWidth) { this.snapParentWidthToGalleryWidth() } } }} > {({ height, width }) => ( { if (ref) { // @ts-ignore this.recomputeGridSize = ref.__proto__.recomputeGridSize.bind(ref); this.getTotalRowsHeight = ref.__proto__.getTotalRowsHeight.bind(ref); this.getTotalColumnsWidth = ref.__proto__.getTotalColumnsWidth.bind(ref); return registerChild(ref); } }} onSectionRendered={this.onSectionRendered} className="gallery" columnCount={this.getColumnCount(width)} rowCount={this.getTotalRowCount(width)} {...this.props.preventBackwardsVirtualization && { overscanIndicesGetter: this.overscanIndicesGetter, }} height={this.getHeight(providedHeight || height)} width={this.getLimitedTotalColumnsWidth(width)} {..._.isNumber(this.props.activeImageIndex) && { scrollToColumn: this.props.activeImageIndex % 3, scrollToRow: Math.floor(this.props.activeImageIndex / 3), }} // TODO: Find the reason why there needs to be the - 6 in rowHeight for an accurate scrollTo rowHeight={this.getRowHeight(width)} columnWidth={this.getColumnWidth(width)} containerStyle={{ height: this.getContainerHeight(width), maxHeight: this.getContainerHeight(width), }} cellRenderer={({ columnIndex, key, rowIndex, style, isVisible, ...other }) => { const imageIndex = (rowIndex * this.getColumnCount(width)) + columnIndex; // Guard against extra columns in row if (!this.isIndexInBounds(imageIndex)) return null; const image = this.props.images[imageIndex] as IImageProps; const remainder = this.getRemainder(width, imageIndex); const topOffset = this.getTopOffsetForCell(style.top); return (
{_.isFunction(this.props.renderImage) ? this.props.renderImage(image, imageIndex, remainder) : }
); }} /> )}
); } private onSectionRendered = ({ columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex }) => { this.onRowsRendered({ startIndex: rowStartIndex, stopIndex: rowStopIndex }); } private onRowsRendered = (...args: any[]) => {} private recomputeGridSize = () => { console.warn("Can't recompute until this method is overriden!"); } private getTotalRowsHeight = (): number | null => { console.warn("Can't get rows height until this method is overriden!"); return null; } private getTotalColumnsWidth = (): number | null => { console.warn("Can't get columns width until this method is overriden!"); return null; } /** * Setting `overscanRowCount` on did not seem to change the * range that was rendered. However, using this override did. */ private overscanIndicesGetter = ({ direction, // One of "horizontal" or "vertical" cellCount, // Number of rows or columns in the current axis scrollDirection, // 1 (forwards) or -1 (backwards) overscanCellsCount, // Maximum number of cells to over-render in either direction startIndex, // Begin of range of visible cells stopIndex // End of range of visible cells }) => { return { // Always render the beginning of the list overscanStartIndex: 0, overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount) } } private loadMoreRows = ({ startIndex, stopIndex }: { startIndex: number, stopIndex: number }) => { return new Promise(async resolve => { // TODO: Use this.getColumnCount const firstUnloadedImageIndex = startIndex * 3; const lastUnloadedImageIndex = (stopIndex * 3) + 2; await this.props.loadMoreRows({ startIndex: firstUnloadedImageIndex, stopIndex: lastUnloadedImageIndex, }); resolve(); }); } private isRowLoaded = ({ index }): boolean => { // TODO: Use this.getColumnCount const imageIndex = index * 3; return !!this.props.images[imageIndex] && !!this.props.images[imageIndex + 1] && !!this.props.images[imageIndex + 2]; } private getTotalRowCount = (gridWidth?: number): number => { const calculatedRowCount = Math.ceil((_.isNumber(this.props.totalImageCount) ? this.props.totalImageCount : _.defaultTo(this.props.images, []).length) / this.getColumnCount(gridWidth)); // Guard against `maxRowCount` being exceeded if (_.isNumber(this.props.maxRowCount) && calculatedRowCount > this.props.maxRowCount) return this.props.maxRowCount; return calculatedRowCount; } private isIndexInBounds = (index: number): boolean => { return _.inRange(index, 0, _.isNumber(this.props.totalImageCount) ? this.props.totalImageCount : this.props.images.length); } private withVirtualGridNode(found: (node: HTMLDivElement) => T, notFound?: () => T): T { this.withGalleryNode((galleryNode) => { const reactVirtualizedContainer = galleryNode.querySelector(".ReactVirtualized__Grid") as HTMLDivElement; if (reactVirtualizedContainer) { return found(reactVirtualizedContainer); } else if (notFound) { return notFound(); } return null; }); return null; } private withGalleryNode(found: (node: HTMLDivElement) => T, notFound?: () => T): T { const galleryNode = this.ref.current; if (galleryNode) { return found(galleryNode); } else if (notFound) { return notFound(); } return null; } private withGalleryImageLoadTrackerNode(found: (node: HTMLDivElement) => T, notFound?: () => T): T { return this.withGalleryNode(node => { const galleryImageLoadTrackerNode = node.querySelector('.gallery__image-load-tracker') as HTMLDivElement; if (galleryImageLoadTrackerNode) { return found(galleryImageLoadTrackerNode); } else if (notFound) { return notFound(); } return null; }); } /** * If the `itemWidth` property is provided, the number of * columns per row is calculated by dividing the provided width * by the `itemWidth` */ private getColumnCount = (gridWidth?: number): number => { // Guard against no itemWidth property if (!_.isNumber(this.props.itemWidth)) return 3; // Guard against no provided gridWidth if (!_.isNumber(gridWidth)) return 3; return Math.floor(gridWidth / this.props.itemWidth); } private getColumnWidth = (gridWidth: number): number => { // Guard against no itemWidth property if (!_.isNumber(this.props.itemWidth)) return Math.floor(gridWidth / this.getColumnCount(gridWidth)) return this.props.itemWidth; } private getLimitedTotalColumnsWidth = (gridWidth: number): number => { // Guard against no itemWidth property if (!_.isNumber(this.props.itemWidth)) return gridWidth; // Guard against remainders or multiple rows if ((this.props.itemWidth * this.props.images.length) >= gridWidth) return gridWidth; // Guard against not snapping width if (!this.props.snapParentWidthToGalleryWidth) return gridWidth; return this.props.itemWidth * this.props.images.length; } private getHeight = (gridHeight: number): number => { // TODO: return gridHeight + this.getGalleryImageLoadTrackerHeight(); return gridHeight; } private getRowHeight = (gridWidth: number): number => { // Guard against no itemHeight property if (!_.isNumber(this.props.itemHeight)) return Math.round(gridWidth / this.getColumnCount(gridWidth)) - 6; return this.props.itemHeight; } private getRemainder = (gridWidth: number, index: number): number | null => { const maxNumberOfItems = this.getColumnCount(gridWidth) * this.getTotalRowCount(gridWidth); // Guard against all items can fit if (maxNumberOfItems >= this.props.images.length) return null; const remainder = this.props.images.length - maxNumberOfItems; // Guard against not at last item if (index !== maxNumberOfItems - 1) return null; return remainder; } private getTopOffsetForCell = (defaultTop: number): number => { // Guard against no top elements if (_.isNil(this.props.imageLoadTracker) || !this.props.imageLoadTracker.mount) return defaultTop; return defaultTop + this.getGalleryImageLoadTrackerHeight(); } private getGalleryImageLoadTrackerHeight = (): number => { // Guard against no image load tracker if (_.isNil(this.props.imageLoadTracker)) return 0; // Guarg against image load tracker closed if (!this.props.imageLoadTracker.mount) return 0; return this.withGalleryImageLoadTrackerNode(node => { return node.clientHeight; }, () => { return 0; }); } private snapParentHeightToGalleryHeight = () => { this.withGalleryNode(node => { const parent = node.parentElement; if (parent) { parent.style.height = `${this.getTotalRowsHeight()}px`; if (_.isFunction(this.props.onSnapParentHeightToGalleryHeight)) { this.props.onSnapParentHeightToGalleryHeight(); } } }); } private snapParentWidthToGalleryWidth = () => { this.withGalleryNode(node => { const parent = node.parentElement; if (parent) { parent.style.width = `${this.getLimitedTotalColumnsWidth(this.getTotalColumnsWidth())}px`; if (_.isFunction(this.props.onSnapParentWidthToGalleryWidth)) { this.props.onSnapParentWidthToGalleryWidth(); } } }); } private getContainerHeight = (gridWith: number): number => { return this.getRowHeight(gridWith) * this.getTotalRowCount(gridWith) + this.getGalleryImageLoadTrackerHeight(); } }