All files / mobile.startup OverlayManager.js

99.05% Statements 105/106
94.73% Branches 54/57
100% Functions 19/19
99.02% Lines 102/103

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417  1x 1x         1x                           29x 29x     29x   29x 29x   29x                 25x     1x                         5x     5x 5x 5x     5x     5x         5x   4x                         27x 23x                             27x           27x   27x 27x                                 12x 12x         14x   14x 3x   11x       14x 2x     14x                         59x   59x 26x   2x     24x       24x 24x 24x 24x                             40x               40x 26x       40x           1x     2x     38x   38x   13x   13x     38x 38x                                         54x 54x   54x 7x 7x   47x 47x 47x                     24x                 54x     26x 2x 2x   24x 24x       2x   22x   24x       28x                                                                                             21x 21x         21x     21x                         2x 1x   1x 1x 1x   1x 1x 1x                   1x 5x   4x 4x   4x   4x 4x 4x         4x   1x   1x   4x   5x     1x     21x     1x  
var
	util = require( './util' ),
	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 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 {Overlay} overlay
 */
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 OverlayManager
	 * @instance
	 * @private
	 */
	_onHideOverlayOutsideOverlayManager() {
		Iif ( !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 OverlayManager
	 * @instance
	 * @private
	 * @param {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 OverlayManager
	 * @instance
	 * @private
	 * @param {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 OverlayManager
	 * @instance
	 * @private
	 * @param {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 OverlayManager
	 * @instance
	 * @private
	 * @param {Object|null} match Object with factory function's result. null if no match.
	 */
	_processMatch( match ) {
		var factoryResult,
			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
				factoryResult = match.factoryResult;
				// We were getting errors relating to no factoryResult.
				// This should never happen.
				// If it does an error should not be thrown.
				Eif ( factoryResult ) {
					match.overlay = factoryResult;
					attachHideEvent( match.overlay );
					self._show( factoryResult );
				}
			}
		}
	},
 
	/**
	 * A callback for Router's `route` event.
	 *
	 * @memberof 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 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 ) {
		var
			next,
			didMatch,
			captures,
			match,
			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 {
				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 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 ) {
		var 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 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;
		Eif ( stackOverlay ) {
			this._hideOverlay( stackOverlay );
		}
		this.stack[0].overlay = overlay;
		attachHideEvent( overlay );
		this._show( overlay );
	}
};
 
/**
 * Retrieve a singleton instance using 'mediawiki.router'.
 *
 * @memberof OverlayManager
 * @return {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;