// Copyright 2014 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';
import {FocusOutlineManager} from 'chrome://resources/js/focus_outline_manager.js';
import {Page} from './page.js';
/**
* PageManager contains a list of root Page objects and handles "navigation"
* by showing and hiding these pages. On initial load, PageManager can use
* the path to open the correct hierarchy of pages.
*/
export class PageManager {
constructor() {
/**
* True if page is served from a dialog.
* @type {boolean}
*/
this.isDialog = false;
/**
* Root pages. Maps lower-case page names to the respective page object.
* @type {!Map<string, !Page>}
*/
this.registeredPages = new Map();
/**
* Observers will be notified when opening and closing overlays.
* @private {!Array<!PageManagerObserver>}
*/
this.observers_ = [];
/** @private {?Page} */
this.defaultPage_ = null;
}
/**
* Initializes the complete page.
* @param {Page} defaultPage The page to be shown when no
* page is specified in the path.
*/
initialize(defaultPage) {
this.defaultPage_ = defaultPage;
FocusOutlineManager.forDocument(document);
}
/**
* Registers new page.
* @param {!Page} page Page to register.
*/
register(page) {
this.registeredPages.set(page.name.toLowerCase(), page);
page.addEventListener(
'page-hash-changed',
e => this.onPageHashChanged_(/** @type {!CustomEvent} */ (e)));
page.initializePage();
}
/**
* Unregisters an existing page.
* @param {!Page} page Page to unregister.
*/
unregister(page) {
this.registeredPages.delete(page.name.toLowerCase());
}
/**
* Shows the default page.
* @param {boolean=} opt_updateHistory If we should update the history after
* showing the page (defaults to true).
*/
showDefaultPage(opt_updateHistory) {
assert(
this.defaultPage_ instanceof Page,
'PageManager must be initialized with a default page.');
this.showPageByName(this.defaultPage_.name, opt_updateHistory);
}
/**
* Shows a registered page.
* @param {string} pageName Page name.
* @param {boolean=} opt_updateHistory If we should update the history after
* showing the page (defaults to true).
* @param {Object=} opt_propertyBag An optional bag of properties including
* replaceState (if history state should be replaced instead of pushed).
* hash (a hash state to attach to the page).
*/
showPageByName(pageName, opt_updateHistory, opt_propertyBag) {
opt_updateHistory = opt_updateHistory !== false;
opt_propertyBag = opt_propertyBag || {};
// Find the currently visible root-level page.
let rootPage = null;
for (const page of this.registeredPages.values()) {
if (page.visible && !page.parentPage) {
rootPage = page;
break;
}
}
// Find the target page.
let targetPage = this.registeredPages.get(pageName.toLowerCase());
if (!targetPage) {
targetPage = this.defaultPage_;
}
pageName = targetPage.name.toLowerCase();
const targetPageWasVisible = targetPage.visible;
// Notify pages if they will be hidden.
this.registeredPages.forEach(page => {
if (page.name !== pageName && !this.isAncestorOfPage(page, targetPage)) {
page.willHidePage();
}
});
// Update the page's hash.
targetPage.hash = opt_propertyBag.hash || '';
// Update visibilities to show only the hierarchy of the target page.
this.registeredPages.forEach(page => {
page.visible =
page.name === pageName || this.isAncestorOfPage(page, targetPage);
});
// Update the history and current location.
if (opt_updateHistory) {
this.updateHistoryState_(!!opt_propertyBag.replaceState);
}
// Update focus if any other control was focused on the previous page,
// or the previous page is not known.
if (document.activeElement !== document.body &&
(!rootPage || rootPage.pageDiv.contains(document.activeElement))) {
targetPage.focus();
}
// Notify pages if they were shown.
this.registeredPages.forEach(page => {
if (!targetPageWasVisible &&
(page.name === pageName || this.isAncestorOfPage(page, targetPage))) {
page.didShowPage();
}
});
// If the target page was already visible, notify it that its hash
// changed externally.
if (targetPageWasVisible) {
targetPage.didChangeHash();
}
// Update the document title. Do this after didShowPage was called, in
// case a page decides to change its title.
this.updateTitle_();
}
/**
* Returns the name of the page from the current path.
* @return {string} Name of the page specified by the current path.
*/
getPageNameFromPath() {
const path = location.pathname;
if (path.length <= 1) {
return this.defaultPage_.name;
}
// Skip starting slash and remove trailing slash (if any).
return path.slice(1).replace(/\/$/, '');
}
/**
* Gets the level of the page. Root pages (e.g., BrowserOptions) are at
* level 0.
* @return {number} How far down this page is from the root page.
*/
getNestingLevel(page) {
let level = 0;
let parent = page.parentPage;
while (parent) {
level++;
parent = parent.parentPage;
}
return level;
}
/**
* Checks whether one page is an ancestor of the other page in terms of
* subpage nesting.
* @param {Page} potentialAncestor Potential ancestor.
* @param {Page} potentialDescendent Potential descendent.
* @return {boolean} True if |potentialDescendent| is nested under
* |potentialAncestor|.
*/
isAncestorOfPage(potentialAncestor, potentialDescendent) {
let parent = potentialDescendent.parentPage;
while (parent) {
if (parent === potentialAncestor) {
return true;
}
parent = parent.parentPage;
}
return false;
}
/**
* Called when a page's hash changes. If the page is the topmost visible
* page, the history state is updated.
* @param {!CustomEvent} e
*/
onPageHashChanged_(e) {
const page = /** @type {!Page} */ (e.target);
if (page === this.getTopmostVisiblePage()) {
this.updateHistoryState_(false);
}
}
/**
* @param {!PageManagerObserver} observer The observer to register.
*/
addObserver(observer) {
this.observers_.push(observer);
}
/**
* Returns the topmost visible page.
* @return {Page}
* @private
*/
getTopmostVisiblePage() {
for (const page of this.registeredPages.values()) {
if (page.visible) {
return page;
}
}
return null;
}
/**
* Updates the title to the title of the current page, or of the topmost
* visible page with a non-empty title.
* @private
*/
updateTitle_() {
let page = this.getTopmostVisiblePage();
while (page) {
if (page.title) {
for (let i = 0; i < this.observers_.length; ++i) {
this.observers_[i].updateTitle(page.title);
}
return;
}
page = page.parentPage;
}
}
/**
* Constructs a new path to push onto the history stack, using observers
* to update the history.
* @param {boolean} replace If true, handlers should replace the current
* history event rather than create new ones.
* @private
*/
updateHistoryState_(replace) {
if (this.isDialog) {
return;
}
const page = this.getTopmostVisiblePage();
let path = window.location.pathname + window.location.hash;
if (path) {
// Remove trailing slash.
path = path.slice(1).replace(/\/(?:#|$)/, '');
}
// If the page is already in history (the user may have clicked the same
// link twice, or this is the initial load), do nothing.
const newPath = (page === this.defaultPage_ ? '' : page.name) + page.hash;
if (path === newPath) {
return;
}
for (let i = 0; i < this.observers_.length; ++i) {
this.observers_[i].updateHistory(newPath, replace);
}
}
/** @return {!PageManager} */
static getInstance() {
return instance || (instance = new PageManager());
}
}
/** @type {?PageManager} */
let instance = null;
/**
* An observer of PageManager.
*/
export class PageManagerObserver {
/**
* Called when a new title should be set.
* @param {string} title The title to set.
*/
updateTitle(title) {}
/**
* Called when a page is navigated to.
* @param {string} path The path of the page being visited.
* @param {boolean} replace If true, allow no history events to be created.
*/
updateHistory(path, replace) {}
}