import * as classNames from "classnames"; import * as _ from "lodash"; import * as React from "react"; import Swiper from "swiper"; import { Sprite, SpriteType } from "../../components/Sprite"; import { iterateMiddleOut, wasShuffled } from "../../utilities/collections"; import { setTapRecognizers } from "../../utilities/interactions"; import { withControlledUpdates, IUpdateControllable } from "../../utilities/withControlledUpdates"; interface IPhotoViewerPhoto { id: string; } interface IPhotoViewerProps

extends IUpdateControllable { /** * @param offset - This is the slide offset from the currently * active slide. For example, call to renderPhotoSlide for the slide before * the active slide will have an offset of -1. */ renderPhotoSlide: (photo: P, offset?: number) => JSX.Element; /** * The list of photos. These can contain any relevant information to the user with the * generic prop type P. However, an `id` is required. */ photos?: (P & IPhotoViewerPhoto)[]; initialSlide?: number; /** * Invoked when a photo is viewed. This is when a slide is 50% * visible to the user. */ onPhotoView?: (photo: P) => void; /** * When the navigation arrows are dismissed or summoned, * this callback is invoked. */ onNavigationToggle?: (isHiding: boolean) => void; /** * Our component supports listening for a mutation change to * the "trigger-load" attribute. This allows us to control the loading * of image srcs in a non-sequential order. */ triggerImageLoadAttribute?: boolean; /** * Prevent users from being able to navigate between slides */ preventSliding?: boolean; } interface IVirtualData

{ offset: string; to: number; from: number; slides: (P & IPhotoViewerPhoto)[]; } interface IPhotoViewerState

{ swiper?: Swiper; virtualData?: IVirtualData

; } const defaultPhotoViewerState = { swiper: null, virtualData: null, } enum PhotoViewerUpdateType { InitializingSwiper, InitiliazedSwiper, PhotosAdded, PhotosRemoved, PhotosShuffled, PhotosUpdated, RenderingVirtualUpdate, SwiperConfigurationChanged, } export const PhotoViewer = withControlledUpdates(class

extends React.Component, IPhotoViewerState

> { public swiperRef: React.RefObject; public state: IPhotoViewerState

= defaultPhotoViewerState; constructor(props) { super(props); this.swiperRef = React.createRef(); } public componentDidMount() { this.initializeSwiper(); this.initializeHideNavigationOnTap(); } public shouldComponentUpdate(nextProps: IPhotoViewerProps

, nextState: IPhotoViewerState

) { return ( !_.isEqual(this.state.virtualData, nextState.virtualData) || !_.isEqual(this.props.photos, nextProps.photos) || (!this.state.swiper && !!nextState.swiper) || this.props.preventSliding !== nextProps.preventSliding ); } /** * There are several supported updates in : * * - new slides are prependedPrepending * - new slides are appended * - a photo is update * - a photo is removed * - the photos are not updated but are shuffled * - Swiper has determined that we need to render virtual slides to the DOM */ public componentDidUpdate(prevProps: IPhotoViewerProps

, prevState: IPhotoViewerState

) { const photoViewerUpdateType = (() => { if (this.props.photos.length > prevProps.photos.length) { return PhotoViewerUpdateType.PhotosAdded; } else if (this.props.photos.length < prevProps.photos.length) { return PhotoViewerUpdateType.PhotosRemoved; } else if (wasShuffled(this.props.photos, prevProps.photos, "id")) { return PhotoViewerUpdateType.PhotosShuffled; } else if (!_.isEqual(this.state.virtualData, prevState.virtualData)) { return PhotoViewerUpdateType.RenderingVirtualUpdate; } else if (_.isEmpty(this.state.swiper)) { return PhotoViewerUpdateType.InitializingSwiper; } else if (_.isEmpty(prevState.swiper) && !_.isEmpty(this.state.swiper)) { return PhotoViewerUpdateType.InitiliazedSwiper; } else if (this.props.preventSliding !== prevProps.preventSliding) { return PhotoViewerUpdateType.SwiperConfigurationChanged; } return PhotoViewerUpdateType.PhotosUpdated; })(); switch (photoViewerUpdateType) { case PhotoViewerUpdateType.PhotosAdded: this.addPhotos(this.props.photos, prevProps.photos); break; case PhotoViewerUpdateType.PhotosRemoved: this.removePhotos(_.differenceBy(prevProps.photos, this.props.photos, "id")); break; case PhotoViewerUpdateType.PhotosShuffled: case PhotoViewerUpdateType.PhotosUpdated: this.updatePhotos(this.props.photos); break; case PhotoViewerUpdateType.InitializingSwiper: case PhotoViewerUpdateType.InitiliazedSwiper: break; case PhotoViewerUpdateType.SwiperConfigurationChanged: this.updateSwiper(); break; case PhotoViewerUpdateType.RenderingVirtualUpdate: if (this.props.triggerImageLoadAttribute) { this.triggerImagesLoadAttribute(); } break; } } public componentWillUnmount() { // @ts-ignore this.state.swiper.destroy(true); } public render() { return (

{!_.isEmpty(this.state.swiper) && !_.isEmpty(this.state.virtualData) && !_.isEmpty(this.state.virtualData.slides) && this.state.virtualData.slides.map((photo, index) => { const currentIndex = this.state.virtualData.from + index; const globalIndex = this.state.swiper.realIndex; return (
{this.props.renderPhotoSlide(photo, currentIndex - globalIndex)}
); })}
); } private initializeSwiper = () => { this.withSwiperNode((swiperNode) => { return new Swiper(swiperNode, { allowSlideNext: !this.props.preventSliding, allowSlidePrev: !this.props.preventSliding, noSwiping: this.props.preventSliding, navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev', }, parallax: true, preventInteractionOnTransition: false, spaceBetween: 30, speed: 350, zoom: true, centeredSlides: true, grabCursor: true, keyboard: true, initialSlide: _.isNumber(this.props.initialSlide) ? this.props.initialSlide : 0, runCallbacksOnInit: false, virtual: { // @ts-ignore addSlidesBefore: 3, addSlidesAfter: 3, cache: false, renderExternal: (data: IVirtualData

) => { this.props.pushUpdate(((virtualData) => () => { return new Promise(async (resolve) => { // @ts-ignore this.setState({ virtualData, }, () => { // @ts-ignore this.withSwiperNode(({ swiper}) => { // TODO: Find why this is needed. Without it when swiping backwards // with virtualization and spaceBetween, the first slide is missing it's // margin. This only happens when the virtual updates are queued and not // when `setState` is called on every external render. swiper.update() }); resolve(); }); }); })(data), "render-virtual-data"); }, slides: this.props.photos, }, on: { init: () => { // @ts-ignore this.withSwiperNode(({ swiper }) => { this.setState({ swiper }); }); // since we do not `runCallbacksOnInit` we need to call this manually // on init if (_.isFunction(this.props.onPhotoView)) { this.props.onPhotoView(this.props.photos[_.isNumber(this.props.initialSlide) ? this.props.initialSlide : 0]) } }, slideChange: () => { // Guard against `slideChange` call during initialization such as // when there is a non-zero initialSlide if (_.isNil(this.state.swiper)) return; // @ts-ignore this.withSwiperNode(({ swiper }) => { if (_.isFunction(this.props.onPhotoView)) { this.props.onPhotoView(this.props.photos[swiper.activeIndex]); } }); }, transitionStart: this.preventUpdates, touchStart: this.preventUpdates, setTransition: this.checkForUnloadedSlides, transitionEnd: this.allowUpdates, /** * There is no `slideChangeEnd` event in Swiper currently. We want * to make sure that we only process this method if the slide is not currently * in transition. * * @see https://github.com/nolimits4web/swiper/issues/2980 */ slideChangeTransitionEnd: this.handleSlideChangeEnd, }, }); }); } private preventUpdates = () => { // Guard against preventing updates when they are already being prevented // by preventing sliding if (this.props.preventSliding) { return; } this.allowUpdates.cancel(); this.props.preventUpdates(); } private handleSlideChangeEnd = () => { if (this.isResting()) { this.props.acceptUpdate("remove-photos"); } } /** * Because of issue documented above with `slideChangeTransitionEnd` * we only want to allow updates if the slide is not currently in * transition. */ private allowUpdates = _.debounce(() => { if (this.isResting()) { // No need to flush since we are about to update this.checkForUnloadedSlides.cancel(); this.props.allowUpdates(); } }, 300); private isResting = (): boolean => { // @ts-ignore return this.withSwiperNode(({ swiper }) => { return ( !swiper.animating && !swiper.touchEventsData.isTouched ); }); } /** * TODO: This is an issue when the flush will also include an update to * new slides. I think this should only flush updates that are related to * virtual slides `renderExternal`. Not updates to children. Hmm... */ private checkForUnloadedSlides = _.throttle(() => { // @ts-ignore this.withSwiperNode(({ swiper }) => { const nextSlideIsLoaded = document.querySelector(`#photo-viewer__swiper-slide--real-index-${swiper.realIndex + 1}`); const prevSlideIsLoaded = document.querySelector(`#photo-viewer__swiper-slide--real-index-${swiper.realIndex - 1}`); if ( (!nextSlideIsLoaded && this.indexIsWithinBounds(swiper.realIndex + 1 )) || // Next slide isn't loaded and is in bounds... (!prevSlideIsLoaded && this.indexIsWithinBounds(swiper.realIndex - 1)) // ...or the previous slide isn't loaded and is in bounds. ) { this.props.flushUpdate("render-virtual-data"); } }); }, 16); private addPhotos = (photos: IPhotoViewerPhoto[], prevPhotos: IPhotoViewerPhoto[]) => { const previousStartingIndex = _.findIndex(photos, (photo) => photo.id === _.first(prevPhotos).id); const previousEndingIndex = _.findIndex(photos, (photo) => photo.id === _.last(prevPhotos).id); this.props.pushUpdate(((prependPhotos, appendPhotos) => () => { return new Promise((resolve) => { !_.isEmpty(prependPhotos) && prependPhotos.forEach(photo => { // @ts-ignore this.state.swiper.virtual.prependSlide(photo); }); !_.isEmpty(appendPhotos) && appendPhotos.forEach(photo => { // @ts-ignore this.state.swiper.virtual.appendSlide(photo); }); if (!_.isEmpty(prependPhotos) || !_.isEmpty(appendPhotos)) { // Appending and prepending slides will trigger a call to // `renderExternal` which we want to process immediately to // reflect the new offset styles this.props.flushUpdate("render-virtual-data"); } resolve(); }); })( previousStartingIndex > 0 ? _.slice(photos, 0, previousStartingIndex) : [], previousEndingIndex + 1 < photos.length ? _.slice(photos, previousEndingIndex + 1, photos.length) : [], )); } private removePhotos = (photos: IPhotoViewerPhoto[]) => { this.props.pushUpdate(((deletePhotos) => () => { return new Promise((resolve, reject) => { if (!_.some(photos, (photo) => photo.id === this.getActivePhoto().id)) { // @ts-ignore this.withSwiperNode(({ swiper }) => { const initialIndex = _.clone(swiper.realIndex); const initialSlide = swiper.virtual.slides[initialIndex]; const currentSlides = _.clone(swiper.virtual.slides); swiper.virtual.slides = []; swiper.update(); swiper.virtual.slides = _.differenceBy(currentSlides, deletePhotos, "id"); swiper.update(); // @ts-ignore const newIndex = _.findIndex(swiper.virtual.slides, (slide) => slide.id === initialSlide.id); swiper.slideTo(newIndex, 0); this.allowUpdates.flush(); resolve(); }); } else { reject(); } }); })(_.clone(photos)), "remove-photos"); } private getActivePhoto = (): IPhotoViewerPhoto => { // @ts-ignore return this.withSwiperNode(({ swiper }) => { return swiper.virtual.slides[swiper.realIndex]; }); } private updatePhotos = (photos: IPhotoViewerPhoto[]) => { this.props.pushUpdate(async () => { // @ts-ignore this.withSwiperNode(({ swiper }) => { const initialIndex = _.clone(swiper.realIndex); // Settings slides to photos does not work unless clearing the slides initially swiper.virtual.slides = []; swiper.virtual.update(); swiper.virtual.slides = photos; swiper.virtual.update(); swiper.slideTo(initialIndex, 0, false); swiper.virtual.update(); }); }); } private indexIsWithinBounds = (index: number): boolean => { return _.inRange(index, 0, this.props.photos.length); } private initializeHideNavigationOnTap = () => { this.withSwiperNode((swiperNode) => { setTapRecognizers(swiperNode, (e) => { const navigationButtons = Array.from(swiperNode.querySelectorAll(".photo-viewer__navigation")); const navigationContainer = swiperNode.querySelector(".photo-viewer__navigation-container"); // @ts-ignore if (!navigationContainer.contains(e.target) && !this.isZoomed()) { Array.from(navigationButtons).forEach(navigationButton => { navigationButton.classList.toggle("swiper-button-hidden"); }); if (_.isFunction(this.props.onNavigationToggle)) { this.props.onNavigationToggle(navigationButtons[0].classList.contains("swiper-button-hidden")); } } }); }); } private isZoomed = (): boolean => { return this.withSwiperNode((swiperNode) => { if (!_.isNil(swiperNode.querySelector(".swiper-slide-zoomed"))) return true; try { const activeSlideZoomContainer = swiperNode.querySelector(".photo-viewer__swiper-slide--active .swiper-zoom-container") as HTMLElement; const [xTranslate, yTranslate] = activeSlideZoomContainer.style.transform.match(/(\(.*\))/)[1].replace("(", "").replace(")", "").replace(/px|%/g, "").split(",").map(val => parseInt(val)); return xTranslate !== 0 && yTranslate !== 0; } catch (e) { return false; } }, () => false); } /** * Update the current instance of swiper. Currently, this * just toggle the allowSlideNext, allowSlidePrev, and no Swiping * properties */ private updateSwiper() { // @ts-ignore this.withSwiperNode(({ swiper }: { swiper: Swiper }) => { swiper.allowSlideNext = !this.props.preventSliding; swiper.allowSlidePrev = !this.props.preventSliding; // @ts-ignore swiper.noSwiping = this.props.preventSliding; }); } /** * Bubble out image loading */ private triggerImagesLoadAttribute() { this.withSwiperNode((swiperNode) => { const imgs = Array.from(swiperNode.querySelectorAll(".image")); // @ts-ignore const relativeActiveIndex = swiperNode.swiper.realIndex - this.state.virtualData.from; iterateMiddleOut(imgs, (img: HTMLImageElement) => { img.setAttribute("trigger-load", "true"); }, relativeActiveIndex); }); } private withSwiperNode( found: (node: HTMLDivElement) => T, notFound?: () => T ): T { const swiperNode = this.swiperRef.current if (swiperNode) { return found(swiperNode) } else if (notFound) { return notFound() } return null } });