Javascript  |  384行  |  13.05 KB

// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

cr.define('uber', function() {
  /**
   * Options for how web history should be handled.
   */
  var HISTORY_STATE_OPTION = {
    PUSH: 1,    // Push a new history state.
    REPLACE: 2, // Replace the current history state.
    NONE: 3,    // Ignore this history state change.
  };

  /**
   * We cache a reference to the #navigation frame here so we don't need to grab
   * it from the DOM on each scroll.
   * @type {Node}
   * @private
   */
  var navFrame;

  /**
   * Handles page initialization.
   */
  function onLoad(e) {
    navFrame = $('navigation');
    navFrame.dataset.width = navFrame.offsetWidth;

    // Select a page based on the page-URL.
    var params = resolvePageInfo();
    showPage(params.id, HISTORY_STATE_OPTION.NONE, params.path);

    window.addEventListener('message', handleWindowMessage);
    window.setTimeout(function() {
      document.documentElement.classList.remove('loading');
    }, 0);

    // HACK(dbeam): This makes the assumption that any second part to a path
    // will result in needing background navigation. We shortcut it to avoid
    // flicker on load.
    // HACK(csilv): Search URLs aren't overlays, special case them.
    if (params.id == 'settings' && params.path &&
        params.path.indexOf('search') != 0) {
      backgroundNavigation();
    }

    ensureNonSelectedFrameContainersAreHidden();
  }

  /**
   * Find page information from window.location. If the location doesn't
   * point to one of our pages, return default parameters.
   * @return {Object} An object containing the following parameters:
   *     id - The 'id' of the page.
   *     path - A path into the page, including search and hash. Optional.
   */
  function resolvePageInfo() {
    var params = {};
    var path = window.location.pathname;
    if (path.length > 1) {
      // Split the path into id and the remaining path.
      path = path.slice(1);
      var index = path.indexOf('/');
      if (index != -1) {
        params.id = path.slice(0, index);
        params.path = path.slice(index + 1);
      } else {
        params.id = path;
      }

      var container = $(params.id);
      if (container) {
        // The id is valid. Add the hash and search parts of the URL to path.
        params.path = (params.path || '') + window.location.search +
            window.location.hash;
      } else {
        // The target sub-page does not exist, discard the params we generated.
        params.id = undefined;
        params.path = undefined;
      }
    }
    // If we don't have a valid page, get a default.
    if (!params.id)
      params.id = getDefaultIframe().id;

    return params;
  }

  /**
   * Handler for window.onpopstate.
   * @param {Event} e The history event.
   */
  function onPopHistoryState(e) {
    if (e.state && e.state.pageId)
      showPage(e.state.pageId, HISTORY_STATE_OPTION.NONE);
  }

  /**
   * @return {Object} The default iframe container.
   */
  function getDefaultIframe() {
    return $(loadTimeData.getString('helpHost'));
  }

  /**
   * @return {Object} The currently selected iframe container.
   */
  function getSelectedIframe() {
    return document.querySelector('.iframe-container.selected');
  }

  /**
   * Handles postMessage calls from the iframes of the contained pages.
   *
   * The pages request functionality from this object by passing an object of
   * the following form:
   *
   *  { method : "methodToInvoke",
   *    params : {...}
   *  }
   *
   * |method| is required, while |params| is optional. Extra parameters required
   * by a method must be specified by that method's documentation.
   *
   * @param {Event} e The posted object.
   */
  function handleWindowMessage(e) {
    if (e.data.method === 'beginInterceptingEvents')
      backgroundNavigation();
    else if (e.data.method === 'stopInterceptingEvents')
      foregroundNavigation();
    else if (e.data.method === 'setPath')
      setPath(e.origin, e.data.params.path);
    else if (e.data.method === 'setTitle')
      setTitle(e.origin, e.data.params.title);
    else if (e.data.method === 'showPage')
      showPage(e.data.params.pageId, HISTORY_STATE_OPTION.PUSH);
    else if (e.data.method === 'navigationControlsLoaded')
      onNavigationControlsLoaded();
    else if (e.data.method === 'adjustToScroll')
      adjustToScroll(e.data.params);
    else if (e.data.method === 'mouseWheel')
      forwardMouseWheel(e.data.params);
    else
      console.error('Received unexpected message', e.data);
  }

  /**
   * Sends the navigation iframe to the background.
   */
  function backgroundNavigation() {
    navFrame.classList.add('background');
    navFrame.firstChild.tabIndex = -1;
    navFrame.firstChild.setAttribute('aria-hidden', true);
  }

  /**
   * Retrieves the navigation iframe from the background.
   */
  function foregroundNavigation() {
    navFrame.classList.remove('background');
    navFrame.firstChild.tabIndex = 0;
    navFrame.firstChild.removeAttribute('aria-hidden');
  }

  /**
   * Enables or disables animated transitions when changing content while
   * horizontally scrolled.
   * @param {boolean} enabled True if enabled, else false to disable.
   */
  function setContentChanging(enabled) {
    navFrame.classList[enabled ? 'add' : 'remove']('changing-content');

    if (isRTL()) {
      uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
                                'setContentChanging',
                                enabled);
    }
  }

  /**
   * Get an iframe based on the origin of a received post message.
   * @param {string} origin The origin of a post message.
   * @return {!HTMLElement} The frame associated to |origin| or null.
   */
  function getIframeFromOrigin(origin) {
    assert(origin.substr(-1) != '/', 'invalid origin given');
    var query = '.iframe-container > iframe[src^="' + origin + '/"]';
    return document.querySelector(query);
  }

  /**
   * Changes the path past the page title (i.e. chrome://chrome/settings/(.*)).
   * @param {string} path The new /path/ to be set after the page name.
   * @param {number} historyOption The type of history modification to make.
   */
  function changePathTo(path, historyOption) {
    assert(!path || path.substr(-1) != '/', 'invalid path given');

    var histFunc;
    if (historyOption == HISTORY_STATE_OPTION.PUSH)
      histFunc = window.history.pushState;
    else if (historyOption == HISTORY_STATE_OPTION.REPLACE)
      histFunc = window.history.replaceState;

    assert(histFunc, 'invalid historyOption given ' + historyOption);

    var pageId = getSelectedIframe().id;
    var args = [{pageId: pageId}, '', '/' + pageId + '/' + (path || '')];
    histFunc.apply(window.history, args);
  }

  /**
   * Sets the "path" of the page (actually the path after the first '/' char).
   * @param {Object} origin The origin of the source iframe.
   * @param {string} title The new "path".
   */
  function setPath(origin, path) {
    assert(!path || path[0] != '/', 'invalid path sent from ' + origin);
    // Only update the currently displayed path if this is the visible frame.
    if (getIframeFromOrigin(origin).parentNode == getSelectedIframe())
      changePathTo(path, HISTORY_STATE_OPTION.REPLACE);
  }

  /**
   * Sets the title of the page.
   * @param {Object} origin The origin of the source iframe.
   * @param {string} title The title of the page.
   */
  function setTitle(origin, title) {
    // Cache the title for the client iframe, i.e., the iframe setting the
    // title. querySelector returns the actual iframe element, so use parentNode
    // to get back to the container.
    var container = getIframeFromOrigin(origin).parentNode;
    container.dataset.title = title;

    // Only update the currently displayed title if this is the visible frame.
    if (container == getSelectedIframe())
      document.title = title;
  }

  /**
   * Selects a subpage. This is called from uber-frame.
   * @param {string} pageId Should matche an id of one of the iframe containers.
   * @param {integer} historyOption Indicates whether we should push or replace
   *     browser history.
   * @param {string} path A sub-page path.
   */
  function showPage(pageId, historyOption, path) {
    var container = $(pageId);
    var lastSelected = document.querySelector('.iframe-container.selected');

    // Lazy load of iframe contents.
    var sourceUrl = container.dataset.url + (path || '');
    var frame = container.querySelector('iframe');
    if (!frame) {
      frame = container.ownerDocument.createElement('iframe');
      container.appendChild(frame);
      frame.src = sourceUrl;
    } else {
      // There's no particularly good way to know what the current URL of the
      // content frame is as we don't have access to its contentWindow's
      // location, so just replace every time until necessary to do otherwise.
      frame.contentWindow.location.replace(sourceUrl);
    }

    // If the last selected container is already showing, ignore the rest.
    if (lastSelected === container)
      return;

    if (lastSelected) {
      lastSelected.classList.remove('selected');
      // Setting aria-hidden hides the container from assistive technology
      // immediately. The 'hidden' attribute is set after the transition
      // finishes - that ensures it's not possible to accidentally focus
      // an element in an unselected container.
      lastSelected.setAttribute('aria-hidden', 'true');
    }

    // Containers that aren't selected have to be hidden so that their
    // content isn't focusable.
    container.hidden = false;
    container.setAttribute('aria-hidden', 'false');

    // Trigger a layout after making it visible and before setting
    // the class to 'selected', so that it animates in.
    container.offsetTop;
    container.classList.add('selected');

    setContentChanging(true);
    adjustToScroll(0);

    var selectedFrame = getSelectedIframe().querySelector('iframe');
    uber.invokeMethodOnWindow(selectedFrame.contentWindow, 'frameSelected');

    if (historyOption != HISTORY_STATE_OPTION.NONE)
      changePathTo(path, historyOption);

    if (container.dataset.title)
      document.title = container.dataset.title;
    $('favicon').href = 'chrome://theme/' + container.dataset.favicon;
    $('favicon2x').href = 'chrome://theme/' + container.dataset.favicon + '@2x';

    updateNavigationControls();
  }

  function onNavigationControlsLoaded() {
    updateNavigationControls();
  }

  /**
   * Sends a message to uber-frame to update the appearance of the nav controls.
   * It should be called whenever the selected iframe changes.
   */
  function updateNavigationControls() {
    var iframe = getSelectedIframe();
    uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
                              'changeSelection', {pageId: iframe.id});
  }

  /**
   * Forwarded scroll offset from a content frame's scroll handler.
   * @param {number} scrollOffset The scroll offset from the content frame.
   */
  function adjustToScroll(scrollOffset) {
    // NOTE: The scroll is reset to 0 and easing turned on every time a user
    // switches frames. If we receive a non-zero value it has to have come from
    // a real user scroll, so we disable easing when this happens.
    if (scrollOffset != 0)
      setContentChanging(false);

    if (isRTL()) {
      uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
                                'adjustToScroll',
                                scrollOffset);
      var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset);
      navFrame.style.width = navWidth + 'px';
    } else {
      navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)';
    }
  }

  /**
   * Forward scroll wheel events to subpages.
   * @param {Object} params Relevant parameters of wheel event.
   */
  function forwardMouseWheel(params) {
    var iframe = getSelectedIframe().querySelector('iframe');
    uber.invokeMethodOnWindow(iframe.contentWindow, 'mouseWheel', params);
  }

  /**
   * Make sure that iframe containers that are not selected are
   * hidden, so that elements in those frames aren't part of the
   * focus order. Containers that are unselected later get hidden
   * when the transition ends. We also set the aria-hidden attribute
   * because that hides the container from assistive technology
   * immediately, rather than only after the transition ends.
   */
  function ensureNonSelectedFrameContainersAreHidden() {
    var containers = document.querySelectorAll('.iframe-container');
    for (var i = 0; i < containers.length; i++) {
      var container = containers[i];
      if (!container.classList.contains('selected')) {
        container.hidden = true;
        container.setAttribute('aria-hidden', 'true');
      }
      container.addEventListener('webkitTransitionEnd', function(event) {
        if (!event.target.classList.contains('selected'))
          event.target.hidden = true;
      });
    }
  }

  return {
    onLoad: onLoad,
    onPopHistoryState: onPopHistoryState
  };
});

window.addEventListener('popstate', uber.onPopHistoryState);
document.addEventListener('DOMContentLoaded', uber.onLoad);