From a91b3874e7454f88712396054920423a68987377 Mon Sep 17 00:00:00 2001
From: Marvin Borner
Date: Fri, 9 Apr 2021 19:13:04 +0200
Subject: Initial
---
js/controllers/autoanimate.js | 619 +++++++++++++++++++++++++++++++++++++++++
js/controllers/backgrounds.js | 397 ++++++++++++++++++++++++++
js/controllers/controls.js | 259 +++++++++++++++++
js/controllers/focus.js | 97 +++++++
js/controllers/fragments.js | 375 +++++++++++++++++++++++++
js/controllers/keyboard.js | 388 ++++++++++++++++++++++++++
js/controllers/location.js | 209 ++++++++++++++
js/controllers/notes.js | 114 ++++++++
js/controllers/overview.js | 255 +++++++++++++++++
js/controllers/plugins.js | 241 ++++++++++++++++
js/controllers/pointer.js | 118 ++++++++
js/controllers/print.js | 195 +++++++++++++
js/controllers/progress.js | 103 +++++++
js/controllers/slidecontent.js | 456 ++++++++++++++++++++++++++++++
js/controllers/slidenumber.js | 126 +++++++++
js/controllers/touch.js | 259 +++++++++++++++++
16 files changed, 4211 insertions(+)
create mode 100644 js/controllers/autoanimate.js
create mode 100644 js/controllers/backgrounds.js
create mode 100644 js/controllers/controls.js
create mode 100644 js/controllers/focus.js
create mode 100644 js/controllers/fragments.js
create mode 100644 js/controllers/keyboard.js
create mode 100644 js/controllers/location.js
create mode 100644 js/controllers/notes.js
create mode 100644 js/controllers/overview.js
create mode 100644 js/controllers/plugins.js
create mode 100644 js/controllers/pointer.js
create mode 100644 js/controllers/print.js
create mode 100644 js/controllers/progress.js
create mode 100644 js/controllers/slidecontent.js
create mode 100644 js/controllers/slidenumber.js
create mode 100644 js/controllers/touch.js
(limited to 'js/controllers')
diff --git a/js/controllers/autoanimate.js b/js/controllers/autoanimate.js
new file mode 100644
index 0000000..ff74eee
--- /dev/null
+++ b/js/controllers/autoanimate.js
@@ -0,0 +1,619 @@
+import { queryAll, extend, createStyleSheet, matches, closest } from '../utils/util.js'
+import { FRAGMENT_STYLE_REGEX } from '../utils/constants.js'
+
+// Counter used to generate unique IDs for auto-animated elements
+let autoAnimateCounter = 0;
+
+/**
+ * Automatically animates matching elements across
+ * slides with the [data-auto-animate] attribute.
+ */
+export default class AutoAnimate {
+
+ constructor( Reveal ) {
+
+ this.Reveal = Reveal;
+
+ }
+
+ /**
+ * 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';
+
+ // Flag the navigation direction, needed for fragment buildup
+ let allSlides = this.Reveal.getSlides();
+ animationOptions.slideDirection = allSlides.indexOf( toSlide ) > allSlides.indexOf( fromSlide ) ? 'forward' : 'backward';
+
+ // Inject our auto-animate styles for this transition
+ let css = this.getAutoAnimatableElements( fromSlide, toSlide ).map( elements => {
+ return this.autoAnimateElements( elements.from, elements.to, elements.options || {}, animationOptions, autoAnimateCounter++ );
+ } );
+
+ // Animate unmatched elements, if enabled
+ if( toSlide.dataset.autoAnimateUnmatched !== 'false' && this.Reveal.getConfig().autoAnimateUnmatched === true ) {
+
+ // Our default timings for unmatched elements
+ let defaultUnmatchedDuration = animationOptions.duration * 0.8,
+ defaultUnmatchedDelay = animationOptions.duration * 0.2;
+
+ this.getUnmatchedAutoAnimateElements( toSlide ).forEach( unmatchedElement => {
+
+ let unmatchedOptions = this.getAutoAnimateOptions( unmatchedElement, animationOptions );
+ let id = 'unmatched';
+
+ // If there is a duration or delay set specifically for this
+ // element our unmatched elements should adhere to those
+ if( unmatchedOptions.duration !== animationOptions.duration || unmatchedOptions.delay !== animationOptions.delay ) {
+ id = 'unmatched-' + autoAnimateCounter++;
+ css.push( `[data-auto-animate="running"] [data-auto-animate-target="${id}"] { transition: opacity ${unmatchedOptions.duration}s ease ${unmatchedOptions.delay}s; }` );
+ }
+
+ unmatchedElement.dataset.autoAnimateTarget = id;
+
+ }, this );
+
+ // Our default transition for unmatched elements
+ css.push( `[data-auto-animate="running"] [data-auto-animate-target="unmatched"] { transition: opacity ${defaultUnmatchedDuration}s ease ${defaultUnmatchedDelay}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({
+ type: 'autoanimate',
+ data: {
+ fromSlide,
+ toSlide,
+ sheet: this.autoAnimateStyleSheet
+ }
+ });
+
+ }
+
+ }
+
+ /**
+ * Rolls back all changes that we've made to the DOM so
+ * that as part of animating.
+ */
+ reset() {
+
+ // Reset slides
+ queryAll( this.Reveal.getRevealElement(), '[data-auto-animate]:not([data-auto-animate=""])' ).forEach( element => {
+ element.dataset.autoAnimate = '';
+ } );
+
+ // Reset elements
+ queryAll( this.Reveal.getRevealElement(), '[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;
+ }
+
+ }
+
+ /**
+ * Creates a FLIP animation where the `to` element starts out
+ * in the `from` element position and animates to its original
+ * 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
+ */
+ autoAnimateElements( 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 );
+
+ // Maintain fragment visibility for matching elements when
+ // we're navigating forwards, this way the viewer won't need
+ // to step through the same fragments twice
+ if( to.classList.contains( 'fragment' ) ) {
+
+ // Don't auto-animate the opacity of fragments to avoid
+ // conflicts with fragment animations
+ delete toProps.styles['opacity'];
+
+ if( from.classList.contains( 'fragment' ) ) {
+
+ let fromFragmentStyle = ( from.className.match( FRAGMENT_STYLE_REGEX ) || [''] )[0];
+ let toFragmentStyle = ( to.className.match( FRAGMENT_STYLE_REGEX ) || [''] )[0];
+
+ // Only skip the fragment if the fragment animation style
+ // remains unchanged
+ if( fromFragmentStyle === toFragmentStyle && animationOptions.slideDirection === 'forward' ) {
+ to.classList.add( 'visible', 'disabled' );
+ }
+
+ }
+
+ }
+
+ // 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.parentNode ) {
+ let autoAnimatedParent = closest( element.parentNode, '[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 config = this.Reveal.getConfig();
+
+ 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 {
+ if( config.center ) {
+ // More precise, but breaks when used in combination
+ // with zoom for scaling the deck ¯\_(ツ)_/¯
+ bounds = element.getBoundingClientRect();
+ }
+ else {
+ let scale = this.Reveal.getScale();
+ bounds = {
+ x: element.offsetLeft * scale,
+ y: element.offsetTop * scale,
+ width: element.offsetWidth * scale,
+ height: element.offsetHeight * scale
+ };
+ }
+ }
+
+ properties.x = bounds.x;
+ properties.y = bounds.y;
+ properties.width = bounds.width;
+ properties.height = bounds.height;
+ }
+
+ const computedStyles = getComputedStyle( element );
+
+ // CSS styles
+ ( elementOptions.styles || config.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 transition
+ // each individual text property instead
+ if( matches( pair.from, textNodes ) ) {
+ pair.options = { scale: false };
+ }
+ // Animate individual lines of code
+ else if( matches( pair.from, 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;
+
+ }, [] );
+
+ }
+
+}
diff --git a/js/controllers/backgrounds.js b/js/controllers/backgrounds.js
new file mode 100644
index 0000000..e8cc996
--- /dev/null
+++ b/js/controllers/backgrounds.js
@@ -0,0 +1,397 @@
+import { queryAll } from '../utils/util.js'
+import { colorToRgb, colorBrightness } from '../utils/color.js'
+
+/**
+ * Creates and updates slide backgrounds.
+ */
+export default class Backgrounds {
+
+ constructor( Reveal ) {
+
+ this.Reveal = Reveal;
+
+ }
+
+ render() {
+
+ this.element = document.createElement( 'div' );
+ this.element.className = 'backgrounds';
+ this.Reveal.getRevealElement().appendChild( this.element );
+
+ }
+
+ /**
+ * Creates the slide background elements and appends them
+ * to the background container. One element is created per
+ * slide no matter if the given slide has visible background.
+ */
+ create() {
+
+ let printMode = this.Reveal.isPrintingPDF();
+
+ // Clear prior backgrounds
+ this.element.innerHTML = '';
+ this.element.classList.add( 'no-transition' );
+
+ // Iterate over all horizontal slides
+ this.Reveal.getHorizontalSlides().forEach( slideh => {
+
+ let backgroundStack = this.createBackground( slideh, this.element );
+
+ // Iterate over all vertical slides
+ queryAll( slideh, 'section' ).forEach( slidev => {
+
+ this.createBackground( slidev, backgroundStack );
+
+ backgroundStack.classList.add( 'stack' );
+
+ } );
+
+ } );
+
+ // Add parallax background if specified
+ if( this.Reveal.getConfig().parallaxBackgroundImage ) {
+
+ this.element.style.backgroundImage = 'url("' + this.Reveal.getConfig().parallaxBackgroundImage + '")';
+ this.element.style.backgroundSize = this.Reveal.getConfig().parallaxBackgroundSize;
+ this.element.style.backgroundRepeat = this.Reveal.getConfig().parallaxBackgroundRepeat;
+ this.element.style.backgroundPosition = this.Reveal.getConfig().parallaxBackgroundPosition;
+
+ // Make sure the below properties are set on the element - these properties are
+ // needed for proper transitions to be set on the element via CSS. To remove
+ // annoying background slide-in effect when the presentation starts, apply
+ // these properties after short time delay
+ setTimeout( () => {
+ this.Reveal.getRevealElement().classList.add( 'has-parallax-background' );
+ }, 1 );
+
+ }
+ else {
+
+ this.element.style.backgroundImage = '';
+ this.Reveal.getRevealElement().classList.remove( 'has-parallax-background' );
+
+ }
+
+ }
+
+ /**
+ * Creates a background for the given slide.
+ *
+ * @param {HTMLElement} slide
+ * @param {HTMLElement} container The element that the background
+ * should be appended to
+ * @return {HTMLElement} New background div
+ */
+ createBackground( slide, container ) {
+
+ // Main slide background element
+ let element = document.createElement( 'div' );
+ element.className = 'slide-background ' + slide.className.replace( /present|past|future/, '' );
+
+ // Inner background element that wraps images/videos/iframes
+ let contentElement = document.createElement( 'div' );
+ contentElement.className = 'slide-background-content';
+
+ element.appendChild( contentElement );
+ container.appendChild( element );
+
+ slide.slideBackgroundElement = element;
+ slide.slideBackgroundContentElement = contentElement;
+
+ // Syncs the background to reflect all current background settings
+ this.sync( slide );
+
+ return element;
+
+ }
+
+ /**
+ * Renders all of the visual properties of a slide background
+ * based on the various background attributes.
+ *
+ * @param {HTMLElement} slide
+ */
+ sync( slide ) {
+
+ let element = slide.slideBackgroundElement,
+ contentElement = slide.slideBackgroundContentElement;
+
+ // Reset the prior background state in case this is not the
+ // initial sync
+ slide.classList.remove( 'has-dark-background' );
+ slide.classList.remove( 'has-light-background' );
+
+ element.removeAttribute( 'data-loaded' );
+ element.removeAttribute( 'data-background-hash' );
+ element.removeAttribute( 'data-background-size' );
+ element.removeAttribute( 'data-background-transition' );
+ element.style.backgroundColor = '';
+
+ contentElement.style.backgroundSize = '';
+ contentElement.style.backgroundRepeat = '';
+ contentElement.style.backgroundPosition = '';
+ contentElement.style.backgroundImage = '';
+ contentElement.style.opacity = '';
+ contentElement.innerHTML = '';
+
+ let data = {
+ background: slide.getAttribute( 'data-background' ),
+ backgroundSize: slide.getAttribute( 'data-background-size' ),
+ backgroundImage: slide.getAttribute( 'data-background-image' ),
+ backgroundVideo: slide.getAttribute( 'data-background-video' ),
+ backgroundIframe: slide.getAttribute( 'data-background-iframe' ),
+ backgroundColor: slide.getAttribute( 'data-background-color' ),
+ backgroundRepeat: slide.getAttribute( 'data-background-repeat' ),
+ backgroundPosition: slide.getAttribute( 'data-background-position' ),
+ backgroundTransition: slide.getAttribute( 'data-background-transition' ),
+ backgroundOpacity: slide.getAttribute( 'data-background-opacity' )
+ };
+
+ if( data.background ) {
+ // Auto-wrap image urls in url(...)
+ if( /^(http|file|\/\/)/gi.test( data.background ) || /\.(svg|png|jpg|jpeg|gif|bmp)([?#\s]|$)/gi.test( data.background ) ) {
+ slide.setAttribute( 'data-background-image', data.background );
+ }
+ else {
+ element.style.background = data.background;
+ }
+ }
+
+ // Create a hash for this combination of background settings.
+ // This is used to determine when two slide backgrounds are
+ // the same.
+ if( data.background || data.backgroundColor || data.backgroundImage || data.backgroundVideo || data.backgroundIframe ) {
+ element.setAttribute( 'data-background-hash', data.background +
+ data.backgroundSize +
+ data.backgroundImage +
+ data.backgroundVideo +
+ data.backgroundIframe +
+ data.backgroundColor +
+ data.backgroundRepeat +
+ data.backgroundPosition +
+ data.backgroundTransition +
+ data.backgroundOpacity );
+ }
+
+ // Additional and optional background properties
+ if( data.backgroundSize ) element.setAttribute( 'data-background-size', data.backgroundSize );
+ if( data.backgroundColor ) element.style.backgroundColor = data.backgroundColor;
+ if( data.backgroundTransition ) element.setAttribute( 'data-background-transition', data.backgroundTransition );
+
+ if( slide.hasAttribute( 'data-preload' ) ) element.setAttribute( 'data-preload', '' );
+
+ // Background image options are set on the content wrapper
+ if( data.backgroundSize ) contentElement.style.backgroundSize = data.backgroundSize;
+ if( data.backgroundRepeat ) contentElement.style.backgroundRepeat = data.backgroundRepeat;
+ if( data.backgroundPosition ) contentElement.style.backgroundPosition = data.backgroundPosition;
+ if( data.backgroundOpacity ) contentElement.style.opacity = data.backgroundOpacity;
+
+ // If this slide has a background color, we add a class that
+ // signals if it is light or dark. If the slide has no background
+ // color, no class will be added
+ let contrastColor = data.backgroundColor;
+
+ // If no bg color was found, check the computed background
+ if( !contrastColor ) {
+ let computedBackgroundStyle = window.getComputedStyle( element );
+ if( computedBackgroundStyle && computedBackgroundStyle.backgroundColor ) {
+ contrastColor = computedBackgroundStyle.backgroundColor;
+ }
+ }
+
+ if( contrastColor ) {
+ let rgb = colorToRgb( contrastColor );
+
+ // Ignore fully transparent backgrounds. Some browsers return
+ // rgba(0,0,0,0) when reading the computed background color of
+ // an element with no background
+ if( rgb && rgb.a !== 0 ) {
+ if( colorBrightness( contrastColor ) < 128 ) {
+ slide.classList.add( 'has-dark-background' );
+ }
+ else {
+ slide.classList.add( 'has-light-background' );
+ }
+ }
+ }
+
+ }
+
+ /**
+ * Updates the background elements to reflect the current
+ * slide.
+ *
+ * @param {boolean} includeAll If true, the backgrounds of
+ * all vertical slides (not just the present) will be updated.
+ */
+ update( includeAll = false ) {
+
+ let currentSlide = this.Reveal.getCurrentSlide();
+ let indices = this.Reveal.getIndices();
+
+ let currentBackground = null;
+
+ // Reverse past/future classes when in RTL mode
+ let horizontalPast = this.Reveal.getConfig().rtl ? 'future' : 'past',
+ horizontalFuture = this.Reveal.getConfig().rtl ? 'past' : 'future';
+
+ // Update the classes of all backgrounds to match the
+ // states of their slides (past/present/future)
+ Array.from( this.element.childNodes ).forEach( ( backgroundh, h ) => {
+
+ backgroundh.classList.remove( 'past', 'present', 'future' );
+
+ if( h < indices.h ) {
+ backgroundh.classList.add( horizontalPast );
+ }
+ else if ( h > indices.h ) {
+ backgroundh.classList.add( horizontalFuture );
+ }
+ else {
+ backgroundh.classList.add( 'present' );
+
+ // Store a reference to the current background element
+ currentBackground = backgroundh;
+ }
+
+ if( includeAll || h === indices.h ) {
+ queryAll( backgroundh, '.slide-background' ).forEach( ( backgroundv, v ) => {
+
+ backgroundv.classList.remove( 'past', 'present', 'future' );
+
+ if( v < indices.v ) {
+ backgroundv.classList.add( 'past' );
+ }
+ else if ( v > indices.v ) {
+ backgroundv.classList.add( 'future' );
+ }
+ else {
+ backgroundv.classList.add( 'present' );
+
+ // Only if this is the present horizontal and vertical slide
+ if( h === indices.h ) currentBackground = backgroundv;
+ }
+
+ } );
+ }
+
+ } );
+
+ // Stop content inside of previous backgrounds
+ if( this.previousBackground ) {
+
+ this.Reveal.slideContent.stopEmbeddedContent( this.previousBackground, { unloadIframes: !this.Reveal.slideContent.shouldPreload( this.previousBackground ) } );
+
+ }
+
+ // Start content in the current background
+ if( currentBackground ) {
+
+ this.Reveal.slideContent.startEmbeddedContent( currentBackground );
+
+ let currentBackgroundContent = currentBackground.querySelector( '.slide-background-content' );
+ if( currentBackgroundContent ) {
+
+ let backgroundImageURL = currentBackgroundContent.style.backgroundImage || '';
+
+ // Restart GIFs (doesn't work in Firefox)
+ if( /\.gif/i.test( backgroundImageURL ) ) {
+ currentBackgroundContent.style.backgroundImage = '';
+ window.getComputedStyle( currentBackgroundContent ).opacity;
+ currentBackgroundContent.style.backgroundImage = backgroundImageURL;
+ }
+
+ }
+
+ // Don't transition between identical backgrounds. This
+ // prevents unwanted flicker.
+ let previousBackgroundHash = this.previousBackground ? this.previousBackground.getAttribute( 'data-background-hash' ) : null;
+ let currentBackgroundHash = currentBackground.getAttribute( 'data-background-hash' );
+ if( currentBackgroundHash && currentBackgroundHash === previousBackgroundHash && currentBackground !== this.previousBackground ) {
+ this.element.classList.add( 'no-transition' );
+ }
+
+ this.previousBackground = currentBackground;
+
+ }
+
+ // If there's a background brightness flag for this slide,
+ // bubble it to the .reveal container
+ if( currentSlide ) {
+ [ 'has-light-background', 'has-dark-background' ].forEach( classToBubble => {
+ if( currentSlide.classList.contains( classToBubble ) ) {
+ this.Reveal.getRevealElement().classList.add( classToBubble );
+ }
+ else {
+ this.Reveal.getRevealElement().classList.remove( classToBubble );
+ }
+ }, this );
+ }
+
+ // Allow the first background to apply without transition
+ setTimeout( () => {
+ this.element.classList.remove( 'no-transition' );
+ }, 1 );
+
+ }
+
+ /**
+ * Updates the position of the parallax background based
+ * on the current slide index.
+ */
+ updateParallax() {
+
+ let indices = this.Reveal.getIndices();
+
+ if( this.Reveal.getConfig().parallaxBackgroundImage ) {
+
+ let horizontalSlides = this.Reveal.getHorizontalSlides(),
+ verticalSlides = this.Reveal.getVerticalSlides();
+
+ let backgroundSize = this.element.style.backgroundSize.split( ' ' ),
+ backgroundWidth, backgroundHeight;
+
+ if( backgroundSize.length === 1 ) {
+ backgroundWidth = backgroundHeight = parseInt( backgroundSize[0], 10 );
+ }
+ else {
+ backgroundWidth = parseInt( backgroundSize[0], 10 );
+ backgroundHeight = parseInt( backgroundSize[1], 10 );
+ }
+
+ let slideWidth = this.element.offsetWidth,
+ horizontalSlideCount = horizontalSlides.length,
+ horizontalOffsetMultiplier,
+ horizontalOffset;
+
+ if( typeof this.Reveal.getConfig().parallaxBackgroundHorizontal === 'number' ) {
+ horizontalOffsetMultiplier = this.Reveal.getConfig().parallaxBackgroundHorizontal;
+ }
+ else {
+ horizontalOffsetMultiplier = horizontalSlideCount > 1 ? ( backgroundWidth - slideWidth ) / ( horizontalSlideCount-1 ) : 0;
+ }
+
+ horizontalOffset = horizontalOffsetMultiplier * indices.h * -1;
+
+ let slideHeight = this.element.offsetHeight,
+ verticalSlideCount = verticalSlides.length,
+ verticalOffsetMultiplier,
+ verticalOffset;
+
+ if( typeof this.Reveal.getConfig().parallaxBackgroundVertical === 'number' ) {
+ verticalOffsetMultiplier = this.Reveal.getConfig().parallaxBackgroundVertical;
+ }
+ else {
+ verticalOffsetMultiplier = ( backgroundHeight - slideHeight ) / ( verticalSlideCount-1 );
+ }
+
+ verticalOffset = verticalSlideCount > 0 ? verticalOffsetMultiplier * indices.v : 0;
+
+ this.element.style.backgroundPosition = horizontalOffset + 'px ' + -verticalOffset + 'px';
+
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/js/controllers/controls.js b/js/controllers/controls.js
new file mode 100644
index 0000000..556bcf0
--- /dev/null
+++ b/js/controllers/controls.js
@@ -0,0 +1,259 @@
+import { queryAll } from '../utils/util.js'
+import { isAndroid } from '../utils/device.js'
+
+/**
+ * Manages our presentation controls. This includes both
+ * the built-in control arrows as well as event monitoring
+ * of any elements within the presentation with either of the
+ * following helper classes:
+ * - .navigate-up
+ * - .navigate-right
+ * - .navigate-down
+ * - .navigate-left
+ * - .navigate-next
+ * - .navigate-prev
+ */
+export default class Controls {
+
+ constructor( Reveal ) {
+
+ this.Reveal = Reveal;
+
+ this.onNavigateLeftClicked = this.onNavigateLeftClicked.bind( this );
+ this.onNavigateRightClicked = this.onNavigateRightClicked.bind( this );
+ this.onNavigateUpClicked = this.onNavigateUpClicked.bind( this );
+ this.onNavigateDownClicked = this.onNavigateDownClicked.bind( this );
+ this.onNavigatePrevClicked = this.onNavigatePrevClicked.bind( this );
+ this.onNavigateNextClicked = this.onNavigateNextClicked.bind( this );
+
+ }
+
+ render() {
+
+ const rtl = this.Reveal.getConfig().rtl;
+ const revealElement = this.Reveal.getRevealElement();
+
+ this.element = document.createElement( 'aside' );
+ this.element.className = 'controls';
+ this.element.innerHTML =
+ `
+
+
+ `;
+
+ this.Reveal.getRevealElement().appendChild( this.element );
+
+ // There can be multiple instances of controls throughout the page
+ this.controlsLeft = queryAll( revealElement, '.navigate-left' );
+ this.controlsRight = queryAll( revealElement, '.navigate-right' );
+ this.controlsUp = queryAll( revealElement, '.navigate-up' );
+ this.controlsDown = queryAll( revealElement, '.navigate-down' );
+ this.controlsPrev = queryAll( revealElement, '.navigate-prev' );
+ this.controlsNext = queryAll( revealElement, '.navigate-next' );
+
+ // The left, right and down arrows in the standard reveal.js controls
+ this.controlsRightArrow = this.element.querySelector( '.navigate-right' );
+ this.controlsLeftArrow = this.element.querySelector( '.navigate-left' );
+ this.controlsDownArrow = this.element.querySelector( '.navigate-down' );
+
+ }
+
+ /**
+ * Called when the reveal.js config is updated.
+ */
+ configure( config, oldConfig ) {
+
+ this.element.style.display = config.controls ? 'block' : 'none';
+
+ this.element.setAttribute( 'data-controls-layout', config.controlsLayout );
+ this.element.setAttribute( 'data-controls-back-arrows', config.controlsBackArrows );
+
+ }
+
+ bind() {
+
+ // Listen to both touch and click events, in case the device
+ // supports both
+ let pointerEvents = [ 'touchstart', 'click' ];
+
+ // Only support touch for Android, fixes double navigations in
+ // stock browser
+ if( isAndroid ) {
+ pointerEvents = [ 'touchstart' ];
+ }
+
+ pointerEvents.forEach( eventName => {
+ this.controlsLeft.forEach( el => el.addEventListener( eventName, this.onNavigateLeftClicked, false ) );
+ this.controlsRight.forEach( el => el.addEventListener( eventName, this.onNavigateRightClicked, false ) );
+ this.controlsUp.forEach( el => el.addEventListener( eventName, this.onNavigateUpClicked, false ) );
+ this.controlsDown.forEach( el => el.addEventListener( eventName, this.onNavigateDownClicked, false ) );
+ this.controlsPrev.forEach( el => el.addEventListener( eventName, this.onNavigatePrevClicked, false ) );
+ this.controlsNext.forEach( el => el.addEventListener( eventName, this.onNavigateNextClicked, false ) );
+ } );
+
+ }
+
+ unbind() {
+
+ [ 'touchstart', 'click' ].forEach( eventName => {
+ this.controlsLeft.forEach( el => el.removeEventListener( eventName, this.onNavigateLeftClicked, false ) );
+ this.controlsRight.forEach( el => el.removeEventListener( eventName, this.onNavigateRightClicked, false ) );
+ this.controlsUp.forEach( el => el.removeEventListener( eventName, this.onNavigateUpClicked, false ) );
+ this.controlsDown.forEach( el => el.removeEventListener( eventName, this.onNavigateDownClicked, false ) );
+ this.controlsPrev.forEach( el => el.removeEventListener( eventName, this.onNavigatePrevClicked, false ) );
+ this.controlsNext.forEach( el => el.removeEventListener( eventName, this.onNavigateNextClicked, false ) );
+ } );
+
+ }
+
+ /**
+ * Updates the state of all control/navigation arrows.
+ */
+ update() {
+
+ let routes = this.Reveal.availableRoutes();
+
+ // Remove the 'enabled' class from all directions
+ [...this.controlsLeft, ...this.controlsRight, ...this.controlsUp, ...this.controlsDown, ...this.controlsPrev, ...this.controlsNext].forEach( node => {
+ node.classList.remove( 'enabled', 'fragmented' );
+
+ // Set 'disabled' attribute on all directions
+ node.setAttribute( 'disabled', 'disabled' );
+ } );
+
+ // Add the 'enabled' class to the available routes; remove 'disabled' attribute to enable buttons
+ if( routes.left ) this.controlsLeft.forEach( el => { el.classList.add( 'enabled' ); el.removeAttribute( 'disabled' ); } );
+ if( routes.right ) this.controlsRight.forEach( el => { el.classList.add( 'enabled' ); el.removeAttribute( 'disabled' ); } );
+ if( routes.up ) this.controlsUp.forEach( el => { el.classList.add( 'enabled' ); el.removeAttribute( 'disabled' ); } );
+ if( routes.down ) this.controlsDown.forEach( el => { el.classList.add( 'enabled' ); el.removeAttribute( 'disabled' ); } );
+
+ // Prev/next buttons
+ if( routes.left || routes.up ) this.controlsPrev.forEach( el => { el.classList.add( 'enabled' ); el.removeAttribute( 'disabled' ); } );
+ if( routes.right || routes.down ) this.controlsNext.forEach( el => { el.classList.add( 'enabled' ); el.removeAttribute( 'disabled' ); } );
+
+ // Highlight fragment directions
+ let currentSlide = this.Reveal.getCurrentSlide();
+ if( currentSlide ) {
+
+ let fragmentsRoutes = this.Reveal.fragments.availableRoutes();
+
+ // Always apply fragment decorator to prev/next buttons
+ if( fragmentsRoutes.prev ) this.controlsPrev.forEach( el => { el.classList.add( 'fragmented', 'enabled' ); el.removeAttribute( 'disabled' ); } );
+ if( fragmentsRoutes.next ) this.controlsNext.forEach( el => { el.classList.add( 'fragmented', 'enabled' ); el.removeAttribute( 'disabled' ); } );
+
+ // Apply fragment decorators to directional buttons based on
+ // what slide axis they are in
+ if( this.Reveal.isVerticalSlide( currentSlide ) ) {
+ if( fragmentsRoutes.prev ) this.controlsUp.forEach( el => { el.classList.add( 'fragmented', 'enabled' ); el.removeAttribute( 'disabled' ); } );
+ if( fragmentsRoutes.next ) this.controlsDown.forEach( el => { el.classList.add( 'fragmented', 'enabled' ); el.removeAttribute( 'disabled' ); } );
+ }
+ else {
+ if( fragmentsRoutes.prev ) this.controlsLeft.forEach( el => { el.classList.add( 'fragmented', 'enabled' ); el.removeAttribute( 'disabled' ); } );
+ if( fragmentsRoutes.next ) this.controlsRight.forEach( el => { el.classList.add( 'fragmented', 'enabled' ); el.removeAttribute( 'disabled' ); } );
+ }
+
+ }
+
+ if( this.Reveal.getConfig().controlsTutorial ) {
+
+ let indices = this.Reveal.getIndices();
+
+ // Highlight control arrows with an animation to ensure
+ // that the viewer knows how to navigate
+ if( !this.Reveal.hasNavigatedVertically() && routes.down ) {
+ this.controlsDownArrow.classList.add( 'highlight' );
+ }
+ else {
+ this.controlsDownArrow.classList.remove( 'highlight' );
+
+ if( this.Reveal.getConfig().rtl ) {
+
+ if( !this.Reveal.hasNavigatedHorizontally() && routes.left && indices.v === 0 ) {
+ this.controlsLeftArrow.classList.add( 'highlight' );
+ }
+ else {
+ this.controlsLeftArrow.classList.remove( 'highlight' );
+ }
+
+ } else {
+
+ if( !this.Reveal.hasNavigatedHorizontally() && routes.right && indices.v === 0 ) {
+ this.controlsRightArrow.classList.add( 'highlight' );
+ }
+ else {
+ this.controlsRightArrow.classList.remove( 'highlight' );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Event handlers for navigation control buttons.
+ */
+ onNavigateLeftClicked( event ) {
+
+ event.preventDefault();
+ this.Reveal.onUserInput();
+
+ if( this.Reveal.getConfig().navigationMode === 'linear' ) {
+ this.Reveal.prev();
+ }
+ else {
+ this.Reveal.left();
+ }
+
+ }
+
+ onNavigateRightClicked( event ) {
+
+ event.preventDefault();
+ this.Reveal.onUserInput();
+
+ if( this.Reveal.getConfig().navigationMode === 'linear' ) {
+ this.Reveal.next();
+ }
+ else {
+ this.Reveal.right();
+ }
+
+ }
+
+ onNavigateUpClicked( event ) {
+
+ event.preventDefault();
+ this.Reveal.onUserInput();
+
+ this.Reveal.up();
+
+ }
+
+ onNavigateDownClicked( event ) {
+
+ event.preventDefault();
+ this.Reveal.onUserInput();
+
+ this.Reveal.down();
+
+ }
+
+ onNavigatePrevClicked( event ) {
+
+ event.preventDefault();
+ this.Reveal.onUserInput();
+
+ this.Reveal.prev();
+
+ }
+
+ onNavigateNextClicked( event ) {
+
+ event.preventDefault();
+ this.Reveal.onUserInput();
+
+ this.Reveal.next();
+
+ }
+
+
+}
\ No newline at end of file
diff --git a/js/controllers/focus.js b/js/controllers/focus.js
new file mode 100644
index 0000000..2191807
--- /dev/null
+++ b/js/controllers/focus.js
@@ -0,0 +1,97 @@
+import { closest } from '../utils/util.js'
+
+/**
+ * Manages focus when a presentation is embedded. This
+ * helps us only capture keyboard from the presentation
+ * a user is currently interacting with in a page where
+ * multiple presentations are embedded.
+ */
+
+const STATE_FOCUS = 'focus';
+const STATE_BLUR = 'blur';
+
+export default class Focus {
+
+ constructor( Reveal ) {
+
+ this.Reveal = Reveal;
+
+ this.onRevealPointerDown = this.onRevealPointerDown.bind( this );
+ this.onDocumentPointerDown = this.onDocumentPointerDown.bind( this );
+
+ }
+
+ /**
+ * Called when the reveal.js config is updated.
+ */
+ configure( config, oldConfig ) {
+
+ if( config.embedded ) {
+ this.blur();
+ }
+ else {
+ this.focus();
+ this.unbind();
+ }
+
+ }
+
+ bind() {
+
+ if( this.Reveal.getConfig().embedded ) {
+ this.Reveal.getRevealElement().addEventListener( 'pointerdown', this.onRevealPointerDown, false );
+ }
+
+ }
+
+ unbind() {
+
+ this.Reveal.getRevealElement().removeEventListener( 'pointerdown', this.onRevealPointerDown, false );
+ document.removeEventListener( 'pointerdown', this.onDocumentPointerDown, false );
+
+ }
+
+ focus() {
+
+ if( this.state !== STATE_FOCUS ) {
+ this.Reveal.getRevealElement().classList.add( 'focused' );
+ document.addEventListener( 'pointerdown', this.onDocumentPointerDown, false );
+ }
+
+ this.state = STATE_FOCUS;
+
+ }
+
+ blur() {
+
+ if( this.state !== STATE_BLUR ) {
+ this.Reveal.getRevealElement().classList.remove( 'focused' );
+ document.removeEventListener( 'pointerdown', this.onDocumentPointerDown, false );
+ }
+
+ this.state = STATE_BLUR;
+
+ }
+
+ isFocused() {
+
+ return this.state === STATE_FOCUS;
+
+ }
+
+ onRevealPointerDown( event ) {
+
+ this.focus();
+
+ }
+
+ onDocumentPointerDown( event ) {
+
+ let revealElement = closest( event.target, '.reveal' );
+ if( !revealElement || revealElement !== this.Reveal.getRevealElement() ) {
+ this.blur();
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/js/controllers/fragments.js b/js/controllers/fragments.js
new file mode 100644
index 0000000..ca83fd6
--- /dev/null
+++ b/js/controllers/fragments.js
@@ -0,0 +1,375 @@
+import { extend, queryAll } 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;
+
+ }
+
+ /**
+ * Called when the reveal.js config is updated.
+ */
+ configure( config, oldConfig ) {
+
+ if( config.fragments === false ) {
+ this.disable();
+ }
+ else if( oldConfig.fragments === false ) {
+ this.enable();
+ }
+
+ }
+
+ /**
+ * If fragments are disabled in the deck, they should all be
+ * visible rather than stepped through.
+ */
+ disable() {
+
+ queryAll( this.Reveal.getSlidesElement(), '.fragment' ).forEach( element => {
+ element.classList.add( 'visible' );
+ element.classList.remove( 'current-fragment' );
+ } );
+
+ }
+
+ /**
+ * Reverse of #disable(). Only called if fragments have
+ * previously been disabled.
+ */
+ enable() {
+
+ queryAll( this.Reveal.getSlidesElement(), '.fragment' ).forEach( element => {
+ element.classList.remove( '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:not(.disabled)' );
+ let hiddenFragments = currentSlide.querySelectorAll( '.fragment:not(.disabled):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 = Array.from( 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 = queryAll( horizontalSlide, '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 );
+ }
+ }
+
+ Array.from( 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 ) {
+ let wasVisible = el.classList.contains( 'visible' )
+ el.classList.add( 'visible' );
+ el.classList.remove( 'current-fragment' );
+
+ if( i === index ) {
+ // Announce the fragments one by one to the Screen Reader
+ this.Reveal.announceStatus( this.Reveal.getStatusText( el ) );
+
+ el.classList.add( 'current-fragment' );
+ this.Reveal.slideContent.startEmbeddedContent( el );
+ }
+
+ if( !wasVisible ) {
+ changedFragments.shown.push( el )
+ this.Reveal.dispatchEvent({
+ target: el,
+ type: 'visible',
+ bubbles: false
+ });
+ }
+ }
+ // Hidden fragments
+ else {
+ let wasVisible = el.classList.contains( 'visible' )
+ el.classList.remove( 'visible' );
+ el.classList.remove( 'current-fragment' );
+
+ if( wasVisible ) {
+ changedFragments.hidden.push( el );
+ this.Reveal.dispatchEvent({
+ target: el,
+ type: 'hidden',
+ bubbles: false
+ });
+ }
+ }
+
+ } );
+
+ // Write the current fragment index to the slide .
+ // 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;
+
+ }
+
+ /**
+ * Formats the fragments on the given slide so that they have
+ * valid indices. Call this if fragments are changed in the DOM
+ * after reveal.js has already initialized.
+ *
+ * @param {HTMLElement} slide
+ * @return {Array} a list of the HTML fragments that were synced
+ */
+ sync( slide = this.Reveal.getCurrentSlide() ) {
+
+ return this.sort( slide.querySelectorAll( '.fragment' ) );
+
+ }
+
+ /**
+ * 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:not(.disabled)' ) );
+ if( fragments.length ) {
+
+ // If no index is specified, find the current
+ if( typeof index !== 'number' ) {
+ let lastVisibleFragment = this.sort( currentSlide.querySelectorAll( '.fragment:not(.disabled).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({
+ type: 'fragmenthidden',
+ data: {
+ fragment: changedFragments.hidden[0],
+ fragments: changedFragments.hidden
+ }
+ });
+ }
+
+ if( changedFragments.shown.length ) {
+ this.Reveal.dispatchEvent({
+ type: 'fragmentshown',
+ data: {
+ fragment: changedFragments.shown[0],
+ fragments: changedFragments.shown
+ }
+ });
+ }
+
+ this.Reveal.controls.update();
+ this.Reveal.progress.update();
+
+ if( this.Reveal.getConfig().fragmentInURL ) {
+ this.Reveal.location.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 );
+
+ }
+
+}
\ No newline at end of file
diff --git a/js/controllers/keyboard.js b/js/controllers/keyboard.js
new file mode 100644
index 0000000..70b361f
--- /dev/null
+++ b/js/controllers/keyboard.js
@@ -0,0 +1,388 @@
+import { enterFullscreen } from '../utils/util.js'
+
+/**
+ * Handles all reveal.js keyboard interactions.
+ */
+export default class Keyboard {
+
+ constructor( Reveal ) {
+
+ this.Reveal = Reveal;
+
+ // A key:value map of keyboard keys and descriptions of
+ // the actions they trigger
+ this.shortcuts = {};
+
+ // Holds custom key code mappings
+ this.bindings = {};
+
+ this.onDocumentKeyDown = this.onDocumentKeyDown.bind( this );
+ this.onDocumentKeyPress = this.onDocumentKeyPress.bind( this );
+
+ }
+
+ /**
+ * Called when the reveal.js config is updated.
+ */
+ configure( config, oldConfig ) {
+
+ if( config.navigationMode === 'linear' ) {
+ this.shortcuts['→ , ↓ , SPACE , N , L , J'] = 'Next slide';
+ this.shortcuts['← , ↑ , P , H , K'] = 'Previous slide';
+ }
+ else {
+ this.shortcuts['N , SPACE'] = 'Next slide';
+ this.shortcuts['P'] = 'Previous slide';
+ this.shortcuts['← , H'] = 'Navigate left';
+ this.shortcuts['→ , L'] = 'Navigate right';
+ this.shortcuts['↑ , K'] = 'Navigate up';
+ this.shortcuts['↓ , J'] = 'Navigate down';
+ }
+
+ this.shortcuts['Home , Shift ←'] = 'First slide';
+ this.shortcuts['End , Shift →'] = 'Last slide';
+ this.shortcuts['B , .'] = 'Pause';
+ this.shortcuts['F'] = 'Fullscreen';
+ this.shortcuts['ESC, O'] = 'Slide overview';
+
+ }
+
+ /**
+ * Starts listening for keyboard events.
+ */
+ bind() {
+
+ document.addEventListener( 'keydown', this.onDocumentKeyDown, false );
+ document.addEventListener( 'keypress', this.onDocumentKeyPress, false );
+
+ }
+
+ /**
+ * Stops listening for keyboard events.
+ */
+ unbind() {
+
+ document.removeEventListener( 'keydown', this.onDocumentKeyDown, false );
+ document.removeEventListener( 'keypress', this.onDocumentKeyPress, false );
+
+ }
+
+ /**
+ * Add a custom key binding with optional description to
+ * be added to the help screen.
+ */
+ addKeyBinding( binding, callback ) {
+
+ if( typeof binding === 'object' && binding.keyCode ) {
+ this.bindings[binding.keyCode] = {
+ callback: callback,
+ key: binding.key,
+ description: binding.description
+ };
+ }
+ else {
+ this.bindings[binding] = {
+ callback: callback,
+ key: null,
+ description: null
+ };
+ }
+
+ }
+
+ /**
+ * Removes the specified custom key binding.
+ */
+ removeKeyBinding( keyCode ) {
+
+ delete this.bindings[keyCode];
+
+ }
+
+ /**
+ * Programmatically triggers a keyboard event
+ *
+ * @param {int} keyCode
+ */
+ triggerKey( keyCode ) {
+
+ this.onDocumentKeyDown( { keyCode } );
+
+ }
+
+ /**
+ * Registers a new shortcut to include in the help overlay
+ *
+ * @param {String} key
+ * @param {String} value
+ */
+ registerKeyboardShortcut( key, value ) {
+
+ this.shortcuts[key] = value;
+
+ }
+
+ getShortcuts() {
+
+ return this.shortcuts;
+
+ }
+
+ getBindings() {
+
+ return this.bindings;
+
+ }
+
+ /**
+ * Handler for the document level 'keypress' event.
+ *
+ * @param {object} event
+ */
+ onDocumentKeyPress( event ) {
+
+ // Check if the pressed key is question mark
+ if( event.shiftKey && event.charCode === 63 ) {
+ this.Reveal.toggleHelp();
+ }
+
+ }
+
+ /**
+ * Handler for the document level 'keydown' event.
+ *
+ * @param {object} event
+ */
+ onDocumentKeyDown( event ) {
+
+ let config = this.Reveal.getConfig();
+
+ // If there's a condition specified and it returns false,
+ // ignore this event
+ if( typeof config.keyboardCondition === 'function' && config.keyboardCondition(event) === false ) {
+ return true;
+ }
+
+ // If keyboardCondition is set, only capture keyboard events
+ // for embedded decks when they are focused
+ if( config.keyboardCondition === 'focused' && !this.Reveal.isFocused() ) {
+ return true;
+ }
+
+ // Shorthand
+ let keyCode = event.keyCode;
+
+ // Remember if auto-sliding was paused so we can toggle it
+ let autoSlideWasPaused = !this.Reveal.isAutoSliding();
+
+ this.Reveal.onUserInput( event );
+
+ // Is there a focused element that could be using the keyboard?
+ let activeElementIsCE = document.activeElement && document.activeElement.isContentEditable === true;
+ let activeElementIsInput = document.activeElement && document.activeElement.tagName && /input|textarea/i.test( document.activeElement.tagName );
+ let activeElementIsNotes = document.activeElement && document.activeElement.className && /speaker-notes/i.test( document.activeElement.className);
+
+ // Whitelist specific modified + keycode combinations
+ let prevSlideShortcut = event.shiftKey && event.keyCode === 32;
+ let firstSlideShortcut = event.shiftKey && keyCode === 37;
+ let lastSlideShortcut = event.shiftKey && keyCode === 39;
+
+ // Prevent all other events when a modifier is pressed
+ let unusedModifier = !prevSlideShortcut && !firstSlideShortcut && !lastSlideShortcut &&
+ ( event.shiftKey || event.altKey || event.ctrlKey || event.metaKey );
+
+ // Disregard the event if there's a focused element or a
+ // keyboard modifier key is present
+ if( activeElementIsCE || activeElementIsInput || activeElementIsNotes || unusedModifier ) return;
+
+ // While paused only allow resume keyboard events; 'b', 'v', '.'
+ let resumeKeyCodes = [66,86,190,191];
+ let key;
+
+ // Custom key bindings for togglePause should be able to resume
+ if( typeof config.keyboard === 'object' ) {
+ for( key in config.keyboard ) {
+ if( config.keyboard[key] === 'togglePause' ) {
+ resumeKeyCodes.push( parseInt( key, 10 ) );
+ }
+ }
+ }
+
+ if( this.Reveal.isPaused() && resumeKeyCodes.indexOf( keyCode ) === -1 ) {
+ return false;
+ }
+
+ // Use linear navigation if we're configured to OR if
+ // the presentation is one-dimensional
+ let useLinearMode = config.navigationMode === 'linear' || !this.Reveal.hasHorizontalSlides() || !this.Reveal.hasVerticalSlides();
+
+ let triggered = false;
+
+ // 1. User defined key bindings
+ if( typeof config.keyboard === 'object' ) {
+
+ for( key in config.keyboard ) {
+
+ // Check if this binding matches the pressed key
+ if( parseInt( key, 10 ) === keyCode ) {
+
+ let value = config.keyboard[ key ];
+
+ // Callback function
+ if( typeof value === 'function' ) {
+ value.apply( null, [ event ] );
+ }
+ // String shortcuts to reveal.js API
+ else if( typeof value === 'string' && typeof this.Reveal[ value ] === 'function' ) {
+ this.Reveal[ value ].call();
+ }
+
+ triggered = true;
+
+ }
+
+ }
+
+ }
+
+ // 2. Registered custom key bindings
+ if( triggered === false ) {
+
+ for( key in this.bindings ) {
+
+ // Check if this binding matches the pressed key
+ if( parseInt( key, 10 ) === keyCode ) {
+
+ let action = this.bindings[ key ].callback;
+
+ // Callback function
+ if( typeof action === 'function' ) {
+ action.apply( null, [ event ] );
+ }
+ // String shortcuts to reveal.js API
+ else if( typeof action === 'string' && typeof this.Reveal[ action ] === 'function' ) {
+ this.Reveal[ action ].call();
+ }
+
+ triggered = true;
+ }
+ }
+ }
+
+ // 3. System defined key bindings
+ if( triggered === false ) {
+
+ // Assume true and try to prove false
+ triggered = true;
+
+ // P, PAGE UP
+ if( keyCode === 80 || keyCode === 33 ) {
+ this.Reveal.prev();
+ }
+ // N, PAGE DOWN
+ else if( keyCode === 78 || keyCode === 34 ) {
+ this.Reveal.next();
+ }
+ // H, LEFT
+ else if( keyCode === 72 || keyCode === 37 ) {
+ if( firstSlideShortcut ) {
+ this.Reveal.slide( 0 );
+ }
+ else if( !this.Reveal.overview.isActive() && useLinearMode ) {
+ this.Reveal.prev();
+ }
+ else {
+ this.Reveal.left();
+ }
+ }
+ // L, RIGHT
+ else if( keyCode === 76 || keyCode === 39 ) {
+ if( lastSlideShortcut ) {
+ this.Reveal.slide( Number.MAX_VALUE );
+ }
+ else if( !this.Reveal.overview.isActive() && useLinearMode ) {
+ this.Reveal.next();
+ }
+ else {
+ this.Reveal.right();
+ }
+ }
+ // K, UP
+ else if( keyCode === 75 || keyCode === 38 ) {
+ if( !this.Reveal.overview.isActive() && useLinearMode ) {
+ this.Reveal.prev();
+ }
+ else {
+ this.Reveal.up();
+ }
+ }
+ // J, DOWN
+ else if( keyCode === 74 || keyCode === 40 ) {
+ if( !this.Reveal.overview.isActive() && useLinearMode ) {
+ this.Reveal.next();
+ }
+ else {
+ this.Reveal.down();
+ }
+ }
+ // HOME
+ else if( keyCode === 36 ) {
+ this.Reveal.slide( 0 );
+ }
+ // END
+ else if( keyCode === 35 ) {
+ this.Reveal.slide( Number.MAX_VALUE );
+ }
+ // SPACE
+ else if( keyCode === 32 ) {
+ if( this.Reveal.overview.isActive() ) {
+ this.Reveal.overview.deactivate();
+ }
+ if( event.shiftKey ) {
+ this.Reveal.prev();
+ }
+ else {
+ this.Reveal.next();
+ }
+ }
+ // TWO-SPOT, SEMICOLON, B, V, PERIOD, LOGITECH PRESENTER TOOLS "BLACK SCREEN" BUTTON
+ else if( keyCode === 58 || keyCode === 59 || keyCode === 66 || keyCode === 86 || keyCode === 190 || keyCode === 191 ) {
+ this.Reveal.togglePause();
+ }
+ // F
+ else if( keyCode === 70 ) {
+ enterFullscreen( config.embedded ? this.Reveal.getViewportElement() : document.documentElement );
+ }
+ // A
+ else if( keyCode === 65 ) {
+ if ( config.autoSlideStoppable ) {
+ this.Reveal.toggleAutoSlide( autoSlideWasPaused );
+ }
+ }
+ else {
+ triggered = false;
+ }
+
+ }
+
+ // If the input resulted in a triggered action we should prevent
+ // the browsers default behavior
+ if( triggered ) {
+ event.preventDefault && event.preventDefault();
+ }
+ // ESC or O key
+ else if( keyCode === 27 || keyCode === 79 ) {
+ if( this.Reveal.closeOverlay() === false ) {
+ this.Reveal.overview.toggle();
+ }
+
+ event.preventDefault && event.preventDefault();
+ }
+
+ // If auto-sliding is enabled we need to cue up
+ // another timeout
+ this.Reveal.cueAutoSlide();
+
+ }
+
+}
\ No newline at end of file
diff --git a/js/controllers/location.js b/js/controllers/location.js
new file mode 100644
index 0000000..42fff62
--- /dev/null
+++ b/js/controllers/location.js
@@ -0,0 +1,209 @@
+/**
+ * Reads and writes the URL based on reveal.js' current state.
+ */
+export default class Location {
+
+ constructor( Reveal ) {
+
+ this.Reveal = Reveal;
+
+ // Delays updates to the URL due to a Chrome thumbnailer bug
+ this.writeURLTimeout = 0;
+
+ this.onWindowHashChange = this.onWindowHashChange.bind( this );
+
+ }
+
+ bind() {
+
+ window.addEventListener( 'hashchange', this.onWindowHashChange, false );
+
+ }
+
+ unbind() {
+
+ window.removeEventListener( 'hashchange', this.onWindowHashChange, false );
+
+ }
+
+ /**
+ * Reads the current URL (hash) and navigates accordingly.
+ */
+ readURL() {
+
+ let config = this.Reveal.getConfig();
+ let indices = this.Reveal.getIndices();
+ let currentSlide = this.Reveal.getCurrentSlide();
+
+ let hash = window.location.hash;
+
+ // Attempt to parse the hash as either an index or name
+ let bits = hash.slice( 2 ).split( '/' ),
+ name = hash.replace( /#\/?/gi, '' );
+
+ // If the first bit is not fully numeric and there is a name we
+ // can assume that this is a named link
+ if( !/^[0-9]*$/.test( bits[0] ) && name.length ) {
+ let element;
+
+ let f;
+
+ // Parse named links with fragments (#/named-link/2)
+ if( /\/[-\d]+$/g.test( name ) ) {
+ f = parseInt( name.split( '/' ).pop(), 10 );
+ f = isNaN(f) ? undefined : f;
+ name = name.split( '/' ).shift();
+ }
+
+ // Ensure the named link is a valid HTML ID attribute
+ try {
+ element = document.getElementById( decodeURIComponent( name ) );
+ }
+ catch ( error ) { }
+
+ // Ensure that we're not already on a slide with the same name
+ let isSameNameAsCurrentSlide = currentSlide ? currentSlide.getAttribute( 'id' ) === name : false;
+
+ if( element ) {
+ // If the slide exists and is not the current slide...
+ if ( !isSameNameAsCurrentSlide || typeof f !== 'undefined' ) {
+ // ...find the position of the named slide and navigate to it
+ let slideIndices = this.Reveal.getIndices( element );
+ this.Reveal.slide( slideIndices.h, slideIndices.v, f );
+ }
+ }
+ // If the slide doesn't exist, navigate to the current slide
+ else {
+ this.Reveal.slide( indices.h || 0, indices.v || 0 );
+ }
+ }
+ else {
+ let hashIndexBase = config.hashOneBasedIndex ? 1 : 0;
+
+ // Read the index components of the hash
+ let h = ( parseInt( bits[0], 10 ) - hashIndexBase ) || 0,
+ v = ( parseInt( bits[1], 10 ) - hashIndexBase ) || 0,
+ f;
+
+ if( config.fragmentInURL ) {
+ f = parseInt( bits[2], 10 );
+ if( isNaN( f ) ) {
+ f = undefined;
+ }
+ }
+
+ if( h !== indices.h || v !== indices.v || f !== undefined ) {
+ this.Reveal.slide( h, v, f );
+ }
+ }
+
+ }
+
+ /**
+ * Updates the page URL (hash) to reflect the current
+ * state.
+ *
+ * @param {number} delay The time in ms to wait before
+ * writing the hash
+ */
+ writeURL( delay ) {
+
+ let config = this.Reveal.getConfig();
+ let currentSlide = this.Reveal.getCurrentSlide();
+
+ // Make sure there's never more than one timeout running
+ clearTimeout( this.writeURLTimeout );
+
+ // If a delay is specified, timeout this call
+ if( typeof delay === 'number' ) {
+ this.writeURLTimeout = setTimeout( this.writeURL, delay );
+ }
+ else if( currentSlide ) {
+
+ let hash = this.getHash();
+
+ // If we're configured to push to history OR the history
+ // API is not avaialble.
+ if( config.history ) {
+ window.location.hash = hash;
+ }
+ // If we're configured to reflect the current slide in the
+ // URL without pushing to history.
+ else if( config.hash ) {
+ // If the hash is empty, don't add it to the URL
+ if( hash === '/' ) {
+ window.history.replaceState( null, null, window.location.pathname + window.location.search );
+ }
+ else {
+ window.history.replaceState( null, null, '#' + hash );
+ }
+ }
+ // UPDATE: The below nuking of all hash changes breaks
+ // anchors on pages where reveal.js is running. Removed
+ // in 4.0. Why was it here in the first place? ¯\_(ツ)_/¯
+ //
+ // If history and hash are both disabled, a hash may still
+ // be added to the URL by clicking on a href with a hash
+ // target. Counter this by always removing the hash.
+ // else {
+ // window.history.replaceState( null, null, window.location.pathname + window.location.search );
+ // }
+
+ }
+
+ }
+
+ /**
+ * Return a hash URL that will resolve to the given slide location.
+ *
+ * @param {HTMLElement} [slide=currentSlide] The slide to link to
+ */
+ getHash( slide ) {
+
+ let url = '/';
+
+ // Attempt to create a named link based on the slide's ID
+ let s = slide || this.Reveal.getCurrentSlide();
+ let id = s ? s.getAttribute( 'id' ) : null;
+ if( id ) {
+ id = encodeURIComponent( id );
+ }
+
+ let index = this.Reveal.getIndices( slide );
+ if( !this.Reveal.getConfig().fragmentInURL ) {
+ index.f = undefined;
+ }
+
+ // If the current slide has an ID, use that as a named link,
+ // but we don't support named links with a fragment index
+ if( typeof id === 'string' && id.length ) {
+ url = '/' + id;
+
+ // If there is also a fragment, append that at the end
+ // of the named link, like: #/named-link/2
+ if( index.f >= 0 ) url += '/' + index.f;
+ }
+ // Otherwise use the /h/v index
+ else {
+ let hashIndexBase = this.Reveal.getConfig().hashOneBasedIndex ? 1 : 0;
+ if( index.h > 0 || index.v > 0 || index.f >= 0 ) url += index.h + hashIndexBase;
+ if( index.v > 0 || index.f >= 0 ) url += '/' + (index.v + hashIndexBase );
+ if( index.f >= 0 ) url += '/' + index.f;
+ }
+
+ return url;
+
+ }
+
+ /**
+ * Handler for the window level 'hashchange' event.
+ *
+ * @param {object} [event]
+ */
+ onWindowHashChange( event ) {
+
+ this.readURL();
+
+ }
+
+}
\ No newline at end of file
diff --git a/js/controllers/notes.js b/js/controllers/notes.js
new file mode 100644
index 0000000..8ec1d42
--- /dev/null
+++ b/js/controllers/notes.js
@@ -0,0 +1,114 @@
+/**
+ * Handles the showing and
+ */
+export default class Notes {
+
+ constructor( Reveal ) {
+
+ this.Reveal = Reveal;
+
+ }
+
+ render() {
+
+ this.element = document.createElement( 'div' );
+ this.element.className = 'speaker-notes';
+ this.element.setAttribute( 'data-prevent-swipe', '' );
+ this.element.setAttribute( 'tabindex', '0' );
+ this.Reveal.getRevealElement().appendChild( this.element );
+
+ }
+
+ /**
+ * Called when the reveal.js config is updated.
+ */
+ configure( config, oldConfig ) {
+
+ if( config.showNotes ) {
+ this.element.setAttribute( 'data-layout', typeof config.showNotes === 'string' ? config.showNotes : 'inline' );
+ }
+
+ }
+
+ /**
+ * Pick up notes from the current slide and display them
+ * to the viewer.
+ *
+ * @see {@link config.showNotes}
+ */
+ update() {
+
+ if( this.Reveal.getConfig().showNotes && this.element && this.Reveal.getCurrentSlide() && !this.Reveal.print.isPrintingPDF() ) {
+
+ this.element.innerHTML = this.getSlideNotes() || 'No notes on this slide.';
+
+ }
+
+ }
+
+ /**
+ * Updates the visibility of the speaker notes sidebar that
+ * is used to share annotated slides. The notes sidebar is
+ * only visible if showNotes is true and there are notes on
+ * one or more slides in the deck.
+ */
+ updateVisibility() {
+
+ if( this.Reveal.getConfig().showNotes && this.hasNotes() && !this.Reveal.print.isPrintingPDF() ) {
+ this.Reveal.getRevealElement().classList.add( 'show-notes' );
+ }
+ else {
+ this.Reveal.getRevealElement().classList.remove( 'show-notes' );
+ }
+
+ }
+
+ /**
+ * Checks if there are speaker notes for ANY slide in the
+ * presentation.
+ */
+ hasNotes() {
+
+ return this.Reveal.getSlidesElement().querySelectorAll( '[data-notes], aside.notes' ).length > 0;
+
+ }
+
+ /**
+ * Checks if this presentation is running inside of the
+ * speaker notes window.
+ *
+ * @return {boolean}
+ */
+ isSpeakerNotesWindow() {
+
+ return !!window.location.search.match( /receiver/gi );
+
+ }
+
+ /**
+ * Retrieves the speaker notes from a slide. Notes can be
+ * defined in two ways:
+ * 1. As a data-notes attribute on the slide
+ * 2. As an