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 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') { 158 pageReady(e.origin); 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, 167 e.data.params.path); 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)); 174 } else { 175 console.error('Received unexpected message', e.data); 176 } 177 } 178 179 /** 180 * Sends the navigation iframe to the background. 181 */ 182 function backgroundNavigation() { 183 navFrame.classList.add('background'); 184 navFrame.firstChild.tabIndex = -1; 185 navFrame.firstChild.setAttribute('aria-hidden', true); 186 } 187 188 /** 189 * Retrieves the navigation iframe from the background. 190 */ 191 function foregroundNavigation() { 192 navFrame.classList.remove('background'); 193 navFrame.firstChild.tabIndex = 0; 194 navFrame.firstChild.removeAttribute('aria-hidden'); 195 } 196 197 /** 198 * Enables or disables animated transitions when changing content while 199 * horizontally scrolled. 200 * @param {boolean} enabled True if enabled, else false to disable. 201 */ 202 function setContentChanging(enabled) { 203 navFrame.classList[enabled ? 'add' : 'remove']('changing-content'); 204 205 if (isRTL()) { 206 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow, 207 'setContentChanging', 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 {!Element} 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 var element = document.querySelector(query); 220 assert(element); 221 return /** @type {!Element} */(element); 222 } 223 224 /** 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. 229 */ 230 function changePathTo(state, path, historyOption) { 231 assert(!path || path.substr(-1) != '/', 'invalid path given'); 232 233 var histFunc; 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; 238 239 assert(histFunc, 'invalid historyOption given ' + historyOption); 240 241 var pageId = getSelectedIframe().id; 242 var args = [state, '', '/' + pageId + '/' + (path || '')]; 243 histFunc.apply(window.history, args); 244 } 245 246 /** 247 * Adds or replaces the current history entry based on a navigation from the 248 * source iframe. 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. 253 */ 254 function updateHistory(origin, state, path, replace) { 255 assert(!path || path[0] != '/', 'invalid path sent from ' + origin); 256 var historyOption = 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); 262 } 263 264 /** 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. 268 */ 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; 275 276 // Only update the currently displayed title if this is the visible frame. 277 if (container == getSelectedIframe()) 278 document.title = title; 279 } 280 281 /** 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. 288 */ 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]); 294 } else { 295 uber.invokeMethodOnWindow(frame.contentWindow, method, opt_params); 296 } 297 } 298 299 /** 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. 303 */ 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]); 312 } 313 } 314 315 /** 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 319 * browser history. 320 * @param {string} path A sub-page path. 321 */ 322 function showPage(pageId, historyOption, path) { 323 var container = $(pageId); 324 325 // Lazy load of iframe contents. 326 var sourceUrl = container.dataset.url + (path || ''); 327 var frame = container.querySelector('iframe'); 328 if (!frame) { 329 frame = container.ownerDocument.createElement('iframe'); 330 frame.name = pageId; 331 container.appendChild(frame); 332 frame.src = sourceUrl; 333 } else { 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; 339 } 340 341 // If the last selected container is already showing, ignore the rest. 342 var lastSelected = document.querySelector('.iframe-container.selected'); 343 if (lastSelected === container) 344 return; 345 346 if (lastSelected) { 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'); 353 } 354 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'); 359 360 // Trigger a layout after making it visible and before setting 361 // the class to 'selected', so that it animates in. 362 /** @suppress {uselessCode} */ 363 container.offsetTop; 364 container.classList.add('selected'); 365 366 setContentChanging(true); 367 adjustToScroll(0); 368 369 var selectedFrame = getSelectedIframe().querySelector('iframe'); 370 uber.invokeMethodOnWindow(selectedFrame.contentWindow, 'frameSelected'); 371 selectedFrame.contentWindow.focus(); 372 373 if (historyOption != HISTORY_STATE_OPTION.NONE) 374 changePathTo({}, path, historyOption); 375 376 if (container.dataset.title) 377 document.title = container.dataset.title; 378 assert('favicon' in container.dataset); 379 380 var dataset = /** @type {{favicon: string}} */(container.dataset); 381 $('favicon').href = 'chrome://theme/' + dataset.favicon; 382 $('favicon2x').href = 'chrome://theme/' + dataset.favicon + '@2x'; 383 384 updateNavigationControls(); 385 } 386 387 function onNavigationControlsLoaded() { 388 updateNavigationControls(); 389 } 390 391 /** 392 * Sends a message to uber-frame to update the appearance of the nav controls. 393 * It should be called whenever the selected iframe changes. 394 */ 395 function updateNavigationControls() { 396 var iframe = getSelectedIframe(); 397 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow, 398 'changeSelection', {pageId: iframe.id}); 399 } 400 401 /** 402 * Forwarded scroll offset from a content frame's scroll handler. 403 * @param {number} scrollOffset The scroll offset from the content frame. 404 */ 405 function adjustToScroll(scrollOffset) { 406 // NOTE: The scroll is reset to 0 and easing turned on every time a user 407 // switches frames. If we receive a non-zero value it has to have come from 408 // a real user scroll, so we disable easing when this happens. 409 if (scrollOffset != 0) 410 setContentChanging(false); 411 412 if (isRTL()) { 413 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow, 414 'adjustToScroll', 415 scrollOffset); 416 var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset); 417 navFrame.style.width = navWidth + 'px'; 418 } else { 419 navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)'; 420 } 421 } 422 423 /** 424 * Forward scroll wheel events to subpages. 425 * @param {Object} params Relevant parameters of wheel event. 426 */ 427 function forwardMouseWheel(params) { 428 var iframe = getSelectedIframe().querySelector('iframe'); 429 uber.invokeMethodOnWindow(iframe.contentWindow, 'mouseWheel', params); 430 } 431 432 /** 433 * Make sure that iframe containers that are not selected are 434 * hidden, so that elements in those frames aren't part of the 435 * focus order. Containers that are unselected later get hidden 436 * when the transition ends. We also set the aria-hidden attribute 437 * because that hides the container from assistive technology 438 * immediately, rather than only after the transition ends. 439 */ 440 function ensureNonSelectedFrameContainersAreHidden() { 441 var containers = document.querySelectorAll('.iframe-container'); 442 for (var i = 0; i < containers.length; i++) { 443 var container = containers[i]; 444 if (!container.classList.contains('selected')) { 445 container.hidden = true; 446 container.setAttribute('aria-hidden', 'true'); 447 } 448 container.addEventListener('webkitTransitionEnd', function(event) { 449 if (!event.target.classList.contains('selected')) 450 event.target.hidden = true; 451 }); 452 } 453 } 454 455 return { 456 onLoad: onLoad, 457 onPopHistoryState: onPopHistoryState 458 }; 459}); 460 461window.addEventListener('popstate', uber.onPopHistoryState); 462document.addEventListener('DOMContentLoaded', uber.onLoad); 463