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    * 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 
    454 window.addEventListener('popstate', uber.onPopHistoryState);
    455 document.addEventListener('DOMContentLoaded', uber.onLoad);
    456