Only grant permissions to new extensions from sync if they have the expected version
[chromium-blink-merge.git] / chrome / browser / resources / uber / uber.js
blob98618065a270351dcf0ec5e8885ac6937ceee6a5
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.
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   };
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;
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 = {};
32   /**
33    * Handles page initialization.
34    */
35   function onLoad(e) {
36     navFrame = $('navigation');
37     navFrame.dataset.width = navFrame.offsetWidth;
39     // Select a page based on the page-URL.
40     var params = resolvePageInfo();
41     showPage(params.id, HISTORY_STATE_OPTION.NONE, params.path);
43     window.addEventListener('message', handleWindowMessage);
44     window.setTimeout(function() {
45       document.documentElement.classList.remove('loading');
46     }, 0);
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     }
57     ensureNonSelectedFrameContainersAreHidden();
58   }
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       }
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;
96     return params;
97   }
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();
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) !== getSelectedIframeContainer())
111       showPage(params.id, HISTORY_STATE_OPTION.NONE, params.path);
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   }
122   /**
123    * @return {Object} The default iframe container.
124    */
125   function getDefaultIframe() {
126     return $(loadTimeData.getString('helpHost'));
127   }
129   /**
130    * @return {Object} The currently selected iframe container.
131    */
132   function getSelectedIframeContainer() {
133     return document.querySelector('.iframe-container.selected');
134   }
136   /**
137    * @return {Object} The currently selected iframe's contentWindow.
138    */
139   function getSelectedIframeWindow() {
140     return getSelectedIframeContainer().querySelector('iframe').contentWindow;
141   }
143   /**
144    * Handles postMessage calls from the iframes of the contained pages.
145    *
146    * The pages request functionality from this object by passing an object of
147    * the following form:
148    *
149    *  { method : "methodToInvoke",
150    *    params : {...}
151    *  }
152    *
153    * |method| is required, while |params| is optional. Extra parameters required
154    * by a method must be specified by that method's documentation.
155    *
156    * @param {Event} e The posted object.
157    */
158   function handleWindowMessage(e) {
159     e = /** @type{!MessageEvent<!{method: string, params: *}>} */(e);
160     if (e.data.method === 'beginInterceptingEvents') {
161       backgroundNavigation();
162     } else if (e.data.method === 'stopInterceptingEvents') {
163       foregroundNavigation();
164     } else if (e.data.method === 'ready') {
165       pageReady(e.origin);
166     } else if (e.data.method === 'updateHistory') {
167       updateHistory(e.origin, e.data.params.state, e.data.params.path,
168                     e.data.params.replace);
169     } else if (e.data.method === 'setTitle') {
170       setTitle(e.origin, e.data.params.title);
171     } else if (e.data.method === 'showPage') {
172       showPage(e.data.params.pageId,
173                HISTORY_STATE_OPTION.PUSH,
174                e.data.params.path);
175     } else if (e.data.method === 'navigationControlsLoaded') {
176       onNavigationControlsLoaded();
177     } else if (e.data.method === 'adjustToScroll') {
178       adjustToScroll(/** @type {number} */(e.data.params));
179     } else if (e.data.method === 'mouseWheel') {
180       forwardMouseWheel(/** @type {Object} */(e.data.params));
181     } else if (e.data.method === 'mouseDown') {
182       forwardMouseDown();
183     } else {
184       console.error('Received unexpected message', e.data);
185     }
186   }
188   /**
189    * Sends the navigation iframe to the background.
190    */
191   function backgroundNavigation() {
192     navFrame.classList.add('background');
193     navFrame.firstChild.tabIndex = -1;
194     navFrame.firstChild.setAttribute('aria-hidden', true);
195   }
197   /**
198    * Retrieves the navigation iframe from the background.
199    */
200   function foregroundNavigation() {
201     navFrame.classList.remove('background');
202     navFrame.firstChild.tabIndex = 0;
203     navFrame.firstChild.removeAttribute('aria-hidden');
204   }
206   /**
207    * Enables or disables animated transitions when changing content while
208    * horizontally scrolled.
209    * @param {boolean} enabled True if enabled, else false to disable.
210    */
211   function setContentChanging(enabled) {
212     navFrame.classList[enabled ? 'add' : 'remove']('changing-content');
214     if (isRTL()) {
215       uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
216                                 'setContentChanging', enabled);
217     }
218   }
220   /**
221    * Get an iframe based on the origin of a received post message.
222    * @param {string} origin The origin of a post message.
223    * @return {!Element} The frame associated to |origin| or null.
224    */
225   function getIframeFromOrigin(origin) {
226     assert(origin.substr(-1) != '/', 'invalid origin given');
227     var query = '.iframe-container > iframe[src^="' + origin + '/"]';
228     var element = document.querySelector(query);
229     assert(element);
230     return /** @type {!Element} */(element);
231   }
233   /**
234    * Changes the path past the page title (i.e. chrome://chrome/settings/(.*)).
235    * @param {Object} state The page's state object for the navigation.
236    * @param {string} path The new /path/ to be set after the page name.
237    * @param {number} historyOption The type of history modification to make.
238    */
239   function changePathTo(state, path, historyOption) {
240     assert(!path || path.substr(-1) != '/', 'invalid path given');
242     var histFunc;
243     if (historyOption == HISTORY_STATE_OPTION.PUSH)
244       histFunc = window.history.pushState;
245     else if (historyOption == HISTORY_STATE_OPTION.REPLACE)
246       histFunc = window.history.replaceState;
248     assert(histFunc, 'invalid historyOption given ' + historyOption);
250     var pageId = getSelectedIframeContainer().id;
251     var args = [state, '', '/' + pageId + '/' + (path || '')];
252     histFunc.apply(window.history, args);
253   }
255   /**
256    * Adds or replaces the current history entry based on a navigation from the
257    * source iframe.
258    * @param {string} origin The origin of the source iframe.
259    * @param {Object} state The source iframe's state object.
260    * @param {string} path The new "path" (e.g. "/createProfile").
261    * @param {boolean} replace Whether to replace the current history entry.
262    */
263   function updateHistory(origin, state, path, replace) {
264     assert(!path || path[0] != '/', 'invalid path sent from ' + origin);
265     var historyOption =
266         replace ? HISTORY_STATE_OPTION.REPLACE : HISTORY_STATE_OPTION.PUSH;
267     // Only update the currently displayed path if this is the visible frame.
268     var container = getIframeFromOrigin(origin).parentNode;
269     if (container == getSelectedIframeContainer())
270       changePathTo(state, path, historyOption);
271   }
273   /**
274    * Sets the title of the page.
275    * @param {string} origin The origin of the source iframe.
276    * @param {string} title The title of the page.
277    */
278   function setTitle(origin, title) {
279     // Cache the title for the client iframe, i.e., the iframe setting the
280     // title. querySelector returns the actual iframe element, so use parentNode
281     // to get back to the container.
282     var container = getIframeFromOrigin(origin).parentNode;
283     container.dataset.title = title;
285     // Only update the currently displayed title if this is the visible frame.
286     if (container == getSelectedIframeContainer())
287       document.title = title;
288   }
290   /**
291    * Invokes a method on a subpage. If the subpage has not signaled readiness,
292    * queue the message for when it does.
293    * @param {string} pageId Should match an id of one of the iframe containers.
294    * @param {string} method The name of the method to invoke.
295    * @param {Object=} opt_params Optional property page of parameters to pass to
296    *     the invoked method.
297    */
298   function invokeMethodOnPage(pageId, method, opt_params) {
299     var frame = $(pageId).querySelector('iframe');
300     if (!frame || !frame.dataset.ready) {
301       queuedInvokes[pageId] = (queuedInvokes[pageId] || []);
302       queuedInvokes[pageId].push([method, opt_params]);
303     } else {
304       uber.invokeMethodOnWindow(frame.contentWindow, method, opt_params);
305     }
306   }
308   /**
309    * Called in response to a page declaring readiness. Calls any deferred method
310    * invocations from invokeMethodOnPage.
311    * @param {string} origin The origin of the source iframe.
312    */
313   function pageReady(origin) {
314     var frame = getIframeFromOrigin(origin);
315     var container = frame.parentNode;
316     frame.dataset.ready = true;
317     var queue = queuedInvokes[container.id] || [];
318     queuedInvokes[container.id] = undefined;
319     for (var i = 0; i < queue.length; i++) {
320       uber.invokeMethodOnWindow(frame.contentWindow, queue[i][0], queue[i][1]);
321     }
322   }
324   /**
325    * Selects and navigates a subpage. This is called from uber-frame.
326    * @param {string} pageId Should match an id of one of the iframe containers.
327    * @param {number} historyOption Indicates whether we should push or replace
328    *     browser history.
329    * @param {string} path A sub-page path.
330    */
331   function showPage(pageId, historyOption, path) {
332     var container = $(pageId);
334     // Lazy load of iframe contents.
335     var sourceUrl = container.dataset.url + (path || '');
336     var frame = container.querySelector('iframe');
337     if (!frame) {
338       frame = container.ownerDocument.createElement('iframe');
339       frame.name = pageId;
340       frame.setAttribute('role', 'presentation');
341       container.appendChild(frame);
342       frame.src = sourceUrl;
343     } else {
344       // There's no particularly good way to know what the current URL of the
345       // content frame is as we don't have access to its contentWindow's
346       // location, so just replace every time until necessary to do otherwise.
347       frame.contentWindow.location.replace(sourceUrl);
348       frame.dataset.ready = false;
349     }
351     // If the last selected container is already showing, ignore the rest.
352     var lastSelected = document.querySelector('.iframe-container.selected');
353     if (lastSelected === container)
354       return;
356     if (lastSelected) {
357       lastSelected.classList.remove('selected');
358       // Setting aria-hidden hides the container from assistive technology
359       // immediately. The 'hidden' attribute is set after the transition
360       // finishes - that ensures it's not possible to accidentally focus
361       // an element in an unselected container.
362       lastSelected.setAttribute('aria-hidden', 'true');
363     }
365     // Containers that aren't selected have to be hidden so that their
366     // content isn't focusable.
367     container.hidden = false;
368     container.setAttribute('aria-hidden', 'false');
370     // Trigger a layout after making it visible and before setting
371     // the class to 'selected', so that it animates in.
372     /** @suppress {uselessCode} */
373     container.offsetTop;
374     container.classList.add('selected');
376     setContentChanging(true);
377     adjustToScroll(0);
379     var selectedWindow = getSelectedIframeWindow();
380     uber.invokeMethodOnWindow(selectedWindow, 'frameSelected');
381     selectedWindow.focus();
383     if (historyOption != HISTORY_STATE_OPTION.NONE)
384       changePathTo({}, path, historyOption);
386     if (container.dataset.title)
387       document.title = container.dataset.title;
388     assert('favicon' in container.dataset);
390     var dataset = /** @type {{favicon: string}} */(container.dataset);
391     $('favicon').href = 'chrome://theme/' + dataset.favicon;
392     $('favicon2x').href = 'chrome://theme/' + dataset.favicon + '@2x';
394     updateNavigationControls();
395   }
397   function onNavigationControlsLoaded() {
398     updateNavigationControls();
399   }
401   /**
402    * Sends a message to uber-frame to update the appearance of the nav controls.
403    * It should be called whenever the selected iframe changes.
404    */
405   function updateNavigationControls() {
406     var container = getSelectedIframeContainer();
407     uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
408                               'changeSelection', {pageId: container.id});
409   }
411   /**
412    * Forwarded scroll offset from a content frame's scroll handler.
413    * @param {number} scrollOffset The scroll offset from the content frame.
414    */
415   function adjustToScroll(scrollOffset) {
416     // NOTE: The scroll is reset to 0 and easing turned on every time a user
417     // switches frames. If we receive a non-zero value it has to have come from
418     // a real user scroll, so we disable easing when this happens.
419     if (scrollOffset != 0)
420       setContentChanging(false);
422     if (isRTL()) {
423       uber.invokeMethodOnWindow(navFrame.firstChild.contentWindow,
424                                 'adjustToScroll',
425                                 scrollOffset);
426       var navWidth = Math.max(0, +navFrame.dataset.width + scrollOffset);
427       navFrame.style.width = navWidth + 'px';
428     } else {
429       navFrame.style.webkitTransform = 'translateX(' + -scrollOffset + 'px)';
430     }
431   }
433   /**
434    * Forward scroll wheel events to subpages.
435    * @param {Object} params Relevant parameters of wheel event.
436    */
437   function forwardMouseWheel(params) {
438     uber.invokeMethodOnWindow(getSelectedIframeWindow(), 'mouseWheel', params);
439   }
441   /** Forward mouse down events to subpages. */
442   function forwardMouseDown() {
443     uber.invokeMethodOnWindow(getSelectedIframeWindow(), 'mouseDown');
444   }
446   /**
447    * Make sure that iframe containers that are not selected are
448    * hidden, so that elements in those frames aren't part of the
449    * focus order. Containers that are unselected later get hidden
450    * when the transition ends. We also set the aria-hidden attribute
451    * because that hides the container from assistive technology
452    * immediately, rather than only after the transition ends.
453    */
454   function ensureNonSelectedFrameContainersAreHidden() {
455     var containers = document.querySelectorAll('.iframe-container');
456     for (var i = 0; i < containers.length; i++) {
457       var container = containers[i];
458       if (!container.classList.contains('selected')) {
459         container.hidden = true;
460         container.setAttribute('aria-hidden', 'true');
461       }
462       container.addEventListener('webkitTransitionEnd', function(event) {
463         if (!event.target.classList.contains('selected'))
464           event.target.hidden = true;
465       });
466     }
467   }
469   return {
470     onLoad: onLoad,
471     onPopHistoryState: onPopHistoryState
472   };
475 window.addEventListener('popstate', uber.onPopHistoryState);
476 document.addEventListener('DOMContentLoaded', uber.onLoad);