hakimel/reveal.js

View on GitHub
js/controllers/slidecontent.js

Summary

Maintainability
F
3 days
Test Coverage
import { extend, queryAll, closest, getMimeTypeFromFile, encodeRFC3986URI } from '../utils/util.js'
import { isMobile } from '../utils/device.js'

import fitty from 'fitty';

/**
 * Handles loading, unloading and playback of slide
 * content such as images, videos and iframes.
 */
export default class SlideContent {

    constructor( Reveal ) {

        this.Reveal = Reveal;

        this.startEmbeddedIframe = this.startEmbeddedIframe.bind( this );

    }

    /**
     * Should the given element be preloaded?
     * Decides based on local element attributes and global config.
     *
     * @param {HTMLElement} element
     */
    shouldPreload( element ) {

        if( this.Reveal.isScrollView() ) {
            return true;
        }

        // 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
        queryAll( slide, '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
        queryAll( slide, 'video, audio' ).forEach( media => {
            let sources = 0;

            queryAll( media, 'source[data-src]' ).forEach( source => {
                source.setAttribute( 'src', source.getAttribute( 'data-src' ) );
                source.removeAttribute( 'data-src' );
                source.setAttribute( 'data-lazy-loaded', '' );
                sources += 1;
            } );

            // Enable inline video playback in mobile Safari
            if( isMobile && media.tagName === 'VIDEO' ) {
                media.setAttribute( 'playsinline', '' );
            }

            // 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 ) {
                    // base64
                    if(  /^data:/.test( backgroundImage.trim() ) ) {
                        backgroundContent.style.backgroundImage = `url(${backgroundImage.trim()})`;
                    }
                    // URL(s)
                    else {
                        backgroundContent.style.backgroundImage = backgroundImage.split( ',' ).map( background => {
                            // Decode URL(s) that are already encoded first
                            let decoded = decodeURI(background.trim());
                            return `url(${encodeRFC3986URI(decoded)})`;
                        }).join( ',' );
                    }
                }
                // Videos
                else if ( backgroundVideo && !this.Reveal.isSpeakerNotes() ) {
                    let video = document.createElement( 'video' );

                    if( backgroundVideoLoop ) {
                        video.setAttribute( 'loop', '' );
                    }

                    if( backgroundVideoMuted ) {
                        video.muted = true;
                    }

                    // Enable inline playback in mobile Safari
                    //
                    // Mute is required for video to play when using
                    // swipe gestures to navigate since they don't
                    // count as direct user actions :'(
                    if( isMobile ) {
                        video.muted = true;
                        video.setAttribute( 'playsinline', '' );
                    }

                    // Support comma separated lists of video sources
                    backgroundVideo.split( ',' ).forEach( source => {
                        const sourceElement = document.createElement( 'source' );
                        sourceElement.setAttribute( 'src', source );

                        let type = getMimeTypeFromFile( source );
                        if( type ) {
                            sourceElement.setAttribute( 'type', type );
                        }

                        video.appendChild( sourceElement );
                    } );

                    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 );
                    }
                }

            }

        }

        this.layout( slide );

    }

    /**
     * Applies JS-dependent layout helpers for the scope.
     */
    layout( scopeElement ) {

        // Autosize text with the r-fit-text class based on the
        // size of its container. This needs to happen after the
        // slide is visible in order to measure the text.
        Array.from( scopeElement.querySelectorAll( '.r-fit-text' ) ).forEach( element => {
            fitty( element, {
                minSize: 24,
                maxSize: this.Reveal.getConfig().height * 0.8,
                observeMutations: false,
                observeWindow: false
            } );
        } );

    }

    /**
     * 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
            queryAll( background, 'iframe[src]' ).forEach( element => {
                element.removeAttribute( 'src' );
            } );
        }

        // Reset lazy-loaded media elements with src attributes
        queryAll( slide, '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
        queryAll( slide, '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 ) => {
            queryAll( this.Reveal.getSlidesElement(), '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
            queryAll( element, '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
            queryAll( element, 'video, audio' ).forEach( el => {
                if( closest( el, '.fragment' ) && !closest( 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' ) || !!closest( 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
            queryAll( element, 'iframe[src]' ).forEach( el => {
                if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
                    return;
                }

                this.startEmbeddedIframe( { target: el } );
            } );

            // Lazy loading iframes
            queryAll( element, 'iframe[data-src]' ).forEach( el => {
                if( closest( el, '.fragment' ) && !closest( 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 = !!closest( event.target, 'html' ),
            isVisible          = !!closest( event.target, '.present' );

        if( isAttachedToDOM && isVisible ) {
            // Don't restart if media is already playing
            if( event.target.paused || event.target.ended ) {
                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 = !!closest( event.target, 'html' ),
                isVisible          = !!closest( 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' ) || !!closest( 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
            queryAll( element, '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
            queryAll( element, 'iframe' ).forEach( el => {
                if( el.contentWindow ) el.contentWindow.postMessage( 'slide:stop', '*' );
                el.removeEventListener( 'load', this.startEmbeddedIframe );
            });

            // YouTube postMessage API
            queryAll( element, '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
            queryAll( element, '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
                queryAll( element, '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' );
                } );
            }
        }

    }

}