// Copyright (c) 2010 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('options', function() {
/////////////////////////////////////////////////////////////////////////////
// OptionsPage class:
/**
* Base class for options page.
* @constructor
* @param {string} name Options page name, also defines id of the div element
* containing the options view and the name of options page navigation bar
* item as name+'PageNav'.
* @param {string} title Options page title, used for navigation bar
* @extends {EventTarget}
*/
function OptionsPage(name, title, pageDivName) {
this.name = name;
this.title = title;
this.pageDivName = pageDivName;
this.pageDiv = $(this.pageDivName);
this.tab = null;
this.managed = false;
}
/**
* Main level option pages.
* @protected
*/
OptionsPage.registeredPages = {};
/**
* Pages which are meant to behave like modal dialogs.
* @protected
*/
OptionsPage.registeredOverlayPages = {};
/**
* Whether or not |initialize| has been called.
* @private
*/
OptionsPage.initialized_ = false;
/**
* Shows a registered page. This handles both top-level pages and sub-pages.
* @param {string} pageName Page name.
*/
OptionsPage.showPageByName = function(pageName) {
var targetPage = this.registeredPages[pageName];
// Determine if the root page is 'sticky', meaning that it
// shouldn't change when showing a sub-page. This can happen for special
// pages like Search.
var rootPage = null;
for (var name in this.registeredPages) {
var page = this.registeredPages[name];
if (page.visible && !page.parentPage) {
rootPage = page;
break;
}
}
var isRootPageLocked =
rootPage && rootPage.sticky && targetPage.parentPage;
// Notify pages if they will be hidden.
for (var name in this.registeredPages) {
var page = this.registeredPages[name];
if (!page.parentPage && isRootPageLocked)
continue;
if (page.willHidePage && name != pageName &&
!page.isAncestorOfPage(targetPage))
page.willHidePage();
}
// Update visibilities to show only the hierarchy of the target page.
for (var name in this.registeredPages) {
var page = this.registeredPages[name];
if (!page.parentPage && isRootPageLocked)
continue;
page.visible = name == pageName ||
(document.documentElement.getAttribute('hide-menu') != 'true' &&
page.isAncestorOfPage(targetPage));
}
// Notify pages if they were shown.
for (var name in this.registeredPages) {
var page = this.registeredPages[name];
if (!page.parentPage && isRootPageLocked)
continue;
if (page.didShowPage && (name == pageName ||
page.isAncestorOfPage(targetPage)))
page.didShowPage();
}
};
/**
* Called on load. Dispatch the URL hash to the given page's handleHash
* function.
* @param {string} pageName The string name of the (registered) options page.
* @param {string} hash The value of the hash component of the URL.
*/
OptionsPage.handleHashForPage = function(pageName, hash) {
var page = this.registeredPages[pageName];
page.handleHash(hash);
};
/**
* Shows a registered Overlay page.
* @param {string} overlayName Page name.
*/
OptionsPage.showOverlay = function(overlayName) {
if (this.registeredOverlayPages[overlayName]) {
this.registeredOverlayPages[overlayName].visible = true;
}
};
/**
* Returns whether or not an overlay is visible.
* @return {boolean} True if an overlay is visible.
* @private
*/
OptionsPage.isOverlayVisible_ = function() {
for (var name in this.registeredOverlayPages) {
if (this.registeredOverlayPages[name].visible)
return true;
}
return false;
};
/**
* Clears overlays (i.e. hide all overlays).
*/
OptionsPage.clearOverlays = function() {
for (var name in this.registeredOverlayPages) {
var page = this.registeredOverlayPages[name];
page.visible = false;
}
};
/**
* Returns the topmost visible page, or null if no page is visible.
* @return {OptionPage} The topmost visible page.
*/
OptionsPage.getTopmostVisiblePage = function() {
var topPage = null;
for (var name in this.registeredPages) {
var page = this.registeredPages[name];
if (page.visible &&
(!topPage || page.nestingLevel > topPage.nestingLevel))
topPage = page;
}
return topPage;
}
/**
* Closes the topmost open subpage, if any.
*/
OptionsPage.closeTopSubPage = function() {
var topPage = this.getTopmostVisiblePage();
if (topPage && topPage.parentPage)
topPage.visible = false;
};
/**
* Closes all subpages below the given level.
* @param {number} level The nesting level to close below.
*/
OptionsPage.closeSubPagesToLevel = function(level) {
var topPage = this.getTopmostVisiblePage();
while (topPage && topPage.nestingLevel > level) {
topPage.visible = false;
topPage = topPage.parentPage;
}
};
/**
* Updates managed banner visibility state based on the topmost page.
*/
OptionsPage.updateManagedBannerVisibility = function() {
var topPage = this.getTopmostVisiblePage();
if (topPage)
topPage.updateManagedBannerVisibility();
};
/**
* Shows the tab contents for the given navigation tab.
* @param {!Element} tab The tab that the user clicked.
*/
OptionsPage.showTab = function(tab) {
// Search parents until we find a tab, or the nav bar itself. This allows
// tabs to have child nodes, e.g. labels in separately-styled spans.
while (tab && !tab.classList.contains('subpages-nav-tabs') &&
!tab.classList.contains('inactive-tab')) {
tab = tab.parentNode;
}
if (!tab || !tab.classList.contains('inactive-tab'))
return;
if (this.activeNavTab != null) {
this.activeNavTab.classList.remove('active-tab');
$(this.activeNavTab.getAttribute('tab-contents')).classList.
remove('active-tab-contents');
}
tab.classList.add('active-tab');
$(tab.getAttribute('tab-contents')).classList.add('active-tab-contents');
this.activeNavTab = tab;
};
/**
* Registers new options page.
* @param {OptionsPage} page Page to register.
*/
OptionsPage.register = function(page) {
this.registeredPages[page.name] = page;
// Create and add new page
element to navbar.
var pageNav = document.createElement('li');
pageNav.id = page.name + 'PageNav';
pageNav.className = 'navbar-item';
pageNav.setAttribute('pageName', page.name);
pageNav.textContent = page.title;
pageNav.tabIndex = 0;
pageNav.onclick = function(event) {
OptionsPage.showPageByName(this.getAttribute('pageName'));
};
pageNav.onkeypress = function(event) {
// Enter or space
if (event.keyCode == 13 || event.keyCode == 32) {
OptionsPage.showPageByName(this.getAttribute('pageName'));
}
};
var navbar = $('navbar');
navbar.appendChild(pageNav);
page.tab = pageNav;
page.initializePage();
};
/**
* Find an enclosing section for an element if it exists.
* @param {Element} element Element to search.
* @return {OptionPage} The section element, or null.
* @private
*/
OptionsPage.findSectionForNode_ = function(node) {
while (node = node.parentNode) {
if (node.nodeName == 'SECTION')
return node;
}
return null;
};
/**
* Registers a new Sub-page.
* @param {OptionsPage} subPage Sub-page to register.
* @param {OptionsPage} parentPage Associated parent page for this page.
* @param {Array} associatedControls Array of control elements that lead to
* this sub-page. The first item is typically a button in a root-level
* page. There may be additional buttons for nested sub-pages.
*/
OptionsPage.registerSubPage = function(subPage,
parentPage,
associatedControls) {
this.registeredPages[subPage.name] = subPage;
subPage.parentPage = parentPage;
if (associatedControls) {
subPage.associatedControls = associatedControls;
if (associatedControls.length) {
subPage.associatedSection =
this.findSectionForNode_(associatedControls[0]);
}
}
subPage.tab = undefined;
subPage.initializePage();
};
/**
* Registers a new Overlay page.
* @param {OptionsPage} page Page to register, must be a class derived from
* @param {Array} associatedControls Array of control elements associated with
* this page.
*/
OptionsPage.registerOverlay = function(page,
associatedControls) {
this.registeredOverlayPages[page.name] = page;
if (associatedControls) {
page.associatedControls = associatedControls;
if (associatedControls.length) {
page.associatedSection =
this.findSectionForNode_(associatedControls[0]);
}
}
page.tab = undefined;
page.isOverlay = true;
page.initializePage();
};
/**
* Callback for window.onpopstate.
* @param {Object} data State data pushed into history.
*/
OptionsPage.setState = function(data) {
if (data && data.pageName) {
this.showPageByName(data.pageName);
}
};
/**
* Initializes the complete options page. This will cause
* all C++ handlers to be invoked to do final setup.
*/
OptionsPage.initialize = function() {
chrome.send('coreOptionsInitialize');
this.initialized_ = true;
// Set up the overlay sheets:
// Close nested sub-pages when clicking the visible part of an earlier page.
for (var level = 1; level <= 2; level++) {
var containerId = 'subpage-sheet-container-' + level;
$(containerId).onclick = this.subPageClosingClickHandler_(level);
}
var self = this;
// Close subpages if the user clicks on the html body. Listen in the
// capturing phase so that we can stop the click from doing anything.
document.body.addEventListener('click',
this.bodyMouseEventHandler_.bind(this),
true);
// We also need to cancel mousedowns on non-subpage content.
document.body.addEventListener('mousedown',
this.bodyMouseEventHandler_.bind(this),
true);
// Hook up the close buttons.
subpageCloseButtons = document.querySelectorAll('.close-subpage');
for (var i = 0; i < subpageCloseButtons.length; i++) {
subpageCloseButtons[i].onclick = function() {
self.closeTopSubPage();
};
};
// Close the top overlay or sub-page on esc.
document.addEventListener('keydown', function(e) {
if (e.keyCode == 27) { // Esc
if (self.isOverlayVisible_())
self.clearOverlays();
else
self.closeTopSubPage();
}
});
};
/**
* Returns a function to handle clicks behind a subpage at level |level| by
* closing all subpages down to |level| - 1.
* @param {number} level The level of the subpage being handled.
* @return {Function} a function to handle clicks outside the given subpage.
* @private
*/
OptionsPage.subPageClosingClickHandler_ = function(level) {
var self = this;
return function(event) {
// Clicks on the narrow strip between the left of the subpage sheet and
// that shows part of the parent page should close the overlay, but
// not fall through to the parent page.
if (!$('subpage-sheet-' + level).contains(event.target))
self.closeSubPagesToLevel(level - 1);
event.stopPropagation();
event.preventDefault();
};
};
/**
* A function to handle mouse events (mousedown or click) on the html body by
* closing subpages and/or stopping event propagation.
* @return {Event} a mousedown or click event.
* @private
*/
OptionsPage.bodyMouseEventHandler_ = function(event) {
// Do nothing if a subpage isn't showing.
var topPage = this.getTopmostVisiblePage();
if (!(topPage && topPage.parentPage))
return;
// If an overlay is currently visible, do nothing.
if (this.isOverlayVisible_())
return;
// If the click was within a subpage, do nothing.
for (var level = 1; level <= 2; level++) {
if ($('subpage-sheet-container-' + level).contains(event.target))
return;
}
// Close all subpages on click.
if (event.type == 'click')
this.closeSubPagesToLevel(0);
// Events should not fall through to the main view,
// but they can fall through for the sidebar.
if ($('mainview-content').contains(event.target)) {
event.stopPropagation();
event.preventDefault();
}
};
/**
* Re-initializes the C++ handlers if necessary. This is called if the
* handlers are torn down and recreated but the DOM may not have been (in
* which case |initialize| won't be called again). If |initialize| hasn't been
* called, this does nothing (since it will be later, once the DOM has
* finished loading).
*/
OptionsPage.reinitializeCore = function() {
if (this.initialized_)
chrome.send('coreOptionsInitialize');
}
OptionsPage.prototype = {
__proto__: cr.EventTarget.prototype,
/**
* The parent page of this option page, or null for top-level pages.
* @type {OptionsPage}
*/
parentPage: null,
/**
* The section on the parent page that is associated with this page.
* Can be null.
* @type {Element}
*/
associatedSection: null,
/**
* An array of controls that are associated with this page. The first
* control should be located on a top-level page.
* @type {OptionsPage}
*/
associatedControls: null,
/**
* Initializes page content.
*/
initializePage: function() {},
/**
* Sets managed banner visibility state.
*/
setManagedBannerVisibility: function(visible) {
this.managed = visible;
if (this.visible) {
this.updateManagedBannerVisibility();
}
},
/**
* Updates managed banner visibility state.
*/
updateManagedBannerVisibility: function() {
if (this.managed) {
$('managed-prefs-banner').classList.remove('hidden');
} else {
$('managed-prefs-banner').classList.add('hidden');
}
},
/**
* Gets page visibility state.
*/
get visible() {
var page = $(this.pageDivName);
return page && page.ownerDocument.defaultView.getComputedStyle(
page).display == 'block';
},
/**
* Sets page visibility.
*/
set visible(visible) {
if ((this.visible && visible) || (!this.visible && !visible))
return;
if (visible) {
this.pageDiv.classList.remove('hidden');
if (this.isOverlay) {
$('overlay').classList.remove('hidden');
} else {
var nestingLevel = this.nestingLevel;
if (nestingLevel > 0) {
var containerId = 'subpage-sheet-container-' + nestingLevel;
$(containerId).classList.remove('hidden');
}
// The managed prefs banner is global, so after any visibility change
// update it based on the topmost page, not necessarily this page.
// (e.g., if an ancestor is made visible after a child).
OptionsPage.updateManagedBannerVisibility();
// Recent webkit change no longer allows url change from "chrome://".
window.history.pushState({pageName: this.name}, this.title);
}
if (this.tab) {
this.tab.classList.add('navbar-item-selected');
}
} else {
this.pageDiv.classList.add('hidden');
if (this.isOverlay) {
$('overlay').classList.add('hidden');
} else if (this.parentPage) {
var nestingLevel = this.nestingLevel;
if (nestingLevel > 0) {
var containerId = 'subpage-sheet-container-' + nestingLevel;
$(containerId).classList.add('hidden');
}
OptionsPage.updateManagedBannerVisibility();
}
if (this.tab) {
this.tab.classList.remove('navbar-item-selected');
}
}
cr.dispatchPropertyChange(this, 'visible', visible, !visible);
},
/**
* The nesting level of this page.
* @type {number} The nesting level of this page (0 for top-level page)
*/
get nestingLevel() {
var level = 0;
var parent = this.parentPage;
while (parent) {
level++;
parent = parent.parentPage;
}
return level;
},
/**
* Whether the page is considered 'sticky', such that it will
* remain a top-level page even if sub-pages change.
* @type {boolean} True if this page is sticky.
*/
get sticky() {
return false;
},
/**
* Checks whether this page is an ancestor of the given page in terms of
* subpage nesting.
* @param {OptionsPage} page
* @return {boolean} True if this page is nested under |page|
*/
isAncestorOfPage: function(page) {
var parent = page.parentPage;
while (parent) {
if (parent == this)
return true;
parent = parent.parentPage;
}
return false;
},
/**
* Handles a hash value in the URL (such as bar in
* chrome://options/foo#bar). Called on page load. By default, this shows
* an overlay that matches the hash name, but can be overriden by individual
* OptionsPage subclasses to get other behavior.
* @param {string} hash The hash value.
*/
handleHash: function(hash) {
OptionsPage.showOverlay(hash);
},
};
// Export
return {
OptionsPage: OptionsPage
};
});