import * as classNames from "classnames"; import * as _ from "lodash"; import * as React from "react"; import { Checkbox } from "../Checkbox"; import { Progress, ProgressType } from "../Progress"; import { filterNil, IStringMap } from "../../utilities/collections"; import { loadImage } from "../../utilities/files"; // import { isCached } from "../../utilities/image"; import { addPrevent3dTouchListener, removePrevent3dTouchListener, setImagePossiblyRevealedDownloadSheetListeners, removeImagePossiblyRevealedDownloadSheetListeners } from "../../utilities/interactions"; import { runPriorityPromises } from "../../utilities/priority-promise"; import { SelectAction } from "../../utilities/states"; import { contain } from "../../utilities/layout"; export enum ImageOrientation { Landscape, Portrait, } export interface IImageProps { id?: string; className?: string; src?: string; placeholder?: string; onTapAndHold?: () => void; onClick?: (e: React.MouseEvent) => void; onLoad?: () => void; style?: React.CSSProperties; performant?: boolean; orientation?: ImageOrientation; /** * Apply a blur filter to the . If a string is provided, * the blur is only applied when the set `src` matches the string. This * src could be from the `src` property or the `sizes` array. */ blur?: boolean | string; /** * Apply a "fade" filter to the . If a string is provided, * the blur is only applied when the set `src` matches the string. This * src could be from the `src` property or the `sizes` array. */ fade?: boolean | string; prevent3dTouch?: boolean; preventTapAndHold?: boolean; /** * An array of progressively larger image URLs. */ sizes?: Array; knownNumberOfSizes?: number; attributes?: object; /** * By default, images take into account their visibility in the viewport * for performing actions. This ensures a high level of performance. One can * optionally disable this telling internally that it is to always be * considered visible. */ alwaysVisible?: boolean; /** * Use this property to ensure that an image never is set to invisible after it's been * revealed once */ neverBecomesInvisible?: boolean; /** * If provided, a component will be show on the bottom right of * the element. */ onSelect?: (selectAction: SelectAction) => void; /** * Indicate to the user the progress of the image loading */ showLoadingProgress?: boolean; /** * If provided, this is called whenever attempted to load sizes. This could happen * more than once if loading sizes is interrupted. */ onStartLoadSize?: (size: string, id?: string) => void; /** * If provided, this is called once all sizes have been loaded. */ onLoadAllSizes?: (id?: string) => void; /** * Lay an "invisible" image on top of the visible image. This is useful if you'd like to control * the image that is downloaded when tapped on as being separate from what has been loaded into the DOM. */ overlayImage?: string; /** * Attach a MutationObserver to the and listen for changes to the `trigger-load` attribute. Changes * to this attribute will toggle the loadability of the image. */ listenForTriggerLoadMutation?: boolean; /** * If provided, the image will have slightly rounded corners and a bit of padding */ bubble?: boolean; bubbleSoft?: boolean; /** * If provided, this method will be invoked and place with full-dimensions over the image element */ renderOverlayContent?: () => JSX.Element; /** * Don't even load low quality src until visible */ lazySrc?: boolean; /** * The root element when applying the IntersectionObserver. This will be the viewport * if null */ visibilityRoot?: string | HTMLElement; visiblityThreshold?: number; } interface IImageState { isVisible: boolean; loaded: IStringMap; } const defaultImageState: IImageState = { isVisible: false, loaded: {}, } export class Image extends React.Component { public static getDerivedStateFromProps(props: IImageProps, state: IImageState): Partial { return { isVisible: props.alwaysVisible ? true : state.isVisible, }; } public imageRef: React.RefObject; public overlayImageRef?: React.RefObject; public state: IImageState = defaultImageState; public imageIntersectionObserver?: IntersectionObserver; public imageMutationObserver?: MutationObserver; constructor(props: IImageProps) { super(props); this.imageRef = React.createRef(); this.overlayImageRef = React.createRef(); } public shouldComponentUpdate(nextProps: IImageProps, nextState: IImageState) { if (this.props.alwaysVisible !== nextProps.alwaysVisible) return true; if (this.props.blur !== nextProps.blur) return true; if (this.props.className !== nextProps.className) return true; if (this.props.id !== nextProps.id) return true; if (this.props.src !== nextProps.src) return true; if (this.props.orientation !== nextProps.orientation) return true; if (this.props.prevent3dTouch !== nextProps.prevent3dTouch) return true; // TODO: Sizes length check if (!_.isEqual(this.props.sizes, nextProps.sizes)) return true; if (!_.isEqual(this.props.attributes, nextProps.attributes)) return true; if (this.props.performant !== nextProps.performant) return true; if (this.props.fade !== nextProps.fade) return true; if (this.props.listenForTriggerLoadMutation !== nextProps.listenForTriggerLoadMutation) return true; if (this.props.bubble !== nextProps.bubble) return true; if (this.props.bubbleSoft !== nextProps.bubbleSoft) return true; if (this.props.renderOverlayContent !== nextProps.renderOverlayContent) return true; if (!this.state.isVisible && nextState.isVisible) return true; if (!_.isEqual(this.state.loaded, nextState.loaded)) return true; return false; } public componentDidMount() { this.addEventListeners(this.props.prevent3dTouch, _.isFunction(this.props.onTapAndHold)); if (!this.props.alwaysVisible) { this.addIntersectionObservers(); } else { // TODO: Remove on mount? this.removeIntersectionObservers(); } if (this.props.listenForTriggerLoadMutation) { this.addMutationObserver(); } else { this.removeMutationObserver(); } if (this.state.isVisible) { this.loadImageSizes(); } // this is strange. The component is an tag because of the zoom property // on Swiper. Therefore, the is simply rendered as a sibling node with no parent // here inside the component. However, to be positioned correctly the parent element // needs to be containing. if (_.isFunction(this.props.onSelect)) { this.withImageNode(node => { contain(node) }); } } public componentDidUpdate(prevProps: IImageProps, prevState: IImageState) { const shouldAdd3dTouchListener = !prevProps.prevent3dTouch && this.props.prevent3dTouch; const shouldAddTapAndHoldListener = !_.isFunction(prevProps.onTapAndHold) && _.isFunction(this.props.onTapAndHold); const shouldRemove3dTouchListener = prevProps.prevent3dTouch && !this.props.prevent3dTouch; const shouldRemoveTapAndHoldListener = _.isFunction(prevProps.onTapAndHold) && !_.isFunction(this.props.onTapAndHold); if (shouldAdd3dTouchListener || shouldAddTapAndHoldListener) { this.addEventListeners(shouldAdd3dTouchListener, shouldAddTapAndHoldListener); } if (shouldRemove3dTouchListener || shouldRemoveTapAndHoldListener) { this.removeEventListeners(shouldRemove3dTouchListener, shouldRemoveTapAndHoldListener); } if ( (!_.isEqual(prevProps.sizes, this.props.sizes) && this.state.isVisible) || (!prevState.isVisible && this.state.isVisible) ) { this.loadImageSizes(); } else if (!prevProps.alwaysVisible && this.props.alwaysVisible) { this.loadImageSizes(); } if ( this.props.showLoadingProgress && prevState.isVisible !== this.state.isVisible ) { this.toggleLoadingProgress(); } if (!_.isEqual(this.state.loaded, prevState.loaded)) { if (_.isEqual(_.keys(this.state.loaded).sort(), this.props.sizes.sort())) { if (_.isFunction(this.props.onLoadAllSizes)) { this.props.onLoadAllSizes(this.props.id); } } } } public componentWillUnmount() { this.removeEventListeners(true, true); this.removeIntersectionObservers(); this.removeMutationObserver(); } public render() { return ( <> {this.props.showLoadingProgress && (
)} {this.props.overlayImage && ( )} {_.isFunction(this.props.renderOverlayContent) && (
{this.props.renderOverlayContent()}
)} {_.isFunction(this.props.onSelect) && (
)} ); } private withImageNode(found: (node: HTMLImageElement, overlay?: HTMLImageElement) => T, notFound?: () => T): T { const imageNode = this.imageRef.current; if (imageNode) { const overlayImageNode = this.overlayImageRef.current; if (overlayImageNode) { return found(imageNode, overlayImageNode); } else { return found(imageNode); } } else if (notFound) { return notFound(); } return null; } private addEventListeners(shouldListenFor3dTouch: boolean, shouldListenForTapAndHold: boolean) { this.withImageNode((img, overlay) => { if (shouldListenFor3dTouch) { addPrevent3dTouchListener(img); addPrevent3dTouchListener(overlay); } if (shouldListenForTapAndHold) { setImagePossiblyRevealedDownloadSheetListeners(overlay || img, () => { this.props.onTapAndHold(); }); } }); } private removeEventListeners(shouldRemove3dTouchListener: boolean, shouldRemoveTapAndHoldListener: boolean) { this.withImageNode((img, overlay) => { if (shouldRemove3dTouchListener) { removePrevent3dTouchListener(img); removePrevent3dTouchListener(overlay); } if (shouldRemoveTapAndHoldListener) { removeImagePossiblyRevealedDownloadSheetListeners(overlay || img); } }); } private addIntersectionObservers = () => { this.withImageNode(imageNode => { try { this.imageIntersectionObserver = new IntersectionObserver((entries) => { const isVisible = entries[0].isIntersecting; // Guard against never setting `isVisible` to false if intended if (this.props.neverBecomesInvisible && !isVisible) return; this.setState({ isVisible, }); }, { rootMargin: "0px", threshold: this.props.visiblityThreshold || 1.0, }); this.imageIntersectionObserver.observe(imageNode); } catch (error) { // this is likely due to an unavailable IntersectionObserver API this.setState({ isVisible: true, }); } }); } private removeIntersectionObservers = () => { // Guard against nil imageObserver if (_.isNil(this.imageIntersectionObserver)) return; try { this.imageIntersectionObserver.disconnect(); } catch (error) { // this is likely due to an unavailable IntersectionObserver API } } /** * Image loading works by asynchronously running multiple size loads and only replacing * the `src` if the loaded image is higher than the currently set src. This ensure that if * a lower quality load happens to finish after a high quality load (as is possible with many request * across many component or when the browser begins to cache images) that the higher quality * src persists. */ private loadImageSizes = async () => { // if (isCached(_.last(this.props.sizes))) {} if (this.props.lazySrc) { this.withImageNode(node => { if (_.isNil(node.src) || _.isEmpty(node.src)) { this.setImageSrc(this.props.src); } }); } // Guard against empty sizes if (_.isEmpty(filterNil(this.props.sizes))) return; // Do not load images of lower quality than what is already loaded const srcs = (() => { return this.withImageNode(img => { // This assumes that the sizes array is ordered from lowest to highest quality return _.takeRightWhile(filterNil(this.props.sizes), size => size !== img.getAttribute("original-src")); }); })(); // Guard against largest image size already being set if (_.isEmpty(srcs)) return; // Guard against an image that is no longer visible if (!this.state.isVisible) return; // @ts-ignore runPriorityPromises(_.chain(srcs) .map((src, index) => { return { promise: () => new Promise(async resolve => { // Guard against the image becoming invisible if (!this.state.isVisible) return; try { if (_.isFunction(this.props.onStartLoadSize)) { this.props.onStartLoadSize(src, this.props.id); } // TODO-1: If loaded, return stored blob string const loadedSrc = await loadImage(src); // Guard again after request if (!this.state.isVisible) return; resolve({ originalSrc: src, src: loadedSrc, }); } catch { // `loadImage` failed } }), priority: index, }; }) .reverse() .value(), ({ originalSrc, src, }) => { // Guard against visibility if (!this.state.isVisible) return; // Guard against invalid src if (_.isEmpty(src) || !_.isString(src)) return; // Guard against the image leaving visibility when frame // is available if (!this.state.isVisible) return; this.setImageSrc(src, originalSrc); }, ({ originalSrc, src, }) => { if ( this.props.showLoadingProgress || _.isFunction(this.props.onLoadAllSizes) || _.isFunction(this.props.onStartLoadSize) ) { const loaded = _.cloneDeep(this.state.loaded); // TODO-1: Store loaded src so it can be return directly loaded[originalSrc] = true; this.setState({ loaded }); } }); } /** * @param src - the src to set on the tag * @param originalSrc - since we use `fetch` to load a blob */ private setImageSrc = (src: string, originalSrc?: string) => { // Guard against nil src if (_.isNil(src)) return; this.toggleBlur.cancel(); this.withImageNode(img => { img.onload = this.handleImageLoad.bind(this) // @ts-ignore img.src = src; img.classList.remove("image--no-src"); img.setAttribute("original-src", originalSrc || src); }); } private handleImageLoad(e: any) { if (_.isFunction(this.props.onLoad)) { this.props.onLoad(); } if (!_.isUndefined(this.props.blur)) { this.toggleBlur(); } if (!_.isUndefined(this.props.fade)) { this.toggleFade(); } } private toggleFade = _.debounce(() => { if (this.props.fade || _.isString(this.props.fade)) { this.withImageNode(img => { const shouldFade = (() => { if (_.isString(this.props.fade)) { return img.getAttribute("original-src") === this.props.fade; } else { return this.props.fade; } })(); // Guard against correct classLists if ( (shouldFade && img.classList.contains("image--fade")) || (!shouldFade && !img.classList.contains("image--fade")) ) return; // TODO: Guard against visibility // if (!this.state.isVisible) return; if (shouldFade) { img.classList.add("image--fade"); } else { img.classList.remove("image--fade"); } }); } }, 200) private toggleLoadingProgress = _.debounce(() => { this.withImageNode(img => { const imageProgressNode = img.parentElement.querySelector(".image__progress") as HTMLElement; if (imageProgressNode) { if (this.state.isVisible) { imageProgressNode.style.opacity = "1"; } else { imageProgressNode.style.opacity = "0"; } } }); }, 200) private toggleBlur = _.debounce(() => { if (this.props.blur || _.isString(this.props.blur)) { this.withImageNode(img => { const shouldBlur = (() => { if (_.isString(this.props.blur)) { return img.getAttribute("original-src") === this.props.blur; } else { return this.props.blur; } })(); // Guard against correct classLists if ( (shouldBlur && img.classList.contains("image--blur")) || (!shouldBlur && !img.classList.contains("image--blur")) ) return; // TODO: Guard against visibility // if (!this.state.isVisible) return; if (shouldBlur) { img.classList.add("image--blur"); } else { img.classList.remove("image--blur"); } }); } }, 200) private calculateImageLoadingProgress(): number { return Math.round((_.values(this.state.loaded).filter(value => value).length / this.props.knownNumberOfSizes) * 100); } private addMutationObserver() { this.withImageNode(imageNode => { try { this.imageMutationObserver = new MutationObserver(([mutation]) => { // Guard against nil mutation if (_.isNil(mutation)) return; if ( mutation.type === "attributes" && mutation.attributeName === "trigger-load" && // @ts-ignore mutation.target.getAttribute(mutation.attributeName) === "true" && !this.state.isVisible ) { this.setState({ isVisible: true, }); } }); this.imageMutationObserver.observe(imageNode, { attributes: true, }); } catch (e) {} }); } private removeMutationObserver() { // Guard against nil if (_.isNil(this.imageMutationObserver)) return; this.imageMutationObserver.disconnect(); } }