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;