gallery/Gallery.js

import '../polyfills/Fetch';
import animateMove from '../utils/animation/animateMove';
import dynamicElement from '../utils/dynamicElement';
import updateIconPath from '../utils/updateIconPath';
import customEvent from '../utils/customEvent';

/**
 * The Gallery module renders "flat galleries" in an article in a gallery modal.
 *
 * @module Gallery
 * @prop {Object} selectors - DOM element selectors
 * @prop {Object} states - different css states
 * @prop {Object} templates - different templates used for populating the gallery
 * @prop {Array} $flatGalleries - an array of flag galleries found in the article
 * @prop {HTMLElement} $activeGallery - the current gallery
 * @prop {HTMLElement} $currentSlide - the current gallery slide
 * @prop {HTMLElement} $prevBtn - the previous slide button
 * @prop {HTMLElement} $nextBtn - the next slide button
 * @prop {HTMLElement} $closeBtn - the close button
 * @prop {HTMLElement} $body - the body element
 * @prop {HTMLElement} $html - the root HTML Element
 * @prop {function} keyboardListener: keyboard key up listener
 * @prop {Integer} uniqueAdCount: 0 - a unique count of ads created
 * @prop {Integer} adFrequency - how frequent does the ad refresh, i.e. refresh the ad per N slides
 * @prop {Integer} maxWidth - maximum width for gallery image in gallery modal
 * @prop {Object} adData - Ad data for rendering ads
 */

const Gallery = {
	selectors: {
		flat: {
			gallery: 'div[data-shortcode="gallery"]',
			slide: '.c-figure--slide:not(.c-ad)',
			caption: '.c-caption__desc, .c-longform-caption__desc',
			cite: '.c-caption__cite, .c-longform-caption__cite',
			image: '.c-figure__image',
			imageButton: '.c-figure__expand',
		},
		modal: {
			gallery: '.c-gallery',
			slidesWrapper: '.c-gallery__slides',
			slide: '.c-gallery__slide',
			caption: '.c-gallery__desc',
			cite: '.c-gallery__cite',
			imageWrapper: '.c-gallery__imageWrapper',
			image: '.c-gallery__image',
			closeBtn: '.c-gallery__close',
			nextBtn: '.c-gallery__next',
			prevBtn: '.c-gallery__prev',
			ad: '.c-gallery__adUnit',
			totalCount: '.c-gallery__total',
			currentCount: '.c-gallery__current',
			socialWrapper: '.c-gallery__socialWrapper',
			social: '.c-gallery__social',
			socialPopup: '.c-gallery__socialPopup',
			viewed: '.c-gallery__slide[data-viewed]',
		},
	},

	states: {
		current: 'c-gallery__slide--current',
		prev: 'c-gallery__slide--prev',
		next: 'c-gallery__slide--next',
		disabled: 'c-gallery__button--disabled',
		portrait: 'c-gallery__image--portrait',
		noScroll: 'is-no-scroll',
		noSmoothScroll: 'is-no-smooth-scroll',
		animateOut: 'c-gallery--animateOut',
		animateIn: 'c-gallery--animateIn',
		fadeIn: 'animate-fadeIn',
		hidden: 'c-gallery--hidden',
		socialLoading: 'c-shimmer',
		imageLoading: 'c-gallery__slide--loading',
	},

	templates: {
		gallery: `
		<div class="c-gallery__inner">
			<ul class="c-gallery__slides"></ul>
			<div class="c-gallery__sidebar">
				<div class="c-gallery__buttons">
					<div class="c-gallery__socialWrapper c-shimmer">
						<div class="c-gallery__placeholder"></div>
						<div class="c-gallery__placeholder"></div>
						<div class="c-gallery__placeholder"></div>
					</div>
					<a href="" title="Close gallery" class="c-gallery__button c-gallery__close c-button c-button--details" data-trackaction="close" data-title="Close">
						<svg class="c-gallery__icon c-icon"><use xlink:href="#minimize"></svg>
					</a>
				</div>
				<div class="c-gallery__caption c-caption">
					<div class="c-gallery__desc c-caption__desc"></div>
					<div class="c-gallery__cite c-caption__cite"></div>
				</div>
				<div class="c-gallery__adContainer">
					<div class="c-gallery__ad c-ad c-ad--left c-ad--bigbox">
						<div class="c-ad__label">Advertisement</div>
						<div class="c-gallery__adUnit"></div>
					</div>
				</div>
				<div class="c-gallery__pagination">
					<div class="c-gallery__label c-caption">
						Image <span class="c-gallery__current"></span> of <span class="c-gallery__total"></span>
					</div>
					<div class="c-gallery__arrowButtons">
						<a href="" title="Next slide" class="c-gallery__prev c-button" data-trackaction="previous slide">
							<svg class="c-gallery__icon c-icon"><use xlink:href="#chevron-left"></svg>
						</a>
						<a href="" title="Previous slide" class="c-gallery__next c-button" data-trackaction="next slide">
							<svg class="c-gallery__icon c-icon"><use xlink:href="#chevron-right"></svg>
						</a>
					</div>
				</div>
			</div>
		</div>
		`,

		slide: `
		<div class="c-gallery__loader c-loader c-loader--small c-loader--dark"></div>
		<img class="c-gallery__image"/>
		`,

		socialShare: '',
	},

	$flatGalleries: false,

	$activeGallery: false,

	$currentSlide: false,

	$prevBtn: false,

	$nextBtn: false,

	$closeBtn: false,

	$body: false,

	$html: false,

	keyboardListener: false,

	uniqueAdCount: 0,

	adFrequency: 6, // refresh the ad per 6 slides

	maxWidth: 2000,

	adData: {
		sizes: '[300,250]',
		biddable: true,
		id: false,
		lazy: true,
		targeting: {
			pos: 1,
		},
	},

	/* global gn_analytics */

	/* global gn_monetize */

	/**
	 * Select flag galleries in an article and set up click listeners
	 * to trigger the gallery modal.
	 *
	 * @method init
	 */
	init() {
		this.$flatGalleries = document.querySelectorAll( this.selectors.flat.gallery );
		this.$body = document.querySelector( 'body' );
		this.$html = document.querySelector( 'html' );

		[].forEach.call( this.$flatGalleries, ( $gallery, galleryIndex ) => {
			const $images = $gallery.querySelectorAll( this.selectors.flat.imageButton );
			[].forEach.call( $images, ( $image, imageIndex ) => {
				const imageClickHandler = this.initializeGallery.bind( this, galleryIndex, imageIndex );
				$image.addEventListener( 'click', imageClickHandler );
			});
		});
	},

	/**
	 * Initialize a selected gallery, with the selected slide opened.
	 *
	 * @param {Integer} galleryIndex - index of selected gallery (there could be multiple galleries)
	 * @param {Integer} imageIndex - index of the selected slide
	 * @param {Event} Event - click event
	 *
	 * @method initializeGallery
	 */
	initializeGallery( galleryIndex, imageIndex, evt ) {
		evt.preventDefault();

		// Create Gallery UI.
		const $gallery = document.createElement( 'div' );
		$gallery.setAttribute( 'id', 'gallery' );
		$gallery.classList.add( this.selectors.modal.gallery.replace( '.', '' ) );
		$gallery.classList.add( this.states.animateIn );
		$gallery.innerHTML = this.templates.gallery;
		$gallery.dataset.index = galleryIndex;

		// Set up click listeners for close, next slide, previous slide buttons.
		this.$closeBtn = $gallery.querySelector( this.selectors.modal.closeBtn );
		this.$closeBtn.addEventListener( 'click', clickEvt => this.close( clickEvt ) );

		this.$nextBtn = $gallery.querySelector( this.selectors.modal.nextBtn );
		this.$nextBtn.addEventListener( 'click', clickEvt => this.nextSlide( clickEvt ) );

		this.$prevBtn = $gallery.querySelector( this.selectors.modal.prevBtn );
		this.$prevBtn.addEventListener( 'click', clickEvt => this.prevSlide( clickEvt ) );

		// Key listeners enabling navigation with arrow keys and ESC key.
		this.keyboardListener = this.interactWithKeyboard.bind( this );
		document.addEventListener( 'keyup', this.keyboardListener );

		// Create the slides to be added to the gallery.
		const $flatGallery = this.$flatGalleries[galleryIndex];
		const $slidesWrapper = $gallery.querySelector( this.selectors.modal.slidesWrapper );
		const $sources = $flatGallery.querySelectorAll( this.selectors.flat.slide );
		[].forEach.call( $sources, ( $source, index ) => {
			// Set id for anchoring on gallery close.
			$source.setAttribute( 'id', `slide-${galleryIndex}-${index}` );

			const $slide = document.createElement( 'li' );
			$slide.classList.add( this.selectors.modal.slide.replace( '.', '' ) );
			$slide.innerHTML = this.templates.slide;
			$slide.dataset.index = index;

			if ( 0 === Math.abs( index - imageIndex ) % this.adFrequency ) {
				$slide.dataset.hasAdRefresh = true;
			}

			// Copy source image attributes to newly created gallery image
			this.copySlide( $slide, $source );

			$slidesWrapper.appendChild( $slide );
		});

		// Set slides total.
		const $total = $gallery.querySelector( this.selectors.modal.totalCount );
		$total.textContent = $sources.length;

		// Set icon paths.
		const $icons = $gallery.querySelectorAll( 'svg use' );
		[].forEach.call( $icons, ( $icon ) => {
			updateIconPath( $icon );
		});

		// Append gallery to the page.
		dynamicElement.add( $gallery, `${this.selectors.modal.socialWrapper}.${this.states.fadeIn}` );
		this.$body.classList.add( this.states.noScroll );
		this.$html.classList.add( this.states.noSmoothScroll );

		// Set the added gallery as the active gallery.
		this.$activeGallery = document.querySelector( this.selectors.modal.gallery );

		// Initialize social share.
		this.initializeSocialShare();

		// Go to selected slide.
		this.goToSlide( imageIndex, evt.currentTarget );
	},

	/**
	 * Fetch social share templates via an ajax call. Populate the social share UI.
	 *
	 * @method initializeSocialShare
	 */
	initializeSocialShare() {
		if ( ! this.templates.socialShare ) {
			fetch( '/gnca-ajax-redesign/gallery-social-share' )
				.then( response => response.text() )
				.then( ( content ) => {
					this.templates.socialShare = content;
					this.showSocialShare();
				});
		} else {
			this.showSocialShare();
		}
	},

	/**
	 * Populate social share component with social share template.
	 *
	 * @method showSocialShare
	 */
	showSocialShare() {
		if ( this.$activeGallery ) {
			const selector = this.selectors.modal.socialWrapper;
			const $socialWrapper = this.$activeGallery.querySelector( selector );
			$socialWrapper.innerHTML = this.templates.socialShare;
			$socialWrapper.classList.remove( this.states.socialLoading );
			$socialWrapper.classList.add( this.states.fadeIn );
		}
	},

	/**
	 * Copy respective image data from a source image slide from a flat gallery
	 * to the destination slide in the gallery modal.
	 *
	 * @param HTMLElement $slide - empty slide in the gallery modal
	 * @param HTMLElement $source - source slide in the flat gallery
	 *
	 * @method copySlide
	 */
	copySlide( $slide, $source ) {
		const validAttrs = ['data-src', 'width', 'height', 'alt'];
		const data = {};
		const $image = $slide.querySelector( this.selectors.modal.image );
		const $sourceImage = $source.querySelector( this.selectors.flat.image );

		[].forEach.call( validAttrs, ( attr ) => {
			let val = $sourceImage.getAttribute( attr );

			// Images loaded in the article are in smaller sizes
			// Use Photon API to display bigger size image
			if ( 'data-src' === attr ) {
				val = val.replace( /\?.+/, '', val );
				val = `${val}?w=${this.maxWidth}`;
			}

			if ( val ) {
				$image.setAttribute( attr, val );
			}
			data[attr] = val;
		});

		if ( data.width && data.height ) {
			if ( parseInt( data.width, 10 ) < parseInt( data.height, 10 ) ) {
				$image.classList.add( this.states.portrait );
			}
		}

		const metadata = ['caption', 'cite'];
		[].forEach.call( metadata, ( field ) => {
			const $elem = $source.querySelector( this.selectors.flat[field]);
			if ( $elem && $elem.textContent ) {
				$slide.dataset[field] = $elem.textContent; // eslint-disable-line no-param-reassign
			}
		});
	},

	/**
	 * Navigate to a specific slide in a gallery.
	 *
	 * @param {Integer} index - index of the slide to navigate to
	 * @param {HTMLElement} $caller - HTML Element which triggered the slide navigation
	 *
	 * @method goToSlide
	 */
	goToSlide( index, $caller ) {
		if ( this.$activeGallery ) {
			const $slides = this.$activeGallery.querySelectorAll( this.selectors.modal.slide );

			// Bail when end of gallery reached.
			if ( index < 0 || index >= $slides.length ) {
				return;
			}

			// Load current image and preload immediate previous / next image.
			const prepIndices = [index - 1, index, index + 1];
			[].forEach.call( prepIndices, ( prepIndex ) => {
				if ( prepIndex >= 0 && prepIndex < $slides.length ) {
					this.preload( $slides[prepIndex]);
				}
			});

			// Remove active state for current slide, and update current slide index
			if ( this.$currentSlide ) {
				this.$currentSlide.classList.remove( this.states.current );
			}
			this.$currentSlide = $slides[index];
			this.$currentSlide.classList.add( this.states.current );

			// Ensure respective flat gallery slide is in view when user closes the gallery
			const id = `slide-${this.$activeGallery.dataset.index}-${this.$currentSlide.dataset.index}`;
			const $slide = document.querySelector( `#${id}` );
			const { height } = $slide.getBoundingClientRect();
			const top = $slide.offsetTop - ( window.innerHeight - height ) * 0.5;
			window.scrollTo( 0, top );

			// Set caption and credits.
			const $caption = this.$activeGallery.querySelector( this.selectors.modal.caption );
			$caption.textContent = this.$currentSlide.dataset.caption || '';

			const $cite = this.$activeGallery.querySelector( this.selectors.modal.cite );
			$cite.textContent = this.$currentSlide.dataset.cite || '';

			// Set pagination count.
			const $currentCount = this.$activeGallery.querySelector( this.selectors.modal.currentCount );
			$currentCount.textContent = index + 1;

			// Set previous button state.
			if ( 0 === index ) {
				this.$prevBtn.classList.add( this.states.disabled );
			} else {
				this.$prevBtn.classList.remove( this.states.disabled );
			}

			// Set next button state.
			if ( index === $slides.length - 1 ) {
				this.$nextBtn.classList.add( this.states.disabled );
			} else {
				this.$nextBtn.classList.remove( this.states.disabled );
			}

			// Refresh ad when applicable.
			if ( this.$currentSlide.dataset.hasAdRefresh ) {
				this.refreshAd();
			}

			// Analytics tracking.
			const trackingData = {
				'gallery.action': $caller.dataset.trackaction,
			};
			if ( ! this.$currentSlide.dataset.viewed ) {
				this.$currentSlide.dataset.viewed = true;

				// Report the number of slides viewed if this slide has not been viewed before.
				const numSlidesViewed = this.$activeGallery.querySelectorAll( this.selectors.modal.viewed );
				trackingData['gallery.slides'] = numSlidesViewed.length.toString();
			}

			this.track( $caller, trackingData );
		}
	},

	/**
	 * Create or refresh an already created ad.
	 *
	 * @method refreshAd
	 */
	refreshAd() {
		/* eslint-disable camelcase */
		const $adDiv = this.$activeGallery.querySelector( this.selectors.modal.ad );
		if ( $adDiv && 'undefined' !== typeof ( gn_monetize ) && 'undefined' !== typeof ( gn_monetize.Ads ) ) {
			// Create ad if not yet created.
			if ( ! $adDiv.getAttribute( 'id' ) ) {
				this.uniqueAdCount += 1;
				const adId = `galleryAd-${this.uniqueAdCount}`;
				$adDiv.setAttribute( 'id', adId );
				this.adData.id = adId;
				gn_monetize.Ads.create( this.adData );
			} else {
				// Refresh existing ad.
				gn_monetize.Ads.refresh( this.adData );
			}
		}
		/* eslint-enable camelcase */
	},

	/**
	 * Navigate to the next slide
	 *
	 * @param {Event} evt - button click event
	 *
	 * @method nextSlide
	 */
	nextSlide( evt ) {
		evt.preventDefault();
		if ( ! evt.currentTarget.classList.contains( this.states.disabled ) ) {
			this.goToSlide( parseInt( this.$currentSlide.dataset.index, 10 ) + 1, evt.currentTarget );
		}
	},

	/**
	 * Navigate to the previous slide.
	 *
	 * @param {Event} evt - button click event
	 *
	 * @method prevSlide
	 */
	prevSlide( evt ) {
		evt.preventDefault();
		if ( ! evt.currentTarget.classList.contains( this.states.disabled ) ) {
			this.goToSlide( parseInt( this.$currentSlide.dataset.index, 10 ) - 1, evt.currentTarget );
		}
	},

	/**
	 * Navigate to next / previous slide with arrow keys, close with ESC key.
	 *
	 * @param {Event} evt - key up event
	 *
	 * @method interactWithKeyboard
	 */
	interactWithKeyboard( evt ) {
		switch ( evt.keyCode ) {
		case 27: // ESC
			this.$closeBtn.click();
			break;
		case 39: // ARROW RIGHT
			this.$nextBtn.click();
			break;
		case 37: // ARROW LEFT
			this.$prevBtn.click();
			break;
		default:
			break;
		}
	},

	/**
	 * Preload an image within a gallery slide
	 *
	 * @param {HTMLElement} $slide - gallery slide
	 *
	 * @method preload
	 */
	preload( $slide ) {
		const $image = $slide.querySelector( this.selectors.modal.image );
		// Bail if image already loaded.
		if ( $image.src ) {
			return;
		}

		const imageLoad = this.imageLoad.bind( this, $slide );
		$slide.classList.add( this.states.imageLoading );
		$image.addEventListener( 'load', imageLoad );

		const attrs = ['data-src'];
		[].forEach.call( attrs, ( attr ) => {
			const val = $image.getAttribute( attr );
			if ( val ) {
				$image.setAttribute( attr.replace( 'data-', '' ), val );
			}
		});
	},

	/**
	 * Flag a slide as being loaded
	 *
	 * @param {HTMLElement} $slide - slide where the loaded image resides in
	 *
	 * @method imageLoad
	 */
	imageLoad( $slide ) {
		$slide.classList.remove( this.states.imageLoading );
	},

	/**
	 * Close the active gallery with an animation.
	 *
	 * @param {Event} evt - mouse click event.
	 *
	 * @method close
	 */
	close( evt ) {
		evt.preventDefault();

		if ( this.$activeGallery ) {
			document.removeEventListener( 'keyup', this.keyboardListener );

			// Reset to original states.
			this.$activeGallery.classList.remove( this.states.animateIn );
			this.$body.classList.remove( this.states.noScroll );
			this.$html.classList.remove( this.states.noSmoothScroll );
			this.$activeGallery.classList.add( this.states.hidden );

			// Transition out the gallery, destory the gallery when the transition is completed.
			const $destination = document.querySelector( `#slide-${this.$activeGallery.dataset.index}-${this.$currentSlide.dataset.index}` );
			const $destImage = $destination.querySelector( this.selectors.flat.image );
			const $slideImage = this.$currentSlide.querySelector( this.selectors.modal.image );
			$slideImage.addEventListener( customEvent.TRANSITION_COMPLETED, () => {
				this.destory();
			});
			animateMove( $slideImage, $destImage, 0.35 );

			// Analytics tracking.
			this.track( evt.currentTarget, {
				'gallery.action': evt.currentTarget.dataset.trackaction,
			});
		}
	},

	/**
	 * Completely remove the active gallery and the reference to it.
	 *
	 * @method destroy
	 */
	destory() {
		if ( this.$activeGallery ) {
			dynamicElement.remove( this.$activeGallery );
			this.$activeGallery = false;
		}
	},

	/**
	 * Analytics tracking.
	 *
	 * @param {HTMLElement} $elem - element triggering click tracking
	 * @param {Object} details - additional details to be sent for tracking
	 *
	 * @method track
	 */
	track( $elem, details = false ) {
		/* eslint-disable camelcase */
		if ( 'undefined' !== typeof ( gn_analytics ) ) {
			const trackingData = {
				eventType: 'click',
				action: `gallery | ${$elem.dataset.trackaction}`,
				target: $elem,
			};

			if ( details ) {
				trackingData.data = details;
			}

			gn_analytics.Analytics.track(['adobe'], trackingData );
		}
		/* eslint-enable camelcase */
	},
};

export default Gallery;