hakimel/reveal.js

View on GitHub
js/controllers/scrollview.js

Summary

Maintainability
F
4 days
Test Coverage
import { HORIZONTAL_SLIDES_SELECTOR, HORIZONTAL_BACKGROUNDS_SELECTOR } from '../utils/constants.js'
import { queryAll } from '../utils/util.js'

const HIDE_SCROLLBAR_TIMEOUT = 500;
const MAX_PROGRESS_SPACING = 4;
const MIN_PROGRESS_SEGMENT_HEIGHT = 6;
const MIN_PLAYHEAD_HEIGHT = 8;

/**
 * The scroll view lets you read a reveal.js presentation
 * as a linear scrollable page.
 */
export default class ScrollView {

    constructor( Reveal ) {

        this.Reveal = Reveal;

        this.active = false;
        this.activatedCallbacks = [];

        this.onScroll = this.onScroll.bind( this );

    }

    /**
     * Activates the scroll view. This rearranges the presentation DOM
     * by—among other things—wrapping each slide in a page element.
     */
    activate() {

        if( this.active ) return;

        const stateBeforeActivation = this.Reveal.getState();

        this.active = true;

        // Store the full presentation HTML so that we can restore it
        // when/if the scroll view is deactivated
        this.slideHTMLBeforeActivation = this.Reveal.getSlidesElement().innerHTML;

        const horizontalSlides = queryAll( this.Reveal.getRevealElement(), HORIZONTAL_SLIDES_SELECTOR );
        const horizontalBackgrounds = queryAll( this.Reveal.getRevealElement(), HORIZONTAL_BACKGROUNDS_SELECTOR );

        this.viewportElement.classList.add( 'loading-scroll-mode', 'reveal-scroll' );

        let presentationBackground;

        const viewportStyles = window.getComputedStyle( this.viewportElement );
        if( viewportStyles && viewportStyles.background ) {
            presentationBackground = viewportStyles.background;
        }

        const pageElements = [];
        const pageContainer = horizontalSlides[0].parentNode;

        let previousSlide;

        // Creates a new page element and appends the given slide/bg
        // to it.
        const createPageElement = ( slide, h, v, isVertical ) => {

            let contentContainer;

            // If this slide is part of an auto-animation sequence, we
            // group it under the same page element as the previous slide
            if( previousSlide && this.Reveal.shouldAutoAnimateBetween( previousSlide, slide ) ) {
                contentContainer = document.createElement( 'div' );
                contentContainer.className = 'scroll-page-content scroll-auto-animate-page';
                contentContainer.style.display = 'none';
                previousSlide.closest( '.scroll-page-content' ).parentNode.appendChild( contentContainer );
            }
            else {
                // Wrap the slide in a page element and hide its overflow
                // so that no page ever flows onto another
                const page = document.createElement( 'div' );
                page.className = 'scroll-page';
                pageElements.push( page );

                // This transfers over the background of the vertical stack containing
                // the slide if it exists. Otherwise, it uses the presentation-wide
                // background.
                if( isVertical && horizontalBackgrounds.length > h ) {
                    const slideBackground = horizontalBackgrounds[h];
                    const pageBackground = window.getComputedStyle( slideBackground );

                    if( pageBackground && pageBackground.background ) {
                        page.style.background = pageBackground.background;
                    }
                    else if( presentationBackground ) {
                        page.style.background = presentationBackground;
                    }
                } else if( presentationBackground ) {
                    page.style.background = presentationBackground;
                }

                const stickyContainer = document.createElement( 'div' );
                stickyContainer.className = 'scroll-page-sticky';
                page.appendChild( stickyContainer );

                contentContainer = document.createElement( 'div' );
                contentContainer.className = 'scroll-page-content';
                stickyContainer.appendChild( contentContainer );
            }

            contentContainer.appendChild( slide );

            slide.classList.remove( 'past', 'future' );
            slide.setAttribute( 'data-index-h', h );
            slide.setAttribute( 'data-index-v', v );

            if( slide.slideBackgroundElement ) {
                slide.slideBackgroundElement.remove( 'past', 'future' );
                contentContainer.insertBefore( slide.slideBackgroundElement, slide );
            }

            previousSlide = slide;

        }

        // Slide and slide background layout
        horizontalSlides.forEach( ( horizontalSlide, h ) => {

            if( this.Reveal.isVerticalStack( horizontalSlide ) ) {
                horizontalSlide.querySelectorAll( 'section' ).forEach( ( verticalSlide, v ) => {
                    createPageElement( verticalSlide, h, v, true );
                });
            }
            else {
                createPageElement( horizontalSlide, h, 0 );
            }

        }, this );

        this.createProgressBar();

        // Remove leftover stacks
        queryAll( this.Reveal.getRevealElement(), '.stack' ).forEach( stack => stack.remove() );

        // Add our newly created pages to the DOM
        pageElements.forEach( page => pageContainer.appendChild( page ) );

        // Re-run JS-based content layout after the slide is added to page DOM
        this.Reveal.slideContent.layout( this.Reveal.getSlidesElement() );

        this.Reveal.layout();
        this.Reveal.setState( stateBeforeActivation );

        this.activatedCallbacks.forEach( callback => callback() );
        this.activatedCallbacks = [];

        this.restoreScrollPosition();

        this.viewportElement.classList.remove( 'loading-scroll-mode' );
        this.viewportElement.addEventListener( 'scroll', this.onScroll, { passive: true } );

    }

    /**
     * Deactivates the scroll view and restores the standard slide-based
     * presentation.
     */
    deactivate() {

        if( !this.active ) return;

        const stateBeforeDeactivation = this.Reveal.getState();

        this.active = false;

        this.viewportElement.removeEventListener( 'scroll', this.onScroll );
        this.viewportElement.classList.remove( 'reveal-scroll' );

        this.removeProgressBar();

        this.Reveal.getSlidesElement().innerHTML = this.slideHTMLBeforeActivation;
        this.Reveal.sync();
        this.Reveal.setState( stateBeforeDeactivation );

        this.slideHTMLBeforeActivation = null;

    }

    toggle( override ) {

        if( typeof override === 'boolean' ) {
            override ? this.activate() : this.deactivate();
        }
        else {
            this.isActive() ? this.deactivate() : this.activate();
        }

    }

    /**
     * Checks if the scroll view is currently active.
     */
    isActive() {

        return this.active;

    }

    /**
     * Renders the progress bar component.
     */
    createProgressBar() {

        this.progressBar = document.createElement( 'div' );
        this.progressBar.className = 'scrollbar';

        this.progressBarInner = document.createElement( 'div' );
        this.progressBarInner.className = 'scrollbar-inner';
        this.progressBar.appendChild( this.progressBarInner );

        this.progressBarPlayhead = document.createElement( 'div' );
        this.progressBarPlayhead.className = 'scrollbar-playhead';
        this.progressBarInner.appendChild( this.progressBarPlayhead );

        this.viewportElement.insertBefore( this.progressBar, this.viewportElement.firstChild );

        const handleDocumentMouseMove    = ( event ) => {

            let progress = ( event.clientY - this.progressBarInner.getBoundingClientRect().top ) / this.progressBarHeight;
            progress = Math.max( Math.min( progress, 1 ), 0 );

            this.viewportElement.scrollTop = progress * ( this.viewportElement.scrollHeight - this.viewportElement.offsetHeight );

        };

        const handleDocumentMouseUp = ( event ) => {

            this.draggingProgressBar = false;
            this.showProgressBar();

            document.removeEventListener( 'mousemove', handleDocumentMouseMove );
            document.removeEventListener( 'mouseup', handleDocumentMouseUp );

        };

        const handleMouseDown = ( event ) => {

            event.preventDefault();

            this.draggingProgressBar = true;

            document.addEventListener( 'mousemove', handleDocumentMouseMove );
            document.addEventListener( 'mouseup', handleDocumentMouseUp );

            handleDocumentMouseMove( event );

        };

        this.progressBarInner.addEventListener( 'mousedown', handleMouseDown );

    }

    removeProgressBar() {

        if( this.progressBar ) {
            this.progressBar.remove();
            this.progressBar = null;
        }

    }

    layout() {

        if( this.isActive() ) {
            this.syncPages();
            this.syncScrollPosition();
        }

    }

    /**
     * Updates our pages to match the latest configuration and
     * presentation size.
     */
    syncPages() {

        const config = this.Reveal.getConfig();

        const slideSize = this.Reveal.getComputedSlideSize( window.innerWidth, window.innerHeight );
        const scale = this.Reveal.getScale();
        const useCompactLayout = config.scrollLayout === 'compact';

        const viewportHeight = this.viewportElement.offsetHeight;
        const compactHeight = slideSize.height * scale;
        const pageHeight = useCompactLayout ? compactHeight : viewportHeight;

        // The height that needs to be scrolled between scroll triggers
        this.scrollTriggerHeight = useCompactLayout ? compactHeight : viewportHeight;

        this.viewportElement.style.setProperty( '--page-height', pageHeight + 'px' );
        this.viewportElement.style.scrollSnapType = typeof config.scrollSnap === 'string' ? `y ${config.scrollSnap}` : '';

        // This will hold all scroll triggers used to show/hide slides
        this.slideTriggers = [];

        const pageElements = Array.from( this.Reveal.getRevealElement().querySelectorAll( '.scroll-page' ) );

        this.pages = pageElements.map( pageElement => {
            const page = this.createPage({
                pageElement,
                slideElement: pageElement.querySelector( 'section' ),
                stickyElement: pageElement.querySelector( '.scroll-page-sticky' ),
                contentElement: pageElement.querySelector( '.scroll-page-content' ),
                backgroundElement: pageElement.querySelector( '.slide-background' ),
                autoAnimateElements: pageElement.querySelectorAll( '.scroll-auto-animate-page' ),
                autoAnimatePages: []
            });

            page.pageElement.style.setProperty( '--slide-height', config.center === true ? 'auto' : slideSize.height + 'px' );

            this.slideTriggers.push({
                page: page,
                activate: () => this.activatePage( page ),
                deactivate: () => this.deactivatePage( page )
            });

            // Create scroll triggers that show/hide fragments
            this.createFragmentTriggersForPage( page );

            // Create scroll triggers for triggering auto-animate steps
            if( page.autoAnimateElements.length > 0 ) {
                this.createAutoAnimateTriggersForPage( page );
            }

            let totalScrollTriggerCount = Math.max( page.scrollTriggers.length - 1, 0 );

            // Each auto-animate step may include its own scroll triggers
            // for fragments, ensure we count those as well
            totalScrollTriggerCount += page.autoAnimatePages.reduce( ( total, page ) => {
                return total + Math.max( page.scrollTriggers.length - 1, 0 );
            }, page.autoAnimatePages.length );

            // Clean up from previous renders
            page.pageElement.querySelectorAll( '.scroll-snap-point' ).forEach( el => el.remove() );

            // Create snap points for all scroll triggers
            // - Can't be absolute in FF
            // - Can't be 0-height in Safari
            // - Can't use snap-align on parent in Safari because then
            //   inner triggers won't work
            for( let i = 0; i < totalScrollTriggerCount + 1; i++ ) {
                const triggerStick = document.createElement( 'div' );
                triggerStick.className = 'scroll-snap-point';
                triggerStick.style.height = this.scrollTriggerHeight + 'px';
                triggerStick.style.scrollSnapAlign = useCompactLayout ? 'center' : 'start';
                page.pageElement.appendChild( triggerStick );

                if( i === 0 ) {
                    triggerStick.style.marginTop = -this.scrollTriggerHeight + 'px';
                }
            }

            // In the compact layout, only slides with scroll triggers cover the
            // full viewport height. This helps avoid empty gaps before or after
            // a sticky slide.
            if( useCompactLayout && page.scrollTriggers.length > 0 ) {
                page.pageHeight = viewportHeight;
                page.pageElement.style.setProperty( '--page-height', viewportHeight + 'px' );
            }
            else {
                page.pageHeight = pageHeight;
                page.pageElement.style.removeProperty( '--page-height' );
            }

            // Add scroll padding based on how many scroll triggers we have
            page.scrollPadding = this.scrollTriggerHeight * totalScrollTriggerCount;

            // The total height including scrollable space
            page.totalHeight = page.pageHeight + page.scrollPadding;

            // This is used to pad the height of our page in CSS
            page.pageElement.style.setProperty( '--page-scroll-padding', page.scrollPadding + 'px' );

            // If this is a sticky page, stick it to the vertical center
            if( totalScrollTriggerCount > 0 ) {
                page.stickyElement.style.position = 'sticky';
                page.stickyElement.style.top = Math.max( ( viewportHeight - page.pageHeight ) / 2, 0 ) + 'px';
            }
            else {
                page.stickyElement.style.position = 'relative';
                page.pageElement.style.scrollSnapAlign = page.pageHeight < viewportHeight ? 'center' : 'start';
            }

            return page;
        } );

        this.setTriggerRanges();

        /*
        console.log(this.slideTriggers.map( t => {
            return {
                range: `${t.range[0].toFixed(2)}-${t.range[1].toFixed(2)}`,
                triggers: t.page.scrollTriggers.map( t => {
                    return `${t.range[0].toFixed(2)}-${t.range[1].toFixed(2)}`
                }).join( ', ' ),
            }
        }))
        */

        this.viewportElement.setAttribute( 'data-scrollbar', config.scrollProgress );

        if( config.scrollProgress && this.totalScrollTriggerCount > 1 ) {
            // Create the progress bar if it doesn't already exist
            if( !this.progressBar ) this.createProgressBar();

            this.syncProgressBar();
        }
        else {
            this.removeProgressBar();
        }

    }

    /**
     * Calculates and sets the scroll range for all of our scroll
     * triggers.
     */
    setTriggerRanges() {

        // Calculate the total number of scroll triggers
        this.totalScrollTriggerCount = this.slideTriggers.reduce( ( total, trigger ) => {
            return total + Math.max( trigger.page.scrollTriggers.length, 1 );
        }, 0 );

        let rangeStart = 0;

        // Calculate the scroll range of each scroll trigger on a scale
        // of 0-1
        this.slideTriggers.forEach( ( trigger, i ) => {
            trigger.range = [
                rangeStart,
                rangeStart + Math.max( trigger.page.scrollTriggers.length, 1 ) / this.totalScrollTriggerCount
            ];

            const scrollTriggerSegmentSize = ( trigger.range[1] - trigger.range[0] ) / trigger.page.scrollTriggers.length;
            // Set the range for each inner scroll trigger
            trigger.page.scrollTriggers.forEach( ( scrollTrigger, i ) => {
                scrollTrigger.range = [
                    rangeStart + i * scrollTriggerSegmentSize,
                    rangeStart + ( i + 1 ) * scrollTriggerSegmentSize
                ];
            } );

            rangeStart = trigger.range[1];
        } );

    }

    /**
     * Creates one scroll trigger for each fragments in the given page.
     *
     * @param {*} page
     */
    createFragmentTriggersForPage( page, slideElement ) {

        slideElement = slideElement || page.slideElement;

        // Each fragment 'group' is an array containing one or more
        // fragments. Multiple fragments that appear at the same time
        // are part of the same group.
        const fragmentGroups = this.Reveal.fragments.sort( slideElement.querySelectorAll( '.fragment' ), true );

        // Create scroll triggers that show/hide fragments
        if( fragmentGroups.length ) {
            page.fragments = this.Reveal.fragments.sort( slideElement.querySelectorAll( '.fragment:not(.disabled)' ) );
            page.scrollTriggers.push(
                // Trigger for the initial state with no fragments visible
                {
                    activate: () => {
                        this.Reveal.fragments.update( -1, page.fragments, slideElement );
                    }
                }
            );

            // Triggers for each fragment group
            fragmentGroups.forEach( ( fragments, i ) => {
                page.scrollTriggers.push({
                    activate: () => {
                        this.Reveal.fragments.update( i, page.fragments, slideElement );
                    }
                });
            } );
        }


        return page.scrollTriggers.length;

    }

    /**
     * Creates scroll triggers for the auto-animate steps in the
     * given page.
     *
     * @param {*} page
     */
    createAutoAnimateTriggersForPage( page ) {

        if( page.autoAnimateElements.length > 0 ) {

            // Triggers for each subsequent auto-animate slide
            this.slideTriggers.push( ...Array.from( page.autoAnimateElements ).map( ( autoAnimateElement, i ) => {
                let autoAnimatePage = this.createPage({
                    slideElement: autoAnimateElement.querySelector( 'section' ),
                    contentElement: autoAnimateElement,
                    backgroundElement: autoAnimateElement.querySelector( '.slide-background' )
                });

                // Create fragment scroll triggers for the auto-animate slide
                this.createFragmentTriggersForPage( autoAnimatePage, autoAnimatePage.slideElement );

                page.autoAnimatePages.push( autoAnimatePage );

                // Return our slide trigger
                return {
                    page: autoAnimatePage,
                    activate: () => this.activatePage( autoAnimatePage ),
                    deactivate: () => this.deactivatePage( autoAnimatePage )
                };
            }));
        }

    }

    /**
     * Helper method for creating a page definition and adding
     * required fields. A "page" is a slide or auto-animate step.
     */
    createPage( page ) {

        page.scrollTriggers = [];
        page.indexh = parseInt( page.slideElement.getAttribute( 'data-index-h' ), 10 );
        page.indexv = parseInt( page.slideElement.getAttribute( 'data-index-v' ), 10 );

        return page;

    }

    /**
     * Rerenders progress bar segments so that they match the current
     * reveal.js config and size.
     */
    syncProgressBar() {

        this.progressBarInner.querySelectorAll( '.scrollbar-slide' ).forEach( slide => slide.remove() );

        const scrollHeight = this.viewportElement.scrollHeight;
        const viewportHeight = this.viewportElement.offsetHeight;
        const viewportHeightFactor = viewportHeight / scrollHeight;

        this.progressBarHeight = this.progressBarInner.offsetHeight;
        this.playheadHeight = Math.max( viewportHeightFactor * this.progressBarHeight, MIN_PLAYHEAD_HEIGHT );
        this.progressBarScrollableHeight = this.progressBarHeight - this.playheadHeight;

        const progressSegmentHeight = viewportHeight / scrollHeight * this.progressBarHeight;
        const spacing = Math.min( progressSegmentHeight / 8, MAX_PROGRESS_SPACING );

        this.progressBarPlayhead.style.height = this.playheadHeight - spacing + 'px';

        // Don't show individual segments if they're too small
        if( progressSegmentHeight > MIN_PROGRESS_SEGMENT_HEIGHT ) {

            this.slideTriggers.forEach( slideTrigger => {

                const { page } = slideTrigger;

                // Visual representation of a slide
                page.progressBarSlide = document.createElement( 'div' );
                page.progressBarSlide.className = 'scrollbar-slide';
                page.progressBarSlide.style.top = slideTrigger.range[0] * this.progressBarHeight + 'px';
                page.progressBarSlide.style.height = ( slideTrigger.range[1] - slideTrigger.range[0] ) * this.progressBarHeight - spacing + 'px';
                page.progressBarSlide.classList.toggle( 'has-triggers', page.scrollTriggers.length > 0 );
                this.progressBarInner.appendChild( page.progressBarSlide );

                // Visual representations of each scroll trigger
                page.scrollTriggerElements = page.scrollTriggers.map( ( trigger, i ) => {

                    const triggerElement = document.createElement( 'div' );
                    triggerElement.className = 'scrollbar-trigger';
                    triggerElement.style.top = ( trigger.range[0] - slideTrigger.range[0] ) * this.progressBarHeight + 'px';
                    triggerElement.style.height = ( trigger.range[1] - trigger.range[0] ) * this.progressBarHeight - spacing + 'px';
                    page.progressBarSlide.appendChild( triggerElement );

                    if( i === 0 ) triggerElement.style.display = 'none';

                    return triggerElement;

                } );

            } );

        }
        else {

            this.pages.forEach( page => page.progressBarSlide = null );

        }

    }

    /**
     * Reads the current scroll position and updates our active
     * trigger states accordingly.
     */
    syncScrollPosition() {

        const viewportHeight = this.viewportElement.offsetHeight;
        const viewportHeightFactor = viewportHeight / this.viewportElement.scrollHeight;

        const scrollTop = this.viewportElement.scrollTop;
        const scrollHeight = this.viewportElement.scrollHeight - viewportHeight
        const scrollProgress = Math.max( Math.min( scrollTop / scrollHeight, 1 ), 0 );
        const scrollProgressMid = Math.max( Math.min( ( scrollTop + viewportHeight / 2 ) / this.viewportElement.scrollHeight, 1 ), 0 );

        let activePage;

        this.slideTriggers.forEach( ( trigger ) => {
            const { page } = trigger;

            const shouldPreload = scrollProgress >= trigger.range[0] - viewportHeightFactor*2 &&
                                                        scrollProgress <= trigger.range[1] + viewportHeightFactor*2;

            // Load slides that are within the preload range
            if( shouldPreload && !page.loaded ) {
                page.loaded = true;
                this.Reveal.slideContent.load( page.slideElement );
            }
            else if( page.loaded ) {
                page.loaded = false;
                this.Reveal.slideContent.unload( page.slideElement );
            }

            // If we're within this trigger range, activate it
            if( scrollProgress >= trigger.range[0] && scrollProgress <= trigger.range[1] ) {
                this.activateTrigger( trigger );
                activePage = trigger.page;
            }
            // .. otherwise deactivate
            else if( trigger.active ) {
                this.deactivateTrigger( trigger );
            }
        } );

        // Each page can have its own scroll triggers, check if any of those
        // need to be activated/deactivated
        if( activePage ) {
            activePage.scrollTriggers.forEach( ( trigger ) => {
                if( scrollProgressMid >= trigger.range[0] && scrollProgressMid <= trigger.range[1] ) {
                    this.activateTrigger( trigger );
                }
                else if( trigger.active ) {
                    this.deactivateTrigger( trigger );
                }
            } );
        }

        // Update our visual progress indication
        this.setProgressBarValue( scrollTop / ( this.viewportElement.scrollHeight - viewportHeight ) );

    }

    /**
     * Moves the progress bar playhead to the specified position.
     *
     * @param {number} progress 0-1
     */
    setProgressBarValue( progress ) {

        if( this.progressBar ) {

            this.progressBarPlayhead.style.transform = `translateY(${progress * this.progressBarScrollableHeight}px)`;

            this.getAllPages()
                .filter( page => page.progressBarSlide )
                .forEach( ( page ) => {
                    page.progressBarSlide.classList.toggle( 'active', page.active === true );

                    page.scrollTriggers.forEach( ( trigger, i ) => {
                        page.scrollTriggerElements[i].classList.toggle( 'active', page.active === true && trigger.active === true );
                    } );
                } );

            this.showProgressBar();

        }

    }

    /**
     * Show the progress bar and, if configured, automatically hide
     * it after a delay.
     */
    showProgressBar() {

        this.progressBar.classList.add( 'visible' );

        clearTimeout( this.hideProgressBarTimeout );

        if( this.Reveal.getConfig().scrollProgress === 'auto' && !this.draggingProgressBar ) {

            this.hideProgressBarTimeout = setTimeout( () => {
                if( this.progressBar ) {
                    this.progressBar.classList.remove( 'visible' );
                }
            }, HIDE_SCROLLBAR_TIMEOUT );

        }

    }

    /**
     * Scroll to the previous page.
     */
    prev() {

        this.viewportElement.scrollTop -= this.scrollTriggerHeight;

    }

    /**
     * Scroll to the next page.
     */
    next() {

        this.viewportElement.scrollTop += this.scrollTriggerHeight;

    }

    /**
     * Scrolls the given slide element into view.
     *
     * @param {HTMLElement} slideElement
     */
    scrollToSlide( slideElement ) {

        // If the scroll view isn't active yet, queue this action
        if( !this.active ) {
            this.activatedCallbacks.push( () => this.scrollToSlide( slideElement ) );
        }
        else {
            // Find the trigger for this slide
            const trigger = this.getScrollTriggerBySlide( slideElement );

            if( trigger ) {
                // Use the trigger's range to calculate the scroll position
                this.viewportElement.scrollTop = trigger.range[0] * ( this.viewportElement.scrollHeight - this.viewportElement.offsetHeight );
            }
        }

    }

    /**
     * Persists the current scroll position to session storage
     * so that it can be restored.
     */
    storeScrollPosition() {

        clearTimeout( this.storeScrollPositionTimeout );

        this.storeScrollPositionTimeout = setTimeout( () => {
            sessionStorage.setItem( 'reveal-scroll-top', this.viewportElement.scrollTop );
            sessionStorage.setItem( 'reveal-scroll-origin', location.origin + location.pathname );

            this.storeScrollPositionTimeout = null;
        }, 50 );

    }

    /**
     * Restores the scroll position when a deck is reloader.
     */
    restoreScrollPosition() {

        const scrollPosition = sessionStorage.getItem( 'reveal-scroll-top' );
        const scrollOrigin = sessionStorage.getItem( 'reveal-scroll-origin' );

        if( scrollPosition && scrollOrigin === location.origin + location.pathname ) {
            this.viewportElement.scrollTop = parseInt( scrollPosition, 10 );
        }

    }

    /**
     * Activates the given page and starts its embedded content
     * if there is any.
     *
     * @param {object} page
     */
    activatePage( page ) {

        if( !page.active ) {

            page.active = true;

            const { slideElement, backgroundElement, contentElement, indexh, indexv } = page;

            contentElement.style.display = 'block';

            slideElement.classList.add( 'present' );

            if( backgroundElement ) {
                backgroundElement.classList.add( 'present' );
            }

            this.Reveal.setCurrentScrollPage( slideElement, indexh, indexv );
            this.Reveal.backgrounds.bubbleSlideContrastClassToElement( slideElement, this.viewportElement );

            // If this page is part of an auto-animation there will be one
            // content element per auto-animated page. We need to show the
            // current page and hide all others.
            Array.from( contentElement.parentNode.querySelectorAll( '.scroll-page-content' ) ).forEach( sibling => {
                if( sibling !== contentElement ) {
                    sibling.style.display = 'none';
                }
            });

        }

    }

    /**
     * Deactivates the page after it has been visible.
     *
     * @param {object} page
     */
    deactivatePage( page ) {

        if( page.active ) {

            page.active = false;
            if( page.slideElement ) page.slideElement.classList.remove( 'present' );
            if( page.backgroundElement ) page.backgroundElement.classList.remove( 'present' );

        }

    }

    activateTrigger( trigger ) {

        if( !trigger.active ) {
            trigger.active = true;
            trigger.activate();
        }

    }

    deactivateTrigger( trigger ) {

        if( trigger.active ) {
            trigger.active = false;

            if( trigger.deactivate ) {
                trigger.deactivate();
            }
        }

    }

    /**
     * Retrieve a slide by its original h/v index (i.e. the indices the
     * slide had before being linearized).
     *
     * @param {number} h
     * @param {number} v
     * @returns {HTMLElement}
     */
    getSlideByIndices( h, v ) {

        const page = this.getAllPages().find( page => {
            return page.indexh === h && page.indexv === v;
        } );

        return page ? page.slideElement : null;

    }

    /**
     * Retrieve a list of all scroll triggers for the given slide
     * DOM element.
     *
     * @param {HTMLElement} slide
     * @returns {Array}
     */
    getScrollTriggerBySlide( slide ) {

        return this.slideTriggers.find( trigger => trigger.page.slideElement === slide );

    }

    /**
     * Get a list of all pages in the scroll view. This includes
     * both top-level slides and auto-animate steps.
     *
     * @returns {Array}
     */
    getAllPages() {

        return this.pages.flatMap( page => [page, ...(page.autoAnimatePages || [])] );

    }

    onScroll() {

        this.syncScrollPosition();
        this.storeScrollPosition();

    }

    get viewportElement() {

        return this.Reveal.getViewportElement();

    }

}