Home | History | Annotate | Download | only in uber
      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