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 5 cr.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 * Handles page initialization. 25 */ 26 function onLoad(e) { 27 navFrame = $('navigation'); 28 navFrame.dataset.width = navFrame.offsetWidth; 29 30 // Select a page based on the page-URL. 31 var params = resolvePageInfo(); 32 showPage(params.id, HISTORY_STATE_OPTION.NONE, params.path); 33 34 window.addEventListener('message', handleWindowMessage); 35 window.setTimeout(function() { 36 document.documentElement.classList.remove('loading'); 37 }, 0); 38 39 // HACK(dbeam): This makes the assumption that any second part to a path 40 // will result in needing background navigation. We shortcut it to avoid 41 // flicker on load. 42 // HACK(csilv): Search URLs aren't overlays, special case them. 43 if (params.id == 'settings' && params.path && 44 params.path.indexOf('search') != 0) { 45 backgroundNavigation(); 46 } 47 48 ensureNonSelectedFrameContainersAreHidden(); 49 } 50 51 /** 52 * Find page information from window.location. If the location doesn't 53 * point to one of our pages, return default parameters. 54 * @return {Object} An object containing the following parameters: 55 * id - The 'id' of the page. 56 * path - A path into the page, including search and hash. Optional. 57 */ 58 function resolvePageInfo() { 59 var params = {}; 60 var path = window.location.pathname; 61 if (path.length > 1) { 62 // Split the path into id and the remaining path. 63 path = path.slice(1); 64 var index = path.indexOf('/'); 65 if (index != -1) { 66 params.id = path.slice(0, index); 67 params.path = path.slice(index + 1); 68 } else { 69 params.id = path; 70 } 71 72 var container = $(params.id); 73 if (container) { 74 // The id is valid. Add the hash and search parts of the URL to path. 75 params.path = (params.path || '') + window.location.search + 76 window.location.hash; 77 } else { 78 // The target sub-page does not exist, discard the params we generated. 79 params.id = undefined; 80 params.path = undefined; 81 } 82 } 83 // If we don't have a valid page, get a default. 84 if (!params.id) 85 params.id = getDefaultIframe().id; 86 87 return params; 88 } 89 90 /** 91 * Handler for window.onpopstate. 92 * @param {Event} e The history event. 93 */ 94 function onPopHistoryState(e) { 95 if (e.state && e.state.pageId) 96 showPage(e.state.pageId, HISTORY_STATE_OPTION.NONE); 97 } 98 99 /** 100 * @return {Object} The default iframe container. 101 */ 102 function getDefaultIframe() { 103 return $(loadTimeData.getString('helpHost')); 104 } 105 106 /** 107 * @return {Object} The currently selected iframe container. 108 */ 109 function getSelectedIframe() { 110 return document.querySelector('.iframe-container.selected'); 111 } 112 113 /** 114 * Handles postMessage calls from the iframes of the contained pages. 115 * 116 * The pages request functionality from this object by passing an object of 117 * the following form: 118 * 119 * { method : "methodToInvoke", 120 * params : {...} 121 * } 122 * 123 * |method| is required, while |params| is optional. Extra parameters required 124 * by a method must be specified by that method's documentation. 125 * 126 * @param {Event} e The posted object. 127 */ 128 function handleWindowMessage(e) { 129 if (e.data.method === 'beginInterceptingEvents') 130 backgroundNavigation(); 131 else if (e.data.method === 'stopInterceptingEvents') 132 foregroundNavigation(); 133 else if (e.data.method === 'setPath') 134 setPath(e.origin, e.data.params.path); 135 else if (e.data.method === 'setTitle') 136 setTitle(e.origin, e.data.params.title); 137 else if (e.data.method === 'showPage') 138 showPage(e.data.params.pageId, HISTORY_STATE_OPTION.PUSH); 139 else if (e.data.method === 'navigationControlsLoaded') 140 onNavigationControlsLoaded(); 141 else if (e.data.method === 'adjustToScroll') 142 adjustToScroll(e.data.params); 143 else if (e.data.method === 'mouseWheel') 144 forwardMouseWheel(e.data.params); 145 else 146 console.error('Received unexpected message', e.data); 147 } 148 149 /** 150 * Sends the navigation iframe to the background. 151 */ 152 function backgroundNavigation() { 153 navFrame.classList.add('background'); 154 navFrame.firstChild.tabIndex = -1; 155 navFrame.firstChild.setAttribute('aria-hidden', true); 156 } 157 158 /** 159 * Retrieves the navigation iframe from the background. 160 */ 161 function foregroundNavigation() { 162 navFrame.classList.remove('background'); 163 navFrame.firstChild.tabIndex = 0; 164 navFrame.firstChild.removeAttribute('aria-hidden'); 165 } 166 167 /** 168 * Enables or disables animated transitions when changing content while 169 * horizontally scrolled. 170 * @param {boolean} enabled True if enabled, else false to disable. 171 */ 172 function setContentChanging(enabled) { 173 navFrame.classList[enabled ? 'add' : 'remove']('changing-content'); 174 175 if (isRTL()) { 176 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow, 177 'setContentChanging', 178 enabled); 179 } 180 } 181 182 /** 183 * Get an iframe based on the origin of a received post message. 184 * @param {string} origin The origin of a post message. 185 * @return {!HTMLElement} The frame associated to |origin| or null. 186 */ 187 function getIframeFromOrigin(origin) { 188 assert(origin.substr(-1) != '/', 'invalid origin given'); 189 var query = '.iframe-container > iframe[src^="' + origin + '/"]'; 190 return document.querySelector(query); 191 } 192 193 /** 194 * Changes the path past the page title (i.e. chrome://chrome/settings/(.*)). 195 * @param {string} path The new /path/ to be set after the page name. 196 * @param {number} historyOption The type of history modification to make. 197 */ 198 function changePathTo(path, historyOption) { 199 assert(!path || path.substr(-1) != '/', 'invalid path given'); 200 201 var histFunc; 202 if (historyOption == HISTORY_STATE_OPTION.PUSH) 203 histFunc = window.history.pushState; 204 else if (historyOption == HISTORY_STATE_OPTION.REPLACE) 205 histFunc = window.history.replaceState; 206 207 assert(histFunc, 'invalid historyOption given ' + historyOption); 208 209 var pageId = getSelectedIframe().id; 210 var args = [{pageId: pageId}, '', '/' + pageId + '/' + (path || '')]; 211 histFunc.apply(window.history, args); 212 } 213 214 /** 215 * Sets the "path" of the page (actually the path after the first '/' char). 216 * @param {Object} origin The origin of the source iframe. 217 * @param {string} title The new "path". 218 */ 219 function setPath(origin, path) { 220 assert(!path || path[0] != '/', 'invalid path sent from ' + origin); 221 // Only update the currently displayed path if this is the visible frame. 222 if (getIframeFromOrigin(origin).parentNode == getSelectedIframe()) 223 changePathTo(path, HISTORY_STATE_OPTION.REPLACE); 224 } 225 226 /** 227 * Sets the title of the page. 228 * @param {Object} origin The origin of the source iframe. 229 * @param {string} title The title of the page. 230 */ 231 function setTitle(origin, title) { 232 // Cache the title for the client iframe, i.e., the iframe setting the 233 // title. querySelector returns the actual iframe element, so use parentNode 234 // to get back to the container. 235 var container = getIframeFromOrigin(origin).parentNode; 236 container.dataset.title = title; 237 238 // Only update the currently displayed title if this is the visible frame. 239 if (container == getSelectedIframe()) 240 document.title = title; 241 } 242 243 /** 244 * Selects a subpage. This is called from uber-frame. 245 * @param {string} pageId Should matche an id of one of the iframe containers. 246 * @param {integer} historyOption Indicates whether we should push or replace 247 * browser history. 248 * @param {string} path A sub-page path. 249 */ 250 function showPage(pageId, historyOption, path) { 251 var container = $(pageId); 252 var lastSelected = document.querySelector('.iframe-container.selected'); 253 254 // Lazy load of iframe contents. 255 var sourceUrl = container.dataset.url + (path || ''); 256 var frame = container.querySelector('iframe'); 257 if (!frame) { 258 frame = container.ownerDocument.createElement('iframe'); 259 container.appendChild(frame); 260 frame.src = sourceUrl; 261 } else { 262 // There's no particularly good way to know what the current URL of the 263 // content frame is as we don't have access to its contentWindow's 264 // location, so just replace every time until necessary to do otherwise. 265 frame.contentWindow.location.replace(sourceUrl); 266 } 267 268 // If the last selected container is already showing, ignore the rest. 269 if (lastSelected === container) 270 return; 271 272 if (lastSelected) { 273 lastSelected.classList.remove('selected'); 274 // Setting aria-hidden hides the container from assistive technology 275 // immediately. The 'hidden' attribute is set after the transition 276 // finishes - that ensures it's not possible to accidentally focus 277 // an element in an unselected container. 278 lastSelected.setAttribute('aria-hidden', 'true'); 279 } 280 281 // Containers that aren't selected have to be hidden so that their 282 // content isn't focusable. 283 container.hidden = false; 284 container.setAttribute('aria-hidden', 'false'); 285 286 // Trigger a layout after making it visible and before setting 287 // the class to 'selected', so that it animates in. 288 container.offsetTop; 289 container.classList.add('selected'); 290 291 setContentChanging(true); 292 adjustToScroll(0); 293 294 var selectedFrame = getSelectedIframe().querySelector('iframe'); 295 uber.invokeMethodOnWindow(selectedFrame.contentWindow, 'frameSelected'); 296 297 if (historyOption != HISTORY_STATE_OPTION.NONE) 298 changePathTo(path, historyOption); 299 300 if (container.dataset.title) 301 document.title = container.dataset.title; 302 $('favicon').href = 'chrome://theme/' + container.dataset.favicon; 303 $('favicon2x').href = 'chrome://theme/' + container.dataset.favicon + '@2x'; 304 305 updateNavigationControls(); 306 } 307 308 function onNavigationControlsLoaded() { 309 updateNavigationControls(); 310 } 311 312 /** 313 * Sends a message to uber-frame to update the appearance of the nav controls. 314 * It should be called whenever the selected iframe changes. 315 */ 316 function updateNavigationControls() { 317 var iframe = getSelectedIframe(); 318 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow, 319 'changeSelection', {pageId: iframe.id}); 320 } 321 322 /** 323 * Forwarded scroll offset from a content frame's scroll handler. 324 * @param {number} scrollOffset The scroll offset from the content frame. 325 */ 326 function adjustToScroll(scrollOffset) { 327 // NOTE: The scroll is reset to 0 and easing turned on every time a user 328 // switches frames. If we receive a non-zero value it has to have come from 329 // a real user scroll, so we disable easing when this happens. 330 if (scrollOffset != 0) 331 setContentChanging(false); 332 333 if (isRTL()) { 334 uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow, 335 'adjustToScroll', 336 scrollOffset); 337 var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset); 338 navFrame.style.width = navWidth + 'px'; 339 } else { 340 navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)'; 341 } 342 } 343 344 /** 345 * Forward scroll wheel events to subpages. 346 * @param {Object} params Relevant parameters of wheel event. 347 */ 348 function forwardMouseWheel(params) { 349 var iframe = getSelectedIframe().querySelector('iframe'); 350 uber.invokeMethodOnWindow(iframe.contentWindow, 'mouseWheel', params); 351 } 352 353 /** 354 * Make sure that iframe containers that are not selected are 355 * hidden, so that elements in those frames aren't part of the 356 * focus order. Containers that are unselected later get hidden 357 * when the transition ends. We also set the aria-hidden attribute 358 * because that hides the container from assistive technology 359 * immediately, rather than only after the transition ends. 360 */ 361 function ensureNonSelectedFrameContainersAreHidden() { 362 var containers = document.querySelectorAll('.iframe-container'); 363 for (var i = 0; i < containers.length; i++) { 364 var container = containers[i]; 365 if (!container.classList.contains('selected')) { 366 container.hidden = true; 367 container.setAttribute('aria-hidden', 'true'); 368 } 369 container.addEventListener('webkitTransitionEnd', function(event) { 370 if (!event.target.classList.contains('selected')) 371 event.target.hidden = true; 372 }); 373 } 374 } 375 376 return { 377 onLoad: onLoad, 378 onPopHistoryState: onPopHistoryState 379 }; 380 }); 381 382 window.addEventListener('popstate', uber.onPopHistoryState); 383 document.addEventListener('DOMContentLoaded', uber.onLoad); 384