// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chrome://resources/js/assert.js';
/**
* The different pages that can be shown at a time.
* Note: This must remain in sync with the page ids in manager.html!
*/
export enum Page {
LIST = 'items-list',
DETAILS = 'details-view',
ACTIVITY_LOG = 'activity-log',
SITE_PERMISSIONS = 'site-permissions',
SITE_PERMISSIONS_ALL_SITES = 'site-permissions-by-site',
SHORTCUTS = 'keyboard-shortcuts',
ERRORS = 'error-page',
}
export enum Dialog {
OPTIONS = 'options',
}
export interface PageState {
page: Page;
extensionId?: string;
subpage?: Dialog;
}
type Listener = (pageState: PageState) => void;
/** @return Whether a and b are equal. */
function isPageStateEqual(a: PageState, b: PageState): boolean {
return a.page === b.page && a.subpage === b.subpage &&
a.extensionId === b.extensionId;
}
/**
* Regular expression that captures the leading slash, the content and the
* trailing slash in three different groups.
*/
const CANONICAL_PATH_REGEX: RegExp = /(^\/)([\/-\w]+)(\/$)/;
/**
* A helper object to manage in-page navigations. Since the extensions page
* needs to support different urls for different subpages (like the details
* page), we use this object to manage the history and url conversions.
*/
export class NavigationHelper {
private nextListenerId_: number = 1;
private listeners_: Map<number, Listener> = new Map();
private previousPage_: PageState;
constructor() {
this.processRoute_();
window.addEventListener('popstate', () => {
this.notifyRouteChanged_(this.getCurrentPage());
});
}
private get currentPath_(): string {
return location.pathname.replace(CANONICAL_PATH_REGEX, '$1$2');
}
/**
* Going to /configureCommands and /shortcuts should land you on /shortcuts,
* and going to /sitePermissions should land you on /sitePermissions.
* These are the only three supported routes, so all other cases will redirect
* you to root path if not already on it.
*/
private processRoute_() {
if (this.currentPath_ === '/configureCommands' ||
this.currentPath_ === '/shortcuts') {
window.history.replaceState(
undefined /* stateObject */, '', '/shortcuts');
} else if (this.currentPath_ === '/sitePermissions') {
window.history.replaceState(
undefined /* stateObject */, '', '/sitePermissions');
} else if (this.currentPath_ === '/sitePermissions/allSites') {
window.history.replaceState(
undefined /* stateObject */, '', '/sitePermissions/allSites');
} else if (this.currentPath_ !== '/') {
window.history.replaceState(undefined /* stateObject */, '', '/');
}
}
/**
* @return The page that should be displayed for the current URL.
*/
getCurrentPage(): PageState {
const search = new URLSearchParams(location.search);
let id = search.get('id');
if (id) {
return {page: Page.DETAILS, extensionId: id};
}
id = search.get('activity');
if (id) {
return {page: Page.ACTIVITY_LOG, extensionId: id};
}
id = search.get('options');
if (id) {
return {page: Page.DETAILS, extensionId: id, subpage: Dialog.OPTIONS};
}
id = search.get('errors');
if (id) {
return {page: Page.ERRORS, extensionId: id};
}
if (this.currentPath_ === '/shortcuts') {
return {page: Page.SHORTCUTS};
}
if (this.currentPath_ === '/sitePermissions') {
return {page: Page.SITE_PERMISSIONS};
}
if (this.currentPath_ === '/sitePermissions/allSites') {
return {page: Page.SITE_PERMISSIONS_ALL_SITES};
}
return {page: Page.LIST};
}
/**
* Function to add subscribers.
* @param {!function(!PageState)} listener
* @return A numerical ID to be used for removing the listener.
*/
addListener(listener: Listener): number {
const nextListenerId = this.nextListenerId_++;
this.listeners_.set(nextListenerId, listener);
return nextListenerId;
}
/**
* Remove a previously registered listener.
* @return Whether a listener with the given ID was actually found and
* removed.
*/
removeListener(id: number): boolean {
return this.listeners_.delete(id);
}
/**
* Function to notify subscribers.
*/
private notifyRouteChanged_(newPage: PageState) {
for (const listener of this.listeners_.values()) {
listener(newPage);
}
}
/**
* @param newPage the page to navigate to.
*/
navigateTo(newPage: PageState) {
const currentPage = this.getCurrentPage();
if (currentPage && isPageStateEqual(currentPage, newPage)) {
return;
}
this.updateHistory(newPage, false /* replaceState */);
this.notifyRouteChanged_(newPage);
}
/**
* @param newPage the page to replace the current page with.
*/
replaceWith(newPage: PageState) {
this.updateHistory(newPage, true /* replaceState */);
if (this.previousPage_ && isPageStateEqual(this.previousPage_, newPage)) {
// Skip the duplicate history entry.
history.back();
return;
}
this.notifyRouteChanged_(newPage);
}
/**
* Called when a page changes, and pushes state to history to reflect it.
*/
updateHistory(entry: PageState, replaceState: boolean) {
let path;
switch (entry.page) {
case Page.LIST:
path = '/';
break;
case Page.ACTIVITY_LOG:
path = '/?activity=' + entry.extensionId;
break;
case Page.DETAILS:
if (entry.subpage) {
assert(entry.subpage === Dialog.OPTIONS);
path = '/?options=' + entry.extensionId;
} else {
path = '/?id=' + entry.extensionId;
}
break;
case Page.SITE_PERMISSIONS:
path = '/sitePermissions';
break;
case Page.SITE_PERMISSIONS_ALL_SITES:
path = '/sitePermissions/allSites';
break;
case Page.SHORTCUTS:
path = '/shortcuts';
break;
case Page.ERRORS:
path = '/?errors=' + entry.extensionId;
break;
}
assert(path);
const state = {url: path};
const currentPage = this.getCurrentPage();
const isDialogNavigation = currentPage.page === entry.page &&
currentPage.extensionId === entry.extensionId;
// Navigating to a dialog doesn't visually change pages; it just opens
// a dialog. As such, we replace state rather than pushing a new state
// on the stack so that hitting the back button doesn't just toggle the
// dialog.
if (replaceState || isDialogNavigation) {
history.replaceState(state, '', path);
} else {
this.previousPage_ = currentPage;
history.pushState(state, '', path);
}
}
}
export const navigation = new NavigationHelper();