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