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) !== getSelectedIframeContainer())
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 getSelectedIframeContainer() {
133 return document.querySelector('.iframe-container.selected');
137 * @return {Object} The currently selected iframe's contentWindow.
139 function getSelectedIframeWindow() {
140 return getSelectedIframeContainer().querySelector('iframe').contentWindow;
144 * Handles postMessage calls from the iframes of the contained pages.
146 * The pages request functionality from this object by passing an object of
147 * the following form:
149 * { method : "methodToInvoke",
153 * |method| is required, while |params| is optional. Extra parameters required
154 * by a method must be specified by that method's documentation.
156 * @param {Event} e The posted object.
158 function handleWindowMessage(e) {
159 e = /** @type{!MessageEvent<!{method: string, params: *}>} */(e);
160 if (e.data.method === 'beginInterceptingEvents') {
161 backgroundNavigation();
162 } else if (e.data.method === 'stopInterceptingEvents') {
163 foregroundNavigation();
164 } else if (e.data.method === 'ready') {
166 } else if (e.data.method === 'updateHistory') {
167 updateHistory(e.origin, e.data.params.state, e.data.params.path,
168 e.data.params.replace);
169 } else if (e.data.method === 'setTitle') {
170 setTitle(e.origin, e.data.params.title);
171 } else if (e.data.method === 'showPage') {
172 showPage(e.data.params.pageId,
173 HISTORY_STATE_OPTION.PUSH,
175 } else if (e.data.method === 'navigationControlsLoaded') {
176 onNavigationControlsLoaded();
177 } else if (e.data.method === 'adjustToScroll') {
178 adjustToScroll(/** @type {number} */(e.data.params));
179 } else if (e.data.method === 'mouseWheel') {
180 forwardMouseWheel(/** @type {Object} */(e.data.params));
181 } else if (e.data.method === 'mouseDown') {
184 console.error('Received unexpected message', e.data);
189 * Sends the navigation iframe to the background.
191 function backgroundNavigation() {
192 navFrame.classList.add('background');
193 navFrame.firstChild.tabIndex = -1;
194 navFrame.firstChild.setAttribute('aria-hidden', true);
198 * Retrieves the navigation iframe from the background.
200 function foregroundNavigation() {
201 navFrame.classList.remove('background');
202 navFrame.firstChild.tabIndex = 0;
203 navFrame.firstChild.removeAttribute('aria-hidden');
207 * Enables or disables animated transitions when changing content while
208 * horizontally scrolled.
209 * @param {boolean} enabled True if enabled, else false to disable.
211 function setContentChanging(enabled) {
212 navFrame.classList[enabled ? 'add' : 'remove']('changing-content');
215 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
216 'setContentChanging', enabled);
221 * Get an iframe based on the origin of a received post message.
222 * @param {string} origin The origin of a post message.
223 * @return {!Element} The frame associated to |origin| or null.
225 function getIframeFromOrigin(origin) {
226 assert(origin.substr(-1) != '/', 'invalid origin given');
227 var query = '.iframe-container > iframe[src^="' + origin + '/"]';
228 var element = document.querySelector(query);
230 return /** @type {!Element} */(element);
234 * Changes the path past the page title (i.e. chrome://chrome/settings/(.*)).
235 * @param {Object} state The page's state object for the navigation.
236 * @param {string} path The new /path/ to be set after the page name.
237 * @param {number} historyOption The type of history modification to make.
239 function changePathTo(state, path, historyOption) {
240 assert(!path || path.substr(-1) != '/', 'invalid path given');
243 if (historyOption == HISTORY_STATE_OPTION.PUSH)
244 histFunc = window.history.pushState;
245 else if (historyOption == HISTORY_STATE_OPTION.REPLACE)
246 histFunc = window.history.replaceState;
248 assert(histFunc, 'invalid historyOption given ' + historyOption);
250 var pageId = getSelectedIframeContainer().id;
251 var args = [state, '', '/' + pageId + '/' + (path || '')];
252 histFunc.apply(window.history, args);
256 * Adds or replaces the current history entry based on a navigation from the
258 * @param {string} origin The origin of the source iframe.
259 * @param {Object} state The source iframe's state object.
260 * @param {string} path The new "path" (e.g. "/createProfile").
261 * @param {boolean} replace Whether to replace the current history entry.
263 function updateHistory(origin, state, path, replace) {
264 assert(!path || path[0] != '/', 'invalid path sent from ' + origin);
266 replace ? HISTORY_STATE_OPTION.REPLACE : HISTORY_STATE_OPTION.PUSH;
267 // Only update the currently displayed path if this is the visible frame.
268 var container = getIframeFromOrigin(origin).parentNode;
269 if (container == getSelectedIframeContainer())
270 changePathTo(state, path, historyOption);
274 * Sets the title of the page.
275 * @param {string} origin The origin of the source iframe.
276 * @param {string} title The title of the page.
278 function setTitle(origin, title) {
279 // Cache the title for the client iframe, i.e., the iframe setting the
280 // title. querySelector returns the actual iframe element, so use parentNode
281 // to get back to the container.
282 var container = getIframeFromOrigin(origin).parentNode;
283 container.dataset.title = title;
285 // Only update the currently displayed title if this is the visible frame.
286 if (container == getSelectedIframeContainer())
287 document.title = title;
291 * Invokes a method on a subpage. If the subpage has not signaled readiness,
292 * queue the message for when it does.
293 * @param {string} pageId Should match an id of one of the iframe containers.
294 * @param {string} method The name of the method to invoke.
295 * @param {Object=} opt_params Optional property page of parameters to pass to
296 * the invoked method.
298 function invokeMethodOnPage(pageId, method, opt_params) {
299 var frame = $(pageId).querySelector('iframe');
300 if (!frame || !frame.dataset.ready) {
301 queuedInvokes[pageId] = (queuedInvokes[pageId] || []);
302 queuedInvokes[pageId].push([method, opt_params]);
304 uber.invokeMethodOnWindow(frame.contentWindow, method, opt_params);
309 * Called in response to a page declaring readiness. Calls any deferred method
310 * invocations from invokeMethodOnPage.
311 * @param {string} origin The origin of the source iframe.
313 function pageReady(origin) {
314 var frame = getIframeFromOrigin(origin);
315 var container = frame.parentNode;
316 frame.dataset.ready = true;
317 var queue = queuedInvokes[container.id] || [];
318 queuedInvokes[container.id] = undefined;
319 for (var i = 0; i < queue.length; i++) {
320 uber.invokeMethodOnWindow(frame.contentWindow, queue[i][0], queue[i][1]);
325 * Selects and navigates a subpage. This is called from uber-frame.
326 * @param {string} pageId Should match an id of one of the iframe containers.
327 * @param {number} historyOption Indicates whether we should push or replace
329 * @param {string} path A sub-page path.
331 function showPage(pageId, historyOption, path) {
332 var container = $(pageId);
334 // Lazy load of iframe contents.
335 var sourceUrl = container.dataset.url + (path || '');
336 var frame = container.querySelector('iframe');
338 frame = container.ownerDocument.createElement('iframe');
340 frame.setAttribute('role', 'presentation');
341 container.appendChild(frame);
342 frame.src = sourceUrl;
344 // There's no particularly good way to know what the current URL of the
345 // content frame is as we don't have access to its contentWindow's
346 // location, so just replace every time until necessary to do otherwise.
347 frame.contentWindow.location.replace(sourceUrl);
348 frame.dataset.ready = false;
351 // If the last selected container is already showing, ignore the rest.
352 var lastSelected = document.querySelector('.iframe-container.selected');
353 if (lastSelected === container)
357 lastSelected.classList.remove('selected');
358 // Setting aria-hidden hides the container from assistive technology
359 // immediately. The 'hidden' attribute is set after the transition
360 // finishes - that ensures it's not possible to accidentally focus
361 // an element in an unselected container.
362 lastSelected.setAttribute('aria-hidden', 'true');
365 // Containers that aren't selected have to be hidden so that their
366 // content isn't focusable.
367 container.hidden = false;
368 container.setAttribute('aria-hidden', 'false');
370 // Trigger a layout after making it visible and before setting
371 // the class to 'selected', so that it animates in.
372 /** @suppress {uselessCode} */
374 container.classList.add('selected');
376 setContentChanging(true);
379 var selectedWindow = getSelectedIframeWindow();
380 uber.invokeMethodOnWindow(selectedWindow, 'frameSelected');
381 selectedWindow.focus();
383 if (historyOption != HISTORY_STATE_OPTION.NONE)
384 changePathTo({}, path, historyOption);
386 if (container.dataset.title)
387 document.title = container.dataset.title;
388 assert('favicon' in container.dataset);
390 var dataset = /** @type {{favicon: string}} */(container.dataset);
391 $('favicon').href = 'chrome://theme/' + dataset.favicon;
392 $('favicon2x').href = 'chrome://theme/' + dataset.favicon + '@2x';
394 updateNavigationControls();
397 function onNavigationControlsLoaded() {
398 updateNavigationControls();
402 * Sends a message to uber-frame to update the appearance of the nav controls.
403 * It should be called whenever the selected iframe changes.
405 function updateNavigationControls() {
406 var container = getSelectedIframeContainer();
407 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
408 'changeSelection', {pageId: container.id});
412 * Forwarded scroll offset from a content frame's scroll handler.
413 * @param {number} scrollOffset The scroll offset from the content frame.
415 function adjustToScroll(scrollOffset) {
416 // NOTE: The scroll is reset to 0 and easing turned on every time a user
417 // switches frames. If we receive a non-zero value it has to have come from
418 // a real user scroll, so we disable easing when this happens.
419 if (scrollOffset != 0)
420 setContentChanging(false);
423 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
426 var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset);
427 navFrame.style.width = navWidth + 'px';
429 navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)';
434 * Forward scroll wheel events to subpages.
435 * @param {Object} params Relevant parameters of wheel event.
437 function forwardMouseWheel(params) {
438 uber.invokeMethodOnWindow(getSelectedIframeWindow(), 'mouseWheel', params);
441 /** Forward mouse down events to subpages. */
442 function forwardMouseDown() {
443 uber.invokeMethodOnWindow(getSelectedIframeWindow(), 'mouseDown');
447 * Make sure that iframe containers that are not selected are
448 * hidden, so that elements in those frames aren't part of the
449 * focus order. Containers that are unselected later get hidden
450 * when the transition ends. We also set the aria-hidden attribute
451 * because that hides the container from assistive technology
452 * immediately, rather than only after the transition ends.
454 function ensureNonSelectedFrameContainersAreHidden() {
455 var containers = document.querySelectorAll('.iframe-container');
456 for (var i = 0; i < containers.length; i++) {
457 var container = containers[i];
458 if (!container.classList.contains('selected')) {
459 container.hidden = true;
460 container.setAttribute('aria-hidden', 'true');
462 container.addEventListener('webkitTransitionEnd', function(event) {
463 if (!event.target.classList.contains('selected'))
464 event.target.hidden = true;
471 onPopHistoryState: onPopHistoryState
475 window.addEventListener('popstate', uber.onPopHistoryState);
476 document.addEventListener('DOMContentLoaded', uber.onLoad);