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. 4 5cr.define('uber', function() { 6 /** 7 * Options for how web history should be handled. 8 */ 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. 13 }; 14 15 /** 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. 18 * @type {Node} 19 * @private 20 */ 21 var navFrame; 22 23 /** 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 26 * it is ready. 27 * @type {Object} 28 * @private 29 */ 30 var queuedInvokes = {}; 31 32 /** 33 * Handles page initialization. 34 */ 35 function onLoad(e) { 36 navFrame = $('navigation'); 37 navFrame.dataset.width = navFrame.offsetWidth; 38 39 // Select a page based on the page-URL. 40 var params = resolvePageInfo(); 41 showPage(params.id, HISTORY_STATE_OPTION.NONE, params.path); 42 43 window.addEventListener('message', handleWindowMessage); 44 window.setTimeout(function() { 45 document.documentElement.classList.remove('loading'); 46 }, 0); 47 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 50 // flicker on load. 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(); 55 } 56 57 ensureNonSelectedFrameContainersAreHidden(); 58 } 59 60 /** 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. 66 */ 67 function resolvePageInfo() { 68 var params = {}; 69 var path = window.location.pathname; 70 if (path.length > 1) { 71 // Split the path into id and the remaining path. 72 path = path.slice(1); 73 var index = path.indexOf('/'); 74 if (index != -1) { 75 params.id = path.slice(0, index); 76 params.path = path.slice(index + 1); 77 } else { 78 params.id = path; 79 } 80 81 var container = $(params.id); 82 if (container) { 83 // The id is valid. Add the hash and search parts of the URL to path. 84 params.path = (params.path || '') + window.location.search + 85 window.location.hash; 86 } else { 87 // The target sub-page does not exist, discard the params we generated. 88 params.id = undefined; 89 params.path = undefined; 90 } 91 } 92 // If we don't have a valid page, get a default. 93 if (!params.id) 94 params.id = getDefaultIframe().id; 95 96 return params; 97 } 98 99 /** 100 * Handler for window.onpopstate. 101 * @param {Event} e The history event. 102 */ 103 function onPopHistoryState(e) { 104 // Use the URL to determine which page to route to. 105 var params = resolvePageInfo(); 106 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); 112 113 // Either way, send the state down to it. 114 // 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}); 120 } 121 122 /** 123 * @return {Object} The default iframe container. 124 */ 125 function getDefaultIframe() { 126 return $(loadTimeData.getString('helpHost')); 127 } 128 129 /** 130 * @return {Object} The currently selected iframe container. 131 */ 132 function getSelectedIframe() { 133 return document.querySelector('.iframe-container.selected'); 134 } 135 136 /** 137 * Handles postMessage calls from the iframes of the contained pages. 138 * 139 * The pages request functionality from this object by passing an object of 140 * the following form: 141 * 142 * { method : "methodToInvoke", 143 * params : {...} 144 * } 145 * 146 * |method| is required, while |params| is optional. Extra parameters required 147 * by a method must be specified by that method's documentation. 148 * 149 * @param {Event} e The posted object. 150 */ 151 function handleWindowMessage(e) { 152 if (e.data.method === 'beginInterceptingEvents') { 153 backgroundNavigation(); 154 } else if (e.data.method === 'stopInterceptingEvents') { 155 foregroundNavigation(); 156 } else if (e.data.method === 'ready') { 157 pageReady(e.origin); 158 } else if (e.data.method === 'updateHistory') { 159 updateHistory(e.origin, e.data.params.state, e.data.params.path, 160 e.data.params.replace); 161 } else if (e.data.method === 'setTitle') { 162 setTitle(e.origin, e.data.params.title); 163 } else if (e.data.method === 'showPage') { 164 showPage(e.data.params.pageId, 165 HISTORY_STATE_OPTION.PUSH, 166 e.data.params.path); 167 } else if (e.data.method === 'navigationControlsLoaded') { 168 onNavigationControlsLoaded(); 169 } else if (e.data.method === 'adjustToScroll') { 170 adjustToScroll(e.data.params); 171 } else if (e.data.method === 'mouseWheel') { 172 forwardMouseWheel(e.data.params); 173 } else { 174 console.error('Received unexpected message', e.data); 175 } 176 } 177 178 /** 179 * Sends the navigation iframe to the background. 180 */ 181 function backgroundNavigation() { 182 navFrame.classList.add('background'); 183 navFrame.firstChild.tabIndex = -1; 184 navFrame.firstChild.setAttribute('aria-hidden', true); 185 } 186 187 /** 188 * Retrieves the navigation iframe from the background. 189 */ 190 function foregroundNavigation() { 191 navFrame.classList.remove('background'); 192 navFrame.firstChild.tabIndex = 0; 193 navFrame.firstChild.removeAttribute('aria-hidden'); 194 } 195 196 /** 197 * Enables or disables animated transitions when changing content while 198 * horizontally scrolled. 199 * @param {boolean} enabled True if enabled, else false to disable. 200 */ 201 function setContentChanging(enabled) { 202 navFrame.classList[enabled ? 'add' : 'remove']('changing-content'); 203 204 if (isRTL()) { 205 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow, 206 'setContentChanging', 207 enabled); 208 } 209 } 210 211 /** 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 {!HTMLElement} The frame associated to |origin| or null. 215 */ 216 function getIframeFromOrigin(origin) { 217 assert(origin.substr(-1) != '/', 'invalid origin given'); 218 var query = '.iframe-container > iframe[src^="' + origin + '/"]'; 219 return document.querySelector(query); 220 } 221 222 /** 223 * Changes the path past the page title (i.e. chrome://chrome/settings/(.*)). 224 * @param {Object} state The page's state object for the navigation. 225 * @param {string} path The new /path/ to be set after the page name. 226 * @param {number} historyOption The type of history modification to make. 227 */ 228 function changePathTo(state, path, historyOption) { 229 assert(!path || path.substr(-1) != '/', 'invalid path given'); 230 231 var histFunc; 232 if (historyOption == HISTORY_STATE_OPTION.PUSH) 233 histFunc = window.history.pushState; 234 else if (historyOption == HISTORY_STATE_OPTION.REPLACE) 235 histFunc = window.history.replaceState; 236 237 assert(histFunc, 'invalid historyOption given ' + historyOption); 238 239 var pageId = getSelectedIframe().id; 240 var args = [state, '', '/' + pageId + '/' + (path || '')]; 241 histFunc.apply(window.history, args); 242 } 243 244 /** 245 * Adds or replaces the current history entry based on a navigation from the 246 * source iframe. 247 * @param {string} origin The origin of the source iframe. 248 * @param {Object} state The source iframe's state object. 249 * @param {string} path The new "path" (e.g. "/createProfile"). 250 * @param {boolean} replace Whether to replace the current history entry. 251 */ 252 function updateHistory(origin, state, path, replace) { 253 assert(!path || path[0] != '/', 'invalid path sent from ' + origin); 254 var historyOption = 255 replace ? HISTORY_STATE_OPTION.REPLACE : HISTORY_STATE_OPTION.PUSH; 256 // Only update the currently displayed path if this is the visible frame. 257 var container = getIframeFromOrigin(origin).parentNode; 258 if (container == getSelectedIframe()) 259 changePathTo(state, path, historyOption); 260 } 261 262 /** 263 * Sets the title of the page. 264 * @param {string} origin The origin of the source iframe. 265 * @param {string} title The title of the page. 266 */ 267 function setTitle(origin, title) { 268 // Cache the title for the client iframe, i.e., the iframe setting the 269 // title. querySelector returns the actual iframe element, so use parentNode 270 // to get back to the container. 271 var container = getIframeFromOrigin(origin).parentNode; 272 container.dataset.title = title; 273 274 // Only update the currently displayed title if this is the visible frame. 275 if (container == getSelectedIframe()) 276 document.title = title; 277 } 278 279 /** 280 * Invokes a method on a subpage. If the subpage has not signaled readiness, 281 * queue the message for when it does. 282 * @param {string} pageId Should match an id of one of the iframe containers. 283 * @param {string} method The name of the method to invoke. 284 * @param {Object=} opt_params Optional property page of parameters to pass to 285 * the invoked method. 286 */ 287 function invokeMethodOnPage(pageId, method, opt_params) { 288 var frame = $(pageId).querySelector('iframe'); 289 if (!frame || !frame.dataset.ready) { 290 queuedInvokes[pageId] = (queuedInvokes[pageId] || []); 291 queuedInvokes[pageId].push([method, opt_params]); 292 } else { 293 uber.invokeMethodOnWindow(frame.contentWindow, method, opt_params); 294 } 295 } 296 297 /** 298 * Called in response to a page declaring readiness. Calls any deferred method 299 * invocations from invokeMethodOnPage. 300 * @param {string} origin The origin of the source iframe. 301 */ 302 function pageReady(origin) { 303 var frame = getIframeFromOrigin(origin); 304 var container = frame.parentNode; 305 frame.dataset.ready = true; 306 var queue = queuedInvokes[container.id] || []; 307 queuedInvokes[container.id] = undefined; 308 for (var i = 0; i < queue.length; i++) { 309 uber.invokeMethodOnWindow(frame.contentWindow, queue[i][0], queue[i][1]); 310 } 311 } 312 313 /** 314 * Selects and navigates a subpage. This is called from uber-frame. 315 * @param {string} pageId Should match an id of one of the iframe containers. 316 * @param {integer} historyOption Indicates whether we should push or replace 317 * browser history. 318 * @param {string} path A sub-page path. 319 */ 320 function showPage(pageId, historyOption, path) { 321 var container = $(pageId); 322 323 // Lazy load of iframe contents. 324 var sourceUrl = container.dataset.url + (path || ''); 325 var frame = container.querySelector('iframe'); 326 if (!frame) { 327 frame = container.ownerDocument.createElement('iframe'); 328 frame.name = pageId; 329 container.appendChild(frame); 330 frame.src = sourceUrl; 331 } else { 332 // There's no particularly good way to know what the current URL of the 333 // content frame is as we don't have access to its contentWindow's 334 // location, so just replace every time until necessary to do otherwise. 335 frame.contentWindow.location.replace(sourceUrl); 336 frame.dataset.ready = false; 337 } 338 339 // If the last selected container is already showing, ignore the rest. 340 var lastSelected = document.querySelector('.iframe-container.selected'); 341 if (lastSelected === container) 342 return; 343 344 if (lastSelected) { 345 lastSelected.classList.remove('selected'); 346 // Setting aria-hidden hides the container from assistive technology 347 // immediately. The 'hidden' attribute is set after the transition 348 // finishes - that ensures it's not possible to accidentally focus 349 // an element in an unselected container. 350 lastSelected.setAttribute('aria-hidden', 'true'); 351 } 352 353 // Containers that aren't selected have to be hidden so that their 354 // content isn't focusable. 355 container.hidden = false; 356 container.setAttribute('aria-hidden', 'false'); 357 358 // Trigger a layout after making it visible and before setting 359 // the class to 'selected', so that it animates in. 360 container.offsetTop; 361 container.classList.add('selected'); 362 363 setContentChanging(true); 364 adjustToScroll(0); 365 366 var selectedFrame = getSelectedIframe().querySelector('iframe'); 367 uber.invokeMethodOnWindow(selectedFrame.contentWindow, 'frameSelected'); 368 369 if (historyOption != HISTORY_STATE_OPTION.NONE) 370 changePathTo({}, path, historyOption); 371 372 if (container.dataset.title) 373 document.title = container.dataset.title; 374 $('favicon').href = 'chrome://theme/' + container.dataset.favicon; 375 $('favicon2x').href = 'chrome://theme/' + container.dataset.favicon + '@2x'; 376 377 updateNavigationControls(); 378 } 379 380 function onNavigationControlsLoaded() { 381 updateNavigationControls(); 382 } 383 384 /** 385 * Sends a message to uber-frame to update the appearance of the nav controls. 386 * It should be called whenever the selected iframe changes. 387 */ 388 function updateNavigationControls() { 389 var iframe = getSelectedIframe(); 390 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow, 391 'changeSelection', {pageId: iframe.id}); 392 } 393 394 /** 395 * Forwarded scroll offset from a content frame's scroll handler. 396 * @param {number} scrollOffset The scroll offset from the content frame. 397 */ 398 function adjustToScroll(scrollOffset) { 399 // NOTE: The scroll is reset to 0 and easing turned on every time a user 400 // switches frames. If we receive a non-zero value it has to have come from 401 // a real user scroll, so we disable easing when this happens. 402 if (scrollOffset != 0) 403 setContentChanging(false); 404 405 if (isRTL()) { 406 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow, 407 'adjustToScroll', 408 scrollOffset); 409 var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset); 410 navFrame.style.width = navWidth + 'px'; 411 } else { 412 navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)'; 413 } 414 } 415 416 /** 417 * Forward scroll wheel events to subpages. 418 * @param {Object} params Relevant parameters of wheel event. 419 */ 420 function forwardMouseWheel(params) { 421 var iframe = getSelectedIframe().querySelector('iframe'); 422 uber.invokeMethodOnWindow(iframe.contentWindow, 'mouseWheel', params); 423 } 424 425 /** 426 * Make sure that iframe containers that are not selected are 427 * hidden, so that elements in those frames aren't part of the 428 * focus order. Containers that are unselected later get hidden 429 * when the transition ends. We also set the aria-hidden attribute 430 * because that hides the container from assistive technology 431 * immediately, rather than only after the transition ends. 432 */ 433 function ensureNonSelectedFrameContainersAreHidden() { 434 var containers = document.querySelectorAll('.iframe-container'); 435 for (var i = 0; i < containers.length; i++) { 436 var container = containers[i]; 437 if (!container.classList.contains('selected')) { 438 container.hidden = true; 439 container.setAttribute('aria-hidden', 'true'); 440 } 441 container.addEventListener('webkitTransitionEnd', function(event) { 442 if (!event.target.classList.contains('selected')) 443 event.target.hidden = true; 444 }); 445 } 446 } 447 448 return { 449 onLoad: onLoad, 450 onPopHistoryState: onPopHistoryState 451 }; 452}); 453 454window.addEventListener('popstate', uber.onPopHistoryState); 455document.addEventListener('DOMContentLoaded', uber.onLoad); 456