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('ntp', function() {
9 // The histogram buckets (keep in sync with extension_constants.h).
10 NTP_APPS_MAXIMIZED: 0,
11 NTP_APPS_COLLAPSED: 1,
14 NTP_RECENTLY_CLOSED: 4,
15 NTP_APP_RE_ENABLE: 16,
16 NTP_WEBSTORE_FOOTER: 18,
17 NTP_WEBSTORE_PLUS_ICON: 19,
20 // Histogram buckets for UMA tracking of where a DnD drop came from.
28 var DRAG_SOURCE_LIMIT = DRAG_SOURCE.OUTSIDE_NTP + 1;
31 * App context menu. The class is designed to be used as a singleton with
32 * the app that is currently showing a context menu stored in this.app_.
35 function AppContextMenu() {
36 this.__proto__ = AppContextMenu.prototype;
39 cr.addSingletonGetter(AppContextMenu);
41 AppContextMenu.prototype = {
42 initialize: function() {
43 var menu = new cr.ui.Menu;
44 cr.ui.decorate(menu, cr.ui.Menu);
45 menu.classList.add('app-context-menu');
48 this.launch_ = this.appendMenuItem_();
49 this.launch_.addEventListener('activate', this.onLaunch_.bind(this));
51 menu.appendChild(cr.ui.MenuItem.createSeparator());
52 if (loadTimeData.getBoolean('enableStreamlinedHostedApps'))
53 this.launchRegularTab_ = this.appendMenuItem_('applaunchtypetab');
55 this.launchRegularTab_ = this.appendMenuItem_('applaunchtyperegular');
56 this.launchPinnedTab_ = this.appendMenuItem_('applaunchtypepinned');
58 this.launchNewWindow_ = this.appendMenuItem_('applaunchtypewindow');
59 this.launchFullscreen_ = this.appendMenuItem_('applaunchtypefullscreen');
62 this.forAllLaunchTypes_(function(launchTypeButton, id) {
63 launchTypeButton.addEventListener('activate',
64 self.onLaunchTypeChanged_.bind(self));
67 this.launchTypeMenuSeparator_ = cr.ui.MenuItem.createSeparator();
68 menu.appendChild(this.launchTypeMenuSeparator_);
69 this.options_ = this.appendMenuItem_('appoptions');
70 this.details_ = this.appendMenuItem_('appdetails');
71 this.uninstall_ = this.appendMenuItem_('appuninstall');
72 this.options_.addEventListener('activate',
73 this.onShowOptions_.bind(this));
74 this.details_.addEventListener('activate',
75 this.onShowDetails_.bind(this));
76 this.uninstall_.addEventListener('activate',
77 this.onUninstall_.bind(this));
80 this.createShortcutSeparator_ =
81 menu.appendChild(cr.ui.MenuItem.createSeparator());
82 this.createShortcut_ = this.appendMenuItem_('appcreateshortcut');
83 this.createShortcut_.addEventListener(
84 'activate', this.onCreateShortcut_.bind(this));
87 document.body.appendChild(menu);
91 * Appends a menu item to |this.menu|.
92 * @param {?string} textId If non-null, the ID for the localized string
93 * that acts as the item's label.
95 appendMenuItem_: function(textId) {
96 var button = cr.doc.createElement('button');
97 this.menu.appendChild(button);
98 cr.ui.decorate(button, cr.ui.MenuItem);
100 button.textContent = loadTimeData.getString(textId);
105 * Iterates over all the launch type menu items.
106 * @param {function(cr.ui.MenuItem, number)} f The function to call for each
107 * menu item. The parameters to the function include the menu item and
108 * the associated launch ID.
110 forAllLaunchTypes_: function(f) {
111 // Order matters: index matches launchType id.
112 var launchTypes = [this.launchPinnedTab_,
113 this.launchRegularTab_,
114 this.launchFullscreen_,
115 this.launchNewWindow_];
117 for (var i = 0; i < launchTypes.length; ++i) {
121 f(launchTypes[i], i);
126 * Does all the necessary setup to show the menu for the given app.
127 * @param {App} app The App object that will be showing a context menu.
129 setupForApp: function(app) {
132 this.launch_.textContent = app.appData.title;
134 var launchTypeRegularTab = this.launchRegularTab_;
135 this.forAllLaunchTypes_(function(launchTypeButton, id) {
136 launchTypeButton.disabled = false;
137 launchTypeButton.checked = app.appData.launch_type == id;
138 // Streamlined hosted apps should only show the "Open as tab" button.
139 launchTypeButton.hidden = app.appData.packagedApp ||
140 (loadTimeData.getBoolean('enableStreamlinedHostedApps') &&
141 launchTypeButton != launchTypeRegularTab);
144 this.launchTypeMenuSeparator_.hidden = app.appData.packagedApp;
146 this.options_.disabled = !app.appData.optionsUrl || !app.appData.enabled;
147 this.details_.disabled = !app.appData.detailsUrl;
148 this.uninstall_.disabled = !app.appData.mayDisable;
151 // On Windows and Linux, these should always be visible. On ChromeOS,
152 // they are never created. On Mac, shortcuts can only be created for
153 // new-style packaged apps, so hide the menu item. Also check if
154 // loadTimeData explicitly disables this as the feature is not yet
155 // enabled by default on Mac.
156 this.createShortcutSeparator_.hidden = this.createShortcut_.hidden =
157 !app.appData.packagedApp ||
158 loadTimeData.getBoolean('disableCreateAppShortcut');
163 * Handlers for menu item activation.
164 * @param {Event} e The activation event.
167 onLaunch_: function(e) {
168 chrome.send('launchApp', [this.app_.appId, APP_LAUNCH.NTP_APPS_MENU]);
170 onLaunchTypeChanged_: function(e) {
171 var pressed = e.currentTarget;
173 var targetLaunchType = pressed;
174 // Streamlined hosted apps can only toggle between open as window and open
176 if (loadTimeData.getBoolean('enableStreamlinedHostedApps')) {
177 targetLaunchType = this.launchRegularTab_.checked ?
178 this.launchNewWindow_ : this.launchRegularTab_;
180 this.forAllLaunchTypes_(function(launchTypeButton, id) {
181 if (launchTypeButton == targetLaunchType) {
182 chrome.send('setLaunchType', [app.appId, id]);
183 // Manually update the launch type. We will only get
184 // appsPrefChangeCallback calls after changes to other NTP instances.
185 app.appData.launch_type = id;
189 onShowOptions_: function(e) {
190 window.location = this.app_.appData.optionsUrl;
192 onShowDetails_: function(e) {
193 var url = this.app_.appData.detailsUrl;
194 url = appendParam(url, 'utm_source', 'chrome-ntp-launcher');
195 window.location = url;
197 onUninstall_: function(e) {
198 chrome.send('uninstallApp', [this.app_.appData.id]);
200 onCreateShortcut_: function(e) {
201 chrome.send('createAppShortcut', [this.app_.appData.id]);
206 * Creates a new App object.
207 * @param {Object} appData The data object that describes the app.
209 * @extends {HTMLDivElement}
211 function App(appData) {
212 var el = cr.doc.createElement('div');
213 el.__proto__ = App.prototype;
214 el.initialize(appData);
220 __proto__: HTMLDivElement.prototype,
223 * Initialize the app object.
224 * @param {Object} appData The data object that describes the app.
226 initialize: function(appData) {
227 this.appData = appData;
228 assert(this.appData_.id, 'Got an app without an ID');
229 this.id = this.appData_.id;
230 this.setAttribute('role', 'menuitem');
232 this.className = 'app focusable';
234 if (!this.appData_.icon_big_exists && this.appData_.icon_small_exists)
235 this.useSmallIcon_ = true;
237 this.appContents_ = this.useSmallIcon_ ?
238 $('app-small-icon-template').cloneNode(true) :
239 $('app-large-icon-template').cloneNode(true);
240 this.appContents_.id = '';
241 this.appendChild(this.appContents_);
243 this.appImgContainer_ = this.querySelector('.app-img-container');
244 this.appImg_ = this.appImgContainer_.querySelector('img');
247 if (this.useSmallIcon_) {
248 this.imgDiv_ = this.querySelector('.app-icon-div');
249 this.addLaunchClickTarget_(this.imgDiv_);
250 this.imgDiv_.title = this.appData_.full_name;
251 chrome.send('getAppIconDominantColor', [this.id]);
253 this.addLaunchClickTarget_(this.appImgContainer_);
254 this.appImgContainer_.title = this.appData_.full_name;
257 // The app's full name is shown in the tooltip, whereas the short name
258 // is used for the label.
259 var appSpan = this.appContents_.querySelector('.title');
260 appSpan.textContent = this.appData_.title;
261 appSpan.title = this.appData_.full_name;
262 this.addLaunchClickTarget_(appSpan);
264 this.addEventListener('keydown', cr.ui.contextMenuHandler);
265 this.addEventListener('keyup', cr.ui.contextMenuHandler);
267 // This hack is here so that appContents.contextMenu will be the same as
270 this.appContents_.__defineGetter__('contextMenu', function() {
271 return self.contextMenu;
273 this.appContents_.addEventListener('contextmenu',
274 cr.ui.contextMenuHandler);
276 this.addEventListener('mousedown', this.onMousedown_, true);
277 this.addEventListener('keydown', this.onKeydown_);
278 this.addEventListener('keyup', this.onKeyup_);
282 * Sets the color of the favicon dominant color bar.
283 * @param {string} color The css-parsable value for the color.
285 set stripeColor(color) {
286 this.querySelector('.color-stripe').style.backgroundColor = color;
290 * Removes the app tile from the page. Should be called after the app has
293 remove: function(opt_animate) {
294 // Unset the ID immediately, because the app is already gone. But leave
295 // the tile on the page as it animates out.
297 this.tile.doRemove(opt_animate);
301 * Set the URL of the icon from |appData_|. This won't actually show the
302 * icon until loadIcon() is called (for performance reasons; we don't want
303 * to load icons until we have to).
305 setIcon: function() {
306 var src = this.useSmallIcon_ ? this.appData_.icon_small :
307 this.appData_.icon_big;
308 if (!this.appData_.enabled ||
309 (!this.appData_.offlineEnabled && !navigator.onLine)) {
310 src += '?grayscale=true';
313 this.appImgSrc_ = src;
314 this.classList.add('icon-loading');
318 * Shows the icon for the app. That is, it causes chrome to load the app
321 loadIcon: function() {
322 if (this.appImgSrc_) {
323 this.appImg_.src = this.appImgSrc_;
324 this.appImg_.classList.remove('invisible');
325 this.appImgSrc_ = null;
328 this.classList.remove('icon-loading');
332 * Set the size and position of the app tile.
333 * @param {number} size The total size of |this|.
334 * @param {number} x The x-position.
335 * @param {number} y The y-position.
338 setBounds: function(size, x, y) {
339 var imgSize = size * APP_IMG_SIZE_FRACTION;
340 this.appImgContainer_.style.width = this.appImgContainer_.style.height =
341 toCssPx(this.useSmallIcon_ ? 16 : imgSize);
342 if (this.useSmallIcon_) {
343 // 3/4 is the ratio of 96px to 128px (the used height and full height
344 // of icons in apps).
345 var iconSize = imgSize * 3 / 4;
346 // The -2 is for the div border to improve the visual alignment for the
348 this.imgDiv_.style.width = this.imgDiv_.style.height =
349 toCssPx(iconSize - 2);
350 // Margins set to get the icon placement right and the text to line up.
351 this.imgDiv_.style.marginTop = this.imgDiv_.style.marginBottom =
352 toCssPx((imgSize - iconSize) / 2);
355 this.style.width = this.style.height = toCssPx(size);
356 this.style.left = toCssPx(x);
357 this.style.right = toCssPx(x);
358 this.style.top = toCssPx(y);
362 * Invoked when an app is clicked.
363 * @param {Event} e The click event.
366 onClick_: function(e) {
367 var url = !this.appData_.is_webstore ? '' :
368 appendParam(this.appData_.url,
372 chrome.send('launchApp',
373 [this.appId, APP_LAUNCH.NTP_APPS_MAXIMIZED, url,
374 e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]);
376 // Don't allow the click to trigger a link or anything
381 * Invoked when the user presses a key while the app is focused.
382 * @param {Event} e The key event.
385 onKeydown_: function(e) {
386 if (e.keyIdentifier == 'Enter') {
387 chrome.send('launchApp',
388 [this.appId, APP_LAUNCH.NTP_APPS_MAXIMIZED, '',
389 0, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]);
393 this.onKeyboardUsed_(e.keyCode);
397 * Invoked when the user releases a key while the app is focused.
398 * @param {Event} e The key event.
401 onKeyup_: function(e) {
402 this.onKeyboardUsed_(e.keyCode);
406 * Called when the keyboard has been used (key down or up). The .click-focus
407 * hack is removed if the user presses a key that can change focus.
408 * @param {number} keyCode The key code of the keyboard event.
411 onKeyboardUsed_: function(keyCode) {
414 case 37: // Left arrow.
415 case 38: // Up arrow.
416 case 39: // Right arrow.
417 case 40: // Down arrow.
418 this.classList.remove('click-focus');
423 * Adds a node to the list of targets that will launch the app. This list
424 * is also used in onMousedown to determine whether the app contents should
425 * be shown as active (if we don't do this, then clicking anywhere in
426 * appContents, even a part that is outside the ideally clickable region,
427 * will cause the app icon to look active).
428 * @param {HTMLElement} node The node that should be clickable.
430 addLaunchClickTarget_: function(node) {
431 node.classList.add('launch-click-target');
432 node.addEventListener('click', this.onClick_.bind(this));
436 * Handler for mousedown on the App. Adds a class that allows us to
437 * not display as :active for right clicks (specifically, don't pulse on
438 * these occasions). Also, we don't pulse for clicks that aren't within the
440 * @param {Event} e The mousedown event.
442 onMousedown_: function(e) {
443 // If the current platform uses middle click to autoscroll and this
444 // mousedown isn't handled, onClick_() will never fire. crbug.com/142939
449 !findAncestorByClass(e.target, 'launch-click-target')) {
450 this.appContents_.classList.add('suppress-active');
452 this.appContents_.classList.remove('suppress-active');
455 // This class is here so we don't show the focus state for apps that
456 // gain keyboard focus via mouse clicking.
457 this.classList.add('click-focus');
461 * Change the appData and update the appearance of the app.
462 * @param {Object} appData The new data object that describes the app.
464 replaceAppData: function(appData) {
465 this.appData_ = appData;
471 * The data and preferences for this app.
475 this.appData_ = data;
478 return this.appData_;
482 return this.appData_.id;
486 * Returns a pointer to the context menu for this app. All apps share the
487 * singleton AppContextMenu. This function is called by the
488 * ContextMenuHandler in response to the 'contextmenu' event.
492 var menu = AppContextMenu.getInstance();
493 menu.setupForApp(this);
498 * Returns whether this element can be 'removed' from chrome (i.e. whether
499 * the user can drag it onto the trash and expect something to happen).
500 * @return {boolean} True if the app can be uninstalled.
502 canBeRemoved: function() {
503 return this.appData_.mayDisable;
507 * Uninstalls the app after it's been dropped on the trash.
509 removeFromChrome: function() {
510 chrome.send('uninstallApp', [this.appData_.id, true]);
511 this.tile.tilePage.removeTile(this.tile, true);
515 * Called when a drag is starting on the tile. Updates dataTransfer with
516 * data for this tile.
518 setDragData: function(dataTransfer) {
519 dataTransfer.setData('Text', this.appData_.title);
520 dataTransfer.setData('URL', this.appData_.url);
524 var TilePage = ntp.TilePage;
526 // The fraction of the app tile size that the icon uses.
527 var APP_IMG_SIZE_FRACTION = 4 / 5;
529 var appsPageGridValues = {
530 // The fewest tiles we will show in a row.
532 // The most tiles we will show in a row.
535 // The smallest a tile can be.
536 minTileWidth: 64 / APP_IMG_SIZE_FRACTION,
537 // The biggest a tile can be.
538 maxTileWidth: 128 / APP_IMG_SIZE_FRACTION,
540 // The padding between tiles, as a fraction of the tile width.
541 tileSpacingFraction: 1 / 8,
543 TilePage.initGridValues(appsPageGridValues);
546 * Creates a new AppsPage object.
548 * @extends {TilePage}
550 function AppsPage() {
551 var el = new TilePage(appsPageGridValues);
552 el.__proto__ = AppsPage.prototype;
558 AppsPage.prototype = {
559 __proto__: TilePage.prototype,
561 initialize: function() {
562 this.classList.add('apps-page');
564 this.addEventListener('cardselected', this.onCardSelected_);
566 this.addEventListener('tilePage:tile_added', this.onTileAdded_);
568 this.content_.addEventListener('scroll', this.onScroll_.bind(this));
572 * Highlight a newly installed app as it's added to the NTP.
573 * @param {Object} appData The data object that describes the app.
575 insertAndHighlightApp: function(appData) {
576 ntp.getCardSlider().selectCardByValue(this);
577 this.content_.scrollTop = this.content_.scrollHeight;
578 this.insertApp(appData, true);
582 * Similar to appendApp, but it respects the app_launch_ordinal field of
584 * @param {Object} appData The data that describes the app.
585 * @param {boolean} animate Whether to animate the insertion.
587 insertApp: function(appData, animate) {
588 var index = this.tileElements_.length;
589 for (var i = 0; i < this.tileElements_.length; i++) {
590 if (appData.app_launch_ordinal <
591 this.tileElements_[i].firstChild.appData.app_launch_ordinal) {
597 this.addTileAt(new App(appData), index, animate);
601 * Handler for 'cardselected' event, fired when |this| is selected. The
602 * first time this is called, we load all the app icons.
605 onCardSelected_: function(e) {
606 var apps = this.querySelectorAll('.app.icon-loading');
607 for (var i = 0; i < apps.length; i++) {
613 * Handler for tile additions to this page.
614 * @param {Event} e The tilePage:tile_added event.
616 onTileAdded_: function(e) {
617 assert(e.currentTarget == this);
618 assert(e.addedTile.firstChild instanceof App);
619 if (this.classList.contains('selected-card'))
620 e.addedTile.firstChild.loadIcon();
624 * A handler for when the apps page is scrolled (then we need to reposition
628 onScroll_: function(e) {
631 for (var i = 0; i < this.tileElements_.length; i++) {
632 var app = this.tileElements_[i].firstChild;
633 assert(app instanceof App);
638 doDragOver: function(e) {
639 // Only animatedly re-arrange if the user is currently dragging an app.
640 var tile = ntp.getCurrentlyDraggingTile();
641 if (tile && tile.querySelector('.app')) {
642 TilePage.prototype.doDragOver.call(this, e);
645 this.setDropEffect(e.dataTransfer);
650 shouldAcceptDrag: function(e) {
651 if (ntp.getCurrentlyDraggingTile())
653 if (!e.dataTransfer || !e.dataTransfer.types)
655 return Array.prototype.indexOf.call(e.dataTransfer.types,
656 'text/uri-list') != -1;
660 addDragData: function(dataTransfer, index) {
662 var currentlyDraggingTile = ntp.getCurrentlyDraggingTile();
663 if (currentlyDraggingTile) {
664 var tileContents = currentlyDraggingTile.firstChild;
665 if (tileContents.classList.contains('app')) {
666 var originalPage = currentlyDraggingTile.tilePage;
667 var samePageDrag = originalPage == this;
668 sourceId = samePageDrag ? DRAG_SOURCE.SAME_APPS_PANE :
669 DRAG_SOURCE.OTHER_APPS_PANE;
670 this.tileGrid_.insertBefore(currentlyDraggingTile,
671 this.tileElements_[index]);
672 this.tileMoved(currentlyDraggingTile);
674 originalPage.fireRemovedEvent(currentlyDraggingTile, index, true);
675 this.fireAddedEvent(currentlyDraggingTile, index, true);
677 } else if (currentlyDraggingTile.querySelector('.most-visited')) {
678 this.generateAppForLink(tileContents.data);
679 sourceId = DRAG_SOURCE.MOST_VISITED_PANE;
682 this.addOutsideData_(dataTransfer);
683 sourceId = DRAG_SOURCE.OUTSIDE_NTP;
686 assert(sourceId != -1);
687 chrome.send('metricsHandler:recordInHistogram',
688 ['NewTabPage.AppsPageDragSource', sourceId, DRAG_SOURCE_LIMIT]);
692 * Adds drag data that has been dropped from a source that is not a tile.
693 * @param {Object} dataTransfer The data transfer object that holds drop
697 addOutsideData_: function(dataTransfer) {
698 var url = dataTransfer.getData('url');
701 // If the dataTransfer has html data, use that html's text contents as the
702 // title of the new link.
703 var html = dataTransfer.getData('text/html');
706 // It's important that we don't attach this node to the document
707 // because it might contain scripts.
708 var node = this.ownerDocument.createElement('div');
709 node.innerHTML = html;
710 title = node.textContent;
713 // Make sure title is >=1 and <=45 characters for Chrome app limits.
716 if (title.length > 45)
717 title = title.substring(0, 45);
718 var data = {url: url, title: title};
720 // Synthesize an app.
721 this.generateAppForLink(data);
725 * Creates a new crx-less app manifest and installs it.
726 * @param {Object} data The data object describing the link. Must have |url|
727 * and |title| members.
729 generateAppForLink: function(data) {
730 assert(data.url != undefined);
731 assert(data.title != undefined);
732 var pageIndex = ntp.getAppsPageIndex(this);
733 chrome.send('generateAppForLink', [data.url, data.title, pageIndex]);
737 tileMoved: function(draggedTile) {
738 if (!(draggedTile.firstChild instanceof App))
741 var pageIndex = ntp.getAppsPageIndex(this);
742 chrome.send('setPageIndex', [draggedTile.firstChild.appId, pageIndex]);
745 for (var i = 0; i < this.tileElements_.length; i++) {
746 var tileContents = this.tileElements_[i].firstChild;
747 if (tileContents instanceof App)
748 appIds.push(tileContents.appId);
751 chrome.send('reorderApps', [draggedTile.firstChild.appId, appIds]);
755 setDropEffect: function(dataTransfer) {
756 var tile = ntp.getCurrentlyDraggingTile();
757 if (tile && tile.querySelector('.app'))
758 ntp.setCurrentDropEffect(dataTransfer, 'move');
760 ntp.setCurrentDropEffect(dataTransfer, 'copy');
765 * Launches the specified app using the APP_LAUNCH_NTP_APP_RE_ENABLE
766 * histogram. This should only be invoked from the AppLauncherHandler.
767 * @param {string} appID The ID of the app.
769 function launchAppAfterEnable(appId) {
770 chrome.send('launchApp', [appId, APP_LAUNCH.NTP_APP_RE_ENABLE]);
774 APP_LAUNCH: APP_LAUNCH,
776 launchAppAfterEnable: launchAppAfterEnable,