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 {
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 > {
public swiperRef: React.RefObject = 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 , 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 (
) => {
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