hakimel/reveal.js

View on GitHub
js/controllers/backgrounds.js

Summary

Maintainability
D
2 days
Test Coverage
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() {

        // 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 ) {

        const element = slide.slideBackgroundElement,
            contentElement = slide.slideBackgroundContentElement;

        const 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' ),
            backgroundGradient: slide.getAttribute( 'data-background-gradient' ),
            backgroundRepeat: slide.getAttribute( 'data-background-repeat' ),
            backgroundPosition: slide.getAttribute( 'data-background-position' ),
            backgroundTransition: slide.getAttribute( 'data-background-transition' ),
            backgroundOpacity: slide.getAttribute( 'data-background-opacity' ),
        };

        const dataPreload = slide.hasAttribute( 'data-preload' );

        // 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 = '';

        if( data.background ) {
            // Auto-wrap image urls in url(...)
            if( /^(http|file|\/\/)/gi.test( data.background ) || /\.(svg|png|jpg|jpeg|gif|bmp|webp)([?#\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.backgroundGradient || data.backgroundImage || data.backgroundVideo || data.backgroundIframe ) {
            element.setAttribute( 'data-background-hash', data.background +
                                                            data.backgroundSize +
                                                            data.backgroundImage +
                                                            data.backgroundVideo +
                                                            data.backgroundIframe +
                                                            data.backgroundColor +
                                                            data.backgroundGradient +
                                                            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.backgroundGradient ) element.style.backgroundImage = data.backgroundGradient;
        if( data.backgroundTransition ) element.setAttribute( 'data-background-transition', data.backgroundTransition );

        if( dataPreload ) 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;

        const contrastClass = this.getContrastClass( slide );

        if( typeof contrastClass === 'string' ) {
            slide.classList.add( contrastClass );
        }

    }

    /**
     * Returns a class name that can be applied to a slide to indicate
     * if it has a light or dark background.
     *
     * @param {*} slide
     *
     * @returns {string|null}
     */
    getContrastClass( slide ) {

        const element = slide.slideBackgroundElement;

        // 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 = slide.getAttribute( 'data-background-color' );

        // If no bg color was found, or it cannot be converted by colorToRgb, check the computed background
        if( !contrastColor || !colorToRgb( contrastColor ) ) {
            let computedBackgroundStyle = window.getComputedStyle( element );
            if( computedBackgroundStyle && computedBackgroundStyle.backgroundColor ) {
                contrastColor = computedBackgroundStyle.backgroundColor;
            }
        }

        if( contrastColor ) {
            const 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 ) {
                    return 'has-dark-background';
                }
                else {
                    return 'has-light-background';
                }
            }
        }

        return null;

    }

    /**
     * Bubble the 'has-light-background'/'has-dark-background' classes.
     */
    bubbleSlideContrastClassToElement( slide, target ) {

        [ 'has-light-background', 'has-dark-background' ].forEach( classToBubble => {
            if( slide.classList.contains( classToBubble ) ) {
                target.classList.add( classToBubble );
            }
            else {
                target.classList.remove( classToBubble );
            }
        }, this );

    }

    /**
     * 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 config = this.Reveal.getConfig();
        let currentSlide = this.Reveal.getCurrentSlide();
        let indices = this.Reveal.getIndices();

        let currentBackground = null;

        // Reverse past/future classes when in RTL mode
        let horizontalPast = config.rtl ? 'future' : 'past',
            horizontalFuture = config.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' );

                    const indexv = typeof indices.v === 'number' ? indices.v : 0;

                    if( v < indexv ) {
                        backgroundv.classList.add( 'past' );
                    }
                    else if ( v > indexv ) {
                        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;
                    }

                } );
            }

        } );

        // The previous background may refer to a DOM element that has
        // been removed after a presentation is synced & bgs are recreated
        if( this.previousBackground && !this.previousBackground.closest( 'body' ) ) {
            this.previousBackground = null;
        }

        if( currentBackground && this.previousBackground ) {

            // Don't transition between identical backgrounds. This
            // prevents unwanted flicker.
            let previousBackgroundHash = this.previousBackground.getAttribute( 'data-background-hash' );
            let currentBackgroundHash = currentBackground.getAttribute( 'data-background-hash' );

            if( currentBackgroundHash && currentBackgroundHash === previousBackgroundHash && currentBackground !== this.previousBackground ) {
                this.element.classList.add( 'no-transition' );

                // If multiple slides have the same background video, carry
                // the <video> element forward so that it plays continuously
                // across multiple slides
                const currentVideo = currentBackground.querySelector( 'video' );
                const previousVideo = this.previousBackground.querySelector( 'video' );

                if( currentVideo && previousVideo ) {

                    const currentVideoParent = currentVideo.parentNode;
                    const previousVideoParent = previousVideo.parentNode;

                    // Swap the two videos
                    previousVideoParent.appendChild( currentVideo );
                    currentVideoParent.appendChild( previousVideo );

                }
            }

        }

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

            }

            this.previousBackground = currentBackground;

        }

        // If there's a background brightness flag for this slide,
        // bubble it to the .reveal container
        if( currentSlide ) {
            this.bubbleSlideContrastClassToElement( currentSlide, this.Reveal.getRevealElement() );
        }

        // Allow the first background to apply without transition
        setTimeout( () => {
            this.element.classList.remove( 'no-transition' );
        }, 10 );

    }

    /**
     * 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';

        }

    }

    destroy() {

        this.element.remove();

    }

}