// Copyright 2017 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. cr.define('extensions', function() { 'use strict'; /** * The different pages that can be shown at a time. * Note: This must remain in sync with the page ids in manager.html! * @enum {string} */ const Page = { LIST: 'items-list', DETAILS: 'details-view', ACTIVITY_LOG: 'activity-log', SHORTCUTS: 'keyboard-shortcuts', ERRORS: 'error-page', }; /** @enum {string} */ const Dialog = { OPTIONS: 'options', }; /** * @typedef {{page: extensions.Page, * extensionId: (string|undefined), * subpage: (!extensions.Dialog|undefined)}} */ let PageState; /** * @param {!extensions.PageState} a * @param {!extensions.PageState} b * @return {boolean} Whether a and b are equal. */ function isPageStateEqual(a, b) { 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 {!RegExp} */ const CANONICAL_PATH_REGEX = /(^\/)([\/-\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. */ class NavigationHelper { constructor() { this.processRoute_(); /** @private {number} */ this.nextListenerId_ = 1; /** @private {!Map} */ this.listeners_ = new Map(); /** @private {!extensions.PageState} */ this.previousPage_; window.addEventListener('popstate', () => { this.notifyRouteChanged_(this.getCurrentPage()); }); } /** @private */ get currentPath_() { return location.pathname.replace(CANONICAL_PATH_REGEX, '$1$2'); } /** * Going to /configureCommands and /shortcuts should land you on /shortcuts. * These are the only two 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_ !== '/') { window.history.replaceState(undefined /* stateObject */, '', '/'); } } /** * @return {!extensions.PageState} The page that should be displayed for the * current URL. */ getCurrentPage() { 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}; } return {page: Page.LIST}; } /** * Function to add subscribers. * @param {!function(!extensions.PageState)} listener * @return {number} A numerical ID to be used for removing the listener. */ addListener(listener) { const nextListenerId = this.nextListenerId_++; this.listeners_.set(nextListenerId, listener); return nextListenerId; } /** * Remove a previously registered listener. * @param {number} id * @return {boolean} Whether a listener with the given ID was actually found * and removed. */ removeListener(id) { return this.listeners_.delete(id); } /** * Function to notify subscribers. * @private */ notifyRouteChanged_(newPage) { this.listeners_.forEach((listener, id) => { listener(newPage); }); } /** * @param {!extensions.PageState} newPage the page to navigate to. */ navigateTo(newPage) { const currentPage = this.getCurrentPage(); if (currentPage && isPageStateEqual(currentPage, newPage)) { return; } this.updateHistory(newPage, false /* replaceState */); this.notifyRouteChanged_(newPage); } /** * @param {!extensions.PageState} newPage the page to replace the current * page with. */ replaceWith(newPage) { 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. * @param {!extensions.PageState} entry * @param {boolean} replaceState */ updateHistory(entry, replaceState) { 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.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); } } } const navigation = new NavigationHelper(); return { Dialog: Dialog, // Constructor exposed for testing purposes. NavigationHelper: NavigationHelper, navigation: navigation, Page: Page, PageState: PageState, }; });