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('ntp', function() {
6  'use strict';
7
8  var APP_LAUNCH = {
9    // The histogram buckets (keep in sync with extension_constants.h).
10    NTP_APPS_MAXIMIZED: 0,
11    NTP_APPS_COLLAPSED: 1,
12    NTP_APPS_MENU: 2,
13    NTP_MOST_VISITED: 3,
14    NTP_RECENTLY_CLOSED: 4,
15    NTP_APP_RE_ENABLE: 16,
16    NTP_WEBSTORE_FOOTER: 18,
17    NTP_WEBSTORE_PLUS_ICON: 19,
18  };
19
20  // Histogram buckets for UMA tracking of where a DnD drop came from.
21  var DRAG_SOURCE = {
22    SAME_APPS_PANE: 0,
23    OTHER_APPS_PANE: 1,
24    MOST_VISITED_PANE: 2,
25    BOOKMARKS_PANE: 3,
26    OUTSIDE_NTP: 4
27  };
28  var DRAG_SOURCE_LIMIT = DRAG_SOURCE.OUTSIDE_NTP + 1;
29
30  /**
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_.
33   * @constructor
34   */
35  function AppContextMenu() {
36    this.__proto__ = AppContextMenu.prototype;
37    this.initialize();
38  }
39  cr.addSingletonGetter(AppContextMenu);
40
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');
46      this.menu = menu;
47
48      this.launch_ = this.appendMenuItem_();
49      this.launch_.addEventListener('activate', this.onLaunch_.bind(this));
50
51      menu.appendChild(cr.ui.MenuItem.createSeparator());
52      if (loadTimeData.getBoolean('enableStreamlinedHostedApps'))
53        this.launchRegularTab_ = this.appendMenuItem_('applaunchtypetab');
54      else
55        this.launchRegularTab_ = this.appendMenuItem_('applaunchtyperegular');
56      this.launchPinnedTab_ = this.appendMenuItem_('applaunchtypepinned');
57      if (!cr.isMac)
58        this.launchNewWindow_ = this.appendMenuItem_('applaunchtypewindow');
59      this.launchFullscreen_ = this.appendMenuItem_('applaunchtypefullscreen');
60
61      var self = this;
62      this.forAllLaunchTypes_(function(launchTypeButton, id) {
63        launchTypeButton.addEventListener('activate',
64            self.onLaunchTypeChanged_.bind(self));
65      });
66
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));
78
79      if (!cr.isChromeOS) {
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));
85      }
86
87      document.body.appendChild(menu);
88    },
89
90    /**
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.
94     */
95    appendMenuItem_: function(textId) {
96      var button = cr.doc.createElement('button');
97      this.menu.appendChild(button);
98      cr.ui.decorate(button, cr.ui.MenuItem);
99      if (textId)
100        button.textContent = loadTimeData.getString(textId);
101      return button;
102    },
103
104    /**
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.
109     */
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_];
116
117      for (var i = 0; i < launchTypes.length; ++i) {
118        if (!launchTypes[i])
119          continue;
120
121        f(launchTypes[i], i);
122      }
123    },
124
125    /**
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.
128     */
129    setupForApp: function(app) {
130      this.app_ = app;
131
132      this.launch_.textContent = app.appData.title;
133
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);
142      });
143
144      this.launchTypeMenuSeparator_.hidden = app.appData.packagedApp;
145
146      this.options_.disabled = !app.appData.optionsUrl || !app.appData.enabled;
147      this.details_.disabled = !app.appData.detailsUrl;
148      this.uninstall_.disabled = !app.appData.mayDisable;
149
150      if (cr.isMac) {
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.
154        this.createShortcutSeparator_.hidden = this.createShortcut_.hidden =
155            !app.appData.packagedApp;
156      }
157    },
158
159    /**
160     * Handlers for menu item activation.
161     * @param {Event} e The activation event.
162     * @private
163     */
164    onLaunch_: function(e) {
165      chrome.send('launchApp', [this.app_.appId, APP_LAUNCH.NTP_APPS_MENU]);
166    },
167    onLaunchTypeChanged_: function(e) {
168      var pressed = e.currentTarget;
169      var app = this.app_;
170      var targetLaunchType = pressed;
171      // Streamlined hosted apps can only toggle between open as window and open
172      // as tab.
173      if (loadTimeData.getBoolean('enableStreamlinedHostedApps')) {
174        targetLaunchType = this.launchRegularTab_.checked ?
175            this.launchNewWindow_ : this.launchRegularTab_;
176      }
177      this.forAllLaunchTypes_(function(launchTypeButton, id) {
178        if (launchTypeButton == targetLaunchType) {
179          chrome.send('setLaunchType', [app.appId, id]);
180          // Manually update the launch type. We will only get
181          // appsPrefChangeCallback calls after changes to other NTP instances.
182          app.appData.launch_type = id;
183        }
184      });
185    },
186    onShowOptions_: function(e) {
187      window.location = this.app_.appData.optionsUrl;
188    },
189    onShowDetails_: function(e) {
190      var url = this.app_.appData.detailsUrl;
191      url = appendParam(url, 'utm_source', 'chrome-ntp-launcher');
192      window.location = url;
193    },
194    onUninstall_: function(e) {
195      chrome.send('uninstallApp', [this.app_.appData.id]);
196    },
197    onCreateShortcut_: function(e) {
198      chrome.send('createAppShortcut', [this.app_.appData.id]);
199    },
200  };
201
202  /**
203   * Creates a new App object.
204   * @param {Object} appData The data object that describes the app.
205   * @constructor
206   * @extends {HTMLDivElement}
207   */
208  function App(appData) {
209    var el = cr.doc.createElement('div');
210    el.__proto__ = App.prototype;
211    el.initialize(appData);
212
213    return el;
214  }
215
216  App.prototype = {
217    __proto__: HTMLDivElement.prototype,
218
219    /**
220     * Initialize the app object.
221     * @param {Object} appData The data object that describes the app.
222     */
223    initialize: function(appData) {
224      this.appData = appData;
225      assert(this.appData_.id, 'Got an app without an ID');
226      this.id = this.appData_.id;
227      this.setAttribute('role', 'menuitem');
228
229      this.className = 'app focusable';
230
231      if (!this.appData_.icon_big_exists && this.appData_.icon_small_exists)
232        this.useSmallIcon_ = true;
233
234      this.appContents_ = this.useSmallIcon_ ?
235          $('app-small-icon-template').cloneNode(true) :
236          $('app-large-icon-template').cloneNode(true);
237      this.appContents_.id = '';
238      this.appendChild(this.appContents_);
239
240      this.appImgContainer_ = this.querySelector('.app-img-container');
241      this.appImg_ = this.appImgContainer_.querySelector('img');
242      this.setIcon();
243
244      if (this.useSmallIcon_) {
245        this.imgDiv_ = this.querySelector('.app-icon-div');
246        this.addLaunchClickTarget_(this.imgDiv_);
247        this.imgDiv_.title = this.appData_.full_name;
248        chrome.send('getAppIconDominantColor', [this.id]);
249      } else {
250        this.addLaunchClickTarget_(this.appImgContainer_);
251        this.appImgContainer_.title = this.appData_.full_name;
252      }
253
254      // The app's full name is shown in the tooltip, whereas the short name
255      // is used for the label.
256      var appSpan = this.appContents_.querySelector('.title');
257      appSpan.textContent = this.appData_.title;
258      appSpan.title = this.appData_.full_name;
259      this.addLaunchClickTarget_(appSpan);
260
261      this.addEventListener('keydown', cr.ui.contextMenuHandler);
262      this.addEventListener('keyup', cr.ui.contextMenuHandler);
263
264      // This hack is here so that appContents.contextMenu will be the same as
265      // this.contextMenu.
266      var self = this;
267      this.appContents_.__defineGetter__('contextMenu', function() {
268        return self.contextMenu;
269      });
270      this.appContents_.addEventListener('contextmenu',
271                                         cr.ui.contextMenuHandler);
272
273      this.addEventListener('mousedown', this.onMousedown_, true);
274      this.addEventListener('keydown', this.onKeydown_);
275      this.addEventListener('keyup', this.onKeyup_);
276    },
277
278    /**
279     * Sets the color of the favicon dominant color bar.
280     * @param {string} color The css-parsable value for the color.
281     */
282    set stripeColor(color) {
283      this.querySelector('.color-stripe').style.backgroundColor = color;
284    },
285
286    /**
287     * Removes the app tile from the page. Should be called after the app has
288     * been uninstalled.
289     */
290    remove: function(opt_animate) {
291      // Unset the ID immediately, because the app is already gone. But leave
292      // the tile on the page as it animates out.
293      this.id = '';
294      this.tile.doRemove(opt_animate);
295    },
296
297    /**
298     * Set the URL of the icon from |appData_|. This won't actually show the
299     * icon until loadIcon() is called (for performance reasons; we don't want
300     * to load icons until we have to).
301     */
302    setIcon: function() {
303      var src = this.useSmallIcon_ ? this.appData_.icon_small :
304                                     this.appData_.icon_big;
305      if (!this.appData_.enabled ||
306          (!this.appData_.offlineEnabled && !navigator.onLine)) {
307        src += '?grayscale=true';
308      }
309
310      this.appImgSrc_ = src;
311      this.classList.add('icon-loading');
312    },
313
314    /**
315     * Shows the icon for the app. That is, it causes chrome to load the app
316     * icon resource.
317     */
318    loadIcon: function() {
319      if (this.appImgSrc_) {
320        this.appImg_.src = this.appImgSrc_;
321        this.appImg_.classList.remove('invisible');
322        this.appImgSrc_ = null;
323      }
324
325      this.classList.remove('icon-loading');
326    },
327
328    /**
329     * Set the size and position of the app tile.
330     * @param {number} size The total size of |this|.
331     * @param {number} x The x-position.
332     * @param {number} y The y-position.
333     *     animate.
334     */
335    setBounds: function(size, x, y) {
336      var imgSize = size * APP_IMG_SIZE_FRACTION;
337      this.appImgContainer_.style.width = this.appImgContainer_.style.height =
338          toCssPx(this.useSmallIcon_ ? 16 : imgSize);
339      if (this.useSmallIcon_) {
340        // 3/4 is the ratio of 96px to 128px (the used height and full height
341        // of icons in apps).
342        var iconSize = imgSize * 3 / 4;
343        // The -2 is for the div border to improve the visual alignment for the
344        // icon div.
345        this.imgDiv_.style.width = this.imgDiv_.style.height =
346            toCssPx(iconSize - 2);
347        // Margins set to get the icon placement right and the text to line up.
348        this.imgDiv_.style.marginTop = this.imgDiv_.style.marginBottom =
349            toCssPx((imgSize - iconSize) / 2);
350      }
351
352      this.style.width = this.style.height = toCssPx(size);
353      this.style.left = toCssPx(x);
354      this.style.right = toCssPx(x);
355      this.style.top = toCssPx(y);
356    },
357
358    /**
359     * Invoked when an app is clicked.
360     * @param {Event} e The click event.
361     * @private
362     */
363    onClick_: function(e) {
364      var url = !this.appData_.is_webstore ? '' :
365          appendParam(this.appData_.url,
366                      'utm_source',
367                      'chrome-ntp-icon');
368
369      chrome.send('launchApp',
370                  [this.appId, APP_LAUNCH.NTP_APPS_MAXIMIZED, url,
371                   e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]);
372
373      // Don't allow the click to trigger a link or anything
374      e.preventDefault();
375    },
376
377    /**
378     * Invoked when the user presses a key while the app is focused.
379     * @param {Event} e The key event.
380     * @private
381     */
382    onKeydown_: function(e) {
383      if (e.keyIdentifier == 'Enter') {
384        chrome.send('launchApp',
385                    [this.appId, APP_LAUNCH.NTP_APPS_MAXIMIZED, '',
386                     0, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]);
387        e.preventDefault();
388        e.stopPropagation();
389      }
390      this.onKeyboardUsed_(e.keyCode);
391    },
392
393    /**
394     * Invoked when the user releases a key while the app is focused.
395     * @param {Event} e The key event.
396     * @private
397     */
398    onKeyup_: function(e) {
399      this.onKeyboardUsed_(e.keyCode);
400    },
401
402    /**
403     * Called when the keyboard has been used (key down or up). The .click-focus
404     * hack is removed if the user presses a key that can change focus.
405     * @param {number} keyCode The key code of the keyboard event.
406     * @private
407     */
408    onKeyboardUsed_: function(keyCode) {
409      switch (keyCode) {
410        case 9:  // Tab.
411        case 37:  // Left arrow.
412        case 38:  // Up arrow.
413        case 39:  // Right arrow.
414        case 40:  // Down arrow.
415          this.classList.remove('click-focus');
416      }
417    },
418
419    /**
420     * Adds a node to the list of targets that will launch the app. This list
421     * is also used in onMousedown to determine whether the app contents should
422     * be shown as active (if we don't do this, then clicking anywhere in
423     * appContents, even a part that is outside the ideally clickable region,
424     * will cause the app icon to look active).
425     * @param {HTMLElement} node The node that should be clickable.
426     */
427    addLaunchClickTarget_: function(node) {
428      node.classList.add('launch-click-target');
429      node.addEventListener('click', this.onClick_.bind(this));
430    },
431
432    /**
433     * Handler for mousedown on the App. Adds a class that allows us to
434     * not display as :active for right clicks (specifically, don't pulse on
435     * these occasions). Also, we don't pulse for clicks that aren't within the
436     * clickable regions.
437     * @param {Event} e The mousedown event.
438     */
439    onMousedown_: function(e) {
440      // If the current platform uses middle click to autoscroll and this
441      // mousedown isn't handled, onClick_() will never fire. crbug.com/142939
442      if (e.button == 1)
443        e.preventDefault();
444
445      if (e.button == 2 ||
446          !findAncestorByClass(e.target, 'launch-click-target')) {
447        this.appContents_.classList.add('suppress-active');
448      } else {
449        this.appContents_.classList.remove('suppress-active');
450      }
451
452      // This class is here so we don't show the focus state for apps that
453      // gain keyboard focus via mouse clicking.
454      this.classList.add('click-focus');
455    },
456
457    /**
458     * Change the appData and update the appearance of the app.
459     * @param {Object} appData The new data object that describes the app.
460     */
461    replaceAppData: function(appData) {
462      this.appData_ = appData;
463      this.setIcon();
464      this.loadIcon();
465    },
466
467    /**
468     * The data and preferences for this app.
469     * @type {Object}
470     */
471    set appData(data) {
472      this.appData_ = data;
473    },
474    get appData() {
475      return this.appData_;
476    },
477
478    get appId() {
479      return this.appData_.id;
480    },
481
482    /**
483     * Returns a pointer to the context menu for this app. All apps share the
484     * singleton AppContextMenu. This function is called by the
485     * ContextMenuHandler in response to the 'contextmenu' event.
486     * @type {cr.ui.Menu}
487     */
488    get contextMenu() {
489      var menu = AppContextMenu.getInstance();
490      menu.setupForApp(this);
491      return menu.menu;
492    },
493
494    /**
495     * Returns whether this element can be 'removed' from chrome (i.e. whether
496     * the user can drag it onto the trash and expect something to happen).
497     * @return {boolean} True if the app can be uninstalled.
498     */
499    canBeRemoved: function() {
500      return this.appData_.mayDisable;
501    },
502
503    /**
504     * Uninstalls the app after it's been dropped on the trash.
505     */
506    removeFromChrome: function() {
507      chrome.send('uninstallApp', [this.appData_.id, true]);
508      this.tile.tilePage.removeTile(this.tile, true);
509    },
510
511    /**
512     * Called when a drag is starting on the tile. Updates dataTransfer with
513     * data for this tile.
514     */
515    setDragData: function(dataTransfer) {
516      dataTransfer.setData('Text', this.appData_.title);
517      dataTransfer.setData('URL', this.appData_.url);
518    },
519  };
520
521  var TilePage = ntp.TilePage;
522
523  // The fraction of the app tile size that the icon uses.
524  var APP_IMG_SIZE_FRACTION = 4 / 5;
525
526  var appsPageGridValues = {
527    // The fewest tiles we will show in a row.
528    minColCount: 3,
529    // The most tiles we will show in a row.
530    maxColCount: 6,
531
532    // The smallest a tile can be.
533    minTileWidth: 64 / APP_IMG_SIZE_FRACTION,
534    // The biggest a tile can be.
535    maxTileWidth: 128 / APP_IMG_SIZE_FRACTION,
536
537    // The padding between tiles, as a fraction of the tile width.
538    tileSpacingFraction: 1 / 8,
539  };
540  TilePage.initGridValues(appsPageGridValues);
541
542  /**
543   * Creates a new AppsPage object.
544   * @constructor
545   * @extends {TilePage}
546   */
547  function AppsPage() {
548    var el = new TilePage(appsPageGridValues);
549    el.__proto__ = AppsPage.prototype;
550    el.initialize();
551
552    return el;
553  }
554
555  AppsPage.prototype = {
556    __proto__: TilePage.prototype,
557
558    initialize: function() {
559      this.classList.add('apps-page');
560
561      this.addEventListener('cardselected', this.onCardSelected_);
562
563      this.addEventListener('tilePage:tile_added', this.onTileAdded_);
564
565      this.content_.addEventListener('scroll', this.onScroll_.bind(this));
566    },
567
568    /**
569     * Highlight a newly installed app as it's added to the NTP.
570     * @param {Object} appData The data object that describes the app.
571     */
572    insertAndHighlightApp: function(appData) {
573      ntp.getCardSlider().selectCardByValue(this);
574      this.content_.scrollTop = this.content_.scrollHeight;
575      this.insertApp(appData, true);
576    },
577
578    /**
579     * Similar to appendApp, but it respects the app_launch_ordinal field of
580     * |appData|.
581     * @param {Object} appData The data that describes the app.
582     * @param {boolean} animate Whether to animate the insertion.
583     */
584    insertApp: function(appData, animate) {
585      var index = this.tileElements_.length;
586      for (var i = 0; i < this.tileElements_.length; i++) {
587        if (appData.app_launch_ordinal <
588            this.tileElements_[i].firstChild.appData.app_launch_ordinal) {
589          index = i;
590          break;
591        }
592      }
593
594      this.addTileAt(new App(appData), index, animate);
595    },
596
597    /**
598     * Handler for 'cardselected' event, fired when |this| is selected. The
599     * first time this is called, we load all the app icons.
600     * @private
601     */
602    onCardSelected_: function(e) {
603      var apps = this.querySelectorAll('.app.icon-loading');
604      for (var i = 0; i < apps.length; i++) {
605        apps[i].loadIcon();
606      }
607    },
608
609    /**
610     * Handler for tile additions to this page.
611     * @param {Event} e The tilePage:tile_added event.
612     */
613    onTileAdded_: function(e) {
614      assert(e.currentTarget == this);
615      assert(e.addedTile.firstChild instanceof App);
616      if (this.classList.contains('selected-card'))
617        e.addedTile.firstChild.loadIcon();
618    },
619
620    /**
621     * A handler for when the apps page is scrolled (then we need to reposition
622     * the bubbles.
623     * @private
624     */
625    onScroll_: function(e) {
626      if (!this.selected)
627        return;
628      for (var i = 0; i < this.tileElements_.length; i++) {
629        var app = this.tileElements_[i].firstChild;
630        assert(app instanceof App);
631      }
632    },
633
634    /** @override */
635    doDragOver: function(e) {
636      // Only animatedly re-arrange if the user is currently dragging an app.
637      var tile = ntp.getCurrentlyDraggingTile();
638      if (tile && tile.querySelector('.app')) {
639        TilePage.prototype.doDragOver.call(this, e);
640      } else {
641        e.preventDefault();
642        this.setDropEffect(e.dataTransfer);
643      }
644    },
645
646    /** @override */
647    shouldAcceptDrag: function(e) {
648      if (ntp.getCurrentlyDraggingTile())
649        return true;
650      if (!e.dataTransfer || !e.dataTransfer.types)
651        return false;
652      return Array.prototype.indexOf.call(e.dataTransfer.types,
653                                          'text/uri-list') != -1;
654    },
655
656    /** @override */
657    addDragData: function(dataTransfer, index) {
658      var sourceId = -1;
659      var currentlyDraggingTile = ntp.getCurrentlyDraggingTile();
660      if (currentlyDraggingTile) {
661        var tileContents = currentlyDraggingTile.firstChild;
662        if (tileContents.classList.contains('app')) {
663          var originalPage = currentlyDraggingTile.tilePage;
664          var samePageDrag = originalPage == this;
665          sourceId = samePageDrag ? DRAG_SOURCE.SAME_APPS_PANE :
666                                    DRAG_SOURCE.OTHER_APPS_PANE;
667          this.tileGrid_.insertBefore(currentlyDraggingTile,
668                                      this.tileElements_[index]);
669          this.tileMoved(currentlyDraggingTile);
670          if (!samePageDrag) {
671            originalPage.fireRemovedEvent(currentlyDraggingTile, index, true);
672            this.fireAddedEvent(currentlyDraggingTile, index, true);
673          }
674        } else if (currentlyDraggingTile.querySelector('.most-visited')) {
675          this.generateAppForLink(tileContents.data);
676          sourceId = DRAG_SOURCE.MOST_VISITED_PANE;
677        }
678      } else {
679        this.addOutsideData_(dataTransfer);
680        sourceId = DRAG_SOURCE.OUTSIDE_NTP;
681      }
682
683      assert(sourceId != -1);
684      chrome.send('metricsHandler:recordInHistogram',
685          ['NewTabPage.AppsPageDragSource', sourceId, DRAG_SOURCE_LIMIT]);
686    },
687
688    /**
689     * Adds drag data that has been dropped from a source that is not a tile.
690     * @param {Object} dataTransfer The data transfer object that holds drop
691     *     data.
692     * @private
693     */
694    addOutsideData_: function(dataTransfer) {
695      var url = dataTransfer.getData('url');
696      assert(url);
697
698      // If the dataTransfer has html data, use that html's text contents as the
699      // title of the new link.
700      var html = dataTransfer.getData('text/html');
701      var title;
702      if (html) {
703        // It's important that we don't attach this node to the document
704        // because it might contain scripts.
705        var node = this.ownerDocument.createElement('div');
706        node.innerHTML = html;
707        title = node.textContent;
708      }
709
710      // Make sure title is >=1 and <=45 characters for Chrome app limits.
711      if (!title)
712        title = url;
713      if (title.length > 45)
714        title = title.substring(0, 45);
715      var data = {url: url, title: title};
716
717      // Synthesize an app.
718      this.generateAppForLink(data);
719    },
720
721    /**
722     * Creates a new crx-less app manifest and installs it.
723     * @param {Object} data The data object describing the link. Must have |url|
724     *     and |title| members.
725     */
726    generateAppForLink: function(data) {
727      assert(data.url != undefined);
728      assert(data.title != undefined);
729      var pageIndex = ntp.getAppsPageIndex(this);
730      chrome.send('generateAppForLink', [data.url, data.title, pageIndex]);
731    },
732
733    /** @override */
734    tileMoved: function(draggedTile) {
735      if (!(draggedTile.firstChild instanceof App))
736        return;
737
738      var pageIndex = ntp.getAppsPageIndex(this);
739      chrome.send('setPageIndex', [draggedTile.firstChild.appId, pageIndex]);
740
741      var appIds = [];
742      for (var i = 0; i < this.tileElements_.length; i++) {
743        var tileContents = this.tileElements_[i].firstChild;
744        if (tileContents instanceof App)
745          appIds.push(tileContents.appId);
746      }
747
748      chrome.send('reorderApps', [draggedTile.firstChild.appId, appIds]);
749    },
750
751    /** @override */
752    setDropEffect: function(dataTransfer) {
753      var tile = ntp.getCurrentlyDraggingTile();
754      if (tile && tile.querySelector('.app'))
755        ntp.setCurrentDropEffect(dataTransfer, 'move');
756      else
757        ntp.setCurrentDropEffect(dataTransfer, 'copy');
758    },
759  };
760
761  /**
762   * Launches the specified app using the APP_LAUNCH_NTP_APP_RE_ENABLE
763   * histogram. This should only be invoked from the AppLauncherHandler.
764   * @param {string} appID The ID of the app.
765   */
766  function launchAppAfterEnable(appId) {
767    chrome.send('launchApp', [appId, APP_LAUNCH.NTP_APP_RE_ENABLE]);
768  }
769
770  return {
771    APP_LAUNCH: APP_LAUNCH,
772    AppsPage: AppsPage,
773    launchAppAfterEnable: launchAppAfterEnable,
774  };
775});
776