1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 cr.define('uber', function() {
7 * Options for how web history should be handled.
9 var HISTORY_STATE_OPTION = {
10 PUSH: 1, // Push a new history state.
11 REPLACE: 2, // Replace the current history state.
12 NONE: 3, // Ignore this history state change.
16 * We cache a reference to the #navigation frame here so we don't need to grab
17 * it from the DOM on each scroll.
24 * A queue of method invocations on one of the iframes; if the iframe has not
25 * loaded by the time there is a method to invoke, delay the invocation until
30 var queuedInvokes = {};
33 * Handles page initialization.
36 navFrame = $('navigation');
37 navFrame.dataset.width = navFrame.offsetWidth;
39 // Select a page based on the page-URL.
40 var params = resolvePageInfo();
41 showPage(params.id, HISTORY_STATE_OPTION.NONE, params.path);
43 window.addEventListener('message', handleWindowMessage);
44 window.setTimeout(function() {
45 document.documentElement.classList.remove('loading');
48 // HACK(dbeam): This makes the assumption that any second part to a path
49 // will result in needing background navigation. We shortcut it to avoid
51 // HACK(csilv): Search URLs aren't overlays, special case them.
52 if (params.id == 'settings' && params.path &&
53 params.path.indexOf('search') != 0) {
54 backgroundNavigation();
57 ensureNonSelectedFrameContainersAreHidden();
61 * Find page information from window.location. If the location doesn't
62 * point to one of our pages, return default parameters.
63 * @return {Object} An object containing the following parameters:
64 * id - The 'id' of the page.
65 * path - A path into the page, including search and hash. Optional.
67 function resolvePageInfo() {
69 var path = window.location.pathname;
70 if (path.length > 1) {
71 // Split the path into id and the remaining path.
73 var index = path.indexOf('/');
75 params.id = path.slice(0, index);
76 params.path = path.slice(index + 1);
81 var container = $(params.id);
83 // The id is valid. Add the hash and search parts of the URL to path.
84 params.path = (params.path || '') + window.location.search +
87 // The target sub-page does not exist, discard the params we generated.
88 params.id = undefined;
89 params.path = undefined;
92 // If we don't have a valid page, get a default.
94 params.id = getDefaultIframe().id;
100 * Handler for window.onpopstate.
101 * @param {Event} e The history event.
103 function onPopHistoryState(e) {
104 // Use the URL to determine which page to route to.
105 var params = resolvePageInfo();
107 // If the page isn't the current page, load it fresh. Even if the page is
108 // already loaded, it may have state not reflected in the URL, such as the
109 // history page's "Remove selected items" overlay. http://crbug.com/377386
110 if (getRequiredElement(params.id) !== getSelectedIframe())
111 showPage(params.id, HISTORY_STATE_OPTION.NONE, params.path);
113 // Either way, send the state down to it.
115 // Note: This assumes that the state and path parameters for every page
116 // under this origin are compatible. All of the downstream pages which
117 // navigate use pushState and replaceState.
118 invokeMethodOnPage(params.id, 'popState',
119 {state: e.state, path: '/' + params.path});
123 * @return {Object} The default iframe container.
125 function getDefaultIframe() {
126 return $(loadTimeData.getString('helpHost'));
130 * @return {Object} The currently selected iframe container.
132 function getSelectedIframe() {
133 return document.querySelector('.iframe-container.selected');
137 * Handles postMessage calls from the iframes of the contained pages.
139 * The pages request functionality from this object by passing an object of
140 * the following form:
142 * { method : "methodToInvoke",
146 * |method| is required, while |params| is optional. Extra parameters required
147 * by a method must be specified by that method's documentation.
149 * @param {Event} e The posted object.
151 function handleWindowMessage(e) {
152 e = /** @type{!MessageEvent.<!{method: string, params: *}>} */(e);
153 if (e.data.method === 'beginInterceptingEvents') {
154 backgroundNavigation();
155 } else if (e.data.method === 'stopInterceptingEvents') {
156 foregroundNavigation();
157 } else if (e.data.method === 'ready') {
159 } else if (e.data.method === 'updateHistory') {
160 updateHistory(e.origin, e.data.params.state, e.data.params.path,
161 e.data.params.replace);
162 } else if (e.data.method === 'setTitle') {
163 setTitle(e.origin, e.data.params.title);
164 } else if (e.data.method === 'showPage') {
165 showPage(e.data.params.pageId,
166 HISTORY_STATE_OPTION.PUSH,
168 } else if (e.data.method === 'navigationControlsLoaded') {
169 onNavigationControlsLoaded();
170 } else if (e.data.method === 'adjustToScroll') {
171 adjustToScroll(/** @type {number} */(e.data.params));
172 } else if (e.data.method === 'mouseWheel') {
173 forwardMouseWheel(/** @type {Object} */(e.data.params));
175 console.error('Received unexpected message', e.data);
180 * Sends the navigation iframe to the background.
182 function backgroundNavigation() {
183 navFrame.classList.add('background');
184 navFrame.firstChild.tabIndex = -1;
185 navFrame.firstChild.setAttribute('aria-hidden', true);
189 * Retrieves the navigation iframe from the background.
191 function foregroundNavigation() {
192 navFrame.classList.remove('background');
193 navFrame.firstChild.tabIndex = 0;
194 navFrame.firstChild.removeAttribute('aria-hidden');
198 * Enables or disables animated transitions when changing content while
199 * horizontally scrolled.
200 * @param {boolean} enabled True if enabled, else false to disable.
202 function setContentChanging(enabled) {
203 navFrame.classList[enabled ? 'add' : 'remove']('changing-content');
206 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
207 'setContentChanging', enabled);
212 * Get an iframe based on the origin of a received post message.
213 * @param {string} origin The origin of a post message.
214 * @return {!Element} The frame associated to |origin| or null.
216 function getIframeFromOrigin(origin) {
217 assert(origin.substr(-1) != '/', 'invalid origin given');
218 var query = '.iframe-container > iframe[src^="' + origin + '/"]';
219 var element = document.querySelector(query);
221 return /** @type {!Element} */(element);
225 * Changes the path past the page title (i.e. chrome://chrome/settings/(.*)).
226 * @param {Object} state The page's state object for the navigation.
227 * @param {string} path The new /path/ to be set after the page name.
228 * @param {number} historyOption The type of history modification to make.
230 function changePathTo(state, path, historyOption) {
231 assert(!path || path.substr(-1) != '/', 'invalid path given');
234 if (historyOption == HISTORY_STATE_OPTION.PUSH)
235 histFunc = window.history.pushState;
236 else if (historyOption == HISTORY_STATE_OPTION.REPLACE)
237 histFunc = window.history.replaceState;
239 assert(histFunc, 'invalid historyOption given ' + historyOption);
241 var pageId = getSelectedIframe().id;
242 var args = [state, '', '/' + pageId + '/' + (path || '')];
243 histFunc.apply(window.history, args);
247 * Adds or replaces the current history entry based on a navigation from the
249 * @param {string} origin The origin of the source iframe.
250 * @param {Object} state The source iframe's state object.
251 * @param {string} path The new "path" (e.g. "/createProfile").
252 * @param {boolean} replace Whether to replace the current history entry.
254 function updateHistory(origin, state, path, replace) {
255 assert(!path || path[0] != '/', 'invalid path sent from ' + origin);
257 replace ? HISTORY_STATE_OPTION.REPLACE : HISTORY_STATE_OPTION.PUSH;
258 // Only update the currently displayed path if this is the visible frame.
259 var container = getIframeFromOrigin(origin).parentNode;
260 if (container == getSelectedIframe())
261 changePathTo(state, path, historyOption);
265 * Sets the title of the page.
266 * @param {string} origin The origin of the source iframe.
267 * @param {string} title The title of the page.
269 function setTitle(origin, title) {
270 // Cache the title for the client iframe, i.e., the iframe setting the
271 // title. querySelector returns the actual iframe element, so use parentNode
272 // to get back to the container.
273 var container = getIframeFromOrigin(origin).parentNode;
274 container.dataset.title = title;
276 // Only update the currently displayed title if this is the visible frame.
277 if (container == getSelectedIframe())
278 document.title = title;
282 * Invokes a method on a subpage. If the subpage has not signaled readiness,
283 * queue the message for when it does.
284 * @param {string} pageId Should match an id of one of the iframe containers.
285 * @param {string} method The name of the method to invoke.
286 * @param {Object=} opt_params Optional property page of parameters to pass to
287 * the invoked method.
289 function invokeMethodOnPage(pageId, method, opt_params) {
290 var frame = $(pageId).querySelector('iframe');
291 if (!frame || !frame.dataset.ready) {
292 queuedInvokes[pageId] = (queuedInvokes[pageId] || []);
293 queuedInvokes[pageId].push([method, opt_params]);
295 uber.invokeMethodOnWindow(frame.contentWindow, method, opt_params);
300 * Called in response to a page declaring readiness. Calls any deferred method
301 * invocations from invokeMethodOnPage.
302 * @param {string} origin The origin of the source iframe.
304 function pageReady(origin) {
305 var frame = getIframeFromOrigin(origin);
306 var container = frame.parentNode;
307 frame.dataset.ready = true;
308 var queue = queuedInvokes[container.id] || [];
309 queuedInvokes[container.id] = undefined;
310 for (var i = 0; i < queue.length; i++) {
311 uber.invokeMethodOnWindow(frame.contentWindow, queue[i][0], queue[i][1]);
316 * Selects and navigates a subpage. This is called from uber-frame.
317 * @param {string} pageId Should match an id of one of the iframe containers.
318 * @param {number} historyOption Indicates whether we should push or replace
320 * @param {string} path A sub-page path.
322 function showPage(pageId, historyOption, path) {
323 var container = $(pageId);
325 // Lazy load of iframe contents.
326 var sourceUrl = container.dataset.url + (path || '');
327 var frame = container.querySelector('iframe');
329 frame = container.ownerDocument.createElement('iframe');
331 container.appendChild(frame);
332 frame.src = sourceUrl;
334 // There's no particularly good way to know what the current URL of the
335 // content frame is as we don't have access to its contentWindow's
336 // location, so just replace every time until necessary to do otherwise.
337 frame.contentWindow.location.replace(sourceUrl);
338 frame.dataset.ready = false;
341 // If the last selected container is already showing, ignore the rest.
342 var lastSelected = document.querySelector('.iframe-container.selected');
343 if (lastSelected === container)
347 lastSelected.classList.remove('selected');
348 // Setting aria-hidden hides the container from assistive technology
349 // immediately. The 'hidden' attribute is set after the transition
350 // finishes - that ensures it's not possible to accidentally focus
351 // an element in an unselected container.
352 lastSelected.setAttribute('aria-hidden', 'true');
355 // Containers that aren't selected have to be hidden so that their
356 // content isn't focusable.
357 container.hidden = false;
358 container.setAttribute('aria-hidden', 'false');
360 // Trigger a layout after making it visible and before setting
361 // the class to 'selected', so that it animates in.
363 container.classList.add('selected');
365 setContentChanging(true);
368 var selectedFrame = getSelectedIframe().querySelector('iframe');
369 uber.invokeMethodOnWindow(selectedFrame.contentWindow, 'frameSelected');
371 if (historyOption != HISTORY_STATE_OPTION.NONE)
372 changePathTo({}, path, historyOption);
374 if (container.dataset.title)
375 document.title = container.dataset.title;
376 assert('favicon' in container.dataset);
378 var dataset = /** @type {{favicon: string}} */(container.dataset);
379 $('favicon').href = 'chrome://theme/' + dataset.favicon;
380 $('favicon2x').href = 'chrome://theme/' + dataset.favicon + '@2x';
382 updateNavigationControls();
385 function onNavigationControlsLoaded() {
386 updateNavigationControls();
390 * Sends a message to uber-frame to update the appearance of the nav controls.
391 * It should be called whenever the selected iframe changes.
393 function updateNavigationControls() {
394 var iframe = getSelectedIframe();
395 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
396 'changeSelection', {pageId: iframe.id});
400 * Forwarded scroll offset from a content frame's scroll handler.
401 * @param {number} scrollOffset The scroll offset from the content frame.
403 function adjustToScroll(scrollOffset) {
404 // NOTE: The scroll is reset to 0 and easing turned on every time a user
405 // switches frames. If we receive a non-zero value it has to have come from
406 // a real user scroll, so we disable easing when this happens.
407 if (scrollOffset != 0)
408 setContentChanging(false);
411 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
414 var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset);
415 navFrame.style.width = navWidth + 'px';
417 navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)';
422 * Forward scroll wheel events to subpages.
423 * @param {Object} params Relevant parameters of wheel event.
425 function forwardMouseWheel(params) {
426 var iframe = getSelectedIframe().querySelector('iframe');
427 uber.invokeMethodOnWindow(iframe.contentWindow, 'mouseWheel', params);
431 * Make sure that iframe containers that are not selected are
432 * hidden, so that elements in those frames aren't part of the
433 * focus order. Containers that are unselected later get hidden
434 * when the transition ends. We also set the aria-hidden attribute
435 * because that hides the container from assistive technology
436 * immediately, rather than only after the transition ends.
438 function ensureNonSelectedFrameContainersAreHidden() {
439 var containers = document.querySelectorAll('.iframe-container');
440 for (var i = 0; i < containers.length; i++) {
441 var container = containers[i];
442 if (!container.classList.contains('selected')) {
443 container.hidden = true;
444 container.setAttribute('aria-hidden', 'true');
446 container.addEventListener('webkitTransitionEnd', function(event) {
447 if (!event.target.classList.contains('selected'))
448 event.target.hidden = true;
455 onPopHistoryState: onPopHistoryState
459 window.addEventListener('popstate', uber.onPopHistoryState);
460 document.addEventListener('DOMContentLoaded', uber.onLoad);