• 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    e = /** @type{!MessageEvent.<!{method: string, params: *}>} */(e);
153    if (e.data.method === 'beginInterceptingEvents') {
154      backgroundNavigation();
155    } else if (e.data.method === 'stopInterceptingEvents') {
156      foregroundNavigation();
157    } else if (e.data.method === 'ready') {
158      pageReady(e.origin);
159    } else if (e.data.method === 'updateHistory') {
160      updateHistory(e.origin, e.data.params.state, e.data.params.path,
161                    e.data.params.replace);
162    } else if (e.data.method === 'setTitle') {
163      setTitle(e.origin, e.data.params.title);
164    } else if (e.data.method === 'showPage') {
165      showPage(e.data.params.pageId,
166               HISTORY_STATE_OPTION.PUSH,
167               e.data.params.path);
168    } else if (e.data.method === 'navigationControlsLoaded') {
169      onNavigationControlsLoaded();
170    } else if (e.data.method === 'adjustToScroll') {
171      adjustToScroll(/** @type {number} */(e.data.params));
172    } else if (e.data.method === 'mouseWheel') {
173      forwardMouseWheel(/** @type {Object} */(e.data.params));
174    } else {
175      console.error('Received unexpected message', e.data);
176    }
177  }
178
179  /**
180   * Sends the navigation iframe to the background.
181   */
182  function backgroundNavigation() {
183    navFrame.classList.add('background');
184    navFrame.firstChild.tabIndex = -1;
185    navFrame.firstChild.setAttribute('aria-hidden', true);
186  }
187
188  /**
189   * Retrieves the navigation iframe from the background.
190   */
191  function foregroundNavigation() {
192    navFrame.classList.remove('background');
193    navFrame.firstChild.tabIndex = 0;
194    navFrame.firstChild.removeAttribute('aria-hidden');
195  }
196
197  /**
198   * Enables or disables animated transitions when changing content while
199   * horizontally scrolled.
200   * @param {boolean} enabled True if enabled, else false to disable.
201   */
202  function setContentChanging(enabled) {
203    navFrame.classList[enabled ? 'add' : 'remove']('changing-content');
204
205    if (isRTL()) {
206      uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
207                                'setContentChanging', 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 {!Element} 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    var element = document.querySelector(query);
220    assert(element);
221    return /** @type {!Element} */(element);
222  }
223
224  /**
225   * Changes the path past the page title (i.e. chrome://chrome/settings/(.*)).
226   * @param {Object} state The page's state object for the navigation.
227   * @param {string} path The new /path/ to be set after the page name.
228   * @param {number} historyOption The type of history modification to make.
229   */
230  function changePathTo(state, path, historyOption) {
231    assert(!path || path.substr(-1) != '/', 'invalid path given');
232
233    var histFunc;
234    if (historyOption == HISTORY_STATE_OPTION.PUSH)
235      histFunc = window.history.pushState;
236    else if (historyOption == HISTORY_STATE_OPTION.REPLACE)
237      histFunc = window.history.replaceState;
238
239    assert(histFunc, 'invalid historyOption given ' + historyOption);
240
241    var pageId = getSelectedIframe().id;
242    var args = [state, '', '/' + pageId + '/' + (path || '')];
243    histFunc.apply(window.history, args);
244  }
245
246  /**
247   * Adds or replaces the current history entry based on a navigation from the
248   * source iframe.
249   * @param {string} origin The origin of the source iframe.
250   * @param {Object} state The source iframe's state object.
251   * @param {string} path The new "path" (e.g. "/createProfile").
252   * @param {boolean} replace Whether to replace the current history entry.
253   */
254  function updateHistory(origin, state, path, replace) {
255    assert(!path || path[0] != '/', 'invalid path sent from ' + origin);
256    var historyOption =
257        replace ? HISTORY_STATE_OPTION.REPLACE : HISTORY_STATE_OPTION.PUSH;
258    // Only update the currently displayed path if this is the visible frame.
259    var container = getIframeFromOrigin(origin).parentNode;
260    if (container == getSelectedIframe())
261      changePathTo(state, path, historyOption);
262  }
263
264  /**
265   * Sets the title of the page.
266   * @param {string} origin The origin of the source iframe.
267   * @param {string} title The title of the page.
268   */
269  function setTitle(origin, title) {
270    // Cache the title for the client iframe, i.e., the iframe setting the
271    // title. querySelector returns the actual iframe element, so use parentNode
272    // to get back to the container.
273    var container = getIframeFromOrigin(origin).parentNode;
274    container.dataset.title = title;
275
276    // Only update the currently displayed title if this is the visible frame.
277    if (container == getSelectedIframe())
278      document.title = title;
279  }
280
281  /**
282   * Invokes a method on a subpage. If the subpage has not signaled readiness,
283   * queue the message for when it does.
284   * @param {string} pageId Should match an id of one of the iframe containers.
285   * @param {string} method The name of the method to invoke.
286   * @param {Object=} opt_params Optional property page of parameters to pass to
287   *     the invoked method.
288   */
289  function invokeMethodOnPage(pageId, method, opt_params) {
290    var frame = $(pageId).querySelector('iframe');
291    if (!frame || !frame.dataset.ready) {
292      queuedInvokes[pageId] = (queuedInvokes[pageId] || []);
293      queuedInvokes[pageId].push([method, opt_params]);
294    } else {
295      uber.invokeMethodOnWindow(frame.contentWindow, method, opt_params);
296    }
297  }
298
299  /**
300   * Called in response to a page declaring readiness. Calls any deferred method
301   * invocations from invokeMethodOnPage.
302   * @param {string} origin The origin of the source iframe.
303   */
304  function pageReady(origin) {
305    var frame = getIframeFromOrigin(origin);
306    var container = frame.parentNode;
307    frame.dataset.ready = true;
308    var queue = queuedInvokes[container.id] || [];
309    queuedInvokes[container.id] = undefined;
310    for (var i = 0; i < queue.length; i++) {
311      uber.invokeMethodOnWindow(frame.contentWindow, queue[i][0], queue[i][1]);
312    }
313  }
314
315  /**
316   * Selects and navigates a subpage. This is called from uber-frame.
317   * @param {string} pageId Should match an id of one of the iframe containers.
318   * @param {number} historyOption Indicates whether we should push or replace
319   *     browser history.
320   * @param {string} path A sub-page path.
321   */
322  function showPage(pageId, historyOption, path) {
323    var container = $(pageId);
324
325    // Lazy load of iframe contents.
326    var sourceUrl = container.dataset.url + (path || '');
327    var frame = container.querySelector('iframe');
328    if (!frame) {
329      frame = container.ownerDocument.createElement('iframe');
330      frame.name = pageId;
331      container.appendChild(frame);
332      frame.src = sourceUrl;
333    } else {
334      // There's no particularly good way to know what the current URL of the
335      // content frame is as we don't have access to its contentWindow's
336      // location, so just replace every time until necessary to do otherwise.
337      frame.contentWindow.location.replace(sourceUrl);
338      frame.dataset.ready = false;
339    }
340
341    // If the last selected container is already showing, ignore the rest.
342    var lastSelected = document.querySelector('.iframe-container.selected');
343    if (lastSelected === container)
344      return;
345
346    if (lastSelected) {
347      lastSelected.classList.remove('selected');
348      // Setting aria-hidden hides the container from assistive technology
349      // immediately. The 'hidden' attribute is set after the transition
350      // finishes - that ensures it's not possible to accidentally focus
351      // an element in an unselected container.
352      lastSelected.setAttribute('aria-hidden', 'true');
353    }
354
355    // Containers that aren't selected have to be hidden so that their
356    // content isn't focusable.
357    container.hidden = false;
358    container.setAttribute('aria-hidden', 'false');
359
360    // Trigger a layout after making it visible and before setting
361    // the class to 'selected', so that it animates in.
362    /** @suppress {uselessCode} */
363    container.offsetTop;
364    container.classList.add('selected');
365
366    setContentChanging(true);
367    adjustToScroll(0);
368
369    var selectedFrame = getSelectedIframe().querySelector('iframe');
370    uber.invokeMethodOnWindow(selectedFrame.contentWindow, 'frameSelected');
371    selectedFrame.contentWindow.focus();
372
373    if (historyOption != HISTORY_STATE_OPTION.NONE)
374      changePathTo({}, path, historyOption);
375
376    if (container.dataset.title)
377      document.title = container.dataset.title;
378    assert('favicon' in container.dataset);
379
380    var dataset = /** @type {{favicon: string}} */(container.dataset);
381    $('favicon').href = 'chrome://theme/' + dataset.favicon;
382    $('favicon2x').href = 'chrome://theme/' + dataset.favicon + '@2x';
383
384    updateNavigationControls();
385  }
386
387  function onNavigationControlsLoaded() {
388    updateNavigationControls();
389  }
390
391  /**
392   * Sends a message to uber-frame to update the appearance of the nav controls.
393   * It should be called whenever the selected iframe changes.
394   */
395  function updateNavigationControls() {
396    var iframe = getSelectedIframe();
397    uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
398                              'changeSelection', {pageId: iframe.id});
399  }
400
401  /**
402   * Forwarded scroll offset from a content frame's scroll handler.
403   * @param {number} scrollOffset The scroll offset from the content frame.
404   */
405  function adjustToScroll(scrollOffset) {
406    // NOTE: The scroll is reset to 0 and easing turned on every time a user
407    // switches frames. If we receive a non-zero value it has to have come from
408    // a real user scroll, so we disable easing when this happens.
409    if (scrollOffset != 0)
410      setContentChanging(false);
411
412    if (isRTL()) {
413      uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
414                                'adjustToScroll',
415                                scrollOffset);
416      var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset);
417      navFrame.style.width = navWidth + 'px';
418    } else {
419      navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)';
420    }
421  }
422
423  /**
424   * Forward scroll wheel events to subpages.
425   * @param {Object} params Relevant parameters of wheel event.
426   */
427  function forwardMouseWheel(params) {
428    var iframe = getSelectedIframe().querySelector('iframe');
429    uber.invokeMethodOnWindow(iframe.contentWindow, 'mouseWheel', params);
430  }
431
432  /**
433   * Make sure that iframe containers that are not selected are
434   * hidden, so that elements in those frames aren't part of the
435   * focus order. Containers that are unselected later get hidden
436   * when the transition ends. We also set the aria-hidden attribute
437   * because that hides the container from assistive technology
438   * immediately, rather than only after the transition ends.
439   */
440  function ensureNonSelectedFrameContainersAreHidden() {
441    var containers = document.querySelectorAll('.iframe-container');
442    for (var i = 0; i < containers.length; i++) {
443      var container = containers[i];
444      if (!container.classList.contains('selected')) {
445        container.hidden = true;
446        container.setAttribute('aria-hidden', 'true');
447      }
448      container.addEventListener('webkitTransitionEnd', function(event) {
449        if (!event.target.classList.contains('selected'))
450          event.target.hidden = true;
451      });
452    }
453  }
454
455  return {
456    onLoad: onLoad,
457    onPopHistoryState: onPopHistoryState
458  };
459});
460
461window.addEventListener('popstate', uber.onPopHistoryState);
462document.addEventListener('DOMContentLoaded', uber.onLoad);
463