All Downloads are FREE. Search and download functionalities are using the official Maven repository.

META-INF.resources.app.App.js Maven / Gradle / Ivy

There is a newer version: 5.0.55
Show newest version
/**
 * SPDX-FileCopyrightText: (c) 2000 Liferay, Inc. https://liferay.com
 * SPDX-License-Identifier: LGPL-2.1-or-later OR LicenseRef-Liferay-DXP-EULA-2.0.0-2023-06
 */

import {EventEmitter, EventHandler, debounce, delegate} from 'frontend-js-web';

import Route from '../route/Route';
import Screen from '../screen/Screen';
import Surface from '../surface/Surface';
import {
	getCurrentBrowserPath,
	getCurrentBrowserPathWithoutHash,
	getNodeOffset,
	getUrlPath,
	getUrlPathWithoutHash,
	getUrlPathWithoutHashAndSearch,
	isCurrentBrowserPath,
	removePathTrailingSlash,
	setReferrer,
} from '../util/utils';

const NavigationStrategy = {
	IMMEDIATE: 'immediate',
	SCHEDULE_LAST: 'scheduleLast',
};

class App extends EventEmitter {

	/**
	 * App class that handle routes and screens lifecycle.
	 */
	constructor(config) {
		super();

		/**
		 * Holds the active screen.
		 * @type {?Screen}
		 * @protected
		 */
		this.activeScreen = null;

		/**
		 * Holds the active path containing the query parameters.
		 * @type {?string}
		 * @protected
		 */
		this.activePath = null;

		/**
		 * Allows prevent navigate from dom prevented event.
		 * @type {boolean}
		 * @default true
		 * @protected
		 */
		this.allowPreventNavigate = true;

		/**
		 * Holds link base path.
		 * @type {!string}
		 * @default ''
		 * @protected
		 */
		this.basePath = '';

		/**
		 * Holds the value of the browser path before a navigation is performed.
		 * @type {!string}
		 * @default the current browser path.
		 * @protected
		 */
		this.browserPathBeforeNavigate = getCurrentBrowserPathWithoutHash();

		/**
		 * Captures scroll position from scroll event.
		 * @type {!boolean}
		 * @default true
		 * @protected
		 */
		this.captureScrollPositionFromScrollEvent = true;

		/**
		 * Holds the default page title.
		 * @type {string}
		 * @default null
		 * @protected
		 */
		this.defaultTitle = document.title;

		/**
		 * Holds the form selector to define forms that are routed.
		 * @type {!string}
		 * @default form[enctype="multipart/form-data"]:not([data-senna-off])
		 * @protected
		 */
		this.formSelector = config?.navigationExceptionSelectors
			? `form${config.navigationExceptionSelectors}`
			: 'form[enctype="multipart/form-data"]:not([data-senna-off])';

		/**
		 * When enabled, the route matching ignores query string from the path.
		 * @type {boolean}
		 * @default false
		 * @protected
		 */
		this.ignoreQueryStringFromRoutePath = false;

		/**
		 * Holds the link selector to define links that are routed.
		 * @type {!string}
		 * @default a:not([data-senna-off])
		 * @protected
		 */
		this.linkSelector = config?.navigationExceptionSelectors
			? `a${config.navigationExceptionSelectors}`
			: 'a:not([data-senna-off]):not([target="_blank"])';

		/**
		 * Holds the loading css class.
		 * @type {!string}
		 * @default senna-loading
		 * @protected
		 */
		this.loadingCssClass = 'senna-loading';

		/**
		 * Using the History API to manage your URLs is awesome and, as it happens,
		 * a crucial feature of good web apps. One of its downsides, however, is
		 * that scroll positions are stored and then, more importantly, restored
		 * whenever you traverse the history. This often means unsightly jumps as
		 * the scroll position changes automatically, and especially so if your app
		 * does transitions, or changes the contents of the page in any way.
		 * Ultimately this leads to an horrible user experience. The good news is,
		 * however, that there's a potential fix: history.scrollRestoration.
		 * https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration
		 * @type {boolean}
		 * @protected
		 */
		this.nativeScrollRestorationSupported =
			'scrollRestoration' in window.history;

		/**
		 * When set to NavigationStrategy.SCHEDULE_LAST means that the current navigation
		 * cannot be Cancelled to start another and will be queued in
		 * scheduledNavigationQueue. When NavigationStrategy.IMMEDIATE means that all
		 * navigation will be cancelled to start another.
		 * @type {!string}
		 * @default immediate
		 * @protected
		 */
		this.navigationStrategy = NavigationStrategy.IMMEDIATE;

		/**
		 * When set to true there is a pendingNavigate that has not yet been
		 * resolved or rejected.
		 * @type {boolean}
		 * @default false
		 * @protected
		 */
		this.isNavigationPending = false;

		/**
		 * Holds a deferred with the current navigation.
		 * @type {?Promise}
		 * @default null
		 * @protected
		 */
		this.pendingNavigate = null;

		/**
		 * Holds the window horizontal scroll position when the navigation using
		 * back or forward happens to be restored after the surfaces are updated.
		 * @type {!Number}
		 * @default 0
		 * @protected
		 */
		this.popstateScrollLeft = 0;

		/**
		 * Holds the window vertical scroll position when the navigation using
		 * back or forward happens to be restored after the surfaces are updated.
		 * @type {!Number}
		 * @default 0
		 * @protected
		 */
		this.popstateScrollTop = 0;

		/**
		 * Whether to preload CSS files prior to surface flipping so that FOUC
		 * does not happen.
		 * @type {!Boolean}
		 * @default false
		 * @protected
		 */
		this.preloadCSS = !!config?.preloadCSS;

		/**
		 * Holds the redirect path containing the query parameters.
		 * @type {?string}
		 * @protected
		 */
		this.redirectPath = null;

		/**
		 * Holds the screen routes configuration.
		 * @type {?Array}
		 * @default []
		 * @protected
		 */
		this.routes = [];

		/**
		 * Holds a queue that stores every DOM event that can initiate a navigation.
		 * @type {!Event}
		 * @default []
		 * @protected
		 */
		this.scheduledNavigationQueue = [];

		/**
		 * Maps the screen instances by the url containing the parameters.
		 * @type {?Object}
		 * @default {}
		 * @protected
		 */
		this.screens = {};

		/**
		 * When set to true the first erroneous popstate fired on page load will be
		 * ignored, only if window.history.state is also
		 * null.
		 * @type {boolean}
		 * @default false
		 * @protected
		 */
		this.skipLoadPopstate = false;

		/**
		 * Maps that index the surfaces instances by the surface id.
		 * @type {?Object}
		 * @default {}
		 * @protected
		 */
		this.surfaces = {};

		/**
		 * When set to true, moves the scroll position after popstate, or to the
		 * top of the viewport for new navigation. If false, the browser will
		 * take care of scroll restoration.
		 * @type {!boolean}
		 * @default true
		 * @protected
		 */
		this.updateScrollPosition = true;

		this.appEventHandlers_ = new EventHandler();

		this.appEventHandlers_.add(
			this.addDOMEventListener(
				window,
				'scroll',
				debounce(this.onScroll_.bind(this), 100)
			),
			this.addDOMEventListener(window, 'load', this.onLoad_.bind(this)),
			this.addDOMEventListener(
				window,
				'popstate',
				this.onPopstate_.bind(this)
			)
		);

		this.on('startNavigate', this.onStartNavigate_);
		this.on('beforeNavigate', this.onBeforeNavigate_);
		this.on('beforeNavigate', this.onBeforeNavigateDefault_, true);
		this.on('beforeUnload', this.onBeforeUnloadDefault_);

		this.setLinkSelector(this.linkSelector);
		this.setFormSelector(this.formSelector);

		this.maybeOverloadBeforeUnload_();
	}

	addDOMEventListener(element, eventName, callback) {
		element.addEventListener(eventName, callback);

		return {
			removeListener() {
				element.removeEventListener(eventName, callback);
			},
		};
	}

	/**
	 * Adds one or more screens to the application.
	 *
	 * Example:
	 *
	 * 
	 *   app.addRoutes({ path: '/foo', handler: FooScreen });
	 *   or
	 *   app.addRoutes([{ path: '/foo', handler: function(route) { return new FooScreen(); } }]);
	 * 
	 *
	 * @param {Object} or {Array} routes Single object or an array of object.
	 *     Each object should contain path and screen.
	 *     The path should be a string or a regex that maps the
	 *     navigation route to a screen class definition (not an instance), e.g:
	 *         { path: "/home:param1", handler: MyScreen }
	 *         { path: /foo.+/, handler: MyScreen }
	 * @chainable
	 */
	addRoutes(routes) {
		if (!Array.isArray(routes)) {
			routes = [routes];
		}
		routes.forEach((route) => {
			if (!(route instanceof Route)) {
				route = new Route(route.path, route.handler);
			}
			this.routes.push(route);
		});

		return this;
	}

	/**
	 * Adds one or more surfaces to the application.
	 * @param {Surface|String|Array.} surfaces
	 *     Surface element id or surface instance. You can also pass an Array
	 *     whichcontains surface instances or id. In case of ID, these should be
	 *     the id of surface element.
	 * @chainable
	 */
	addSurfaces(surfaces) {
		if (!Array.isArray(surfaces)) {
			surfaces = [surfaces];
		}
		surfaces.forEach((surface) => {
			if (typeof surface === 'string') {
				surface = new Surface(surface);
			}
			this.surfaces[surface.getId()] = surface;
		});

		return this;
	}

	/**
	 * Returns if can navigate to path.
	 * @param {!string} url
	 * @return {boolean}
	 */
	canNavigate(url) {
		try {
			const uri = url.startsWith('/')
				? new URL(url, window.location.origin)
				: new URL(url);

			const path = getUrlPath(url);

			if (!this.isLinkSameOrigin_(uri.host)) {
				return false;
			}

			if (!this.isSameBasePath_(path)) {
				return false;
			}

			// Prevents navigation if it's a hash change on the same url.

			if ((uri.hash || url.endsWith('#')) && isCurrentBrowserPath(path)) {
				return false;
			}

			if (!this.findRoute(path)) {
				return false;
			}

			return true;
		}
		catch (error) {
			return false;
		}
	}

	/**
	 * Clear screens cache.
	 * @chainable
	 */
	clearScreensCache() {
		Object.keys(this.screens).forEach((path) => {
			if (path === this.activePath) {
				this.activeScreen.clearCache();
			}
			else if (
				!(
					this.isNavigationPending &&
					this.pendingNavigate.path === path
				)
			) {
				this.removeScreen(path);
			}
		});
	}

	/**
	 * Retrieves or create a screen instance to a path.
	 * @param {!string} path Path containing the querystring part.
	 * @return {Screen}
	 */
	createScreenInstance(path, route) {
		if (!this.pendingNavigate && path === this.activePath) {
			return this.activeScreen;
		}

		/* jshint newcap: false */
		let screen = this.screens[path];
		if (!screen) {
			const handler = route.getHandler();
			if (
				handler === Screen ||
				Screen.isImplementedBy(handler.prototype)
			) {
				screen = new handler();
			}
			else {
				screen = handler(route) || new Screen();
			}
		}

		return screen;
	}

	/**
	 * @inheritDoc
	 */
	disposeInternal() {
		if (this.activeScreen) {
			this.removeScreen(this.activePath);
		}
		this.clearScreensCache();
		this.formEventHandler_.dispose();
		this.linkEventHandler_.dispose();
		this.appEventHandlers_.removeAllListeners();
		super.disposeInternal();
	}

	/**
	 * Dispatches to the first route handler that matches the current path, if
	 * any.
	 * @return {Promise} Returns a pending request cancellable promise.
	 */
	dispatch() {
		return this.navigate(getCurrentBrowserPath(), true);
	}

	/**
	 * Starts navigation to a path.
	 * @param {!string} path Path containing the querystring part.
	 * @param {boolean=} opt_replaceHistory Replaces browser history.
	 * @return {Promise} Returns a pending request cancellable promise.
	 */
	doNavigate_(path, opt_replaceHistory) {
		const route = this.findRoute(path);
		if (!route) {
			return Promise.reject(new Error('No route for ' + path));
		}

		this.stopPendingNavigate_();
		this.isNavigationPending = true;

		const nextScreen = this.createScreenInstance(path, route);

		return this.maybePreventDeactivate_()
			.then(() => this.maybePreventActivate_(nextScreen))
			.then(() => nextScreen.load(path))
			.then(() => {

				// At this point we cannot stop navigation and all received
				// navigate candidates will be queued at scheduledNavigationQueue.

				this.navigationStrategy = NavigationStrategy.SCHEDULE_LAST;

				if (this.activeScreen) {
					this.activeScreen.deactivate();
				}
				this.prepareNavigateHistory_(
					path,
					nextScreen,
					opt_replaceHistory
				);
				this.prepareNavigateSurfaces_(
					nextScreen,
					this.surfaces,
					this.extractParams(route, path)
				);
			})
			.then(() =>
				this.preloadCSS
					? nextScreen.preloadStyles(this.surfaces)
					: Promise.resolve()
			)
			.then(() =>
				this.preloadCSS
					? nextScreen.evaluateStyles(this.surfaces)
					: nextScreen.flip(this.surfaces)
			)
			.then(() =>
				this.preloadCSS
					? nextScreen.flip(this.surfaces)
					: nextScreen.evaluateStyles(this.surfaces)
			)
			.then(() => nextScreen.evaluateScripts(this.surfaces))
			.then(() => this.maybeUpdateScrollPositionState_())
			.then(() => this.syncScrollPositionSyncThenAsync_())
			.then(() => this.finalizeNavigate_(path, nextScreen))
			.then(() => this.maybeOverloadBeforeUnload_())
			.catch((reason) => {
				this.isNavigationPending = false;
				this.handleNavigateError_(path, nextScreen, reason);
				throw reason;
			})
			.finally(() => {
				this.navigationStrategy = NavigationStrategy.IMMEDIATE;

				if (this.scheduledNavigationQueue.length) {
					const scheduledNavigation =
						this.scheduledNavigationQueue.shift();
					this.maybeNavigate_(
						scheduledNavigation.href,
						scheduledNavigation
					);
				}
			});
	}

	/**
	 * Extracts params according to the given path and route.
	 * @param {!Route} route
	 * @param {string} path
	 * @param {!Object}
	 */
	extractParams(route, path) {
		return route.extractParams(this.getRoutePath(path));
	}

	/**
	 * Finalizes a screen navigation.
	 * @param {!string} path Path containing the querystring part.
	 * @param {!Screen} nextScreen
	 * @protected
	 */
	finalizeNavigate_(path, nextScreen) {
		nextScreen.activate();

		if (this.activeScreen && !this.activeScreen.isCacheable()) {
			if (this.activeScreen !== nextScreen) {
				this.removeScreen(this.activePath);
			}
		}

		this.activePath = path;
		this.activeScreen = nextScreen;
		this.browserPathBeforeNavigate = getCurrentBrowserPathWithoutHash();
		this.screens[path] = nextScreen;
		this.isNavigationPending = false;
		this.pendingNavigate = null;
		Liferay.SPA.__capturedFormElement__ = null;
		Liferay.SPA.__capturedFormButtonElement__ = null;
	}

	/**
	 * Finds a route for the test path. Returns true if matches has a route,
	 * otherwise returns null.
	 * @param {!string} path Path containing the querystring part.
	 * @return {?Object} Route handler if match any or null if the
	 *     path is the same as the current url and the path contains a fragment.
	 */
	findRoute(path) {
		path = this.getRoutePath(path);
		for (let i = 0; i < this.routes.length; i++) {
			const route = this.routes[i];
			if (route.matchesPath(path)) {
				return route;
			}
		}

		return null;
	}

	/**
	 * Gets allow prevent navigate.
	 * @return {boolean}
	 */
	getAllowPreventNavigate() {
		return this.allowPreventNavigate;
	}

	/**
	 * Gets link base path.
	 * @return {!string}
	 */
	getBasePath() {
		return this.basePath;
	}

	/**
	 * Gets the default page title.
	 * @return {string} defaultTitle
	 */
	getDefaultTitle() {
		return this.defaultTitle;
	}

	/**
	 * Gets the form selector.
	 * @return {!string}
	 */
	getFormSelector() {
		return this.formSelector;
	}

	/**
	 * Check if route matching is ignoring query string from the route path.
	 * @return {boolean}
	 */
	getIgnoreQueryStringFromRoutePath() {
		return this.ignoreQueryStringFromRoutePath;
	}

	/**
	 * Gets the link selector.
	 * @return {!string}
	 */
	getLinkSelector() {
		return this.linkSelector;
	}

	/**
	 * Gets the loading css class.
	 * @return {!string}
	 */
	getLoadingCssClass() {
		return this.loadingCssClass;
	}

	/**
	 * Returns the given path formatted to be matched by a route. This will,
	 * for example, remove the base path from it, but make sure it will end
	 * with a '/'.
	 * @param {string} path
	 * @return {string}
	 */
	getRoutePath(path) {
		if (this.getIgnoreQueryStringFromRoutePath()) {
			path = getUrlPathWithoutHashAndSearch(path);

			return getUrlPathWithoutHashAndSearch(
				path.substr(this.basePath.length)
			);
		}

		path = getUrlPathWithoutHash(path);

		return getUrlPathWithoutHash(path.substr(this.basePath.length));
	}

	/**
	 * Gets the update scroll position value.
	 * @return {boolean}
	 */
	getUpdateScrollPosition() {
		return this.updateScrollPosition;
	}

	/**
	 * Handle navigation error.
	 * @param {!string} path Path containing the querystring part.
	 * @param {!Screen} nextScreen
	 * @param {!Error} error
	 * @protected
	 */
	handleNavigateError_(path, nextScreen, error) {
		this.emit('navigationError', {
			error,
			nextScreen,
			path,
		});
		if (!isCurrentBrowserPath(path)) {
			if (this.isNavigationPending && this.pendingNavigate) {
				this.pendingNavigate.finally(
					() => this.removeScreen(path),
					this
				);
			}
			else {
				this.removeScreen(path);
			}
		}
	}

	/**
	 * Checks if app has routes.
	 * @return {boolean}
	 */
	hasRoutes() {
		return !!this.routes.length;
	}

	/**
	 * Tests if host is an offsite link.
	 * @param {!string} host Link host to compare with
	 *     window.location.host.
	 * @return {boolean}
	 * @protected
	 */
	isLinkSameOrigin_(host) {
		return host === window.location.host;
	}

	/**
	 * Tests if link element has the same app's base path.
	 * @param {!string} path Link path containing the querystring part.
	 * @return {boolean}
	 * @protected
	 */
	isSameBasePath_(path) {
		return path.indexOf(this.basePath) === 0;
	}

	/**
	 * Lock the document scroll in order to avoid the browser native back and
	 * forward navigation to change the scroll position. In the end of
	 * navigation lifecycle scroll is repositioned.
	 * @protected
	 */
	lockHistoryScrollPosition_() {
		const state = window.history.state;
		if (!state) {
			return;
		}

		// Browsers are inconsistent when re-positioning the scroll history on
		// popstate. At some browsers, history scroll happens before popstate, then
		// lock the scroll on the last known position as soon as possible after the
		// current JS execution context and capture the current value. Some others,
		// history scroll happens after popstate, in this case, we bind an once
		// scroll event to lock the las known position. Lastly, the previous two
		// behaviors can happen even on the same browser, hence the race will decide
		// the winner.

		let winner = false;
		const switchScrollPositionRace = function () {
			document.removeEventListener(
				'scroll',
				switchScrollPositionRace,
				false
			);
			if (!winner) {
				window.scrollTo(state.scrollLeft, state.scrollTop);
				winner = true;
			}
		};
		setTimeout(switchScrollPositionRace);
		document.addEventListener('scroll', switchScrollPositionRace, false);
	}

	/**
	 * If supported by the browser, disables native scroll restoration and
	 * stores current value.
	 */
	maybeDisableNativeScrollRestoration() {
		if (this.nativeScrollRestorationSupported) {
			this.nativeScrollRestoration_ = window.history.scrollRestoration;
			window.history.scrollRestoration = 'manual';
		}
	}

	/**
	 * This method is used to evaluate if is possible to queue received
	 *  dom event to scheduleNavigationQueue and enqueue it.
	 * @param {string} href Information about the link's href.
	 * @param {Event} event Dom event that initiated the navigation.
	 */
	maybeScheduleNavigation_(href, event) {
		if (
			this.isNavigationPending &&
			this.navigationStrategy === NavigationStrategy.SCHEDULE_LAST
		) {
			this.scheduledNavigationQueue = [
				{
					href,
					isScheduledNavigation: true,
				},
				...event,
			];

			return true;
		}

		return false;
	}

	/**
	 * Maybe navigate to a path.
	 * @param {string} href Information about the link's href.
	 * @param {Event} event Dom event that initiated the navigation.
	 */
	maybeNavigate_(href, event) {
		if (!this.canNavigate(href)) {
			return;
		}

		const isNavigationScheduled = this.maybeScheduleNavigation_(
			href,
			event
		);

		if (isNavigationScheduled) {
			event.preventDefault();

			return;
		}

		let navigateFailed = false;
		try {
			this.navigate(getUrlPath(href), false, event);
		}
		catch (error) {

			// Do not prevent link navigation in case some synchronous error occurs

			navigateFailed = true;
		}

		if (!navigateFailed && !event.isScheduledNavigation) {
			event.preventDefault();
		}
	}

	/**
	 * Checks whether the onbeforeunload global event handler is overloaded
	 * by client code. If so, it replaces with a function that halts the normal
	 * event flow in relation with the client onbeforeunload function.
	 * This can be in most part used to prematurely terminate navigation to other pages
	 * according to the given constrait(s).
	 * @protected
	 */
	maybeOverloadBeforeUnload_() {
		if ('function' === typeof window.onbeforeunload) {
			window._onbeforeunload = window.onbeforeunload;

			window.onbeforeunload = (event) => {
				this.emit('beforeUnload', event);
				if (event && event.defaultPrevented) {
					return true;
				}
			};

			// mark the updated handler due unwanted recursion

			window.onbeforeunload._overloaded = true;
		}
	}

	/**
	 * Cancels navigation if nextScreen's beforeActivate lifecycle method
	 * resolves to true.
	 * @param {!Screen} nextScreen
	 * @return {!Promise}
	 */
	maybePreventActivate_(nextScreen) {
		return Promise.resolve()
			.then(() => {
				return nextScreen.beforeActivate();
			})
			.then((prevent) => {
				if (prevent) {
					return Promise.reject(
						new Error('Cancelled by next screen')
					);
				}
			});
	}

	/**
	 * Cancels navigation if activeScreen's beforeDeactivate lifecycle
	 * method resolves to true.
	 * @return {!Promise}
	 */
	maybePreventDeactivate_() {
		return Promise.resolve()
			.then(() => {
				if (this.activeScreen) {
					return this.activeScreen.beforeDeactivate();
				}
			})
			.then((prevent) => {
				if (prevent) {
					return Promise.reject(
						new Error('Cancelled by active screen')
					);
				}
			});
	}

	/**
	 * Maybe reposition scroll to hashed anchor.
	 */
	maybeRepositionScrollToHashedAnchor() {
		const hash = window.location.hash;
		if (hash) {
			const anchorElement = document.getElementById(hash.substring(1));
			if (anchorElement) {
				const {offsetLeft, offsetTop} = getNodeOffset(anchorElement);
				window.scrollTo(offsetLeft, offsetTop);
			}
		}
	}

	/**
	 * If supported by the browser, restores native scroll restoration to the
	 * value captured by `maybeDisableNativeScrollRestoration`.
	 */
	maybeRestoreNativeScrollRestoration() {
		if (
			this.nativeScrollRestorationSupported &&
			this.nativeScrollRestoration_
		) {
			window.history.scrollRestoration = this.nativeScrollRestoration_;
		}
	}

	/**
	 * Maybe restore redirected path hash in case both the current path and
	 * the given path are the same.
	 * @param {!string} path Path before navigation.
	 * @param {!string} redirectPath Path after navigation.
	 * @param {!string} hash Hash to be added to the path.
	 * @return {!string} Returns the path with the hash restored.
	 */
	maybeRestoreRedirectPathHash_(path, redirectPath, hash) {
		if (redirectPath === getUrlPathWithoutHash(path)) {
			return redirectPath + hash;
		}

		return redirectPath;
	}

	/**
	 * Maybe update scroll position in history state to anchor on path.
	 * @param {!string} path Path containing anchor
	 */
	maybeUpdateScrollPositionState_() {
		const hash = window.location.hash;

		if (hash) {
			const anchorElement = document.getElementById(hash.substring(1));

			if (anchorElement) {
				const {offsetLeft, offsetTop} = getNodeOffset(anchorElement);

				this.saveHistoryCurrentPageScrollPosition_(
					offsetTop,
					offsetLeft
				);
			}
		}
	}

	/**
	 * Navigates to the specified path if there is a route handler that matches.
	 * @param {!string} path Path to navigate containing the base path.
	 * @param {boolean=} opt_replaceHistory Replaces browser history.
	 * @param {Event=} event Optional event object that triggered the navigation.
	 * @return {Promise} Returns a pending request cancellable promise.
	 */
	navigate(path, opt_replaceHistory, opt_event) {
		if (opt_event) {
			Liferay.SPA.__capturedFormElement__ = opt_event.capturedFormElement;
			Liferay.SPA.__capturedFormButtonElement__ =
				opt_event.capturedFormButtonElement;
		}

		// When reloading the same path do replaceState instead of pushState to
		// avoid polluting history with states with the same path.

		if (path === this.activePath) {
			opt_replaceHistory = true;
		}

		this.emit('beforeNavigate', {
			event: opt_event,
			path,
			replaceHistory: !!opt_replaceHistory,
		});

		return this.pendingNavigate;
	}

	/**
	 * Befores navigation to a path.
	 * @param {!Event} event Event facade containing path and
	 *     replaceHistory.
	 * @protected
	 */
	onBeforeNavigate_(event) {
		if (Liferay.SPA.__capturedFormElement__) {
			event.form = Liferay.SPA.__capturedFormElement__;
		}
	}

	/**
	 * Befores navigation to a path. Runs after external listeners.
	 * @param {!Event} event Event facade containing path and
	 *     replaceHistory.
	 * @protected
	 */
	onBeforeNavigateDefault_(event) {
		if (this.pendingNavigate) {
			if (
				this.pendingNavigate.path === event.path ||
				this.navigationStrategy === NavigationStrategy.SCHEDULE_LAST
			) {
				return;
			}
		}

		this.emit('beforeUnload', event);

		this.emit('startNavigate', {
			form: event.form,
			path: event.path,
			replaceHistory: event.replaceHistory,
		});
	}

	/**
	 * Custom event handler that executes the original listener that has been
	 * added by the client code and terminates the navigation accordingly.
	 * @param {!Event} event original Event facade.
	 * @protected
	 */
	onBeforeUnloadDefault_(event) {
		const func = window._onbeforeunload;
		if (func && !func._overloaded && func()) {
			event.preventDefault();
		}
	}

	/**
	 * Intercepts document clicks and test link elements in order to decide
	 * whether Surface app can navigate.
	 * @param {!Event} event Event facade
	 * @protected
	 */
	onDocClickDelegate_(event) {
		if (
			event.altKey ||
			event.ctrlKey ||
			event.metaKey ||
			event.shiftKey ||
			event.button
		) {
			return;
		}
		this.maybeNavigate_(event.delegateTarget.href, event);
	}

	/**
	 * Intercepts document form submits and test action path in order to decide
	 * whether Surface app can navigate.
	 * @param {!Event} event Event facade
	 * @protected
	 */
	onDocSubmitDelegate_(event) {
		const form = event.delegateTarget;
		if (form.method === 'get') {
			return;
		}
		event.capturedFormElement = form;
		const buttonSelector =
			'button:not([type]),button[type=submit],input[type=submit]';
		if (document.activeElement.matches(buttonSelector)) {
			event.capturedFormButtonElement = document.activeElement;
		}
		else {
			event.capturedFormButtonElement =
				form.querySelector(buttonSelector);
		}
		this.maybeNavigate_(form.action, event);
	}

	/**
	 * Listens to the window's load event in order to avoid issues with some browsers
	 * that trigger popstate calls on the first load. For more information see
	 * http://stackoverflow.com/questions/6421769/popstate-on-pages-load-in-chrome.
	 * @protected
	 */
	onLoad_() {
		this.skipLoadPopstate = true;
		setTimeout(() => {

			// The timeout ensures that popstate events will be unblocked right
			// after the load event occured, but not in the same event-loop cycle.

			this.skipLoadPopstate = false;
		});

		// Try to reposition scroll to the hashed anchor when page loads.

		this.maybeRepositionScrollToHashedAnchor();
	}

	/**
	 * Handles browser history changes and fires app's navigation if the state
	 * belows to us. If we detect a popstate and the state is null,
	 * assume it is navigating to an external page or to a page we don't have
	 * route, then window.location.reload() is invoked in order to
	 * reload the content to the current url.
	 * @param {!Event} event Event facade
	 * @protected
	 */
	onPopstate_(event) {
		if (this.skipLoadPopstate) {
			return;
		}

		// Do not navigate if the popstate was triggered by a hash change.

		if (isCurrentBrowserPath(this.browserPathBeforeNavigate)) {
			this.maybeRepositionScrollToHashedAnchor();

			return;
		}

		const state = event.state;

		if (!state) {
			if (window.location.hash) {

				// If senna is on an redirect path and a hash popstate happens
				// to a different url, reload the browser. This behavior doesn't
				// require senna to route hashed links and is closer to native
				// browser behavior.

				if (
					this.redirectPath &&
					!isCurrentBrowserPath(this.redirectPath)
				) {
					this.reloadPage();
				}

				// Always try to reposition scroll to the hashed anchor when
				// hash popstate happens.

				this.maybeRepositionScrollToHashedAnchor();
			}
			else {
				this.reloadPage();
			}

			return;
		}

		if (state.senna) {
			this.popstateScrollTop = state.scrollTop;
			this.popstateScrollLeft = state.scrollLeft;
			if (!this.nativeScrollRestorationSupported) {
				this.lockHistoryScrollPosition_();
			}
			this.once('endNavigate', () => {
				if (state.referrer) {
					setReferrer(state.referrer);
				}
			});
			const uri = state.path.startsWith('/')
				? new URL(state.path, window.location.origin)
				: new URL(state.path);
			uri.hostname = window.location.hostname;
			uri.port = window.location.port;
			const isNavigationScheduled = this.maybeScheduleNavigation_(
				uri.toString(),
				new Map()
			);
			if (isNavigationScheduled) {
				return;
			}
			this.navigate(state.path, true);
		}
	}

	/**
	 * Listens document scroll changes in order to capture the possible lock
	 * scroll position for history scrolling.
	 * @protected
	 */
	onScroll_() {
		if (this.captureScrollPositionFromScrollEvent) {
			this.saveHistoryCurrentPageScrollPosition_(
				window.pageYOffset,
				window.pageXOffset
			);
		}
	}

	/**
	 * Starts navigation to a path.
	 * @param {!Event} event Event facade containing path and
	 *     replaceHistory.
	 * @protected
	 */
	onStartNavigate_(event) {
		this.maybeDisableNativeScrollRestoration();
		this.captureScrollPositionFromScrollEvent = false;
		document.documentElement.classList.add(this.loadingCssClass);

		const endNavigatePayload = {
			form: event.form,
			path: event.path,
		};

		this.pendingNavigate = this.doNavigate_(
			event.path,
			event.replaceHistory
		)
			.catch((reason) => {
				endNavigatePayload.error = reason;
				throw reason;
			})
			.finally(() => {
				if (
					!this.pendingNavigate &&
					!this.scheduledNavigationQueue.length
				) {
					document.documentElement.classList.remove(
						this.loadingCssClass
					);
					this.maybeRestoreNativeScrollRestoration();
					this.captureScrollPositionFromScrollEvent = true;
				}
				this.emit('endNavigate', endNavigatePayload);
			});

		this.pendingNavigate.path = event.path;
	}

	/**
	 * Prefetches the specified path if there is a route handler that matches.
	 * @param {!string} path Path to navigate containing the base path.
	 * @return {Promise} Returns a pending request cancellable promise.
	 */
	prefetch(path) {
		const route = this.findRoute(path);
		if (!route) {
			return Promise.reject(new Error('No route for ' + path));
		}

		const nextScreen = this.createScreenInstance(path, route);

		return nextScreen
			.load(path)
			.then(() => {
				this.screens[path] = nextScreen;
			})
			.catch((reason) => {
				this.handleNavigateError_(path, nextScreen, reason);
				throw reason;
			});
	}

	/**
	 * Prepares screen flip. Updates history state and surfaces content.
	 * @param {!string} path Path containing the querystring part.
	 * @param {!Screen} nextScreen
	 * @param {boolean=} opt_replaceHistory Replaces browser history.
	 */
	prepareNavigateHistory_(path, nextScreen, opt_replaceHistory) {
		let title = nextScreen.getTitle();
		if (typeof title !== 'string') {
			title = this.getDefaultTitle();
		}
		let redirectPath = nextScreen.beforeUpdateHistoryPath(path);
		const hash = path.startsWith('/')
			? new URL(path, window.location.origin).hash
			: new URL(path).hash;
		redirectPath = this.maybeRestoreRedirectPathHash_(
			path,
			redirectPath,
			hash
		);
		const historyState = {
			form: !!Liferay.SPA.__capturedFormElement__,
			path,
			redirectPath,
			scrollLeft: 0,
			scrollTop: 0,
			senna: true,
		};
		if (opt_replaceHistory) {
			historyState.scrollTop = this.popstateScrollTop;
			historyState.scrollLeft = this.popstateScrollLeft;
		}
		this.updateHistory_(
			title,
			redirectPath,
			nextScreen.beforeUpdateHistoryState(historyState),
			opt_replaceHistory
		);
		this.redirectPath = redirectPath;
	}

	/**
	 * Prepares screen flip. Updates history state and surfaces content.
	 * @param {!Screen} nextScreen
	 * @param {!Object} surfaces Map of surfaces to flip keyed by surface id.
	 * @param {!Object} params Params extracted from the current path.
	 */
	prepareNavigateSurfaces_(nextScreen, surfaces, params) {
		Object.keys(surfaces).forEach((id) => {
			const surfaceContent = nextScreen.getSurfaceContent(id, params);
			surfaces[id].addContent(nextScreen.getId(), surfaceContent);
		});
	}

	/**
	 * Reloads the page by performing `window.location.reload()`.
	 */
	reloadPage() {
		window.location.reload();
	}

	/**
	 * Removes route instance from app routes.
	 * @param {Route} route
	 * @return {boolean} True if an element was removed.
	 */
	removeRoute(route) {
		const routeIndex = this.routes.indexOf(route);

		if (routeIndex >= 0) {
			this.routes.splice(routeIndex, 1);
		}

		return routeIndex >= 0;
	}

	/**
	 * Removes a screen.
	 * @param {!string} path Path containing the querystring part.
	 */
	removeScreen(path) {
		const screen = this.screens[path];
		if (screen) {
			Object.keys(this.surfaces).forEach((surfaceId) =>
				this.surfaces[surfaceId].remove(screen.getId())
			);
			screen.dispose();
			delete this.screens[path];
		}
	}

	/**
	 * Saves given scroll position into history state.
	 * @param {!number} scrollTop Number containing the top scroll position to be saved.
	 * @param {!number} scrollLeft Number containing the left scroll position to be saved.
	 */
	saveHistoryCurrentPageScrollPosition_(scrollTop, scrollLeft) {
		const state = window.history.state;
		if (state && state.senna) {
			[state.scrollTop, state.scrollLeft] = [scrollTop, scrollLeft];
			window.history.replaceState(state, null, null);
		}
	}

	/**
	 * Sets allow prevent navigate.
	 * @param {boolean} allowPreventNavigate
	 */
	setAllowPreventNavigate(allowPreventNavigate) {
		this.allowPreventNavigate = allowPreventNavigate;
	}

	/**
	 * Sets link base path.
	 * @param {!string} path
	 */
	setBasePath(basePath) {
		this.basePath = removePathTrailingSlash(basePath);
	}

	/**
	 * Sets the default page title.
	 * @param {string} defaultTitle
	 */
	setDefaultTitle(defaultTitle) {
		this.defaultTitle = defaultTitle;
	}

	/**
	 * Sets the form selector.
	 * @param {!string} formSelector
	 */
	setFormSelector(formSelector) {
		this.formSelector = formSelector;
		if (this.formEventHandler_) {
			this.formEventHandler_.dispose();
		}
		this.formEventHandler_ = delegate(
			document,
			'submit',
			this.formSelector,
			this.onDocSubmitDelegate_.bind(this),
			this.allowPreventNavigate
		);
	}

	/**
	 * Sets if route matching should ignore query string from the route path.
	 * @param {boolean} ignoreQueryStringFromRoutePath
	 */
	setIgnoreQueryStringFromRoutePath(ignoreQueryStringFromRoutePath) {
		this.ignoreQueryStringFromRoutePath = ignoreQueryStringFromRoutePath;
	}

	/**
	 * Sets the link selector.
	 * @param {!string} linkSelector
	 */
	setLinkSelector(linkSelector) {
		this.linkSelector = linkSelector;
		if (this.linkEventHandler_) {
			this.linkEventHandler_.dispose();
		}
		this.linkEventHandler_ = delegate(
			document,
			'click',
			this.linkSelector,
			this.onDocClickDelegate_.bind(this),
			this.allowPreventNavigate
		);
	}

	/**
	 * Sets the loading css class.
	 * @param {!string} loadingCssClass
	 */
	setLoadingCssClass(loadingCssClass) {
		this.loadingCssClass = loadingCssClass;
	}

	/**
	 * Sets the update scroll position value.
	 * @param {boolean} updateScrollPosition
	 */
	setUpdateScrollPosition(updateScrollPosition) {
		this.updateScrollPosition = updateScrollPosition;
	}

	/**
	 * Cancels pending navigate with Cancel pending navigation error.
	 * @protected
	 */
	stopPendingNavigate_() {
		if (this.pendingNavigate) {

			// this.pendingNavigate.cancel('Cancel pending navigation');

		}
		this.pendingNavigate = null;
	}

	/**
	 * Sync document scroll position twice, the first one synchronous and then
	 * one inside setTimeout(cb, 0). Relevant to browsers that fires
	 * scroll restoration asynchronously after popstate.
	 * @protected
	 * @return {?Promise=}
	 */
	syncScrollPositionSyncThenAsync_() {
		const state = window.history.state;
		if (!state) {
			return;
		}

		const scrollTop = state.scrollTop;
		const scrollLeft = state.scrollLeft;

		const sync = () => {
			if (this.updateScrollPosition) {
				window.scrollTo(scrollLeft, scrollTop);
			}
		};

		return new Promise((resolve) => {
			sync();

			setTimeout(() => {
				sync();
				resolve();
			});
		});
	}

	/**
	 * Updates or replace browser history.
	 * @param {?string} title Document title.
	 * @param {!string} path Path containing the querystring part.
	 * @param {!object} state
	 * @param {boolean=} opt_replaceHistory Replaces browser history.
	 * @protected
	 */
	updateHistory_(title, path, state, opt_replaceHistory) {
		const referrer = window.location.href;

		if (state) {
			state.referrer = referrer;
		}

		if (opt_replaceHistory) {
			window.history.replaceState(state, title, path);
		}
		else {
			window.history.pushState(state, title, path);
		}

		setReferrer(referrer);

		const titleNode = document.querySelector('title');
		if (titleNode) {
			titleNode.innerHTML = title;
		}
		else {
			document.title = title;
		}
	}
}

export default App;




© 2015 - 2024 Weber Informatics LLC | Privacy Policy