• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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   * 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
454window.addEventListener('popstate', uber.onPopHistoryState);
455document.addEventListener('DOMContentLoaded', uber.onLoad);
456