Upstream version 9.38.198.0
[platform/framework/web/crosswalk.git] / src / chrome / browser / resources / uber / uber.js
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     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     container.offsetTop;
363     container.classList.add('selected');
364
365     setContentChanging(true);
366     adjustToScroll(0);
367
368     var selectedFrame = getSelectedIframe().querySelector('iframe');
369     uber.invokeMethodOnWindow(selectedFrame.contentWindow, 'frameSelected');
370
371     if (historyOption != HISTORY_STATE_OPTION.NONE)
372       changePathTo({}, path, historyOption);
373
374     if (container.dataset.title)
375       document.title = container.dataset.title;
376     assert('favicon' in container.dataset);
377
378     var dataset = /** @type {{favicon: string}} */(container.dataset);
379     $('favicon').href = 'chrome://theme/' + dataset.favicon;
380     $('favicon2x').href = 'chrome://theme/' + dataset.favicon + '@2x';
381
382     updateNavigationControls();
383   }
384
385   function onNavigationControlsLoaded() {
386     updateNavigationControls();
387   }
388
389   /**
390    * Sends a message to uber-frame to update the appearance of the nav controls.
391    * It should be called whenever the selected iframe changes.
392    */
393   function updateNavigationControls() {
394     var iframe = getSelectedIframe();
395     uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
396                               'changeSelection', {pageId: iframe.id});
397   }
398
399   /**
400    * Forwarded scroll offset from a content frame's scroll handler.
401    * @param {number} scrollOffset The scroll offset from the content frame.
402    */
403   function adjustToScroll(scrollOffset) {
404     // NOTE: The scroll is reset to 0 and easing turned on every time a user
405     // switches frames. If we receive a non-zero value it has to have come from
406     // a real user scroll, so we disable easing when this happens.
407     if (scrollOffset != 0)
408       setContentChanging(false);
409
410     if (isRTL()) {
411       uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
412                                 'adjustToScroll',
413                                 scrollOffset);
414       var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset);
415       navFrame.style.width = navWidth + 'px';
416     } else {
417       navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)';
418     }
419   }
420
421   /**
422    * Forward scroll wheel events to subpages.
423    * @param {Object} params Relevant parameters of wheel event.
424    */
425   function forwardMouseWheel(params) {
426     var iframe = getSelectedIframe().querySelector('iframe');
427     uber.invokeMethodOnWindow(iframe.contentWindow, 'mouseWheel', params);
428   }
429
430   /**
431    * Make sure that iframe containers that are not selected are
432    * hidden, so that elements in those frames aren't part of the
433    * focus order. Containers that are unselected later get hidden
434    * when the transition ends. We also set the aria-hidden attribute
435    * because that hides the container from assistive technology
436    * immediately, rather than only after the transition ends.
437    */
438   function ensureNonSelectedFrameContainersAreHidden() {
439     var containers = document.querySelectorAll('.iframe-container');
440     for (var i = 0; i < containers.length; i++) {
441       var container = containers[i];
442       if (!container.classList.contains('selected')) {
443         container.hidden = true;
444         container.setAttribute('aria-hidden', 'true');
445       }
446       container.addEventListener('webkitTransitionEnd', function(event) {
447         if (!event.target.classList.contains('selected'))
448           event.target.hidden = true;
449       });
450     }
451   }
452
453   return {
454     onLoad: onLoad,
455     onPopHistoryState: onPopHistoryState
456   };
457 });
458
459 window.addEventListener('popstate', uber.onPopHistoryState);
460 document.addEventListener('DOMContentLoaded', uber.onLoad);