wikimedia/mediawiki-extensions-MobileFrontend

View on GitHub
src/mobile.startup/OverlayManager.js

Summary

Maintainability
A
3 hrs
Test Coverage
const util = require( './util' );

let overlayManager = null;

// We pass this to history.pushState/replaceState to indicate that we're controlling the page URL.
// Then we look for this marker on page load so that if the page is refreshed, we don't generate an
// extra history entry (see #getSingleton below and T201852).
const MANAGED_STATE = 'MobileFrontend OverlayManager was here!';

/**
 * Manages opening and closing overlays when the URL hash changes to one
 * of the registered values (see OverlayManager.add()).
 *
 * This allows overlays to function like real pages, with similar browser back/forward
 * and refresh behavior.
 *
 * @class module:mobile.startup/OverlayManager
 * @param {Router} router
 * @param {Element} container where overlays should be managed
 */
function OverlayManager( router, container ) {
    router.on( 'route', this._checkRoute.bind( this ) );
    this.router = router;
    // use an object instead of an array for entries so that we don't
    // duplicate entries that already exist
    this.entries = {};
    // stack of all the open overlays, stack[0] is the latest one
    this.stack = [];
    this.hideCurrent = true;
    // Set the element that overlays will be appended to
    this.container = container;
}

/**
 * Attach an event to the overlays hide event
 *
 * @param {module:mobile.startup/Overlay} overlay
 * @private
 */
function attachHideEvent( overlay ) {
    overlay.on( 'hide', () => overlay.emit( '_om_hide' ) );
}

OverlayManager.prototype = {
    /**
     * Don't try to hide the active overlay on a route change event triggered
     * by hiding another overlay.
     * Called when something other than OverlayManager calls Overlay.hide
     * on an overlay that it itself managed by the OverlayManager.
     * MUST be called when the stack is not empty.
     *
     * @memberof module:mobile.startup/OverlayManager
     * @instance
     * @private
     */
    _onHideOverlayOutsideOverlayManager() {
        if ( !this.stack.length ) {
            return;
        }
        const currentRoute = this.stack[0].route,
            routeIsString = typeof currentRoute === 'string',
            currentPath = this.router.getPath(),
            // Since routes can be strings or regexes, it's important to do an equality
            // check BEFORE a match check.
            routeIsSame = ( routeIsString && currentPath === currentRoute ) ||
                currentPath.match( currentRoute );

        this.hideCurrent = false;

        // If the path hasn't changed then the user didn't close the overlay by
        // calling history.back() or triggering a route change. We must go back
        // to get out of the overlay. See T237677.
        if ( routeIsSame ) {
            // does the route need to change?
            this.router.back();
        }
    },

    /**
     * Attach overlay to DOM
     *
     * @memberof module:mobile.startup/OverlayManager
     * @instance
     * @private
     * @param {module:mobile.startup/Overlay} overlay to attach
     */
    _attachOverlay( overlay ) {
        if ( !overlay.$el.parents().length ) {
            this.container.appendChild( overlay.$el[0] );
        }
    },
    /**
     * Show the overlay and bind the '_om_hide' event to _onHideOverlay.
     *
     * @memberof module:mobile.startup/OverlayManager
     * @instance
     * @private
     * @param {module:mobile.startup/Overlay} overlay to show
     */
    _show( overlay ) {
        // Mark the state so that if the page is refreshed, we don't generate an extra history entry
        // (see #getSingleton below and T201852).
        // eslint-disable-next-line no-restricted-properties
        window.history.replaceState( MANAGED_STATE, null, window.location.href );

        // the _om_hide event is added to an overlay that is displayed.
        // It will fire if an Overlay emits a hide event (See attachHideEvent)
        // in the case where a route change has not occurred (this event is disabled
        // inside _hideOverlay which is called inside _checkRoute)
        overlay.once( '_om_hide', this._onHideOverlayOutsideOverlayManager.bind( this ) );

        this._attachOverlay( overlay );
        overlay.show();
    },

    /**
     * Hide overlay
     *
     * @memberof module:mobile.startup/OverlayManager
     * @instance
     * @private
     * @param {module:mobile.startup/Overlay} overlay to hide
     * @param {Function} onBeforeExitCancel to pass to onBeforeExit
     * @return {boolean} Whether the overlay has been hidden
     */
    _hideOverlay( overlay, onBeforeExitCancel ) {
        let result;

        function exit() {
            result = true;
            overlay.hide();
        }

        // remove the callback for updating state when overlay closed using
        // overlay close button
        overlay.off( '_om_hide' );

        if ( overlay.options && overlay.options.onBeforeExit ) {
            overlay.options.onBeforeExit( exit, onBeforeExitCancel );
        } else {
            exit();
        }

        // if closing prevented, reattach the callback
        if ( !result ) {
            overlay.once( '_om_hide', this._onHideOverlayOutsideOverlayManager.bind( this ) );
        }

        return result;
    },

    /**
     * Show match's overlay if match is not null.
     *
     * @memberof module:mobile.startup/OverlayManager
     * @instance
     * @private
     * @param {Object|null} match Object with factory function's result. null if no match.
     */
    _processMatch( match ) {
        const self = this;

        if ( match ) {
            if ( match.overlay ) {
                // if the match is an overlay that was previously opened, reuse it
                self._show( match.overlay );
            } else {
                // else create an overlay using the factory function result
                const factoryResult = match.factoryResult;
                // We were getting errors relating to no factoryResult.
                // This should never happen.
                // If it does an error should not be thrown.
                if ( factoryResult ) {
                    match.overlay = factoryResult;
                    attachHideEvent( match.overlay );
                    self._show( factoryResult );
                }
            }
        }
    },

    /**
     * A callback for Router's `route` event.
     *
     * @memberof module:mobile.startup/OverlayManager
     * @instance
     * @private
     * @param {jQuery.Event} ev Event object.
     */
    _checkRoute( ev ) {
        const current = this.stack[0];

        // When entering an overlay for the first time,
        // the manager should remember the user's scroll position
        // overlays always open at top of page
        // and we'll want to restore it later.
        // This should happen before the call to _matchRoute which will "show" the overlay.
        // The Overlay has similar logic for overlays that are not managed via the overlay.
        if ( !current ) {
            this.scrollTop = window.pageYOffset;
        }

        // if there is an overlay in the stack and it's opened, try to close it
        if (
            current &&
            current.overlay !== undefined &&
            this.hideCurrent &&
            !this._hideOverlay( current.overlay, () => {
                // if hide prevented, prevent route change event
                ev.preventDefault();
            } )
        ) {
            return;
        }

        const match = Object.keys( this.entries ).reduce( ( m, id ) => m ||
            this._matchRoute( ev.path, this.entries[id] ), null );

        if ( !match ) {
            // if hidden and no new matches, reset the stack
            this.stack = [];
            // restore the scroll position.
            window.scrollTo( window.pageXOffset, this.scrollTop );
        }

        this.hideCurrent = true;
        this._processMatch( match );
    },

    /**
     * Check if a given path matches one of the existing entries and
     * remove it from the stack.
     *
     * @memberof module:mobile.startup/OverlayManager
     * @instance
     * @private
     * @param {string} path Path (hash) to check.
     * @param {Object} entry Entry object created in OverlayManager#add.
     * @return {Object|null} Match object with factory function's result.
     *  Returns null if no match.
     */
    _matchRoute( path, entry ) {
        let
            didMatch,
            captures,
            match;

        const
            previous = this.stack[1],
            self = this;

        if ( typeof entry.route === 'string' ) {
            didMatch = entry.route === path;
            captures = [];
        } else {
            match = path.match( entry.route );
            didMatch = !!match;
            captures = didMatch ? match.slice( 1 ) : [];
        }

        /**
         * Returns object to add to stack
         *
         * @method
         * @ignore
         * @return {Object}
         */
        function getNext() {
            return {
                path,
                // Important for managing states of things such as the image overlay which change
                // overlay routing parameters during usage.
                route: entry.route,
                factoryResult: entry.factory.apply( self, captures )
            };
        }

        if ( didMatch ) {
            // if previous stacked overlay's path matches, assume we're going back
            // and reuse a previously opened overlay
            if ( previous && previous.path === path ) {
                self.stack.shift();
                return previous;
            } else {
                const next = getNext();
                if ( this.stack[0] && next.path === this.stack[0].path ) {
                    // current overlay path is same as path to check which means overlay
                    // is attempting to refresh so just replace current overlay with new
                    // overlay
                    self.stack[0] = next;
                } else {
                    self.stack.unshift( next );
                }
                return next;
            }
        }

        return null;
    },

    /**
     * Add an overlay that should be shown for a specific fragment identifier.
     *
     * The following code will display an overlay whenever a user visits a URL that
     * ends with '#/hi/name'. The value of `name` will be passed to the overlay.
     * Note the factory must return an Overlay.
     * If the overlay needs to load code asynchronously that should be done inside
     * the overlay.
     *
     *     @example
     *     overlayManager.add( /\/hi\/(.*)/, function ( name ) {
     *         var HiOverlay = M.require( 'HiOverlay' );
     *         return new HiOverlay( { name: name } ) );
     *     } );
     *
     * @memberof module:mobile.startup/OverlayManager
     * @instance
     * @param {RegExp|string} route definition that can be a regular
     * expression (optionally with parameters) or a string literal.
     *
     * T238364: Routes should only contain characters allowed by RFC3986 to ensure
     * compatibility across browsers. Encode the route with `encodeURIComponent()`
     * prior to registering it with OverlayManager if necessary (should probably
     * be done with all routes containing user generated characters) to avoid
     * inconsistencies with how different browsers encode illegal URI characters:
     *
     * ```
     *   var encodedRoute = encodeURIComponent('ugc < " ` >');
     *
     *   overlayManager.add(
     *     encodedRoute,
     *     function () { return new Overlay(); }
     *   );
     *
     *   window.location.hash = '#' + encodedRoute;
     * ```
     * The above example shows how to register a string literal route with illegal
     * URI characters. Routes registered as a regex will likely NOT have to
     * perform any encoding (unless they explicitly contain illegal URI
     * characters) as their user generated content portion will likely just be a
     * capturing group (e.g. `/\/hi\/(.*)/`).
     * @param {Function} factory a function returning an overlay
     */
    add( route, factory ) {
        const self = this,
            entry = {
                route,
                factory
            };

        this.entries[route] = entry;
        // Check if overlay should be shown for the current path.
        // The DOM must fully load before we can show the overlay because Overlay relies on it.
        util.docReady( () => self._processMatch( self._matchRoute( self.router.getPath(),
            entry ) ) );
    },

    /**
     * Replace the currently displayed overlay with a new overlay without changing the
     * URL. This is useful for when you want to switch overlays, but don't want to
     * change the back button or close box behavior.
     *
     * @memberof module:mobile.startup/OverlayManager
     * @instance
     * @param {Object} overlay The overlay to display
     */
    replaceCurrent( overlay ) {
        if ( this.stack.length === 0 ) {
            throw new Error( "Trying to replace OverlayManager's current overlay, but stack is empty" );
        }
        const stackOverlay = this.stack[0].overlay;
        if ( stackOverlay ) {
            this._hideOverlay( stackOverlay );
        }
        this.stack[0].overlay = overlay;
        attachHideEvent( overlay );
        this._show( overlay );
    }
};

/**
 * Retrieve a singleton instance using 'mediawiki.router'.
 *
 * @memberof module:mobile.startup/OverlayManager
 * @return {module:mobile.startup/OverlayManager}
 */
OverlayManager.getSingleton = function () {
    if ( !overlayManager ) {
        const
            router = __non_webpack_require__( 'mediawiki.router' ),
            container = document.createElement( 'div' ),
            // Note getPath returns hash minus the '#' character:
            hash = router.getPath(),
            // eslint-disable-next-line no-restricted-properties
            state = window.history.state;
        container.className = 'mw-overlays-container';
        document.body.appendChild( container );
        // If an overlay was loaded by directly navigating to an URL with a hash (e.g. linked from
        // another page or browser bookmark), generate an extra history entry to allow closing the
        // overlay without leaving the page (see T201852). Put our marker into the entry state so
        // that we can detect it if the page is refreshed and do not generate another entry.
        if ( hash && state !== MANAGED_STATE ) {
            // eslint-disable-next-line no-restricted-properties
            window.history.replaceState( null, null, '#' );
            // eslint-disable-next-line no-restricted-properties
            window.history.pushState( MANAGED_STATE, null, `#${ hash }` );
        }
        overlayManager = new OverlayManager( router, container );
    }
    return overlayManager;
};

OverlayManager.test = {
    MANAGED_STATE,
    __clearCache: () => {
        overlayManager = null;
    }
};
module.exports = OverlayManager;