瀏覽代碼

Merge branch 'dev' into dev_importBundledPlugins

Hakim El Hattab 5 年之前
父節點
當前提交
4ff79dbc9d
共有 12 個文件被更改,包括 2370 次插入1994 次删除
  1. 0 0
      dist/reveal.min.js
  2. 548 0
      js/controllers/autoanimate.js
  3. 304 0
      js/controllers/fragments.js
  4. 249 0
      js/controllers/overview.js
  5. 2 2
      js/controllers/plugins.js
  6. 433 0
      js/controllers/slidecontent.js
  7. 193 1361
      js/reveal.js
  8. 15 0
      js/utils/device.js
  9. 13 7
      js/utils/util.js
  10. 0 11
      plugin/markdown/example.html
  11. 613 1
      test/test.html
  12. 0 612
      test/test.js

File diff suppressed because it is too large
+ 0 - 0
dist/reveal.min.js


+ 548 - 0
js/controllers/autoanimate.js

@@ -0,0 +1,548 @@
+import { extend, toArray, createStyleSheet } from '../utils/util.js'
+
+/**
+ * Automatically animates matching elements across
+ * slides with the [data-auto-animate] attribute.
+ */
+export default class AutoAnimate {
+
+	constructor( Reveal ) {
+
+		this.Reveal = Reveal;
+
+		// Counter used to generate unique IDs for auto-animated elements
+		this.autoAnimateCounter = 0;
+
+	}
+
+	/**
+	 * Runs an auto-animation between the given slides.
+	 *
+	 * @param  {HTMLElement} fromSlide
+	 * @param  {HTMLElement} toSlide
+	 */
+	run( fromSlide, toSlide ) {
+
+		// Clean up after prior animations
+		this.reset();
+
+		// Ensure that both slides are auto-animate targets
+		if( fromSlide.hasAttribute( 'data-auto-animate' ) && toSlide.hasAttribute( 'data-auto-animate' ) ) {
+
+			// Create a new auto-animate sheet
+			this.autoAnimateStyleSheet = this.autoAnimateStyleSheet || createStyleSheet();
+
+			let animationOptions = this.getAutoAnimateOptions( toSlide );
+
+			// Set our starting state
+			fromSlide.dataset.autoAnimate = 'pending';
+			toSlide.dataset.autoAnimate = 'pending';
+
+			// Inject our auto-animate styles for this transition
+			let css = this.getAutoAnimatableElements( fromSlide, toSlide ).map( elements => {
+				return this.getAutoAnimateCSS( elements.from, elements.to, elements.options || {}, animationOptions, this.autoAnimateCounter++ );
+			} );
+
+			// Animate unmatched elements, if enabled
+			if( toSlide.dataset.autoAnimateUnmatched !== 'false' && this.Reveal.getConfig().autoAnimateUnmatched === true ) {
+				this.getUnmatchedAutoAnimateElements( toSlide ).forEach( unmatchedElement => {
+					unmatchedElement.dataset.autoAnimateTarget = 'unmatched';
+				} );
+
+				css.push( `[data-auto-animate="running"] [data-auto-animate-target="unmatched"] { transition: opacity ${animationOptions.duration*0.8}s ease ${animationOptions.duration*0.2}s; }` );
+			}
+
+			// Setting the whole chunk of CSS at once is the most
+			// efficient way to do this. Using sheet.insertRule
+			// is multiple factors slower.
+			this.autoAnimateStyleSheet.innerHTML = css.join( '' );
+
+			// Start the animation next cycle
+			requestAnimationFrame( () => {
+				if( this.autoAnimateStyleSheet ) {
+					// This forces our newly injected styles to be applied in Firefox
+					getComputedStyle( this.autoAnimateStyleSheet ).fontWeight;
+
+					toSlide.dataset.autoAnimate = 'running';
+				}
+			} );
+
+			this.Reveal.dispatchEvent( 'autoanimate', { fromSlide: fromSlide, toSlide: toSlide, sheet: this.autoAnimateStyleSheet } );
+
+		}
+
+	}
+
+	/**
+	 * Rolls back all changes that we've made to the DOM so
+	 * that as part of animating.
+	 */
+	reset() {
+
+		// Reset slides
+		toArray( this.Reveal.getRevealElement().querySelectorAll( '[data-auto-animate]:not([data-auto-animate=""])' ) ).forEach( element => {
+			element.dataset.autoAnimate = '';
+		} );
+
+		// Reset elements
+		toArray( this.Reveal.getRevealElement().querySelectorAll( '[data-auto-animate-target]' ) ).forEach( element => {
+			delete element.dataset.autoAnimateTarget;
+		} );
+
+		// Remove the animation sheet
+		if( this.autoAnimateStyleSheet && this.autoAnimateStyleSheet.parentNode ) {
+			this.autoAnimateStyleSheet.parentNode.removeChild( this.autoAnimateStyleSheet );
+			this.autoAnimateStyleSheet = null;
+		}
+
+	}
+
+	/**
+	 * Auto-animates the properties of an element from their original
+	 * values to their new state.
+	 *
+	 * @param {HTMLElement} from
+	 * @param {HTMLElement} to
+	 * @param {Object} elementOptions Options for this element pair
+	 * @param {Object} animationOptions Options set at the slide level
+	 * @param {String} id Unique ID that we can use to identify this
+	 * auto-animate element in the DOM
+	 */
+	getAutoAnimateCSS( from, to, elementOptions, animationOptions, id ) {
+
+		// 'from' elements are given a data-auto-animate-target with no value,
+		// 'to' elements are are given a data-auto-animate-target with an ID
+		from.dataset.autoAnimateTarget = '';
+		to.dataset.autoAnimateTarget = id;
+
+		// Each element may override any of the auto-animate options
+		// like transition easing, duration and delay via data-attributes
+		let options = this.getAutoAnimateOptions( to, animationOptions );
+
+		// If we're using a custom element matcher the element options
+		// may contain additional transition overrides
+		if( typeof elementOptions.delay !== 'undefined' ) options.delay = elementOptions.delay;
+		if( typeof elementOptions.duration !== 'undefined' ) options.duration = elementOptions.duration;
+		if( typeof elementOptions.easing !== 'undefined' ) options.easing = elementOptions.easing;
+
+		let fromProps = this.getAutoAnimatableProperties( 'from', from, elementOptions ),
+			toProps = this.getAutoAnimatableProperties( 'to', to, elementOptions );
+
+		// If translation and/or scaling are enabled, css transform
+		// the 'to' element so that it matches the position and size
+		// of the 'from' element
+		if( elementOptions.translate !== false || elementOptions.scale !== false ) {
+
+			let presentationScale = this.Reveal.getScale();
+
+			let delta = {
+				x: ( fromProps.x - toProps.x ) / presentationScale,
+				y: ( fromProps.y - toProps.y ) / presentationScale,
+				scaleX: fromProps.width / toProps.width,
+				scaleY: fromProps.height / toProps.height
+			};
+
+			// Limit decimal points to avoid 0.0001px blur and stutter
+			delta.x = Math.round( delta.x * 1000 ) / 1000;
+			delta.y = Math.round( delta.y * 1000 ) / 1000;
+			delta.scaleX = Math.round( delta.scaleX * 1000 ) / 1000;
+			delta.scaleX = Math.round( delta.scaleX * 1000 ) / 1000;
+
+			let translate = elementOptions.translate !== false && ( delta.x !== 0 || delta.y !== 0 ),
+				scale = elementOptions.scale !== false && ( delta.scaleX !== 0 || delta.scaleY !== 0 );
+
+			// No need to transform if nothing's changed
+			if( translate || scale ) {
+
+				let transform = [];
+
+				if( translate ) transform.push( `translate(${delta.x}px, ${delta.y}px)` );
+				if( scale ) transform.push( `scale(${delta.scaleX}, ${delta.scaleY})` );
+
+				fromProps.styles['transform'] = transform.join( ' ' );
+				fromProps.styles['transform-origin'] = 'top left';
+
+				toProps.styles['transform'] = 'none';
+
+			}
+
+		}
+
+		// Delete all unchanged 'to' styles
+		for( let propertyName in toProps.styles ) {
+			const toValue = toProps.styles[propertyName];
+			const fromValue = fromProps.styles[propertyName];
+
+			if( toValue === fromValue ) {
+				delete toProps.styles[propertyName];
+			}
+			else {
+				// If these property values were set via a custom matcher providing
+				// an explicit 'from' and/or 'to' value, we always inject those values.
+				if( toValue.explicitValue === true ) {
+					toProps.styles[propertyName] = toValue.value;
+				}
+
+				if( fromValue.explicitValue === true ) {
+					fromProps.styles[propertyName] = fromValue.value;
+				}
+			}
+		}
+
+		let css = '';
+
+		let toStyleProperties = Object.keys( toProps.styles );
+
+		// Only create animate this element IF at least one style
+		// property has changed
+		if( toStyleProperties.length > 0 ) {
+
+			// Instantly move to the 'from' state
+			fromProps.styles['transition'] = 'none';
+
+			// Animate towards the 'to' state
+			toProps.styles['transition'] = `all ${options.duration}s ${options.easing} ${options.delay}s`;
+			toProps.styles['transition-property'] = toStyleProperties.join( ', ' );
+			toProps.styles['will-change'] = toStyleProperties.join( ', ' );
+
+			// Build up our custom CSS. We need to override inline styles
+			// so we need to make our styles vErY IMPORTANT!1!!
+			let fromCSS = Object.keys( fromProps.styles ).map( propertyName => {
+				return propertyName + ': ' + fromProps.styles[propertyName] + ' !important;';
+			} ).join( '' );
+
+			let toCSS = Object.keys( toProps.styles ).map( propertyName => {
+				return propertyName + ': ' + toProps.styles[propertyName] + ' !important;';
+			} ).join( '' );
+
+			css = 	'[data-auto-animate-target="'+ id +'"] {'+ fromCSS +'}' +
+					'[data-auto-animate="running"] [data-auto-animate-target="'+ id +'"] {'+ toCSS +'}';
+
+		}
+
+		return css;
+
+	}
+
+	/**
+	 * Returns the auto-animate options for the given element.
+	 *
+	 * @param {HTMLElement} element Element to pick up options
+	 * from, either a slide or an animation target
+	 * @param {Object} [inheritedOptions] Optional set of existing
+	 * options
+	 */
+	getAutoAnimateOptions( element, inheritedOptions ) {
+
+		let options = {
+			easing: this.Reveal.getConfig().autoAnimateEasing,
+			duration: this.Reveal.getConfig().autoAnimateDuration,
+			delay: 0
+		};
+
+		options = extend( options, inheritedOptions );
+
+		// Inherit options from parent elements
+		if( element.closest && element.parentNode ) {
+			let autoAnimatedParent = element.parentNode.closest( '[data-auto-animate-target]' );
+			if( autoAnimatedParent ) {
+				options = this.getAutoAnimateOptions( autoAnimatedParent, options );
+			}
+		}
+
+		if( element.dataset.autoAnimateEasing ) {
+			options.easing = element.dataset.autoAnimateEasing;
+		}
+
+		if( element.dataset.autoAnimateDuration ) {
+			options.duration = parseFloat( element.dataset.autoAnimateDuration );
+		}
+
+		if( element.dataset.autoAnimateDelay ) {
+			options.delay = parseFloat( element.dataset.autoAnimateDelay );
+		}
+
+		return options;
+
+	}
+
+	/**
+	 * Returns an object containing all of the properties
+	 * that can be auto-animated for the given element and
+	 * their current computed values.
+	 *
+	 * @param {String} direction 'from' or 'to'
+	 */
+	getAutoAnimatableProperties( direction, element, elementOptions ) {
+
+		let properties = { styles: [] };
+
+		// Position and size
+		if( elementOptions.translate !== false || elementOptions.scale !== false ) {
+			let bounds;
+
+			// Custom auto-animate may optionally return a custom tailored
+			// measurement function
+			if( typeof elementOptions.measure === 'function' ) {
+				bounds = elementOptions.measure( element );
+			}
+			else {
+				bounds = element.getBoundingClientRect();
+			}
+
+			properties.x = bounds.x;
+			properties.y = bounds.y;
+			properties.width = bounds.width;
+			properties.height = bounds.height;
+		}
+
+		const computedStyles = getComputedStyle( element );
+
+		// CSS styles
+		( elementOptions.styles || this.Reveal.getConfig().autoAnimateStyles ).forEach( style => {
+			let value;
+
+			// `style` is either the property name directly, or an object
+			// definition of a style property
+			if( typeof style === 'string' ) style = { property: style };
+
+			if( typeof style.from !== 'undefined' && direction === 'from' ) {
+				value = { value: style.from, explicitValue: true };
+			}
+			else if( typeof style.to !== 'undefined' && direction === 'to' ) {
+				value = { value: style.to, explicitValue: true };
+			}
+			else {
+				value = computedStyles[style.property];
+			}
+
+			if( value !== '' ) {
+				properties.styles[style.property] = value;
+			}
+		} );
+
+		return properties;
+
+	}
+
+	/**
+	 * Get a list of all element pairs that we can animate
+	 * between the given slides.
+	 *
+	 * @param {HTMLElement} fromSlide
+	 * @param {HTMLElement} toSlide
+	 *
+	 * @return {Array} Each value is an array where [0] is
+	 * the element we're animating from and [1] is the
+	 * element we're animating to
+	 */
+	getAutoAnimatableElements( fromSlide, toSlide ) {
+
+		let matcher = typeof this.Reveal.getConfig().autoAnimateMatcher === 'function' ? this.Reveal.getConfig().autoAnimateMatcher : this.getAutoAnimatePairs;
+
+		let pairs = matcher.call( this, fromSlide, toSlide );
+
+		let reserved = [];
+
+		// Remove duplicate pairs
+		return pairs.filter( ( pair, index ) => {
+			if( reserved.indexOf( pair.to ) === -1 ) {
+				reserved.push( pair.to );
+				return true;
+			}
+		} );
+
+	}
+
+	/**
+	 * Identifies matching elements between slides.
+	 *
+	 * You can specify a custom matcher function by using
+	 * the `autoAnimateMatcher` config option.
+	 */
+	getAutoAnimatePairs( fromSlide, toSlide ) {
+
+		let pairs = [];
+
+		const codeNodes = 'pre';
+		const textNodes = 'h1, h2, h3, h4, h5, h6, p, li';
+		const mediaNodes = 'img, video, iframe';
+
+		// Eplicit matches via data-id
+		this.findAutoAnimateMatches( pairs, fromSlide, toSlide, '[data-id]', node => {
+			return node.nodeName + ':::' + node.getAttribute( 'data-id' );
+		} );
+
+		// Text
+		this.findAutoAnimateMatches( pairs, fromSlide, toSlide, textNodes, node => {
+			return node.nodeName + ':::' + node.innerText;
+		} );
+
+		// Media
+		this.findAutoAnimateMatches( pairs, fromSlide, toSlide, mediaNodes, node => {
+			return node.nodeName + ':::' + ( node.getAttribute( 'src' ) || node.getAttribute( 'data-src' ) );
+		} );
+
+		// Code
+		this.findAutoAnimateMatches( pairs, fromSlide, toSlide, codeNodes, node => {
+			return node.nodeName + ':::' + node.innerText;
+		} );
+
+		pairs.forEach( pair => {
+
+			// Disable scale transformations on text nodes, we transiition
+			// each individual text property instead
+			if( pair.from.matches( textNodes ) ) {
+				pair.options = { scale: false };
+			}
+			// Animate individual lines of code
+			else if( pair.from.matches( codeNodes ) ) {
+
+				// Transition the code block's width and height instead of scaling
+				// to prevent its content from being squished
+				pair.options = { scale: false, styles: [ 'width', 'height' ] };
+
+				// Lines of code
+				this.findAutoAnimateMatches( pairs, pair.from, pair.to, '.hljs .hljs-ln-code', node => {
+					return node.textContent;
+				}, {
+					scale: false,
+					styles: [],
+					measure: this.getLocalBoundingBox.bind( this )
+				} );
+
+				// Line numbers
+				this.findAutoAnimateMatches( pairs, pair.from, pair.to, '.hljs .hljs-ln-line[data-line-number]', node => {
+					return node.getAttribute( 'data-line-number' );
+				}, {
+					scale: false,
+					styles: [ 'width' ],
+					measure: this.getLocalBoundingBox.bind( this )
+				} );
+
+			}
+
+		}, this );
+
+		return pairs;
+
+	}
+
+	/**
+	 * Helper method which returns a bounding box based on
+	 * the given elements offset coordinates.
+	 *
+	 * @param {HTMLElement} element
+	 * @return {Object} x, y, width, height
+	 */
+	getLocalBoundingBox( element ) {
+
+		const presentationScale = this.Reveal.getScale();
+
+		return {
+			x: Math.round( ( element.offsetLeft * presentationScale ) * 100 ) / 100,
+			y: Math.round( ( element.offsetTop * presentationScale ) * 100 ) / 100,
+			width: Math.round( ( element.offsetWidth * presentationScale ) * 100 ) / 100,
+			height: Math.round( ( element.offsetHeight * presentationScale ) * 100 ) / 100
+		};
+
+	}
+
+	/**
+	 * Finds matching elements between two slides.
+	 *
+	 * @param {Array} pairs            	List of pairs to push matches to
+	 * @param {HTMLElement} fromScope   Scope within the from element exists
+	 * @param {HTMLElement} toScope     Scope within the to element exists
+	 * @param {String} selector         CSS selector of the element to match
+	 * @param {Function} serializer     A function that accepts an element and returns
+	 *                                  a stringified ID based on its contents
+	 * @param {Object} animationOptions Optional config options for this pair
+	 */
+	findAutoAnimateMatches( pairs, fromScope, toScope, selector, serializer, animationOptions ) {
+
+		let fromMatches = {};
+		let toMatches = {};
+
+		[].slice.call( fromScope.querySelectorAll( selector ) ).forEach( ( element, i ) => {
+			const key = serializer( element );
+			if( typeof key === 'string' && key.length ) {
+				fromMatches[key] = fromMatches[key] || [];
+				fromMatches[key].push( element );
+			}
+		} );
+
+		[].slice.call( toScope.querySelectorAll( selector ) ).forEach( ( element, i ) => {
+			const key = serializer( element );
+			toMatches[key] = toMatches[key] || [];
+			toMatches[key].push( element );
+
+			let fromElement;
+
+			// Retrieve the 'from' element
+			if( fromMatches[key] ) {
+				const pimaryIndex = toMatches[key].length - 1;
+				const secondaryIndex = fromMatches[key].length - 1;
+
+				// If there are multiple identical from elements, retrieve
+				// the one at the same index as our to-element.
+				if( fromMatches[key][ pimaryIndex ] ) {
+					fromElement = fromMatches[key][ pimaryIndex ];
+					fromMatches[key][ pimaryIndex ] = null;
+				}
+				// If there are no matching from-elements at the same index,
+				// use the last one.
+				else if( fromMatches[key][ secondaryIndex ] ) {
+					fromElement = fromMatches[key][ secondaryIndex ];
+					fromMatches[key][ secondaryIndex ] = null;
+				}
+			}
+
+			// If we've got a matching pair, push it to the list of pairs
+			if( fromElement ) {
+				pairs.push({
+					from: fromElement,
+					to: element,
+					options: animationOptions
+				});
+			}
+		} );
+
+	}
+
+	/**
+	 * Returns a all elements within the given scope that should
+	 * be considered unmatched in an auto-animate transition. If
+	 * fading of unmatched elements is turned on, these elements
+	 * will fade when going between auto-animate slides.
+	 *
+	 * Note that parents of auto-animate targets are NOT considerd
+	 * unmatched since fading them would break the auto-animation.
+	 *
+	 * @param {HTMLElement} rootElement
+	 * @return {Array}
+	 */
+	getUnmatchedAutoAnimateElements( rootElement ) {
+
+		return [].slice.call( rootElement.children ).reduce( ( result, element ) => {
+
+			const containsAnimatedElements = element.querySelector( '[data-auto-animate-target]' );
+
+			// The element is unmatched if
+			// - It is not an auto-animate target
+			// - It does not contain any auto-animate targets
+			if( !element.hasAttribute( 'data-auto-animate-target' ) && !containsAnimatedElements ) {
+				result.push( element );
+			}
+
+			if( element.querySelector( '[data-auto-animate-target]' ) ) {
+				result = result.concat( this.getUnmatchedAutoAnimateElements( element ) );
+			}
+
+			return result;
+
+		}, [] );
+
+	}
+
+}

+ 304 - 0
js/controllers/fragments.js

@@ -0,0 +1,304 @@
+import { extend, toArray } from '../utils/util.js'
+
+/**
+ * Handles sorting and navigation of slide fragments.
+ * Fragments are elements within a slide that are
+ * revealed/animated incrementally.
+ */
+export default class Fragments {
+
+	constructor( Reveal ) {
+
+		this.Reveal = Reveal;
+
+	}
+
+	/**
+	 * Shows all fragments in the presentation. Used when
+	 * fragments are disabled presentation-wide.
+	 */
+	showAll() {
+
+		toArray( this.Reveal.getSlidesElement().querySelectorAll( '.fragment' ) ).forEach( element => {
+			element.classList.add( 'visible' );
+			element.classList.remove( 'current-fragment' );
+		} );
+
+	}
+
+	/**
+	 * Returns an object describing the available fragment
+	 * directions.
+	 *
+	 * @return {{prev: boolean, next: boolean}}
+	 */
+	availableRoutes() {
+
+		let currentSlide = this.Reveal.getCurrentSlide();
+		if( currentSlide && this.Reveal.getConfig().fragments ) {
+			let fragments = currentSlide.querySelectorAll( '.fragment' );
+			let hiddenFragments = currentSlide.querySelectorAll( '.fragment:not(.visible)' );
+
+			return {
+				prev: fragments.length - hiddenFragments.length > 0,
+				next: !!hiddenFragments.length
+			};
+		}
+		else {
+			return { prev: false, next: false };
+		}
+
+	}
+
+	/**
+	 * Return a sorted fragments list, ordered by an increasing
+	 * "data-fragment-index" attribute.
+	 *
+	 * Fragments will be revealed in the order that they are returned by
+	 * this function, so you can use the index attributes to control the
+	 * order of fragment appearance.
+	 *
+	 * To maintain a sensible default fragment order, fragments are presumed
+	 * to be passed in document order. This function adds a "fragment-index"
+	 * attribute to each node if such an attribute is not already present,
+	 * and sets that attribute to an integer value which is the position of
+	 * the fragment within the fragments list.
+	 *
+	 * @param {object[]|*} fragments
+	 * @param {boolean} grouped If true the returned array will contain
+	 * nested arrays for all fragments with the same index
+	 * @return {object[]} sorted Sorted array of fragments
+	 */
+	sort( fragments, grouped = false ) {
+
+		fragments = toArray( fragments );
+
+		let ordered = [],
+			unordered = [],
+			sorted = [];
+
+		// Group ordered and unordered elements
+		fragments.forEach( fragment => {
+			if( fragment.hasAttribute( 'data-fragment-index' ) ) {
+				let index = parseInt( fragment.getAttribute( 'data-fragment-index' ), 10 );
+
+				if( !ordered[index] ) {
+					ordered[index] = [];
+				}
+
+				ordered[index].push( fragment );
+			}
+			else {
+				unordered.push( [ fragment ] );
+			}
+		} );
+
+		// Append fragments without explicit indices in their
+		// DOM order
+		ordered = ordered.concat( unordered );
+
+		// Manually count the index up per group to ensure there
+		// are no gaps
+		let index = 0;
+
+		// Push all fragments in their sorted order to an array,
+		// this flattens the groups
+		ordered.forEach( group => {
+			group.forEach( fragment => {
+				sorted.push( fragment );
+				fragment.setAttribute( 'data-fragment-index', index );
+			} );
+
+			index ++;
+		} );
+
+		return grouped === true ? ordered : sorted;
+
+	}
+
+	/**
+	 * Sorts and formats all of fragments in the
+	 * presentation.
+	 */
+	sortAll() {
+
+		this.Reveal.getHorizontalSlides().forEach( horizontalSlide => {
+
+			let verticalSlides = toArray( horizontalSlide.querySelectorAll( 'section' ) );
+			verticalSlides.forEach( ( verticalSlide, y ) => {
+
+				this.sort( verticalSlide.querySelectorAll( '.fragment' ) );
+
+			}, this );
+
+			if( verticalSlides.length === 0 ) this.sort( horizontalSlide.querySelectorAll( '.fragment' ) );
+
+		} );
+
+	}
+
+	/**
+	 * Refreshes the fragments on the current slide so that they
+	 * have the appropriate classes (.visible + .current-fragment).
+	 *
+	 * @param {number} [index] The index of the current fragment
+	 * @param {array} [fragments] Array containing all fragments
+	 * in the current slide
+	 *
+	 * @return {{shown: array, hidden: array}}
+	 */
+	update( index, fragments ) {
+
+		let changedFragments = {
+			shown: [],
+			hidden: []
+		};
+
+		let currentSlide = this.Reveal.getCurrentSlide();
+		if( currentSlide && this.Reveal.getConfig().fragments ) {
+
+			fragments = fragments || this.sort( currentSlide.querySelectorAll( '.fragment' ) );
+
+			if( fragments.length ) {
+
+				let maxIndex = 0;
+
+				if( typeof index !== 'number' ) {
+					let currentFragment = this.sort( currentSlide.querySelectorAll( '.fragment.visible' ) ).pop();
+					if( currentFragment ) {
+						index = parseInt( currentFragment.getAttribute( 'data-fragment-index' ) || 0, 10 );
+					}
+				}
+
+				toArray( fragments ).forEach( ( el, i ) => {
+
+					if( el.hasAttribute( 'data-fragment-index' ) ) {
+						i = parseInt( el.getAttribute( 'data-fragment-index' ), 10 );
+					}
+
+					maxIndex = Math.max( maxIndex, i );
+
+					// Visible fragments
+					if( i <= index ) {
+						if( !el.classList.contains( 'visible' ) ) changedFragments.shown.push( el );
+						el.classList.add( 'visible' );
+						el.classList.remove( 'current-fragment' );
+
+						// Announce the fragments one by one to the Screen Reader
+						this.Reveal.announceStatus( this.Reveal.getStatusText( el ) );
+
+						if( i === index ) {
+							el.classList.add( 'current-fragment' );
+							this.Reveal.slideContent.startEmbeddedContent( el );
+						}
+					}
+					// Hidden fragments
+					else {
+						if( el.classList.contains( 'visible' ) ) changedFragments.hidden.push( el );
+						el.classList.remove( 'visible' );
+						el.classList.remove( 'current-fragment' );
+					}
+
+				} );
+
+				// Write the current fragment index to the slide <section>.
+				// This can be used by end users to apply styles based on
+				// the current fragment index.
+				index = typeof index === 'number' ? index : -1;
+				index = Math.max( Math.min( index, maxIndex ), -1 );
+				currentSlide.setAttribute( 'data-fragment', index );
+
+			}
+
+		}
+
+		return changedFragments;
+
+	}
+
+	/**
+	 * Navigate to the specified slide fragment.
+	 *
+	 * @param {?number} index The index of the fragment that
+	 * should be shown, -1 means all are invisible
+	 * @param {number} offset Integer offset to apply to the
+	 * fragment index
+	 *
+	 * @return {boolean} true if a change was made in any
+	 * fragments visibility as part of this call
+	 */
+	goto( index, offset = 0 ) {
+
+		let currentSlide = this.Reveal.getCurrentSlide();
+		if( currentSlide && this.Reveal.getConfig().fragments ) {
+
+			let fragments = this.sort( currentSlide.querySelectorAll( '.fragment' ) );
+			if( fragments.length ) {
+
+				// If no index is specified, find the current
+				if( typeof index !== 'number' ) {
+					let lastVisibleFragment = this.sort( currentSlide.querySelectorAll( '.fragment.visible' ) ).pop();
+
+					if( lastVisibleFragment ) {
+						index = parseInt( lastVisibleFragment.getAttribute( 'data-fragment-index' ) || 0, 10 );
+					}
+					else {
+						index = -1;
+					}
+				}
+
+				// Apply the offset if there is one
+				index += offset;
+
+				let changedFragments = this.update( index, fragments );
+
+				if( changedFragments.hidden.length ) {
+					this.Reveal.dispatchEvent( 'fragmenthidden', { fragment: changedFragments.hidden[0], fragments: changedFragments.hidden } );
+				}
+
+				if( changedFragments.shown.length ) {
+					this.Reveal.dispatchEvent( 'fragmentshown', { fragment: changedFragments.shown[0], fragments: changedFragments.shown } );
+				}
+
+				this.Reveal.updateControls();
+				this.Reveal.updateProgress();
+
+				if( this.Reveal.getConfig().fragmentInURL ) {
+					this.Reveal.writeURL();
+				}
+
+				return !!( changedFragments.shown.length || changedFragments.hidden.length );
+
+			}
+
+		}
+
+		return false;
+
+	}
+
+	/**
+	 * Navigate to the next slide fragment.
+	 *
+	 * @return {boolean} true if there was a next fragment,
+	 * false otherwise
+	 */
+	next() {
+
+		return this.goto( null, 1 );
+
+	}
+
+	/**
+	 * Navigate to the previous slide fragment.
+	 *
+	 * @return {boolean} true if there was a previous fragment,
+	 * false otherwise
+	 */
+	prev() {
+
+		return this.goto( null, -1 );
+
+	}
+
+}

+ 249 - 0
js/controllers/overview.js

@@ -0,0 +1,249 @@
+import { SLIDES_SELECTOR } from '../utils/constants.js'
+import { extend, toArray, transformElement } from '../utils/util.js'
+
+/**
+ * Handles all logic related to the overview mode
+ * (birds-eye view of all slides).
+ */
+export default class Overview {
+
+	constructor( Reveal ) {
+
+		this.Reveal = Reveal;
+
+		this.active = false;
+
+		this.onSlideClicked = this.onSlideClicked.bind( this );
+
+	}
+
+	/**
+	 * Displays the overview of slides (quick nav) by scaling
+	 * down and arranging all slide elements.
+	 */
+	activate() {
+
+		// Only proceed if enabled in config
+		if( this.Reveal.getConfig().overview && !this.isActive() ) {
+
+			this.active = true;
+
+			this.Reveal.getRevealElement().classList.add( 'overview' );
+
+			// Don't auto-slide while in overview mode
+			this.Reveal.cancelAutoSlide();
+
+			// Move the backgrounds element into the slide container to
+			// that the same scaling is applied
+			this.Reveal.getSlidesElement().appendChild( this.Reveal.getBackgroundsElement() );
+
+			// Clicking on an overview slide navigates to it
+			toArray( this.Reveal.getRevealElement().querySelectorAll( SLIDES_SELECTOR ) ).forEach( slide => {
+				if( !slide.classList.contains( 'stack' ) ) {
+					slide.addEventListener( 'click', this.onSlideClicked, true );
+				}
+			} );
+
+			// Calculate slide sizes
+			const margin = 70;
+			const slideSize = this.Reveal.getComputedSlideSize();
+			this.overviewSlideWidth = slideSize.width + margin;
+			this.overviewSlideHeight = slideSize.height + margin;
+
+			// Reverse in RTL mode
+			if( this.Reveal.getConfig().rtl ) {
+				this.overviewSlideWidth = -this.overviewSlideWidth;
+			}
+
+			this.Reveal.updateSlidesVisibility();
+
+			this.layout();
+			this.update();
+
+			this.Reveal.layout();
+
+			const indices = this.Reveal.getIndices();
+
+			// Notify observers of the overview showing
+			this.Reveal.dispatchEvent( 'overviewshown', {
+				'indexh': indices.h,
+				'indexv': indices.v,
+				'currentSlide': this.Reveal.getCurrentSlide()
+			} );
+
+		}
+
+	}
+
+	/**
+	 * Uses CSS transforms to position all slides in a grid for
+	 * display inside of the overview mode.
+	 */
+	layout() {
+
+		// Layout slides
+		this.Reveal.getHorizontalSlides().forEach( ( hslide, h ) => {
+			hslide.setAttribute( 'data-index-h', h );
+			transformElement( hslide, 'translate3d(' + ( h * this.overviewSlideWidth ) + 'px, 0, 0)' );
+
+			if( hslide.classList.contains( 'stack' ) ) {
+
+				toArray( hslide.querySelectorAll( 'section' ) ).forEach( ( vslide, v ) => {
+					vslide.setAttribute( 'data-index-h', h );
+					vslide.setAttribute( 'data-index-v', v );
+
+					transformElement( vslide, 'translate3d(0, ' + ( v * this.overviewSlideHeight ) + 'px, 0)' );
+				} );
+
+			}
+		} );
+
+		// Layout slide backgrounds
+		toArray( this.Reveal.getBackgroundsElement().childNodes ).forEach( ( hbackground, h ) => {
+			transformElement( hbackground, 'translate3d(' + ( h * this.overviewSlideWidth ) + 'px, 0, 0)' );
+
+			toArray( hbackground.querySelectorAll( '.slide-background' ) ).forEach( ( vbackground, v ) => {
+				transformElement( vbackground, 'translate3d(0, ' + ( v * this.overviewSlideHeight ) + 'px, 0)' );
+			} );
+		} );
+
+	}
+
+	/**
+	 * Moves the overview viewport to the current slides.
+	 * Called each time the current slide changes.
+	 */
+	update() {
+
+		const vmin = Math.min( window.innerWidth, window.innerHeight );
+		const scale = Math.max( vmin / 5, 150 ) / vmin;
+		const indices = this.Reveal.getIndices();
+
+		this.Reveal.transformSlides( {
+			overview: [
+				'scale('+ scale +')',
+				'translateX('+ ( -indices.h * this.overviewSlideWidth ) +'px)',
+				'translateY('+ ( -indices.v * this.overviewSlideHeight ) +'px)'
+			].join( ' ' )
+		} );
+
+	}
+
+	/**
+	 * Exits the slide overview and enters the currently
+	 * active slide.
+	 */
+	deactivate() {
+
+		// Only proceed if enabled in config
+		if( this.Reveal.getConfig().overview ) {
+
+			this.active = false;
+
+			this.Reveal.getRevealElement().classList.remove( 'overview' );
+
+			// Temporarily add a class so that transitions can do different things
+			// depending on whether they are exiting/entering overview, or just
+			// moving from slide to slide
+			this.Reveal.getRevealElement().classList.add( 'overview-deactivating' );
+
+			setTimeout( () => {
+				this.Reveal.getRevealElement().classList.remove( 'overview-deactivating' );
+			}, 1 );
+
+			// Move the background element back out
+			this.Reveal.getRevealElement().appendChild( this.Reveal.getBackgroundsElement() );
+
+			// Clean up changes made to slides
+			toArray( this.Reveal.getRevealElement().querySelectorAll( SLIDES_SELECTOR ) ).forEach( slide => {
+				transformElement( slide, '' );
+
+				slide.removeEventListener( 'click', this.onSlideClicked, true );
+			} );
+
+			// Clean up changes made to backgrounds
+			toArray( this.Reveal.getBackgroundsElement().querySelectorAll( '.slide-background' ) ).forEach( background => {
+				transformElement( background, '' );
+			} );
+
+			this.Reveal.transformSlides( { overview: '' } );
+
+			const indices = this.Reveal.getIndices();
+
+			this.Reveal.slide( indices.h, indices.v );
+			this.Reveal.layout();
+			this.Reveal.cueAutoSlide();
+
+			// Notify observers of the overview hiding
+			this.Reveal.dispatchEvent( 'overviewhidden', {
+				'indexh': indices.h,
+				'indexv': indices.v,
+				'currentSlide': this.Reveal.getCurrentSlide()
+			} );
+
+		}
+	}
+
+	/**
+	 * Toggles the slide overview mode on and off.
+	 *
+	 * @param {Boolean} [override] Flag which overrides the
+	 * toggle logic and forcibly sets the desired state. True means
+	 * overview is open, false means it's closed.
+	 */
+	toggle( override ) {
+
+		if( typeof override === 'boolean' ) {
+			override ? this.activate() : this.deactivate();
+		}
+		else {
+			this.isActive() ? this.deactivate() : this.activate();
+		}
+
+	}
+
+	/**
+	 * Checks if the overview is currently active.
+	 *
+	 * @return {Boolean} true if the overview is active,
+	 * false otherwise
+	 */
+	isActive() {
+
+		return this.active;
+
+	}
+
+	/**
+	 * Invoked when a slide is and we're in the overview.
+	 *
+	 * @param {object} event
+	 */
+	onSlideClicked( event ) {
+
+		if( this.isActive() ) {
+			event.preventDefault();
+
+			let element = event.target;
+
+			while( element && !element.nodeName.match( /section/gi ) ) {
+				element = element.parentNode;
+			}
+
+			if( element && !element.classList.contains( 'disabled' ) ) {
+
+				this.deactivate();
+
+				if( element.nodeName.match( /section/gi ) ) {
+					let h = parseInt( element.getAttribute( 'data-index-h' ), 10 ),
+						v = parseInt( element.getAttribute( 'data-index-v' ), 10 );
+
+					this.Reveal.slide( h, v );
+				}
+
+			}
+		}
+
+	}
+
+}

+ 2 - 2
js/controllers/plugins.js

@@ -7,8 +7,8 @@ export default class Plugins {
 
 
 	constructor() {
 	constructor() {
 
 
-		// Flags our current state (pending -> loading -> loaded)
-		this.state = 'pending';
+		// Flags our current state (idle -> loading -> loaded)
+		this.state = 'idle';
 
 
 		// An id:instance map of currently registed plugins
 		// An id:instance map of currently registed plugins
 		this.registeredPlugins = {};
 		this.registeredPlugins = {};

+ 433 - 0
js/controllers/slidecontent.js

@@ -0,0 +1,433 @@
+import { HORIZONTAL_SLIDES_SELECTOR, VERTICAL_SLIDES_SELECTOR } from '../utils/constants.js'
+import { extend, toArray, closestParent } from '../utils/util.js'
+import { isMobile } from '../utils/device.js'
+
+/**
+ * Handles loading, unloading and playback of slide
+ * content such as images, videos and iframes.
+ */
+export default class SlideContent {
+
+	constructor( Reveal ) {
+
+		this.Reveal = Reveal;
+
+	}
+
+	/**
+	 * Should the given element be preloaded?
+	 * Decides based on local element attributes and global config.
+	 *
+	 * @param {HTMLElement} element
+	 */
+	shouldPreload( element ) {
+
+		// Prefer an explicit global preload setting
+		let preload = this.Reveal.getConfig().preloadIframes;
+
+		// If no global setting is available, fall back on the element's
+		// own preload setting
+		if( typeof preload !== 'boolean' ) {
+			preload = element.hasAttribute( 'data-preload' );
+		}
+
+		return preload;
+	}
+
+	/**
+	 * Called when the given slide is within the configured view
+	 * distance. Shows the slide element and loads any content
+	 * that is set to load lazily (data-src).
+	 *
+	 * @param {HTMLElement} slide Slide to show
+	 */
+	load( slide, options = {} ) {
+
+		// Show the slide element
+		slide.style.display = this.Reveal.getConfig().display;
+
+		// Media elements with data-src attributes
+		toArray( slide.querySelectorAll( 'img[data-src], video[data-src], audio[data-src], iframe[data-src]' ) ).forEach( element => {
+			if( element.tagName !== 'IFRAME' || this.shouldPreload( element ) ) {
+				element.setAttribute( 'src', element.getAttribute( 'data-src' ) );
+				element.setAttribute( 'data-lazy-loaded', '' );
+				element.removeAttribute( 'data-src' );
+			}
+		} );
+
+		// Media elements with <source> children
+		toArray( slide.querySelectorAll( 'video, audio' ) ).forEach( media => {
+			let sources = 0;
+
+			toArray( media.querySelectorAll( 'source[data-src]' ) ).forEach( source => {
+				source.setAttribute( 'src', source.getAttribute( 'data-src' ) );
+				source.removeAttribute( 'data-src' );
+				source.setAttribute( 'data-lazy-loaded', '' );
+				sources += 1;
+			} );
+
+			// If we rewrote sources for this video/audio element, we need
+			// to manually tell it to load from its new origin
+			if( sources > 0 ) {
+				media.load();
+			}
+		} );
+
+
+		// Show the corresponding background element
+		let background = slide.slideBackgroundElement;
+		if( background ) {
+			background.style.display = 'block';
+
+			let backgroundContent = slide.slideBackgroundContentElement;
+			let backgroundIframe = slide.getAttribute( 'data-background-iframe' );
+
+			// If the background contains media, load it
+			if( background.hasAttribute( 'data-loaded' ) === false ) {
+				background.setAttribute( 'data-loaded', 'true' );
+
+				let backgroundImage = slide.getAttribute( 'data-background-image' ),
+					backgroundVideo = slide.getAttribute( 'data-background-video' ),
+					backgroundVideoLoop = slide.hasAttribute( 'data-background-video-loop' ),
+					backgroundVideoMuted = slide.hasAttribute( 'data-background-video-muted' );
+
+				// Images
+				if( backgroundImage ) {
+					backgroundContent.style.backgroundImage = 'url('+ encodeURI( backgroundImage ) +')';
+				}
+				// Videos
+				else if ( backgroundVideo && !this.Reveal.isSpeakerNotes() ) {
+					let video = document.createElement( 'video' );
+
+					if( backgroundVideoLoop ) {
+						video.setAttribute( 'loop', '' );
+					}
+
+					if( backgroundVideoMuted ) {
+						video.muted = true;
+					}
+
+					// Inline video playback works (at least in Mobile Safari) as
+					// long as the video is muted and the `playsinline` attribute is
+					// present
+					if( isMobile ) {
+						video.muted = true;
+						video.autoplay = true;
+						video.setAttribute( 'playsinline', '' );
+					}
+
+					// Support comma separated lists of video sources
+					backgroundVideo.split( ',' ).forEach( source => {
+						video.innerHTML += '<source src="'+ source +'">';
+					} );
+
+					backgroundContent.appendChild( video );
+				}
+				// Iframes
+				else if( backgroundIframe && options.excludeIframes !== true ) {
+					let iframe = document.createElement( 'iframe' );
+					iframe.setAttribute( 'allowfullscreen', '' );
+					iframe.setAttribute( 'mozallowfullscreen', '' );
+					iframe.setAttribute( 'webkitallowfullscreen', '' );
+					iframe.setAttribute( 'allow', 'autoplay' );
+
+					iframe.setAttribute( 'data-src', backgroundIframe );
+
+					iframe.style.width  = '100%';
+					iframe.style.height = '100%';
+					iframe.style.maxHeight = '100%';
+					iframe.style.maxWidth = '100%';
+
+					backgroundContent.appendChild( iframe );
+				}
+			}
+
+			// Start loading preloadable iframes
+			let backgroundIframeElement = backgroundContent.querySelector( 'iframe[data-src]' );
+			if( backgroundIframeElement ) {
+
+				// Check if this iframe is eligible to be preloaded
+				if( this.shouldPreload( background ) && !/autoplay=(1|true|yes)/gi.test( backgroundIframe ) ) {
+					if( backgroundIframeElement.getAttribute( 'src' ) !== backgroundIframe ) {
+						backgroundIframeElement.setAttribute( 'src', backgroundIframe );
+					}
+				}
+
+			}
+
+		}
+
+	}
+
+	/**
+	 * Unloads and hides the given slide. This is called when the
+	 * slide is moved outside of the configured view distance.
+	 *
+	 * @param {HTMLElement} slide
+	 */
+	unload( slide ) {
+
+		// Hide the slide element
+		slide.style.display = 'none';
+
+		// Hide the corresponding background element
+		let background = this.Reveal.getSlideBackground( slide );
+		if( background ) {
+			background.style.display = 'none';
+
+			// Unload any background iframes
+			toArray( background.querySelectorAll( 'iframe[src]' ) ).forEach( element => {
+				element.removeAttribute( 'src' );
+			} );
+		}
+
+		// Reset lazy-loaded media elements with src attributes
+		toArray( slide.querySelectorAll( 'video[data-lazy-loaded][src], audio[data-lazy-loaded][src], iframe[data-lazy-loaded][src]' ) ).forEach( element => {
+			element.setAttribute( 'data-src', element.getAttribute( 'src' ) );
+			element.removeAttribute( 'src' );
+		} );
+
+		// Reset lazy-loaded media elements with <source> children
+		toArray( slide.querySelectorAll( 'video[data-lazy-loaded] source[src], audio source[src]' ) ).forEach( source => {
+			source.setAttribute( 'data-src', source.getAttribute( 'src' ) );
+			source.removeAttribute( 'src' );
+		} );
+
+	}
+
+	/**
+	 * Enforces origin-specific format rules for embedded media.
+	 */
+	formatEmbeddedContent() {
+
+		let _appendParamToIframeSource = ( sourceAttribute, sourceURL, param ) => {
+			toArray( this.Reveal.getSlidesElement().querySelectorAll( 'iframe['+ sourceAttribute +'*="'+ sourceURL +'"]' ) ).forEach( el => {
+				let src = el.getAttribute( sourceAttribute );
+				if( src && src.indexOf( param ) === -1 ) {
+					el.setAttribute( sourceAttribute, src + ( !/\?/.test( src ) ? '?' : '&' ) + param );
+				}
+			});
+		};
+
+		// YouTube frames must include "?enablejsapi=1"
+		_appendParamToIframeSource( 'src', 'youtube.com/embed/', 'enablejsapi=1' );
+		_appendParamToIframeSource( 'data-src', 'youtube.com/embed/', 'enablejsapi=1' );
+
+		// Vimeo frames must include "?api=1"
+		_appendParamToIframeSource( 'src', 'player.vimeo.com/', 'api=1' );
+		_appendParamToIframeSource( 'data-src', 'player.vimeo.com/', 'api=1' );
+
+	}
+
+	/**
+	 * Start playback of any embedded content inside of
+	 * the given element.
+	 *
+	 * @param {HTMLElement} element
+	 */
+	startEmbeddedContent( element ) {
+
+		if( element && !this.Reveal.isSpeakerNotes() ) {
+
+			// Restart GIFs
+			toArray( element.querySelectorAll( 'img[src$=".gif"]' ) ).forEach( el => {
+				// Setting the same unchanged source like this was confirmed
+				// to work in Chrome, FF & Safari
+				el.setAttribute( 'src', el.getAttribute( 'src' ) );
+			} );
+
+			// HTML5 media elements
+			toArray( element.querySelectorAll( 'video, audio' ) ).forEach( el => {
+				if( closestParent( el, '.fragment' ) && !closestParent( el, '.fragment.visible' ) ) {
+					return;
+				}
+
+				// Prefer an explicit global autoplay setting
+				let autoplay = this.Reveal.getConfig().autoPlayMedia;
+
+				// If no global setting is available, fall back on the element's
+				// own autoplay setting
+				if( typeof autoplay !== 'boolean' ) {
+					autoplay = el.hasAttribute( 'data-autoplay' ) || !!closestParent( el, '.slide-background' );
+				}
+
+				if( autoplay && typeof el.play === 'function' ) {
+
+					// If the media is ready, start playback
+					if( el.readyState > 1 ) {
+						this.startEmbeddedMedia( { target: el } );
+					}
+					// Mobile devices never fire a loaded event so instead
+					// of waiting, we initiate playback
+					else if( isMobile ) {
+						let promise = el.play();
+
+						// If autoplay does not work, ensure that the controls are visible so
+						// that the viewer can start the media on their own
+						if( promise && typeof promise.catch === 'function' && el.controls === false ) {
+							promise.catch( () => {
+								el.controls = true;
+
+								// Once the video does start playing, hide the controls again
+								el.addEventListener( 'play', () => {
+									el.controls = false;
+								} );
+							} );
+						}
+					}
+					// If the media isn't loaded, wait before playing
+					else {
+						el.removeEventListener( 'loadeddata', this.startEmbeddedMedia ); // remove first to avoid dupes
+						el.addEventListener( 'loadeddata', this.startEmbeddedMedia );
+					}
+
+				}
+			} );
+
+			// Normal iframes
+			toArray( element.querySelectorAll( 'iframe[src]' ) ).forEach( el => {
+				if( closestParent( el, '.fragment' ) && !closestParent( el, '.fragment.visible' ) ) {
+					return;
+				}
+
+				this.startEmbeddedIframe( { target: el } );
+			} );
+
+			// Lazy loading iframes
+			toArray( element.querySelectorAll( 'iframe[data-src]' ) ).forEach( el => {
+				if( closestParent( el, '.fragment' ) && !closestParent( el, '.fragment.visible' ) ) {
+					return;
+				}
+
+				if( el.getAttribute( 'src' ) !== el.getAttribute( 'data-src' ) ) {
+					el.removeEventListener( 'load', this.startEmbeddedIframe ); // remove first to avoid dupes
+					el.addEventListener( 'load', this.startEmbeddedIframe );
+					el.setAttribute( 'src', el.getAttribute( 'data-src' ) );
+				}
+			} );
+
+		}
+
+	}
+
+	/**
+	 * Starts playing an embedded video/audio element after
+	 * it has finished loading.
+	 *
+	 * @param {object} event
+	 */
+	startEmbeddedMedia( event ) {
+
+		let isAttachedToDOM = !!closestParent( event.target, 'html' ),
+			isVisible  		= !!closestParent( event.target, '.present' );
+
+		if( isAttachedToDOM && isVisible ) {
+			event.target.currentTime = 0;
+			event.target.play();
+		}
+
+		event.target.removeEventListener( 'loadeddata', this.startEmbeddedMedia );
+
+	}
+
+	/**
+	 * "Starts" the content of an embedded iframe using the
+	 * postMessage API.
+	 *
+	 * @param {object} event
+	 */
+	startEmbeddedIframe( event ) {
+
+		let iframe = event.target;
+
+		if( iframe && iframe.contentWindow ) {
+
+			let isAttachedToDOM = !!closestParent( event.target, 'html' ),
+				isVisible  		= !!closestParent( event.target, '.present' );
+
+			if( isAttachedToDOM && isVisible ) {
+
+				// Prefer an explicit global autoplay setting
+				let autoplay = this.Reveal.getConfig().autoPlayMedia;
+
+				// If no global setting is available, fall back on the element's
+				// own autoplay setting
+				if( typeof autoplay !== 'boolean' ) {
+					autoplay = iframe.hasAttribute( 'data-autoplay' ) || !!closestParent( iframe, '.slide-background' );
+				}
+
+				// YouTube postMessage API
+				if( /youtube\.com\/embed\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
+					iframe.contentWindow.postMessage( '{"event":"command","func":"playVideo","args":""}', '*' );
+				}
+				// Vimeo postMessage API
+				else if( /player\.vimeo\.com\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
+					iframe.contentWindow.postMessage( '{"method":"play"}', '*' );
+				}
+				// Generic postMessage API
+				else {
+					iframe.contentWindow.postMessage( 'slide:start', '*' );
+				}
+
+			}
+
+		}
+
+	}
+
+	/**
+	 * Stop playback of any embedded content inside of
+	 * the targeted slide.
+	 *
+	 * @param {HTMLElement} element
+	 */
+	stopEmbeddedContent( element, options = {} ) {
+
+		options = extend( {
+			// Defaults
+			unloadIframes: true
+		}, options );
+
+		if( element && element.parentNode ) {
+			// HTML5 media elements
+			toArray( element.querySelectorAll( 'video, audio' ) ).forEach( el => {
+				if( !el.hasAttribute( 'data-ignore' ) && typeof el.pause === 'function' ) {
+					el.setAttribute('data-paused-by-reveal', '');
+					el.pause();
+				}
+			} );
+
+			// Generic postMessage API for non-lazy loaded iframes
+			toArray( element.querySelectorAll( 'iframe' ) ).forEach( el => {
+				if( el.contentWindow ) el.contentWindow.postMessage( 'slide:stop', '*' );
+				el.removeEventListener( 'load', this.startEmbeddedIframe );
+			});
+
+			// YouTube postMessage API
+			toArray( element.querySelectorAll( 'iframe[src*="youtube.com/embed/"]' ) ).forEach( el => {
+				if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
+					el.contentWindow.postMessage( '{"event":"command","func":"pauseVideo","args":""}', '*' );
+				}
+			});
+
+			// Vimeo postMessage API
+			toArray( element.querySelectorAll( 'iframe[src*="player.vimeo.com/"]' ) ).forEach( el => {
+				if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
+					el.contentWindow.postMessage( '{"method":"pause"}', '*' );
+				}
+			});
+
+			if( options.unloadIframes === true ) {
+				// Unload lazy-loaded iframes
+				toArray( element.querySelectorAll( 'iframe[data-src]' ) ).forEach( el => {
+					// Only removing the src doesn't actually unload the frame
+					// in all browsers (Firefox) so we set it to blank first
+					el.setAttribute( 'src', 'about:blank' );
+					el.removeAttribute( 'src' );
+				} );
+			}
+		}
+
+	}
+
+}

File diff suppressed because it is too large
+ 193 - 1361
js/reveal.js


+ 15 - 0
js/utils/device.js

@@ -0,0 +1,15 @@
+const UA = navigator.userAgent;
+const testElement = document.createElement( 'div' );
+
+export const isMobile = /(iphone|ipod|ipad|android)/gi.test( UA ) ||
+						( navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 ); // iPadOS
+
+export const isChrome = /chrome/i.test( UA ) && !/edge/i.test( UA );
+
+export const isAndroid = /android/gi.test( UA );
+
+// Flags if we should use zoom instead of transform to scale
+// up slides. Zoom produces crisper results but has a lot of
+// xbrowser quirks so we only use it in whitelsited browsers.
+export const supportsZoom = 'zoom' in testElement.style && !isMobile &&
+				( isChrome || /Version\/[\d\.]+.*Safari/.test( UA ) );

+ 13 - 7
js/utils/util.js

@@ -140,16 +140,22 @@ export const enterFullscreen = () => {
  *
  *
  * @param {string} value
  * @param {string} value
  */
  */
-export const injectStyleSheet = ( value ) => {
+export const createStyleSheet = ( value ) => {
 
 
 	let tag = document.createElement( 'style' );
 	let tag = document.createElement( 'style' );
 	tag.type = 'text/css';
 	tag.type = 'text/css';
-	if( tag.styleSheet ) {
-		tag.styleSheet.cssText = value;
-	}
-	else {
-		tag.appendChild( document.createTextNode( value ) );
+
+	if( value && value.length > 0 ) {
+		if( tag.styleSheet ) {
+			tag.styleSheet.cssText = value;
+		}
+		else {
+			tag.appendChild( document.createTextNode( value ) );
+		}
 	}
 	}
-	document.getElementsByTagName( 'head' )[0].appendChild( tag );
+
+	document.head.appendChild( tag );
+
+	return tag;
 
 
 }
 }

+ 0 - 11
plugin/markdown/example.html

@@ -18,17 +18,6 @@
 
 
 			<div class="slides">
 			<div class="slides">
 
 
-                <section data-markdown>
-                    ```php
-                    public function foo()
-                    {
-                        $foo = array(
-                            'bar' => 'bar'
-                        )
-                    }
-                    ```
-                </section>
-
                 <!-- Use external markdown resource, separate slides by three newlines; vertical slides by two newlines -->
                 <!-- Use external markdown resource, separate slides by three newlines; vertical slides by two newlines -->
                 <section data-markdown="example.md" data-separator="^\n\n\n" data-separator-vertical="^\n\n"></section>
                 <section data-markdown="example.md" data-separator="^\n\n\n" data-separator-vertical="^\n\n"></section>
 
 

+ 613 - 1
test/test.html

@@ -78,7 +78,619 @@
 		</div>
 		</div>
 
 
 		<script src="../dist/reveal.min.js"></script>
 		<script src="../dist/reveal.min.js"></script>
-		<script src="test.js"></script>
+		<script>
+			// These tests expect the DOM to contain a presentation
+			// with the following slide structure:
+			//
+			// 1
+			// 2 - Three sub-slides
+			// 3 - Three fragment elements
+			// 3 - Two fragments with same data-fragment-index
+			// 4
+			Reveal.initialize().then( function() {
+
+				// ---------------------------------------------------------------
+				// DOM TESTS
+
+				QUnit.module( 'DOM' );
+
+				QUnit.test( 'Initial slides classes', function( assert ) {
+					var horizontalSlides = document.querySelectorAll( '.reveal .slides>section' )
+
+					assert.strictEqual( document.querySelectorAll( '.reveal .slides section.past' ).length, 0, 'no .past slides' );
+					assert.strictEqual( document.querySelectorAll( '.reveal .slides section.present' ).length, 1, 'one .present slide' );
+					assert.strictEqual( document.querySelectorAll( '.reveal .slides>section.future' ).length, horizontalSlides.length - 1, 'remaining horizontal slides are .future' );
+
+					assert.strictEqual( document.querySelectorAll( '.reveal .slides section.stack' ).length, 2, 'two .stacks' );
+
+					assert.ok( document.querySelectorAll( '.reveal .slides section.stack' )[0].querySelectorAll( '.future' ).length > 0, 'vertical slides are given .future' );
+				});
+
+				// ---------------------------------------------------------------
+				// API TESTS
+
+				QUnit.module( 'API' );
+
+				QUnit.test( 'Reveal.isReady', function( assert ) {
+					assert.strictEqual( Reveal.isReady(), true, 'returns true' );
+				});
+
+				QUnit.test( 'Reveal.isOverview', function( assert ) {
+					assert.strictEqual( Reveal.isOverview(), false, 'false by default' );
+
+					Reveal.toggleOverview();
+					assert.strictEqual( Reveal.isOverview(), true, 'true after toggling on' );
+
+					Reveal.toggleOverview();
+					assert.strictEqual( Reveal.isOverview(), false, 'false after toggling off' );
+				});
+
+				QUnit.test( 'Reveal.isPaused', function( assert ) {
+					assert.strictEqual( Reveal.isPaused(), false, 'false by default' );
+
+					Reveal.togglePause();
+					assert.strictEqual( Reveal.isPaused(), true, 'true after pausing' );
+
+					Reveal.togglePause();
+					assert.strictEqual( Reveal.isPaused(), false, 'false after resuming' );
+				});
+
+				QUnit.test( 'Reveal.isFirstSlide', function( assert ) {
+					Reveal.slide( 0, 0 );
+					assert.strictEqual( Reveal.isFirstSlide(), true, 'true after Reveal.slide( 0, 0 )' );
+
+					Reveal.slide( 1, 0 );
+					assert.strictEqual( Reveal.isFirstSlide(), false, 'false after Reveal.slide( 1, 0 )' );
+
+					Reveal.slide( 0, 0 );
+					assert.strictEqual( Reveal.isFirstSlide(), true, 'true after Reveal.slide( 0, 0 )' );
+				});
+
+				QUnit.test( 'Reveal.isFirstSlide after vertical slide', function( assert ) {
+					Reveal.slide( 1, 1 );
+					Reveal.slide( 0, 0 );
+					assert.strictEqual( Reveal.isFirstSlide(), true, 'true after Reveal.slide( 1, 1 ) and then Reveal.slide( 0, 0 )' );
+				});
+
+				QUnit.test( 'Reveal.isLastSlide', function( assert ) {
+					Reveal.slide( 0, 0 );
+					assert.strictEqual( Reveal.isLastSlide(), false, 'false after Reveal.slide( 0, 0 )' );
+
+					var lastSlideIndex = document.querySelectorAll( '.reveal .slides>section' ).length - 1;
+
+					Reveal.slide( lastSlideIndex, 0 );
+					assert.strictEqual( Reveal.isLastSlide(), true, 'true after Reveal.slide( '+ lastSlideIndex +', 0 )' );
+
+					Reveal.slide( 0, 0 );
+					assert.strictEqual( Reveal.isLastSlide(), false, 'false after Reveal.slide( 0, 0 )' );
+				});
+
+				QUnit.test( 'Reveal.isLastSlide after vertical slide', function( assert ) {
+					var lastSlideIndex = document.querySelectorAll( '.reveal .slides>section' ).length - 1;
+
+					Reveal.slide( 1, 1 );
+					Reveal.slide( lastSlideIndex );
+					assert.strictEqual( Reveal.isLastSlide(), true, 'true after Reveal.slide( 1, 1 ) and then Reveal.slide( '+ lastSlideIndex +', 0 )' );
+				});
+
+				QUnit.test( 'Reveal.getTotalSlides', function( assert ) {
+					assert.strictEqual( Reveal.getTotalSlides(), 8, 'eight slides in total' );
+				});
+
+				QUnit.test( 'Reveal.getIndices', function( assert ) {
+					var indices = Reveal.getIndices();
+
+					assert.ok( indices.hasOwnProperty( 'h' ), 'h exists' );
+					assert.ok( indices.hasOwnProperty( 'v' ), 'v exists' );
+					assert.ok( indices.hasOwnProperty( 'f' ), 'f exists' );
+
+					Reveal.slide( 1, 0 );
+					assert.strictEqual( Reveal.getIndices().h, 1, 'h 1' );
+					assert.strictEqual( Reveal.getIndices().v, 0, 'v 0' );
+
+					Reveal.slide( 1, 2 );
+					assert.strictEqual( Reveal.getIndices().h, 1, 'h 1' );
+					assert.strictEqual( Reveal.getIndices().v, 2, 'v 2' );
+
+					Reveal.slide( 0, 0 );
+					assert.strictEqual( Reveal.getIndices().h, 0, 'h 0' );
+					assert.strictEqual( Reveal.getIndices().v, 0, 'v 0' );
+				});
+
+				QUnit.test( 'Reveal.getSlide', function( assert ) {
+					assert.equal( Reveal.getSlide( 0 ), document.querySelector( '.reveal .slides>section:first-child' ), 'gets correct first slide' );
+					assert.equal( Reveal.getSlide( 1 ), document.querySelector( '.reveal .slides>section:nth-child(2)' ), 'no v index returns stack' );
+					assert.equal( Reveal.getSlide( 1, 0 ), document.querySelector( '.reveal .slides>section:nth-child(2)>section:nth-child(1)' ), 'v index 0 returns first vertical child' );
+					assert.equal( Reveal.getSlide( 1, 1 ), document.querySelector( '.reveal .slides>section:nth-child(2)>section:nth-child(2)' ), 'v index 1 returns second vertical child' );
+
+					assert.strictEqual( Reveal.getSlide( 100 ), undefined, 'undefined when out of horizontal bounds' );
+					assert.strictEqual( Reveal.getSlide( 1, 100 ), undefined, 'undefined when out of vertical bounds' );
+				});
+
+				QUnit.test( 'Reveal.getSlideBackground', function( assert ) {
+					assert.equal( Reveal.getSlideBackground( 0 ), document.querySelector( '.reveal .backgrounds>.slide-background:first-child' ), 'gets correct first background' );
+					assert.equal( Reveal.getSlideBackground( 1 ), document.querySelector( '.reveal .backgrounds>.slide-background:nth-child(2)' ), 'no v index returns stack' );
+					assert.equal( Reveal.getSlideBackground( 1, 0 ), document.querySelector( '.reveal .backgrounds>.slide-background:nth-child(2) .slide-background:nth-child(2)' ), 'v index 0 returns first vertical child' );
+					assert.equal( Reveal.getSlideBackground( 1, 1 ), document.querySelector( '.reveal .backgrounds>.slide-background:nth-child(2) .slide-background:nth-child(3)' ), 'v index 1 returns second vertical child' );
+
+					assert.strictEqual( Reveal.getSlideBackground( 100 ), undefined, 'undefined when out of horizontal bounds' );
+					assert.strictEqual( Reveal.getSlideBackground( 1, 100 ), undefined, 'undefined when out of vertical bounds' );
+				});
+
+				QUnit.test( 'Reveal.getSlideNotes', function( assert ) {
+					Reveal.slide( 0, 0 );
+					assert.ok( Reveal.getSlideNotes() === 'speaker notes 1', 'works with <aside class="notes">' );
+
+					Reveal.slide( 1, 0 );
+					assert.ok( Reveal.getSlideNotes() === 'speaker notes 2', 'works with <section data-notes="">' );
+				});
+
+				QUnit.test( 'Reveal.getPreviousSlide/getCurrentSlide', function( assert ) {
+					Reveal.slide( 0, 0 );
+					Reveal.slide( 1, 0 );
+
+					var firstSlide = document.querySelector( '.reveal .slides>section:first-child' );
+					var secondSlide = document.querySelector( '.reveal .slides>section:nth-child(2)>section' );
+
+					assert.equal( Reveal.getPreviousSlide(), firstSlide, 'previous is slide #0' );
+					assert.equal( Reveal.getCurrentSlide(), secondSlide, 'current is slide #1' );
+				});
+
+				QUnit.test( 'Reveal.getProgress', function( assert ) {
+					Reveal.slide( 0, 0 );
+					assert.strictEqual( Reveal.getProgress(), 0, 'progress is 0 on first slide' );
+
+					var lastSlideIndex = document.querySelectorAll( '.reveal .slides>section' ).length - 1;
+
+					Reveal.slide( lastSlideIndex, 0 );
+					assert.strictEqual( Reveal.getProgress(), 1, 'progress is 1 on last slide' );
+				});
+
+				QUnit.test( 'Reveal.getScale', function( assert ) {
+					assert.ok( typeof Reveal.getScale() === 'number', 'has scale' );
+				});
+
+				QUnit.test( 'Reveal.getConfig', function( assert ) {
+					assert.ok( typeof Reveal.getConfig() === 'object', 'has config' );
+				});
+
+				QUnit.test( 'Reveal.configure', function( assert ) {
+					assert.strictEqual( Reveal.getConfig().loop, false, '"loop" is false to start with' );
+
+					Reveal.configure({ loop: true });
+					assert.strictEqual( Reveal.getConfig().loop, true, '"loop" has changed to true' );
+
+					Reveal.configure({ loop: false, customTestValue: 1 });
+					assert.strictEqual( Reveal.getConfig().customTestValue, 1, 'supports custom values' );
+				});
+
+				QUnit.test( 'Reveal.availableRoutes', function( assert ) {
+					Reveal.slide( 0, 0 );
+					assert.deepEqual( Reveal.availableRoutes(), { left: false, up: false, down: false, right: true }, 'correct for first slide' );
+
+					Reveal.slide( 1, 0 );
+					assert.deepEqual( Reveal.availableRoutes(), { left: true, up: false, down: true, right: true }, 'correct for vertical slide' );
+				});
+
+				QUnit.test( 'Reveal.next', function( assert ) {
+					Reveal.slide( 0, 0 );
+
+					// Step through vertical child slides
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 1, v: 0, f: undefined } );
+
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 1, v: 1, f: undefined } );
+
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 1, v: 2, f: undefined } );
+
+					// Step through fragments
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: -1 } );
+
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 0 } );
+
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 1 } );
+
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 2 } );
+				});
+
+				QUnit.test( 'Reveal.next at end', function( assert ) {
+					Reveal.slide( 3 );
+
+					// We're at the end, this should have no effect
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 3, v: 0, f: undefined } );
+
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 3, v: 0, f: undefined } );
+				});
+
+
+				// ---------------------------------------------------------------
+				// FRAGMENT TESTS
+
+				QUnit.module( 'Fragments' );
+
+				QUnit.test( 'Sliding to fragments', function( assert ) {
+					Reveal.slide( 2, 0, -1 );
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: -1 }, 'Reveal.slide( 2, 0, -1 )' );
+
+					Reveal.slide( 2, 0, 0 );
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 0 }, 'Reveal.slide( 2, 0, 0 )' );
+
+					Reveal.slide( 2, 0, 2 );
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 2 }, 'Reveal.slide( 2, 0, 2 )' );
+
+					Reveal.slide( 2, 0, 1 );
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 1 }, 'Reveal.slide( 2, 0, 1 )' );
+				});
+
+				QUnit.test( 'data-fragment is set on slide <section>', function( assert ) {
+					Reveal.slide( 2, 0, -1 );
+					assert.deepEqual( Reveal.getCurrentSlide().getAttribute( 'data-fragment' ), '-1' );
+
+					Reveal.slide( 2, 0, 2 );
+					assert.deepEqual( Reveal.getCurrentSlide().getAttribute( 'data-fragment' ), '2' );
+
+					Reveal.slide( 2, 0, 0 );
+					assert.deepEqual( Reveal.getCurrentSlide().getAttribute( 'data-fragment' ), '0' );
+
+					var fragmentSlide = Reveal.getCurrentSlide();
+
+					Reveal.slide( 3, 0 );
+					assert.deepEqual( fragmentSlide.getAttribute( 'data-fragment' ), '0', 'data-fragment persists when jumping to another slide' );
+				});
+
+				QUnit.test( 'Hiding all fragments', function( assert ) {
+					var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(1)' );
+
+					Reveal.slide( 2, 0, 0 );
+					assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 1, 'one fragment visible when index is 0' );
+
+					Reveal.slide( 2, 0, -1 );
+					assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 0, 'no fragments visible when index is -1' );
+				});
+
+				QUnit.test( 'Current fragment', function( assert ) {
+					var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(1)' );
+					var lastFragmentIndex = [].slice.call( fragmentSlide.querySelectorAll( '.fragment' ) ).pop().getAttribute( 'data-fragment-index' );
+
+					Reveal.slide( 2, 0 );
+					assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.current-fragment' ).length, 0, 'no current fragment at index -1' );
+
+					Reveal.slide( 2, 0, 0 );
+					assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.current-fragment' ).length, 1, 'one current fragment at index 0' );
+
+					Reveal.slide( 1, 0, 0 );
+					assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.current-fragment' ).length, 0, 'no current fragment when navigating to previous slide' );
+
+					Reveal.slide( 3, 0, 0 );
+					assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.current-fragment' ).length, 0, 'no current fragment when navigating to next slide' );
+
+					Reveal.slide( 2, 1, -1 );
+					Reveal.prev();
+					assert.strictEqual( fragmentSlide.querySelector( '.fragment.current-fragment' ).getAttribute( 'data-fragment-index' ), lastFragmentIndex, 'last fragment is current fragment when returning from future slide' );
+				});
+
+				QUnit.test( 'Stepping through fragments', function( assert ) {
+					Reveal.slide( 2, 0, -1 );
+
+					// forwards:
+
+					Reveal.next();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 0 }, 'next() goes to next fragment' );
+
+					Reveal.right();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 1 }, 'right() goes to next fragment' );
+
+					Reveal.down();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 2 }, 'down() goes to next fragment' );
+
+					Reveal.down(); // moves to f #3
+
+					// backwards:
+
+					Reveal.prev();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 2 }, 'prev() goes to prev fragment' );
+
+					Reveal.left();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 1 }, 'left() goes to prev fragment' );
+
+					Reveal.up();
+					assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 0 }, 'up() goes to prev fragment' );
+				});
+
+				QUnit.test( 'Stepping past fragments', function( assert ) {
+					var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(1)' );
+
+					Reveal.slide( 0, 0, 0 );
+					assert.equal( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 0, 'no fragments visible when on previous slide' );
+
+					Reveal.slide( 3, 0, 0 );
+					assert.equal( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 3, 'all fragments visible when on future slide' );
+				});
+
+				QUnit.test( 'Fragment indices', function( assert ) {
+					var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(2)' );
+
+					Reveal.slide( 3, 0, 0 );
+					assert.equal( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 2, 'both fragments of same index are shown' );
+
+					// This slide has three fragments, first one is index 0, second and third have index 1
+					Reveal.slide( 2, 2, 0 );
+					assert.equal( Reveal.getIndices().f, 0, 'returns correct index for first fragment' );
+
+					Reveal.slide( 2, 2, 1 );
+					assert.equal( Reveal.getIndices().f, 1, 'returns correct index for two fragments with same index' );
+				});
+
+				QUnit.test( 'Index generation', function( assert ) {
+					var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(1)' );
+
+					// These have no indices defined to start with
+					assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[0].getAttribute( 'data-fragment-index' ), '0' );
+					assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[1].getAttribute( 'data-fragment-index' ), '1' );
+					assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[2].getAttribute( 'data-fragment-index' ), '2' );
+				});
+
+				QUnit.test( 'Index normalization', function( assert ) {
+					var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(3)' );
+
+					// These start out as 1-4-4 and should normalize to 0-1-1
+					assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[0].getAttribute( 'data-fragment-index' ), '0' );
+					assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[1].getAttribute( 'data-fragment-index' ), '1' );
+					assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[2].getAttribute( 'data-fragment-index' ), '1' );
+				});
+
+				QUnit.test( 'fragmentshown event', function( assert ) {
+					assert.expect( 2 );
+					var done = assert.async( 2 );
+
+					var _onEvent = function( event ) {
+						assert.ok( true, 'event fired' );
+						done();
+					}
+
+					Reveal.addEventListener( 'fragmentshown', _onEvent );
+
+					Reveal.slide( 2, 0 );
+					Reveal.slide( 2, 0 ); // should do nothing
+					Reveal.slide( 2, 0, 0 ); // should do nothing
+					Reveal.next();
+					Reveal.next();
+					Reveal.prev(); // shouldn't fire fragmentshown
+
+					Reveal.removeEventListener( 'fragmentshown', _onEvent );
+				});
+
+				QUnit.test( 'fragmenthidden event', function( assert ) {
+					assert.expect( 2 );
+					var done = assert.async( 2 );
+
+					var _onEvent = function( event ) {
+						assert.ok( true, 'event fired' );
+						done();
+					}
+
+					Reveal.addEventListener( 'fragmenthidden', _onEvent );
+
+					Reveal.slide( 2, 0, 2 );
+					Reveal.slide( 2, 0, 2 ); // should do nothing
+					Reveal.prev();
+					Reveal.prev();
+					Reveal.next(); // shouldn't fire fragmenthidden
+
+					Reveal.removeEventListener( 'fragmenthidden', _onEvent );
+				});
+
+
+				// ---------------------------------------------------------------
+				// AUTO-SLIDE TESTS
+
+				QUnit.module( 'Auto Sliding' );
+
+				QUnit.test( 'Reveal.isAutoSliding', function( assert ) {
+					assert.strictEqual( Reveal.isAutoSliding(), false, 'false by default' );
+
+					Reveal.configure({ autoSlide: 10000 });
+					assert.strictEqual( Reveal.isAutoSliding(), true, 'true after starting' );
+
+					Reveal.configure({ autoSlide: 0 });
+					assert.strictEqual( Reveal.isAutoSliding(), false, 'false after setting to 0' );
+				});
+
+				QUnit.test( 'Reveal.toggleAutoSlide', function( assert ) {
+					Reveal.configure({ autoSlide: 10000 });
+
+					Reveal.toggleAutoSlide();
+					assert.strictEqual( Reveal.isAutoSliding(), false, 'false after first toggle' );
+					Reveal.toggleAutoSlide();
+					assert.strictEqual( Reveal.isAutoSliding(), true, 'true after second toggle' );
+
+					Reveal.configure({ autoSlide: 0 });
+				});
+
+				QUnit.test( 'autoslidepaused', function( assert ) {
+					assert.expect( 1 );
+					var done = assert.async();
+
+					var _onEvent = function( event ) {
+						assert.ok( true, 'event fired' );
+						done();
+					}
+
+					Reveal.addEventListener( 'autoslidepaused', _onEvent );
+					Reveal.configure({ autoSlide: 10000 });
+					Reveal.toggleAutoSlide();
+
+					// cleanup
+					Reveal.configure({ autoSlide: 0 });
+					Reveal.removeEventListener( 'autoslidepaused', _onEvent );
+				});
+
+				QUnit.test( 'autoslideresumed', function( assert ) {
+					assert.expect( 1 );
+					var done = assert.async();
+
+					var _onEvent = function( event ) {
+						assert.ok( true, 'event fired' );
+						done();
+					}
+
+					Reveal.addEventListener( 'autoslideresumed', _onEvent );
+					Reveal.configure({ autoSlide: 10000 });
+					Reveal.toggleAutoSlide();
+					Reveal.toggleAutoSlide();
+
+					// cleanup
+					Reveal.configure({ autoSlide: 0 });
+					Reveal.removeEventListener( 'autoslideresumed', _onEvent );
+				});
+
+
+				// ---------------------------------------------------------------
+				// CONFIGURATION VALUES
+
+				QUnit.module( 'Configuration' );
+
+				QUnit.test( 'Controls', function( assert ) {
+					var controlsElement = document.querySelector( '.reveal>.controls' );
+
+					Reveal.configure({ controls: false });
+					assert.equal( controlsElement.style.display, 'none', 'controls are hidden' );
+
+					Reveal.configure({ controls: true });
+					assert.equal( controlsElement.style.display, 'block', 'controls are visible' );
+				});
+
+				QUnit.test( 'Progress', function( assert ) {
+					var progressElement = document.querySelector( '.reveal>.progress' );
+
+					Reveal.configure({ progress: false });
+					assert.equal( progressElement.style.display, 'none', 'progress are hidden' );
+
+					Reveal.configure({ progress: true });
+					assert.equal( progressElement.style.display, 'block', 'progress are visible' );
+				});
+
+				QUnit.test( 'Loop', function( assert ) {
+					Reveal.configure({ loop: true });
+
+					Reveal.slide( 0, 0 );
+
+					Reveal.left();
+					assert.notEqual( Reveal.getIndices().h, 0, 'looped from start to end' );
+
+					Reveal.right();
+					assert.equal( Reveal.getIndices().h, 0, 'looped from end to start' );
+
+					Reveal.configure({ loop: false });
+				});
+
+
+				// ---------------------------------------------------------------
+				// LAZY-LOADING TESTS
+
+				QUnit.module( 'Lazy-Loading' );
+
+				QUnit.test( 'img with data-src', function( assert ) {
+					assert.strictEqual( document.querySelectorAll( '.reveal section img[src]' ).length, 1, 'Image source has been set' );
+				});
+
+				QUnit.test( 'video with data-src', function( assert ) {
+					assert.strictEqual( document.querySelectorAll( '.reveal section video[src]' ).length, 1, 'Video source has been set' );
+				});
+
+				QUnit.test( 'audio with data-src', function( assert ) {
+					assert.strictEqual( document.querySelectorAll( '.reveal section audio[src]' ).length, 1, 'Audio source has been set' );
+				});
+
+				QUnit.test( 'iframe with data-src', function( assert ) {
+					Reveal.slide( 0, 0 );
+					assert.strictEqual( document.querySelectorAll( '.reveal section iframe[src]' ).length, 0, 'Iframe source is not set' );
+					Reveal.slide( 2, 1 );
+					assert.strictEqual( document.querySelectorAll( '.reveal section iframe[src]' ).length, 1, 'Iframe source is set' );
+					Reveal.slide( 2, 2 );
+					assert.strictEqual( document.querySelectorAll( '.reveal section iframe[src]' ).length, 0, 'Iframe source is not set' );
+				});
+
+				QUnit.test( 'background images', function( assert ) {
+					var imageSource1 = Reveal.getSlide( 0 ).getAttribute( 'data-background-image' );
+					var imageSource2 = Reveal.getSlide( 1, 0 ).getAttribute( 'data-background' );
+
+					// check that the images are applied to the background elements
+					assert.ok( Reveal.getSlideBackground( 0 ).querySelector( '.slide-background-content' ).style.backgroundImage.indexOf( imageSource1 ) !== -1, 'data-background-image worked' );
+					assert.ok( Reveal.getSlideBackground( 1, 0 ).querySelector( '.slide-background-content' ).style.backgroundImage.indexOf( imageSource2 ) !== -1, 'data-background worked' );
+				});
+
+
+				// ---------------------------------------------------------------
+				// EVENT TESTS
+
+				QUnit.module( 'Events' );
+
+				QUnit.test( 'slidechanged', function( assert ) {
+					assert.expect( 3 );
+					var done = assert.async( 3 );
+
+					var _onEvent = function( event ) {
+						assert.ok( true, 'event fired' );
+						done();
+					}
+
+					Reveal.addEventListener( 'slidechanged', _onEvent );
+
+					Reveal.slide( 1, 0 ); // should trigger
+					Reveal.slide( 1, 0 ); // should do nothing
+					Reveal.next(); // should trigger
+					Reveal.slide( 3, 0 ); // should trigger
+					Reveal.next(); // should do nothing
+
+					Reveal.removeEventListener( 'slidechanged', _onEvent );
+
+				});
+
+				QUnit.test( 'paused', function( assert ) {
+					assert.expect( 1 );
+					var done = assert.async();
+
+					var _onEvent = function( event ) {
+						assert.ok( true, 'event fired' );
+						done();
+					}
+
+					Reveal.addEventListener( 'paused', _onEvent );
+
+					Reveal.togglePause();
+					Reveal.togglePause();
+
+					Reveal.removeEventListener( 'paused', _onEvent );
+				});
+
+				QUnit.test( 'resumed', function( assert ) {
+					assert.expect( 1 );
+					var done = assert.async();
+
+					var _onEvent = function( event ) {
+						assert.ok( true, 'event fired' );
+						done();
+					}
+
+					Reveal.addEventListener( 'resumed', _onEvent );
+
+					Reveal.togglePause();
+					Reveal.togglePause();
+
+					Reveal.removeEventListener( 'resumed', _onEvent );
+				});
+
+			} );
+		</script>
 
 
 	</body>
 	</body>
 </html>
 </html>

+ 0 - 612
test/test.js

@@ -1,612 +0,0 @@
-// These tests expect the DOM to contain a presentation
-// with the following slide structure:
-//
-// 1
-// 2 - Three sub-slides
-// 3 - Three fragment elements
-// 3 - Two fragments with same data-fragment-index
-// 4
-
-Reveal.initialize().then( function() {
-
-	// ---------------------------------------------------------------
-	// DOM TESTS
-
-	QUnit.module( 'DOM' );
-
-	QUnit.test( 'Initial slides classes', function( assert ) {
-		var horizontalSlides = document.querySelectorAll( '.reveal .slides>section' )
-
-		assert.strictEqual( document.querySelectorAll( '.reveal .slides section.past' ).length, 0, 'no .past slides' );
-		assert.strictEqual( document.querySelectorAll( '.reveal .slides section.present' ).length, 1, 'one .present slide' );
-		assert.strictEqual( document.querySelectorAll( '.reveal .slides>section.future' ).length, horizontalSlides.length - 1, 'remaining horizontal slides are .future' );
-
-		assert.strictEqual( document.querySelectorAll( '.reveal .slides section.stack' ).length, 2, 'two .stacks' );
-
-		assert.ok( document.querySelectorAll( '.reveal .slides section.stack' )[0].querySelectorAll( '.future' ).length > 0, 'vertical slides are given .future' );
-	});
-
-	// ---------------------------------------------------------------
-	// API TESTS
-
-	QUnit.module( 'API' );
-
-	QUnit.test( 'Reveal.isReady', function( assert ) {
-		assert.strictEqual( Reveal.isReady(), true, 'returns true' );
-	});
-
-	QUnit.test( 'Reveal.isOverview', function( assert ) {
-		assert.strictEqual( Reveal.isOverview(), false, 'false by default' );
-
-		Reveal.toggleOverview();
-		assert.strictEqual( Reveal.isOverview(), true, 'true after toggling on' );
-
-		Reveal.toggleOverview();
-		assert.strictEqual( Reveal.isOverview(), false, 'false after toggling off' );
-	});
-
-	QUnit.test( 'Reveal.isPaused', function( assert ) {
-		assert.strictEqual( Reveal.isPaused(), false, 'false by default' );
-
-		Reveal.togglePause();
-		assert.strictEqual( Reveal.isPaused(), true, 'true after pausing' );
-
-		Reveal.togglePause();
-		assert.strictEqual( Reveal.isPaused(), false, 'false after resuming' );
-	});
-
-	QUnit.test( 'Reveal.isFirstSlide', function( assert ) {
-		Reveal.slide( 0, 0 );
-		assert.strictEqual( Reveal.isFirstSlide(), true, 'true after Reveal.slide( 0, 0 )' );
-
-		Reveal.slide( 1, 0 );
-		assert.strictEqual( Reveal.isFirstSlide(), false, 'false after Reveal.slide( 1, 0 )' );
-
-		Reveal.slide( 0, 0 );
-		assert.strictEqual( Reveal.isFirstSlide(), true, 'true after Reveal.slide( 0, 0 )' );
-	});
-
-	QUnit.test( 'Reveal.isFirstSlide after vertical slide', function( assert ) {
-		Reveal.slide( 1, 1 );
-		Reveal.slide( 0, 0 );
-		assert.strictEqual( Reveal.isFirstSlide(), true, 'true after Reveal.slide( 1, 1 ) and then Reveal.slide( 0, 0 )' );
-	});
-
-	QUnit.test( 'Reveal.isLastSlide', function( assert ) {
-		Reveal.slide( 0, 0 );
-		assert.strictEqual( Reveal.isLastSlide(), false, 'false after Reveal.slide( 0, 0 )' );
-
-		var lastSlideIndex = document.querySelectorAll( '.reveal .slides>section' ).length - 1;
-
-		Reveal.slide( lastSlideIndex, 0 );
-		assert.strictEqual( Reveal.isLastSlide(), true, 'true after Reveal.slide( '+ lastSlideIndex +', 0 )' );
-
-		Reveal.slide( 0, 0 );
-		assert.strictEqual( Reveal.isLastSlide(), false, 'false after Reveal.slide( 0, 0 )' );
-	});
-
-	QUnit.test( 'Reveal.isLastSlide after vertical slide', function( assert ) {
-		var lastSlideIndex = document.querySelectorAll( '.reveal .slides>section' ).length - 1;
-
-		Reveal.slide( 1, 1 );
-		Reveal.slide( lastSlideIndex );
-		assert.strictEqual( Reveal.isLastSlide(), true, 'true after Reveal.slide( 1, 1 ) and then Reveal.slide( '+ lastSlideIndex +', 0 )' );
-	});
-
-	QUnit.test( 'Reveal.getTotalSlides', function( assert ) {
-		assert.strictEqual( Reveal.getTotalSlides(), 8, 'eight slides in total' );
-	});
-
-	QUnit.test( 'Reveal.getIndices', function( assert ) {
-		var indices = Reveal.getIndices();
-
-		assert.ok( indices.hasOwnProperty( 'h' ), 'h exists' );
-		assert.ok( indices.hasOwnProperty( 'v' ), 'v exists' );
-		assert.ok( indices.hasOwnProperty( 'f' ), 'f exists' );
-
-		Reveal.slide( 1, 0 );
-		assert.strictEqual( Reveal.getIndices().h, 1, 'h 1' );
-		assert.strictEqual( Reveal.getIndices().v, 0, 'v 0' );
-
-		Reveal.slide( 1, 2 );
-		assert.strictEqual( Reveal.getIndices().h, 1, 'h 1' );
-		assert.strictEqual( Reveal.getIndices().v, 2, 'v 2' );
-
-		Reveal.slide( 0, 0 );
-		assert.strictEqual( Reveal.getIndices().h, 0, 'h 0' );
-		assert.strictEqual( Reveal.getIndices().v, 0, 'v 0' );
-	});
-
-	QUnit.test( 'Reveal.getSlide', function( assert ) {
-		assert.equal( Reveal.getSlide( 0 ), document.querySelector( '.reveal .slides>section:first-child' ), 'gets correct first slide' );
-		assert.equal( Reveal.getSlide( 1 ), document.querySelector( '.reveal .slides>section:nth-child(2)' ), 'no v index returns stack' );
-		assert.equal( Reveal.getSlide( 1, 0 ), document.querySelector( '.reveal .slides>section:nth-child(2)>section:nth-child(1)' ), 'v index 0 returns first vertical child' );
-		assert.equal( Reveal.getSlide( 1, 1 ), document.querySelector( '.reveal .slides>section:nth-child(2)>section:nth-child(2)' ), 'v index 1 returns second vertical child' );
-
-		assert.strictEqual( Reveal.getSlide( 100 ), undefined, 'undefined when out of horizontal bounds' );
-		assert.strictEqual( Reveal.getSlide( 1, 100 ), undefined, 'undefined when out of vertical bounds' );
-	});
-
-	QUnit.test( 'Reveal.getSlideBackground', function( assert ) {
-		assert.equal( Reveal.getSlideBackground( 0 ), document.querySelector( '.reveal .backgrounds>.slide-background:first-child' ), 'gets correct first background' );
-		assert.equal( Reveal.getSlideBackground( 1 ), document.querySelector( '.reveal .backgrounds>.slide-background:nth-child(2)' ), 'no v index returns stack' );
-		assert.equal( Reveal.getSlideBackground( 1, 0 ), document.querySelector( '.reveal .backgrounds>.slide-background:nth-child(2) .slide-background:nth-child(2)' ), 'v index 0 returns first vertical child' );
-		assert.equal( Reveal.getSlideBackground( 1, 1 ), document.querySelector( '.reveal .backgrounds>.slide-background:nth-child(2) .slide-background:nth-child(3)' ), 'v index 1 returns second vertical child' );
-
-		assert.strictEqual( Reveal.getSlideBackground( 100 ), undefined, 'undefined when out of horizontal bounds' );
-		assert.strictEqual( Reveal.getSlideBackground( 1, 100 ), undefined, 'undefined when out of vertical bounds' );
-	});
-
-	QUnit.test( 'Reveal.getSlideNotes', function( assert ) {
-		Reveal.slide( 0, 0 );
-		assert.ok( Reveal.getSlideNotes() === 'speaker notes 1', 'works with <aside class="notes">' );
-
-		Reveal.slide( 1, 0 );
-		assert.ok( Reveal.getSlideNotes() === 'speaker notes 2', 'works with <section data-notes="">' );
-	});
-
-	QUnit.test( 'Reveal.getPreviousSlide/getCurrentSlide', function( assert ) {
-		Reveal.slide( 0, 0 );
-		Reveal.slide( 1, 0 );
-
-		var firstSlide = document.querySelector( '.reveal .slides>section:first-child' );
-		var secondSlide = document.querySelector( '.reveal .slides>section:nth-child(2)>section' );
-
-		assert.equal( Reveal.getPreviousSlide(), firstSlide, 'previous is slide #0' );
-		assert.equal( Reveal.getCurrentSlide(), secondSlide, 'current is slide #1' );
-	});
-
-	QUnit.test( 'Reveal.getProgress', function( assert ) {
-		Reveal.slide( 0, 0 );
-		assert.strictEqual( Reveal.getProgress(), 0, 'progress is 0 on first slide' );
-
-		var lastSlideIndex = document.querySelectorAll( '.reveal .slides>section' ).length - 1;
-
-		Reveal.slide( lastSlideIndex, 0 );
-		assert.strictEqual( Reveal.getProgress(), 1, 'progress is 1 on last slide' );
-	});
-
-	QUnit.test( 'Reveal.getScale', function( assert ) {
-		assert.ok( typeof Reveal.getScale() === 'number', 'has scale' );
-	});
-
-	QUnit.test( 'Reveal.getConfig', function( assert ) {
-		assert.ok( typeof Reveal.getConfig() === 'object', 'has config' );
-	});
-
-	QUnit.test( 'Reveal.configure', function( assert ) {
-		assert.strictEqual( Reveal.getConfig().loop, false, '"loop" is false to start with' );
-
-		Reveal.configure({ loop: true });
-		assert.strictEqual( Reveal.getConfig().loop, true, '"loop" has changed to true' );
-
-		Reveal.configure({ loop: false, customTestValue: 1 });
-		assert.strictEqual( Reveal.getConfig().customTestValue, 1, 'supports custom values' );
-	});
-
-	QUnit.test( 'Reveal.availableRoutes', function( assert ) {
-		Reveal.slide( 0, 0 );
-		assert.deepEqual( Reveal.availableRoutes(), { left: false, up: false, down: false, right: true }, 'correct for first slide' );
-
-		Reveal.slide( 1, 0 );
-		assert.deepEqual( Reveal.availableRoutes(), { left: true, up: false, down: true, right: true }, 'correct for vertical slide' );
-	});
-
-	QUnit.test( 'Reveal.next', function( assert ) {
-		Reveal.slide( 0, 0 );
-
-		// Step through vertical child slides
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 1, v: 0, f: undefined } );
-
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 1, v: 1, f: undefined } );
-
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 1, v: 2, f: undefined } );
-
-		// Step through fragments
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: -1 } );
-
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 0 } );
-
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 1 } );
-
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 2 } );
-	});
-
-	QUnit.test( 'Reveal.next at end', function( assert ) {
-		Reveal.slide( 3 );
-
-		// We're at the end, this should have no effect
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 3, v: 0, f: undefined } );
-
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 3, v: 0, f: undefined } );
-	});
-
-
-	// ---------------------------------------------------------------
-	// FRAGMENT TESTS
-
-	QUnit.module( 'Fragments' );
-
-	QUnit.test( 'Sliding to fragments', function( assert ) {
-		Reveal.slide( 2, 0, -1 );
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: -1 }, 'Reveal.slide( 2, 0, -1 )' );
-
-		Reveal.slide( 2, 0, 0 );
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 0 }, 'Reveal.slide( 2, 0, 0 )' );
-
-		Reveal.slide( 2, 0, 2 );
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 2 }, 'Reveal.slide( 2, 0, 2 )' );
-
-		Reveal.slide( 2, 0, 1 );
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 1 }, 'Reveal.slide( 2, 0, 1 )' );
-	});
-
-	QUnit.test( 'data-fragment is set on slide <section>', function( assert ) {
-		Reveal.slide( 2, 0, -1 );
-		assert.deepEqual( Reveal.getCurrentSlide().getAttribute( 'data-fragment' ), '-1' );
-
-		Reveal.slide( 2, 0, 2 );
-		assert.deepEqual( Reveal.getCurrentSlide().getAttribute( 'data-fragment' ), '2' );
-
-		Reveal.slide( 2, 0, 0 );
-		assert.deepEqual( Reveal.getCurrentSlide().getAttribute( 'data-fragment' ), '0' );
-
-		var fragmentSlide = Reveal.getCurrentSlide();
-
-		Reveal.slide( 3, 0 );
-		assert.deepEqual( fragmentSlide.getAttribute( 'data-fragment' ), '0', 'data-fragment persists when jumping to another slide' );
-	});
-
-	QUnit.test( 'Hiding all fragments', function( assert ) {
-		var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(1)' );
-
-		Reveal.slide( 2, 0, 0 );
-		assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 1, 'one fragment visible when index is 0' );
-
-		Reveal.slide( 2, 0, -1 );
-		assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 0, 'no fragments visible when index is -1' );
-	});
-
-	QUnit.test( 'Current fragment', function( assert ) {
-		var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(1)' );
-		var lastFragmentIndex = [].slice.call( fragmentSlide.querySelectorAll( '.fragment' ) ).pop().getAttribute( 'data-fragment-index' );
-
-		Reveal.slide( 2, 0 );
-		assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.current-fragment' ).length, 0, 'no current fragment at index -1' );
-
-		Reveal.slide( 2, 0, 0 );
-		assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.current-fragment' ).length, 1, 'one current fragment at index 0' );
-
-		Reveal.slide( 1, 0, 0 );
-		assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.current-fragment' ).length, 0, 'no current fragment when navigating to previous slide' );
-
-		Reveal.slide( 3, 0, 0 );
-		assert.strictEqual( fragmentSlide.querySelectorAll( '.fragment.current-fragment' ).length, 0, 'no current fragment when navigating to next slide' );
-
-		Reveal.slide( 2, 1, -1 );
-		Reveal.prev();
-		assert.strictEqual( fragmentSlide.querySelector( '.fragment.current-fragment' ).getAttribute( 'data-fragment-index' ), lastFragmentIndex, 'last fragment is current fragment when returning from future slide' );
-	});
-
-	QUnit.test( 'Stepping through fragments', function( assert ) {
-		Reveal.slide( 2, 0, -1 );
-
-		// forwards:
-
-		Reveal.next();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 0 }, 'next() goes to next fragment' );
-
-		Reveal.right();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 1 }, 'right() goes to next fragment' );
-
-		Reveal.down();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 2 }, 'down() goes to next fragment' );
-
-		Reveal.down(); // moves to f #3
-
-		// backwards:
-
-		Reveal.prev();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 2 }, 'prev() goes to prev fragment' );
-
-		Reveal.left();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 1 }, 'left() goes to prev fragment' );
-
-		Reveal.up();
-		assert.deepEqual( Reveal.getIndices(), { h: 2, v: 0, f: 0 }, 'up() goes to prev fragment' );
-	});
-
-	QUnit.test( 'Stepping past fragments', function( assert ) {
-		var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(1)' );
-
-		Reveal.slide( 0, 0, 0 );
-		assert.equal( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 0, 'no fragments visible when on previous slide' );
-
-		Reveal.slide( 3, 0, 0 );
-		assert.equal( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 3, 'all fragments visible when on future slide' );
-	});
-
-	QUnit.test( 'Fragment indices', function( assert ) {
-		var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(2)' );
-
-		Reveal.slide( 3, 0, 0 );
-		assert.equal( fragmentSlide.querySelectorAll( '.fragment.visible' ).length, 2, 'both fragments of same index are shown' );
-
-		// This slide has three fragments, first one is index 0, second and third have index 1
-		Reveal.slide( 2, 2, 0 );
-		assert.equal( Reveal.getIndices().f, 0, 'returns correct index for first fragment' );
-
-		Reveal.slide( 2, 2, 1 );
-		assert.equal( Reveal.getIndices().f, 1, 'returns correct index for two fragments with same index' );
-	});
-
-	QUnit.test( 'Index generation', function( assert ) {
-		var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(1)' );
-
-		// These have no indices defined to start with
-		assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[0].getAttribute( 'data-fragment-index' ), '0' );
-		assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[1].getAttribute( 'data-fragment-index' ), '1' );
-		assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[2].getAttribute( 'data-fragment-index' ), '2' );
-	});
-
-	QUnit.test( 'Index normalization', function( assert ) {
-		var fragmentSlide = document.querySelector( '#fragment-slides>section:nth-child(3)' );
-
-		// These start out as 1-4-4 and should normalize to 0-1-1
-		assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[0].getAttribute( 'data-fragment-index' ), '0' );
-		assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[1].getAttribute( 'data-fragment-index' ), '1' );
-		assert.equal( fragmentSlide.querySelectorAll( '.fragment' )[2].getAttribute( 'data-fragment-index' ), '1' );
-	});
-
-	QUnit.test( 'fragmentshown event', function( assert ) {
-		assert.expect( 2 );
-		var done = assert.async( 2 );
-
-		var _onEvent = function( event ) {
-			assert.ok( true, 'event fired' );
-			done();
-		}
-
-		Reveal.addEventListener( 'fragmentshown', _onEvent );
-
-		Reveal.slide( 2, 0 );
-		Reveal.slide( 2, 0 ); // should do nothing
-		Reveal.slide( 2, 0, 0 ); // should do nothing
-		Reveal.next();
-		Reveal.next();
-		Reveal.prev(); // shouldn't fire fragmentshown
-
-		Reveal.removeEventListener( 'fragmentshown', _onEvent );
-	});
-
-	QUnit.test( 'fragmenthidden event', function( assert ) {
-		assert.expect( 2 );
-		var done = assert.async( 2 );
-
-		var _onEvent = function( event ) {
-			assert.ok( true, 'event fired' );
-			done();
-		}
-
-		Reveal.addEventListener( 'fragmenthidden', _onEvent );
-
-		Reveal.slide( 2, 0, 2 );
-		Reveal.slide( 2, 0, 2 ); // should do nothing
-		Reveal.prev();
-		Reveal.prev();
-		Reveal.next(); // shouldn't fire fragmenthidden
-
-		Reveal.removeEventListener( 'fragmenthidden', _onEvent );
-	});
-
-
-	// ---------------------------------------------------------------
-	// AUTO-SLIDE TESTS
-
-	QUnit.module( 'Auto Sliding' );
-
-	QUnit.test( 'Reveal.isAutoSliding', function( assert ) {
-		assert.strictEqual( Reveal.isAutoSliding(), false, 'false by default' );
-
-		Reveal.configure({ autoSlide: 10000 });
-		assert.strictEqual( Reveal.isAutoSliding(), true, 'true after starting' );
-
-		Reveal.configure({ autoSlide: 0 });
-		assert.strictEqual( Reveal.isAutoSliding(), false, 'false after setting to 0' );
-	});
-
-	QUnit.test( 'Reveal.toggleAutoSlide', function( assert ) {
-		Reveal.configure({ autoSlide: 10000 });
-
-		Reveal.toggleAutoSlide();
-		assert.strictEqual( Reveal.isAutoSliding(), false, 'false after first toggle' );
-		Reveal.toggleAutoSlide();
-		assert.strictEqual( Reveal.isAutoSliding(), true, 'true after second toggle' );
-
-		Reveal.configure({ autoSlide: 0 });
-	});
-
-	QUnit.test( 'autoslidepaused', function( assert ) {
-		assert.expect( 1 );
-		var done = assert.async();
-
-		var _onEvent = function( event ) {
-			assert.ok( true, 'event fired' );
-			done();
-		}
-
-		Reveal.addEventListener( 'autoslidepaused', _onEvent );
-		Reveal.configure({ autoSlide: 10000 });
-		Reveal.toggleAutoSlide();
-
-		// cleanup
-		Reveal.configure({ autoSlide: 0 });
-		Reveal.removeEventListener( 'autoslidepaused', _onEvent );
-	});
-
-	QUnit.test( 'autoslideresumed', function( assert ) {
-		assert.expect( 1 );
-		var done = assert.async();
-
-		var _onEvent = function( event ) {
-			assert.ok( true, 'event fired' );
-			done();
-		}
-
-		Reveal.addEventListener( 'autoslideresumed', _onEvent );
-		Reveal.configure({ autoSlide: 10000 });
-		Reveal.toggleAutoSlide();
-		Reveal.toggleAutoSlide();
-
-		// cleanup
-		Reveal.configure({ autoSlide: 0 });
-		Reveal.removeEventListener( 'autoslideresumed', _onEvent );
-	});
-
-
-	// ---------------------------------------------------------------
-	// CONFIGURATION VALUES
-
-	QUnit.module( 'Configuration' );
-
-	QUnit.test( 'Controls', function( assert ) {
-		var controlsElement = document.querySelector( '.reveal>.controls' );
-
-		Reveal.configure({ controls: false });
-		assert.equal( controlsElement.style.display, 'none', 'controls are hidden' );
-
-		Reveal.configure({ controls: true });
-		assert.equal( controlsElement.style.display, 'block', 'controls are visible' );
-	});
-
-	QUnit.test( 'Progress', function( assert ) {
-		var progressElement = document.querySelector( '.reveal>.progress' );
-
-		Reveal.configure({ progress: false });
-		assert.equal( progressElement.style.display, 'none', 'progress are hidden' );
-
-		Reveal.configure({ progress: true });
-		assert.equal( progressElement.style.display, 'block', 'progress are visible' );
-	});
-
-	QUnit.test( 'Loop', function( assert ) {
-		Reveal.configure({ loop: true });
-
-		Reveal.slide( 0, 0 );
-
-		Reveal.left();
-		assert.notEqual( Reveal.getIndices().h, 0, 'looped from start to end' );
-
-		Reveal.right();
-		assert.equal( Reveal.getIndices().h, 0, 'looped from end to start' );
-
-		Reveal.configure({ loop: false });
-	});
-
-
-	// ---------------------------------------------------------------
-	// LAZY-LOADING TESTS
-
-	QUnit.module( 'Lazy-Loading' );
-
-	QUnit.test( 'img with data-src', function( assert ) {
-		assert.strictEqual( document.querySelectorAll( '.reveal section img[src]' ).length, 1, 'Image source has been set' );
-	});
-
-	QUnit.test( 'video with data-src', function( assert ) {
-		assert.strictEqual( document.querySelectorAll( '.reveal section video[src]' ).length, 1, 'Video source has been set' );
-	});
-
-	QUnit.test( 'audio with data-src', function( assert ) {
-		assert.strictEqual( document.querySelectorAll( '.reveal section audio[src]' ).length, 1, 'Audio source has been set' );
-	});
-
-	QUnit.test( 'iframe with data-src', function( assert ) {
-		Reveal.slide( 0, 0 );
-		assert.strictEqual( document.querySelectorAll( '.reveal section iframe[src]' ).length, 0, 'Iframe source is not set' );
-		Reveal.slide( 2, 1 );
-		assert.strictEqual( document.querySelectorAll( '.reveal section iframe[src]' ).length, 1, 'Iframe source is set' );
-		Reveal.slide( 2, 2 );
-		assert.strictEqual( document.querySelectorAll( '.reveal section iframe[src]' ).length, 0, 'Iframe source is not set' );
-	});
-
-	QUnit.test( 'background images', function( assert ) {
-		var imageSource1 = Reveal.getSlide( 0 ).getAttribute( 'data-background-image' );
-		var imageSource2 = Reveal.getSlide( 1, 0 ).getAttribute( 'data-background' );
-
-		// check that the images are applied to the background elements
-		assert.ok( Reveal.getSlideBackground( 0 ).querySelector( '.slide-background-content' ).style.backgroundImage.indexOf( imageSource1 ) !== -1, 'data-background-image worked' );
-		assert.ok( Reveal.getSlideBackground( 1, 0 ).querySelector( '.slide-background-content' ).style.backgroundImage.indexOf( imageSource2 ) !== -1, 'data-background worked' );
-	});
-
-
-	// ---------------------------------------------------------------
-	// EVENT TESTS
-
-	QUnit.module( 'Events' );
-
-	QUnit.test( 'slidechanged', function( assert ) {
-		assert.expect( 3 );
-		var done = assert.async( 3 );
-
-		var _onEvent = function( event ) {
-			assert.ok( true, 'event fired' );
-			done();
-		}
-
-		Reveal.addEventListener( 'slidechanged', _onEvent );
-
-		Reveal.slide( 1, 0 ); // should trigger
-		Reveal.slide( 1, 0 ); // should do nothing
-		Reveal.next(); // should trigger
-		Reveal.slide( 3, 0 ); // should trigger
-		Reveal.next(); // should do nothing
-
-		Reveal.removeEventListener( 'slidechanged', _onEvent );
-
-	});
-
-	QUnit.test( 'paused', function( assert ) {
-		assert.expect( 1 );
-		var done = assert.async();
-
-		var _onEvent = function( event ) {
-			assert.ok( true, 'event fired' );
-			done();
-		}
-
-		Reveal.addEventListener( 'paused', _onEvent );
-
-		Reveal.togglePause();
-		Reveal.togglePause();
-
-		Reveal.removeEventListener( 'paused', _onEvent );
-	});
-
-	QUnit.test( 'resumed', function( assert ) {
-		assert.expect( 1 );
-		var done = assert.async();
-
-		var _onEvent = function( event ) {
-			assert.ok( true, 'event fired' );
-			done();
-		}
-
-		Reveal.addEventListener( 'resumed', _onEvent );
-
-		Reveal.togglePause();
-		Reveal.togglePause();
-
-		Reveal.removeEventListener( 'resumed', _onEvent );
-	});
-
-} );

Some files were not shown because too many files changed in this diff