MediaWiki:Gadget-ImageStackPopup.js

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/******************************************************************************/
/**** THIS PAGE TRACKS [[mw:MediaWiki:Gadget-Global-ImageStackPopup.js]]. PLEASE AVOID EDITING DIRECTLY. 
/**** EDITS SHOULD BE PROPOSED DIRECTLY to [[mw:MediaWiki:Gadget-Global-ImageStackPopup.js]].
/**** A BOT WILL RAISE AN EDIT REQUEST IF IT BECOMES DIFFERENT FROM UPSTREAM.
/******************************************************************************/

// Script written by Bawolff for WikiProject Med Foundation based on earlier ImageStack script by Hellerhoff.
var ImageStackPopup = {

	messages: {
		en: {
			ImageStackPopupFrameBack: 'Back',
			ImageStackPopupFrameImageCredit: 'View media credits',
			ImageStackPopupNextImage: "Next image",
			ImageStackPopupPreviousImage: "Previous image",
			ImageStackPopupSliderLabel: "Select image",
			ImageStackPopupPlayLabel: "Show slideshow",
			ImageStackPopupLoading: "Loading... $1%"
		},
	},

	init: function () {
		ImageStackPopup.setMessages();
		mw.hook( 'wikipage.content' ).add( ImageStackPopup.addPlayButton );
	},

	/**
	 * Set the interface messages in the most appropriate language
	 *
	 * Favor the user language first, the page language second, the wiki language third, and lastly English
	 */
	setMessages: function () {
		var userLanguage = mw.config.get( 'wgUserLanguage' );
		if ( userLanguage in ImageStackPopup.messages ) {
			mw.messages.set( ImageStackPopup.messages[ userLanguage ] );
			return;
		}
		var pageLanguage = mw.config.get( 'wgPageContentLanguage' );
		if ( pageLanguage in ImageStackPopup.messages ) {
			mw.messages.set( ImageStackPopup.messages[ pageLanguage ] );
			return;
		}
		var contentLanguage = mw.config.get( 'wgContentLanguage' );
		if ( contentLanguage in ImageStackPopup.messages ) {
			mw.messages.set( ImageStackPopup.messages[ contentLanguage ] );
			return;
		}
		mw.messages.set( ImageStackPopup.messages.en );
	},

	/**
	 * Append a play button ► to every ImageStackPopup div
 	 */
	addPlayButton: function ( $content ) {
		$content.find( 'div.ImageStackPopup' ).each( function () {
			var $frame = $( this );
			var viewerInfo = $frame.data( 'imagestackpopupConfig' );
			if ( !( viewerInfo instanceof Array) ) {
				return;
			}
			// match both img and span for broken files in galleries
			$frame.find( '.mw-file-element, .lazy-image-placeholder' ).each( function ( i ) {
				if ( viewerInfo[i] instanceof Object && typeof viewerInfo[i].list === "string" ) {
					var $play = $( '<button></button>' )
						.attr( {
							type: 'button',
							"class": 'ImageStackPopup-play',
							title: mw.msg( 'ImageStackPopupPlayLabel' ),
							"aria-label": mw.msg( 'ImageStackPopupPlayLabel' )
						} ).text( '►' );
					var data = viewerInfo[i];
					$play.on( 'click', data, ImageStackPopup.showFrame );
					var $this = $( this );
					$this.parent().css( {display: 'inline-block', height: 'fit-content', position: 'relative' } );
					$this.after( $play );
				}
			} );
		} );
	},

	showFrame: function ( event ) {
		event.preventDefault();
		var data = event.data;

		var $loading = $( '#ImageStackPopupLoading' );
		if ( !$loading.length ) {
				$loading = $( '<div></div>' )
					.attr( {
						id: "ImageStackPopupLoading",
						role: "status"
					}
				);
				$( document.body ).append( $loading );
		}
		$loading.text( mw.msg( 'ImageStackPopupLoading', "0" ) );
		// Load dependencies
		var state = mw.loader.getState( 'oojs-ui-windows' );
		if ( state === 'registered' ) {
			mw.loader.using( 'oojs-ui-windows', function () { ImageStackPopup.showFrame( event ) } );
			return;
		}
		var $viewer = ImageStackPopup.getViewer();

		var config = {
			size: 'full',
			// This doesn't seem to work.
			classes: 'ImageStackPopupDialog',
			title: typeof data.title === 'string' ? data.title : false,
			actions: [ {
				action: 'accept',
				label: mw.msg( 'ImageStackPopupFrameBack' ),
				flags: [ 'primary', 'progressive' ]
			} ],
			message: $viewer
		};

		var dialog = function ( config ) {
			dialog.super.call( this, config );
			this.$element.addClass( 'ImageStackPopupDialog' );
		}

		OO.inheritClass( dialog, OO.ui.MessageDialog );
		dialog.static.name = 'ImageStack'
		OO.ui.getWindowManager().addWindows( [ new dialog() ] );
		// copied from OO.ui.alert definition.
		OO.ui.getWindowManager().openWindow( 'ImageStack', config )
			.closed.done( function () {
				// There has to be a better way to do this.
				if ( window.ImageStackPopupCancel ) {
					window.ImageStackPopupCancel();
				}
			});
		ImageStackPopup.loadImages( $viewer, data );
	},

	getViewer: function () {
		var $viewer = $( '<div></div>' ).attr( {
			class: 'ImageStackPopup-viewer ImageStackPopup-loading'
		} );
		// From https://commons.wikimedia.org/wiki/File:Loading_spinner.svg
		$viewer.append( '<svg xmlns="http://www.w3.org/2000/svg" aria-label="Loading..." viewBox="0 0 100 100" width="25%" height="25%" style="display:block;margin:auto"><rect fill="#555" height="6" opacity=".083" rx="3" ry="3" transform="rotate(-60 50 50)" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".167" rx="3" ry="3" transform="rotate(-30 50 50)" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".25" rx="3" ry="3" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".333" rx="3" ry="3" transform="rotate(30 50 50)" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".417" rx="3" ry="3" transform="rotate(60 50 50)" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".5" rx="3" ry="3" transform="rotate(90 50 50)" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".583" rx="3" ry="3" transform="rotate(120 50 50)" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".667" rx="3" ry="3" transform="rotate(150 50 50)" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".75" rx="3" ry="3" transform="rotate(180 50 50)" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".833" rx="3" ry="3" transform="rotate(210 50 50)" width="25" x="72" y="47"/><rect fill="#555" height="6" opacity=".917" rx="3" ry="3" transform="rotate(240 50 50)" width="25" x="72" y="47"/></svg>' );
		return $viewer;
	},

	loadImages: function ( $viewer, data ) {
		var page = mw.Title.newFromText( data.list );
		if ( !page ) {
			console.log( "Image stack error, invalid page " + data.list );
			return;
		}

		fetch( page.getUrl() )
			.then( function ( response ) { return response.text() } )
			.then( function ( text ) { return ImageStackPopup.handlePage( $viewer, data, text ) } );
	},

	handlePage: function( $viewer, data, text ) {
		var parser = new DOMParser;
		var listDoc = parser.parseFromString( text, 'text/html' );
		var idSelector = mw.Title.newFromText( data.list ).getFragment();

		var listElm = listDoc.getElementById( idSelector );
		if ( !listElm ) {
			console.log( "Error finding element in list document" );
			return;
		}

		var imgs = listElm.querySelectorAll( 'img.mw-file-element' );

		var width = imgs[0].width;
		var height = imgs[0].height;

		var context = new ImageStackPopup.Context( $viewer, data, imgs, width, height );
	},

	getSource: function ( imgElm, width, height ) {
		// desired dimensions
		var w = width * window.devicePixelRatio;
		var h = height * window.devicePixelRatio;
		// current candidate
		var imgW = parseInt(imgElm.width);
		var imgH = parseInt(imgElm.height);
		// img tag width/height.
		var originalW = imgW;
		var originalH = imgH;
		var src = imgElm.src;
		if ( imgW >= w && imgH >= h ) {
			return src;
		}
		var srcSets = imgElm.srcset.split( /\s*,\s*/ );
		for ( var i = 0; i < srcSets.length; i++ ) {
			var parts = srcSets[i].match( /^(\S+)\s+([0-9.])x\s*$/ );
			if (
				parts &&
				parts.length === 3
			) {
				var pixelRatio = parseFloat( parts[2] );
				if (
					( imgW < w && originalW*pixelRatio > imgW ) ||
					( imgW > w && originalW*pixelRatio - w >= 0 && originalW*pixelRatio < imgW )
				) {
					imgW = originalW*pixelRatio;
					imgH = originalH*pixelRatio;
					src = parts[1];
				}
			}
		}
		return src;
	},

	Context: function ( $viewer, config, imgs, width, height ) {
		this.$viewer = $viewer;
		this.loop = !!config.loop;
		this.start = typeof config.start === 'number' ? config.start - 1 : 0;
		this.urls = null;
		this.infoUrls = null;
		this.imgs = imgs;
		this.captionId = typeof config.caption === 'string' ? config.caption : false;
		// Future TODO - make the size of image adaptive to screen size
		// Future TODO - handle images of different sizes and aspect ratios.
		this.width = config.width;
		this.height = config.height;
		if ( this.width && !this.height ) {
			this.height = this.width * (imgs[0].height)/(imgs[0].width);
		}
		if ( !this.width && this.height ) {
			this.width = this.height * (imgs[0].width)/(imgs[0].height);
		}
		this.imgWidth = width;
		this.imgHeight = height;
		this.currentImage = this.start;
		this.pendingFrame = false;
		this.$loading = $( '#ImageStackPopupLoading' );
		this.urlsLoaded = 0;
		this.pendingTouches = {};

		this.init();
	}
};

// This part is based on Hellerhoff's https://commons.wikimedia.org/wiki/MediaWiki:Gadget-ImageStack.js
ImageStackPopup.Context.prototype = {
	init: function () {
		var that = this;
		// Chrome scrolls much faster than firefox
		const SCROLL_SLOWDOWN = navigator.userAgent.includes( "Chrome/" ) ? 25 : 2;
		this.pendingScrollDelta = 0;

		var containingWidth = this.$viewer[0].parentElement.parentElement.parentElement.clientWidth;
		var containingHeight = this.$viewer[0].parentElement.parentElement.parentElement.clientHeight;
		this.$viewer.empty();
		$counter = $('<div class="ImageStackCounter">');
		this.$leftLink = $('<a>', {
			href: '#',
			text: '← ',
			title: mw.msg( 'ImageStackPopupPreviousImage' ),
			"aria-label": mw.msg( 'ImageStackPopupPreviousImage' ),
		}).click(function() {
			that.currentImage--;
			that.repaint();
			return false;
		});
		this.$rightLink = $('<a>', {
			href: '#',
			text: ' →',
			title: mw.msg( 'ImageStackPopupNextImage' ),
			"aria-label": mw.msg( 'ImageStackPopupNextImage' ),
		}).click(function() {
			that.currentImage++;
			that.repaint();
			return false;
		});

		this.$slider = $( '<input>', {
			type: 'range',
			min: 0,
			max: that.imgs.length - 1,
			value: this.currentImage,
			"aria-label": mw.msg( 'ImageStackPopupSliderLabel' ),
			class: 'ImageStackPopupSlider'
		} ).on( 'input', function (e) {
			that.currentImage = parseInt( e.target.value );
			that.repaint();
		} ).on( 'keydown', function (e) {
			// Hacky fix. Not enough browsers support the direction: css
			// keyword, so we fix up events here.
			if ( e.key === 'ArrowUp' ) {
				e.preventDefault();
				that.currentImage--;
				that.repaint();
			} else if ( e.key === 'ArrowDown' ) {
				e.preventDefault();
				that.currentImage++;
				that.repaint();
			}
		} );

		var handleTouchStart = this.handleTouchStart.bind(this);
		var handleTouchMove = this.handleTouchMove.bind(this);
		var handleTouchCancel = this.handleTouchCancel.bind(this);
		var handleTouchEnd = this.handleTouchEnd.bind(this);
		var touchElement = this.$viewer[0].parentElement.parentElement;
		var opt = { passive: true };

		// For now it seems like we don't have to cancel events. Unclear if we should
		touchElement.addEventListener( 'touchstart', handleTouchStart, opt );
		touchElement.addEventListener( 'touchmove', handleTouchMove, opt );
		touchElement.addEventListener( 'touchend', handleTouchEnd, opt );
		touchElement.addEventListener( 'touchcancel', handleTouchCancel, opt );

		var keyeventhandler = this.handleArrow.bind(this);
		document.addEventListener( 'keydown', keyeventhandler );
		// Hacky!
		window.ImageStackPopupCancel = function () {
			document.removeEventListener( 'keydown', keyeventhandler );
			touchElement.removeEventListener( 'touchstart', handleTouchStart, opt );
			touchElement.removeEventListener( 'touchmove', handleTouchMove, opt );
			touchElement.removeEventListener( 'touchend', handleTouchEnd, opt );
			touchElement.removeEventListener( 'touchcancel', handleTouchCancel, opt );
		};
		this.$currentCount = $('<span>', {
			'class': 'ImageStackCounterCurrent',
			text: that.currentImage + 1
		});
		var left = $( '<span>', { class: "ImageStackPopupCounterHideMobile" } ).append( this.$leftLink, '(' );
		var right = $( '<span>', { class: "ImageStackPopupCounterHideMobile" } ).append( ')', this.$rightLink );
		$counter.append(left, this.$currentCount, '/', that.imgs.length, right);
		this.$leftLink.add(this.$rightLink).css({
			fontSize: "110%",
			fontweight: "bold"
		});

		this.img = new Image();
		this.img.fetchPriority = 'high';
		this.img.loading = 'eager';
		this.img.decoding = 'sync';
		this.img.className = 'ImageStackPopupImg';
		// width/height set later.
		var $img = $( this.img );
		$img.on('mousewheel', function(event, delta) {
			// Scroll is too fast (Esp. on chrome), so we buffer scroll events.
			that.pendingScrollDelta += delta;
			var realDelta = Math.floor(that.pendingScrollDelta/SCROLL_SLOWDOWN);
			if (delta !== 0) {
				// We reverse the direction of scroll.
				that.currentImage -= realDelta > 2 ? 2 : realDelta;
				that.pendingScrollDelta -= realDelta*SCROLL_SLOWDOWN;
				that.repaint();
			}
			return false;
		});
		$img.on('mousedown', function(event) { // prepare scroll by drag
			mouse_y = event.screenY; // remember mouse-position
			that.scrollobject = true; // set flag
			return false;
		});
		$img.on('mousemove', function(event) {
			if (that.scrollobject && Math.abs(mouse_y - event.screenY) > 10) {
				var offset = (mouse_y < event.screenY) ? 1 : -1;
				mouse_y = event.screenY; //  remember mouse-position for next event
				that.currentImage += offset;
				that.repaint();
			}
			return false;
		});

		this.img.addEventListener( 'load', this.urlLoaded.bind( this ), { once: true } );
		this.img.addEventListener( 'error', this.urlLoaded.bind( this ), { once: true } );

		var $container = $( '<div class="ImageStackPopupImgContainer"></div>' )
			.append( $counter )
			.append( this.$slider )
			.append( $img );

		this.$viewer.append( $container );
		this.$credit = $( '<a></a>' );
		this.$credit.text( mw.msg( 'ImageStackPopupFrameImageCredit' ) );
		var $creditDiv = $( '<div class="ImageStackPopupCredit"></div>' ).append( this.$credit );
		this.$viewer.append( $creditDiv );
		var $wrapper = false;
		if ( this.captionId ) {
			var captionElm = document.getElementById( this.captionId );
			if ( captionElm ) {
				var newCaption = $( captionElm ).clone();
				newCaption.show();
				$wrapper = $( '<div class="ImageStackPopup-caption"></div>' ).append( newCaption );
				this.$viewer.append( $wrapper );
			}
		}
		// Try to adjust image size to viewer window
		// but do not go so far that the image is blurry
		if ( !this.width ) {
			var controlHeight = $creditDiv[0].clientHeight;
			var paddingDivStyles = getComputedStyle( this.$viewer[0].parentElement.parentElement );
			controlHeight += parseFloat( paddingDivStyles.getPropertyValue( 'padding-top' ) ) + parseFloat( paddingDivStyles.getPropertyValue( 'padding-bottom' ) );
			containingWidth -= parseFloat( paddingDivStyles.getPropertyValue( 'padding-left' ) ) + parseFloat( paddingDivStyles.getPropertyValue( 'padding-right' ) );
			if ( $wrapper ) {
				controlHeight += $wrapper[0].clientHeight;
			}
			controlHeight += 5; // fudge factor.
			if ( this.$viewer[0].parentElement.previousElementSibling ) {
				// OOUI window label. This is a bit hacky.
				controlHeight += this.$viewer[0].parentElement.previousElementSibling.clientHeight;
			}
			var maxImgDim = this.getMaxImgDim();
			var aspect = maxImgDim[0]/maxImgDim[1];
			containingHeight -= controlHeight;
			// 3 to account for slider and text controls. but not on narrow screens.
			if ( containingWidth >= 500 ) {
				containingWidth -= parseFloat( getComputedStyle( this.$slider[0] ).getPropertyValue( 'width' ) ) * 3;
			}

			if ( maxImgDim[0] > maxImgDim[1] ) {
				if ( maxImgDim[0] > containingWidth ) {
					// shrink to fit.
					maxImgDim[0] = containingWidth;
					maxImgDim[1] = Math.floor(containingWidth/aspect);
				}
				if ( maxImgDim[1] > containingHeight ) {
					maxImgDim[1] = containingHeight;
					maxImgDim[0] = Math.floor( containingHeight * aspect );
				}
			} else {
				if ( maxImgDim[1] > containingHeight ) {
					maxImgDim[1] = containingHeight;
					maxImgDim[0] = Math.floor( containingHeight * aspect );
				}
				if ( maxImgDim[0] > containingWidth ) {
					// shrink to fit.
					maxImgDim[0] = containingWidth;
					maxImgDim[1] = Math.floor(containingWidth/aspect);
				}
			}
			this.width = maxImgDim[0];
			this.height = maxImgDim[1];
		}
		this.img.width = this.width;
		this.img.height = this.height;
		// different font size in credit div, so don't use em.
		var sliderRoom;
		if ( containingWidth >= 500 ) {
			sliderRoom = parseFloat( getComputedStyle( this.$slider[0] ).getPropertyValue( 'width' ) ) * 3;
		} else {
			sliderRoom = 0;
		}
		$creditDiv.css( 'width', this.width + sliderRoom + 'px' );
		$creditDiv.css( 'padding-right', sliderRoom + 'px' );
		$container.css( 'width', 'calc( ' + this.width + 'px' + ' + 3em )' );
		this.$slider.css( 'height', this.height + 'px' );
		$counter.css( 'min-height', this.height + 'px' );

		this.getUrls();
		this.toggleImg();
		this.preload();
	},

	getMaxImgDim: function () {
		// This assumes that even on high-DPI displays, enlarging to 96dpi is ok.
		var w = this.imgs[0].width;
		var h = this.imgs[0].height;
		if ( this.imgs[0].srcset.match( /\s2x\s*(,|$)/ ) ) {
			w *= 2;
			h *= 2;
		} else if ( this.imgs[0].srcset.match( /\s1.5x\s*(,|$)/ ) ) {
			w = Math.floor( 1.5*w );
			h = Math.floor( 1.5*h );
		}
		return [w,h];
	},

	repaint: function () {
		if ( this.pendingFrame ) {
			return;
		}
		requestAnimationFrame( this.toggleImg.bind( this ) );
	},

	toggleImg: function () {
		if ( this.loop ) {
			if ( this.currentImage < 0 ) {
				this.currentImage = this.urls.length - 1;
			} else if ( this.currentImage >= this.urls.length ) {
				this.currentImage = 0;
			}
		} else {
			this.$rightLink.css( 'visibility', 'visible' );
			this.$leftLink.css( 'visibility', 'visible' );
			if ( this.currentImage <= 0 ) {
				this.currentImage = 0;
				this.$leftLink.css( 'visibility', 'hidden' );
			} else if ( this.currentImage >= this.urls.length - 1 ) {
				this.currentImage = this.urls.length - 1;
				this.$rightLink.css( 'visibility', 'hidden' );
			}
		}
		this.$slider[0].value = this.currentImage;
		// Future todo might be to localize digits.
		this.$currentCount[0].textContent = this.currentImage + 1;
		this.img.src = this.urls[this.currentImage];
		this.$credit[0].href = this.infoUrls[this.currentImage];
		if ( this.infoUrls[this.currentImage] === false ) {
			this.$credit.css( 'visibility', 'hidden' );
		} else {
			this.$credit.css( 'visibility', 'visible' );
		}
		this.pendingFrame = false;
	},
	
	preload: function () {
		for ( var i = 0; i < this.urls.length; i++ ) {
			if ( i === this.currentImage ) {
				// already fetched.
				continue;
			}
			var img = new Image();
			if ( Math.abs( this.currentImage - i ) > 4 ) {
				img.fetchPriority = 'low';
			}
			img.loading = 'eager';
			img.decoding = 'sync';
			img.addEventListener( 'load', this.urlLoaded.bind( this ), { once: true } );
			img.addEventListener( 'error', this.urlLoaded.bind( this ), { once: true} );
			img.src = this.urls[i];
		}

	},

	getUrls: function () {
		this.urls = [];
		this.infoUrls = [];
		for( var i = 0; i < this.imgs.length; i++ ) {
			this.urls[i] = ImageStackPopup.getSource( this.imgs[i], this.width, this.height );
			if ( this.imgs[i].parentElement.href ) {
				this.infoUrls[i] = this.imgs[i].parentElement.href;
			} else {
				this.infoUrls[i] = false;
			}
		}
	},

	urlLoaded: function () {
		// For now, this still increments for failed loads, so
		// as not to have the progress bar stuck.
		this.urlsLoaded++;
		var progress = Math.floor( ( this.urlsLoaded / this.urls.length ) * 100 );
		if ( this.$loading.length ) {
			this.$loading.text( mw.msg( 'ImageStackPopupLoading', progress ) );
			if ( this.urlsLoaded === this.urls.length ) {
				this.$viewer.removeClass( 'ImageStackPopup-loading' );
				this.$loading.remove();
			}
		}
	},
	
	handleArrow: function (e) {
		// Not sure if we should prevent default here
		// possible accessibility issue if there is somehow something scrollable.
		// in theory, nothing here should be scrollable so it shouldn't matter.
		if (
			( e.key === 'ArrowUp' ||
			e.key === 'ArrowDown' ||
			e.key === 'ArrowRight' ||
			e.key === 'ArrowLeft' )
			&& e.target.tagName !== 'INPUT' 
			&& this.$viewer.find(e)
		) {
			if ( e.key === 'ArrowUp' || e.key === 'ArrowRight' ) {
				this.currentImage--;
				this.repaint();
			} else if ( e.key === 'ArrowDown' || e.key === 'ArrowLeft' ) {
				this.currentImage++;
				this.repaint();
			}
		}
	},

	handleTouchStart: function (e) {
		for ( var i = 0; i < e.changedTouches.length; i++ ) {
			var t = e.changedTouches[i];
			this.pendingTouches[t.identifier] = [t.clientX, t.clientY];
		}
	},
	handleTouchCancel: function (e) {
		for ( var i = 0; i < e.changedTouches.length; i++ ) {
			var t = e.changedTouches[i];
			delete this.pendingTouches[t.identifier];
		}
	},
	handleTouchMove: function (e) {
		for ( var i = 0; i < e.changedTouches.length; i++ ) {
			var t = e.changedTouches[i];
			if ( !this.pendingTouches[t.identifier] ) {
				continue;
			}
			var startX = this.pendingTouches[t.identifier][0];
			var startY = this.pendingTouches[t.identifier][1];
			var angle = Math.abs( Math.atan( ( startY - t.clientY ) / ( startX - t.clientX ) ) );

			if ( angle > 1 ) {
				// vertical. > ~60 degrees
				if ( Math.abs( startY - t.clientY ) < 15 ) {
					// Not large enough
					continue;
				}
				// reset calculation so we move image if they move 15 more pixels
				this.pendingTouches[t.identifier] = [t.clientX, t.clientY];
				if ( startY - t.clientY > 0 ) {
					// swipe up
					this.currentImage--;
					this.repaint();
				} else {
					// swipe down
					this.currentImage++;
					this.repaint();
				}
			}
		}
	},
	handleTouchEnd: function (e) {
		for ( var i = 0; i < e.changedTouches.length; i++ ) {
			var t = e.changedTouches[i];
			if ( !this.pendingTouches[t.identifier] ) {
				continue;
			}
			var startX = this.pendingTouches[t.identifier][0];
			var startY = this.pendingTouches[t.identifier][1];
			var angle = Math.abs( Math.atan( ( startY - t.clientY ) / ( startX - t.clientX ) ) );
			if ( angle < 0.7 ) {
				// horizontal swipe. < 40 degrees
				if ( Math.abs( startX - t.clientX ) < 30 ) {
					// Not large enough
					continue;
				}

				if ( startX - t.clientX < 0 ) {
					// swipe right
					this.currentImage--;
					this.repaint();
				} else {
					// swipe left
					this.currentImage++;
					this.repaint();
				}
			}
			if ( angle > 1 ) {
				// vertical swipe. > ~60 degrees
				if ( Math.abs( startY - t.clientY ) < 30 ) {
					// Not large enough
					continue;
				}
				if ( startY - t.clientY > 0 ) {
					// swipe up
					this.currentImage--;
					this.repaint();
				} else {
					// swipe down
					this.currentImage++;
					this.repaint();
				}
			}

			delete this.pendingTouches[t.identifier];
		}
	},
};

// Include jquery.mousewheel dependency.
// --------
/*! Copyright (c) 2013 Brandon Aaron (http://brandon.aaron.sh)
 * Licensed under the MIT License (LICENSE.txt).
 *
 * Version: 3.1.11
 *
 * Requires: jQuery 1.2.2+
 */

(function (factory) {
    if ( typeof define === 'function' && define.amd ) {
        // AMD. Register as an anonymous module.
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        // Node/CommonJS style for Browserify
        module.exports = factory;
    } else {
        // Browser globals
        factory(jQuery);
    }
}(function ($) {

    var toFix  = ['wheel', 'mousewheel', 'DOMMouseScroll', 'MozMousePixelScroll'],
        toBind = ( 'onwheel' in document || document.documentMode >= 9 ) ?
                    ['wheel'] : ['mousewheel', 'DomMouseScroll', 'MozMousePixelScroll'],
        slice  = Array.prototype.slice,
        nullLowestDeltaTimeout, lowestDelta;

    if ( $.event.fixHooks ) {
        for ( var i = toFix.length; i; ) {
            $.event.fixHooks[ toFix[--i] ] = $.event.mouseHooks;
        }
    }

    var special = $.event.special.mousewheel = {
        version: '3.1.11',

        setup: function() {
            if ( this.addEventListener ) {
                for ( var i = toBind.length; i; ) {
                    this.addEventListener( toBind[--i], handler, false );
                }
            } else {
                this.onmousewheel = handler;
            }
            // Store the line height and page height for this particular element
            $.data(this, 'mousewheel-line-height', special.getLineHeight(this));
            $.data(this, 'mousewheel-page-height', special.getPageHeight(this));
        },

        teardown: function() {
            if ( this.removeEventListener ) {
                for ( var i = toBind.length; i; ) {
                    this.removeEventListener( toBind[--i], handler, false );
                }
            } else {
                this.onmousewheel = null;
            }
            // Clean up the data we added to the element
            $.removeData(this, 'mousewheel-line-height');
            $.removeData(this, 'mousewheel-page-height');
        },

        getLineHeight: function(elem) {
            var $parent = $(elem)['offsetParent' in $.fn ? 'offsetParent' : 'parent']();
            if (!$parent.length) {
                $parent = $('body');
            }
            return parseInt($parent.css('fontSize'), 10);
        },

        getPageHeight: function(elem) {
            return $(elem).height();
        },

        settings: {
            adjustOldDeltas: true, // see shouldAdjustOldDeltas() below
            normalizeOffset: true  // calls getBoundingClientRect for each event
        }
    };

    $.fn.extend({
        mousewheel: function(fn) {
            return fn ? this.on('mousewheel', fn) : this.trigger('mousewheel');
        },

        unmousewheel: function(fn) {
            return this.off('mousewheel', fn);
        }
    });


    function handler(event) {
        var orgEvent   = event || window.event,
            args       = slice.call(arguments, 1),
            delta      = 0,
            deltaX     = 0,
            deltaY     = 0,
            absDelta   = 0,
            offsetX    = 0,
            offsetY    = 0;
        event = $.event.fix(orgEvent);
        event.type = 'mousewheel';

        // Old school scrollwheel delta
        if ( 'detail'      in orgEvent ) { deltaY = orgEvent.detail * -1;      }
        if ( 'wheelDelta'  in orgEvent ) { deltaY = orgEvent.wheelDelta;       }
        if ( 'wheelDeltaY' in orgEvent ) { deltaY = orgEvent.wheelDeltaY;      }
        if ( 'wheelDeltaX' in orgEvent ) { deltaX = orgEvent.wheelDeltaX * -1; }

        // Firefox < 17 horizontal scrolling related to DOMMouseScroll event
        if ( 'axis' in orgEvent && orgEvent.axis === orgEvent.HORIZONTAL_AXIS ) {
            deltaX = deltaY * -1;
            deltaY = 0;
        }

        // Set delta to be deltaY or deltaX if deltaY is 0 for backwards compatabilitiy
        delta = deltaY === 0 ? deltaX : deltaY;

        // New school wheel delta (wheel event)
        if ( 'deltaY' in orgEvent ) {
            deltaY = orgEvent.deltaY * -1;
            delta  = deltaY;
        }
        if ( 'deltaX' in orgEvent ) {
            deltaX = orgEvent.deltaX;
            if ( deltaY === 0 ) { delta  = deltaX * -1; }
        }

        // No change actually happened, no reason to go any further
        if ( deltaY === 0 && deltaX === 0 ) { return; }

        // Need to convert lines and pages to pixels if we aren't already in pixels
        // There are three delta modes:
        //   * deltaMode 0 is by pixels, nothing to do
        //   * deltaMode 1 is by lines
        //   * deltaMode 2 is by pages
        if ( orgEvent.deltaMode === 1 ) {
            var lineHeight = $.data(this, 'mousewheel-line-height');
            delta  *= lineHeight;
            deltaY *= lineHeight;
            deltaX *= lineHeight;
        } else if ( orgEvent.deltaMode === 2 ) {
            var pageHeight = $.data(this, 'mousewheel-page-height');
            delta  *= pageHeight;
            deltaY *= pageHeight;
            deltaX *= pageHeight;
        }

        // Store lowest absolute delta to normalize the delta values
        absDelta = Math.max( Math.abs(deltaY), Math.abs(deltaX) );

        if ( !lowestDelta || absDelta < lowestDelta ) {
            lowestDelta = absDelta;

            // Adjust older deltas if necessary
            if ( shouldAdjustOldDeltas(orgEvent, absDelta) ) {
                lowestDelta /= 40;
            }
        }

        // Adjust older deltas if necessary
        if ( shouldAdjustOldDeltas(orgEvent, absDelta) ) {
            // Divide all the things by 40!
            delta  /= 40;
            deltaX /= 40;
            deltaY /= 40;
        }

        // Get a whole, normalized value for the deltas
        delta  = Math[ delta  >= 1 ? 'floor' : 'ceil' ](delta  / lowestDelta);
        deltaX = Math[ deltaX >= 1 ? 'floor' : 'ceil' ](deltaX / lowestDelta);
        deltaY = Math[ deltaY >= 1 ? 'floor' : 'ceil' ](deltaY / lowestDelta);

        // Normalise offsetX and offsetY properties
        if ( special.settings.normalizeOffset && this.getBoundingClientRect ) {
            var boundingRect = this.getBoundingClientRect();
            offsetX = event.clientX - boundingRect.left;
            offsetY = event.clientY - boundingRect.top;
        }

        // Add information to the event object
        event.deltaX = deltaX;
        event.deltaY = deltaY;
        event.deltaFactor = lowestDelta;
        event.offsetX = offsetX;
        event.offsetY = offsetY;
        // Go ahead and set deltaMode to 0 since we converted to pixels
        // Although this is a little odd since we overwrite the deltaX/Y
        // properties with normalized deltas.
        event.deltaMode = 0;

        // Add event and delta to the front of the arguments
        args.unshift(event, delta, deltaX, deltaY);

        // Clearout lowestDelta after sometime to better
        // handle multiple device types that give different
        // a different lowestDelta
        // Ex: trackpad = 3 and mouse wheel = 120
        if (nullLowestDeltaTimeout) { clearTimeout(nullLowestDeltaTimeout); }
        nullLowestDeltaTimeout = setTimeout(nullLowestDelta, 200);

        return ($.event.dispatch || $.event.handle).apply(this, args);
    }

    function nullLowestDelta() {
        lowestDelta = null;
    }

    function shouldAdjustOldDeltas(orgEvent, absDelta) {
        // If this is an older event and the delta is divisable by 120,
        // then we are assuming that the browser is treating this as an
        // older mouse wheel event and that we should divide the deltas
        // by 40 to try and get a more usable deltaFactor.
        // Side note, this actually impacts the reported scroll distance
        // in older browsers and can cause scrolling to be slower than native.
        // Turn this off by setting $.event.special.mousewheel.settings.adjustOldDeltas to false.
        return special.settings.adjustOldDeltas && orgEvent.type === 'mousewheel' && absDelta % 120 === 0;
    }

}));


// --- Start image stack popup
$( ImageStackPopup.init );