blob: 23417eaf749730ef678913687bc3b3310795a586 [file] [log] [blame]
// Copyright (c) 2012 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('uber', function() {
/**
* Options for how web history should be handled.
*/
var HISTORY_STATE_OPTION = {
PUSH: 1, // Push a new history state.
REPLACE: 2, // Replace the current history state.
NONE: 3, // Ignore this history state change.
};
/**
* We cache a reference to the #navigation frame here so we don't need to grab
* it from the DOM on each scroll.
* @type {Node}
* @private
*/
var navFrame;
/**
* A queue of method invocations on one of the iframes; if the iframe has not
* loaded by the time there is a method to invoke, delay the invocation until
* it is ready.
* @type {Object}
* @private
*/
var queuedInvokes = {};
/**
* Handles page initialization.
*/
function onLoad(e) {
navFrame = $('navigation');
navFrame.dataset.width = navFrame.offsetWidth;
// Select a page based on the page-URL.
var params = resolvePageInfo();
showPage(params.id, HISTORY_STATE_OPTION.NONE, params.path);
window.addEventListener('message', handleWindowMessage);
window.setTimeout(function() {
document.documentElement.classList.remove('loading');
}, 0);
// HACK(dbeam): This makes the assumption that any second part to a path
// will result in needing background navigation. We shortcut it to avoid
// flicker on load.
// HACK(csilv): Search URLs aren't overlays, special case them.
if (params.id == 'settings' && params.path &&
params.path.indexOf('search') != 0) {
backgroundNavigation();
}
ensureNonSelectedFrameContainersAreHidden();
}
/**
* Find page information from window.location. If the location doesn't
* point to one of our pages, return default parameters.
* @return {Object} An object containing the following parameters:
* id - The 'id' of the page.
* path - A path into the page, including search and hash. Optional.
*/
function resolvePageInfo() {
var params = {};
var path = window.location.pathname;
if (path.length > 1) {
// Split the path into id and the remaining path.
path = path.slice(1);
var index = path.indexOf('/');
if (index != -1) {
params.id = path.slice(0, index);
params.path = path.slice(index + 1);
} else {
params.id = path;
}
var container = $(params.id);
if (container) {
// The id is valid. Add the hash and search parts of the URL to path.
params.path = (params.path || '') + window.location.search +
window.location.hash;
} else {
// The target sub-page does not exist, discard the params we generated.
params.id = undefined;
params.path = undefined;
}
}
// If we don't have a valid page, get a default.
if (!params.id)
params.id = getDefaultIframe().id;
return params;
}
/**
* Handler for window.onpopstate.
* @param {Event} e The history event.
*/
function onPopHistoryState(e) {
// Use the URL to determine which page to route to.
var params = resolvePageInfo();
// If the page isn't the current page, load it fresh. Even if the page is
// already loaded, it may have state not reflected in the URL, such as the
// history page's "Remove selected items" overlay. http://crbug.com/377386
if (getRequiredElement(params.id) !== getSelectedIframe())
showPage(params.id, HISTORY_STATE_OPTION.NONE, params.path);
// Either way, send the state down to it.
//
// Note: This assumes that the state and path parameters for every page
// under this origin are compatible. All of the downstream pages which
// navigate use pushState and replaceState.
invokeMethodOnPage(params.id, 'popState',
{state: e.state, path: '/' + params.path});
}
/**
* @return {Object} The default iframe container.
*/
function getDefaultIframe() {
return $(loadTimeData.getString('helpHost'));
}
/**
* @return {Object} The currently selected iframe container.
*/
function getSelectedIframe() {
return document.querySelector('.iframe-container.selected');
}
/**
* Handles postMessage calls from the iframes of the contained pages.
*
* The pages request functionality from this object by passing an object of
* the following form:
*
* { method : "methodToInvoke",
* params : {...}
* }
*
* |method| is required, while |params| is optional. Extra parameters required
* by a method must be specified by that method's documentation.
*
* @param {Event} e The posted object.
*/
function handleWindowMessage(e) {
e = /** @type{!MessageEvent.<!{method: string, params: *}>} */(e);
if (e.data.method === 'beginInterceptingEvents') {
backgroundNavigation();
} else if (e.data.method === 'stopInterceptingEvents') {
foregroundNavigation();
} else if (e.data.method === 'ready') {
pageReady(e.origin);
} else if (e.data.method === 'updateHistory') {
updateHistory(e.origin, e.data.params.state, e.data.params.path,
e.data.params.replace);
} else if (e.data.method === 'setTitle') {
setTitle(e.origin, e.data.params.title);
} else if (e.data.method === 'showPage') {
showPage(e.data.params.pageId,
HISTORY_STATE_OPTION.PUSH,
e.data.params.path);
} else if (e.data.method === 'navigationControlsLoaded') {
onNavigationControlsLoaded();
} else if (e.data.method === 'adjustToScroll') {
adjustToScroll(/** @type {number} */(e.data.params));
} else if (e.data.method === 'mouseWheel') {
forwardMouseWheel(/** @type {Object} */(e.data.params));
} else {
console.error('Received unexpected message', e.data);
}
}
/**
* Sends the navigation iframe to the background.
*/
function backgroundNavigation() {
navFrame.classList.add('background');
navFrame.firstChild.tabIndex = -1;
navFrame.firstChild.setAttribute('aria-hidden', true);
}
/**
* Retrieves the navigation iframe from the background.
*/
function foregroundNavigation() {
navFrame.classList.remove('background');
navFrame.firstChild.tabIndex = 0;
navFrame.firstChild.removeAttribute('aria-hidden');
}
/**
* Enables or disables animated transitions when changing content while
* horizontally scrolled.
* @param {boolean} enabled True if enabled, else false to disable.
*/
function setContentChanging(enabled) {
navFrame.classList[enabled ? 'add' : 'remove']('changing-content');
if (isRTL()) {
uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
'setContentChanging', enabled);
}
}
/**
* Get an iframe based on the origin of a received post message.
* @param {string} origin The origin of a post message.
* @return {!Element} The frame associated to |origin| or null.
*/
function getIframeFromOrigin(origin) {
assert(origin.substr(-1) != '/', 'invalid origin given');
var query = '.iframe-container > iframe[src^="' + origin + '/"]';
var element = document.querySelector(query);
assert(element);
return /** @type {!Element} */(element);
}
/**
* Changes the path past the page title (i.e. chrome://chrome/settings/(.*)).
* @param {Object} state The page's state object for the navigation.
* @param {string} path The new /path/ to be set after the page name.
* @param {number} historyOption The type of history modification to make.
*/
function changePathTo(state, path, historyOption) {
assert(!path || path.substr(-1) != '/', 'invalid path given');
var histFunc;
if (historyOption == HISTORY_STATE_OPTION.PUSH)
histFunc = window.history.pushState;
else if (historyOption == HISTORY_STATE_OPTION.REPLACE)
histFunc = window.history.replaceState;
assert(histFunc, 'invalid historyOption given ' + historyOption);
var pageId = getSelectedIframe().id;
var args = [state, '', '/' + pageId + '/' + (path || '')];
histFunc.apply(window.history, args);
}
/**
* Adds or replaces the current history entry based on a navigation from the
* source iframe.
* @param {string} origin The origin of the source iframe.
* @param {Object} state The source iframe's state object.
* @param {string} path The new "path" (e.g. "/createProfile").
* @param {boolean} replace Whether to replace the current history entry.
*/
function updateHistory(origin, state, path, replace) {
assert(!path || path[0] != '/', 'invalid path sent from ' + origin);
var historyOption =
replace ? HISTORY_STATE_OPTION.REPLACE : HISTORY_STATE_OPTION.PUSH;
// Only update the currently displayed path if this is the visible frame.
var container = getIframeFromOrigin(origin).parentNode;
if (container == getSelectedIframe())
changePathTo(state, path, historyOption);
}
/**
* Sets the title of the page.
* @param {string} origin The origin of the source iframe.
* @param {string} title The title of the page.
*/
function setTitle(origin, title) {
// Cache the title for the client iframe, i.e., the iframe setting the
// title. querySelector returns the actual iframe element, so use parentNode
// to get back to the container.
var container = getIframeFromOrigin(origin).parentNode;
container.dataset.title = title;
// Only update the currently displayed title if this is the visible frame.
if (container == getSelectedIframe())
document.title = title;
}
/**
* Invokes a method on a subpage. If the subpage has not signaled readiness,
* queue the message for when it does.
* @param {string} pageId Should match an id of one of the iframe containers.
* @param {string} method The name of the method to invoke.
* @param {Object=} opt_params Optional property page of parameters to pass to
* the invoked method.
*/
function invokeMethodOnPage(pageId, method, opt_params) {
var frame = $(pageId).querySelector('iframe');
if (!frame || !frame.dataset.ready) {
queuedInvokes[pageId] = (queuedInvokes[pageId] || []);
queuedInvokes[pageId].push([method, opt_params]);
} else {
uber.invokeMethodOnWindow(frame.contentWindow, method, opt_params);
}
}
/**
* Called in response to a page declaring readiness. Calls any deferred method
* invocations from invokeMethodOnPage.
* @param {string} origin The origin of the source iframe.
*/
function pageReady(origin) {
var frame = getIframeFromOrigin(origin);
var container = frame.parentNode;
frame.dataset.ready = true;
var queue = queuedInvokes[container.id] || [];
queuedInvokes[container.id] = undefined;
for (var i = 0; i < queue.length; i++) {
uber.invokeMethodOnWindow(frame.contentWindow, queue[i][0], queue[i][1]);
}
}
/**
* Selects and navigates a subpage. This is called from uber-frame.
* @param {string} pageId Should match an id of one of the iframe containers.
* @param {number} historyOption Indicates whether we should push or replace
* browser history.
* @param {string} path A sub-page path.
*/
function showPage(pageId, historyOption, path) {
var container = $(pageId);
// Lazy load of iframe contents.
var sourceUrl = container.dataset.url + (path || '');
var frame = container.querySelector('iframe');
if (!frame) {
frame = container.ownerDocument.createElement('iframe');
frame.name = pageId;
container.appendChild(frame);
frame.src = sourceUrl;
} else {
// There's no particularly good way to know what the current URL of the
// content frame is as we don't have access to its contentWindow's
// location, so just replace every time until necessary to do otherwise.
frame.contentWindow.location.replace(sourceUrl);
frame.dataset.ready = false;
}
// If the last selected container is already showing, ignore the rest.
var lastSelected = document.querySelector('.iframe-container.selected');
if (lastSelected === container)
return;
if (lastSelected) {
lastSelected.classList.remove('selected');
// Setting aria-hidden hides the container from assistive technology
// immediately. The 'hidden' attribute is set after the transition
// finishes - that ensures it's not possible to accidentally focus
// an element in an unselected container.
lastSelected.setAttribute('aria-hidden', 'true');
}
// Containers that aren't selected have to be hidden so that their
// content isn't focusable.
container.hidden = false;
container.setAttribute('aria-hidden', 'false');
// Trigger a layout after making it visible and before setting
// the class to 'selected', so that it animates in.
/** @suppress {uselessCode} */
container.offsetTop;
container.classList.add('selected');
setContentChanging(true);
adjustToScroll(0);
var selectedFrame = getSelectedIframe().querySelector('iframe');
uber.invokeMethodOnWindow(selectedFrame.contentWindow, 'frameSelected');
selectedFrame.contentWindow.focus();
if (historyOption != HISTORY_STATE_OPTION.NONE)
changePathTo({}, path, historyOption);
if (container.dataset.title)
document.title = container.dataset.title;
assert('favicon' in container.dataset);
var dataset = /** @type {{favicon: string}} */(container.dataset);
$('favicon').href = 'chrome://theme/' + dataset.favicon;
$('favicon2x').href = 'chrome://theme/' + dataset.favicon + '@2x';
updateNavigationControls();
}
function onNavigationControlsLoaded() {
updateNavigationControls();
}
/**
* Sends a message to uber-frame to update the appearance of the nav controls.
* It should be called whenever the selected iframe changes.
*/
function updateNavigationControls() {
var iframe = getSelectedIframe();
uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
'changeSelection', {pageId: iframe.id});
}
/**
* Forwarded scroll offset from a content frame's scroll handler.
* @param {number} scrollOffset The scroll offset from the content frame.
*/
function adjustToScroll(scrollOffset) {
// NOTE: The scroll is reset to 0 and easing turned on every time a user
// switches frames. If we receive a non-zero value it has to have come from
// a real user scroll, so we disable easing when this happens.
if (scrollOffset != 0)
setContentChanging(false);
if (isRTL()) {
uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
'adjustToScroll',
scrollOffset);
var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset);
navFrame.style.width = navWidth + 'px';
} else {
navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)';
}
}
/**
* Forward scroll wheel events to subpages.
* @param {Object} params Relevant parameters of wheel event.
*/
function forwardMouseWheel(params) {
var iframe = getSelectedIframe().querySelector('iframe');
uber.invokeMethodOnWindow(iframe.contentWindow, 'mouseWheel', params);
}
/**
* Make sure that iframe containers that are not selected are
* hidden, so that elements in those frames aren't part of the
* focus order. Containers that are unselected later get hidden
* when the transition ends. We also set the aria-hidden attribute
* because that hides the container from assistive technology
* immediately, rather than only after the transition ends.
*/
function ensureNonSelectedFrameContainersAreHidden() {
var containers = document.querySelectorAll('.iframe-container');
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
if (!container.classList.contains('selected')) {
container.hidden = true;
container.setAttribute('aria-hidden', 'true');
}
container.addEventListener('webkitTransitionEnd', function(event) {
if (!event.target.classList.contains('selected'))
event.target.hidden = true;
});
}
}
return {
onLoad: onLoad,
onPopHistoryState: onPopHistoryState
};
});
window.addEventListener('popstate', uber.onPopHistoryState);
document.addEventListener('DOMContentLoaded', uber.onLoad);