// Copyright (c) 2011 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. var MAX_APPS_PER_ROW = []; MAX_APPS_PER_ROW[LayoutMode.SMALL] = 4; MAX_APPS_PER_ROW[LayoutMode.NORMAL] = 6; function getAppsCallback(data) { logEvent('received apps'); // In the case of prefchange-triggered updates, we don't receive this flag. // Just leave it set as it was before in that case. if ('showPromo' in data) apps.showPromo = data.showPromo; var appsSection = $('apps'); var appsSectionContent = $('apps-content'); var appsMiniview = appsSection.getElementsByClassName('miniview')[0]; var appsPromo = $('apps-promo'); var appsPromoLink = $('apps-promo-link'); var appsPromoPing = APP_LAUNCH_URL.PING_WEBSTORE + '+' + apps.showPromo; var webStoreEntry, webStoreMiniEntry; // Hide menu options that are not supported on the OS or windowing system. // The "Launch as Window" menu option. $('apps-launch-type-window-menu-item').hidden = data.disableAppWindowLaunch; // The "Create App Shortcut" menu option. $('apps-create-shortcut-command-menu-item').hidden = $('apps-create-shortcut-command-separator').hidden = data.disableCreateAppShortcut; // Hide the context menu, if there is any open. cr.ui.contextMenuHandler.hideMenu(); appsMiniview.textContent = ''; appsSectionContent.textContent = ''; data.apps.sort(function(a,b) { return a.app_launch_index - b.app_launch_index; }); // Determines if the web store link should be detached and place in the // top right of the screen. apps.detachWebstoreEntry = !apps.showPromo && data.apps.length >= MAX_APPS_PER_ROW[layoutMode]; markNewApps(data.apps); apps.data = data.apps; clearClosedMenu(apps.menu); // We wait for the app icons to load before displaying them, but never wait // longer than 200ms. apps.loadedImages = 0; apps.imageTimer = setTimeout(apps.showImages.bind(apps), 200); data.apps.forEach(function(app) { appsSectionContent.appendChild(apps.createElement(app)); }); if (data.showPromo) { // Add the promo content... $('apps-promo-heading').textContent = data.promoHeader; appsPromoLink.href = data.promoLink; appsPromoLink.textContent = data.promoButton; appsPromoLink.ping = appsPromoPing; $('apps-promo-hide').textContent = data.promoExpire; // ... then display the promo. document.documentElement.classList.add('apps-promo-visible'); } else { document.documentElement.classList.remove('apps-promo-visible'); } // Only show the web store entry if there are apps installed, since the promo // is sufficient otherwise. if (data.apps.length > 0) { webStoreEntry = apps.createWebStoreElement(); webStoreEntry.querySelector('a').ping = appsPromoPing; appsSectionContent.appendChild(webStoreEntry); if (apps.detachWebstoreEntry) { webStoreEntry.classList.add('loner'); } else { webStoreEntry.classList.remove('loner'); apps.data.push('web-store-entry'); } } data.apps.slice(0, MAX_MINIVIEW_ITEMS).forEach(function(app) { appsMiniview.appendChild(apps.createMiniviewElement(app)); addClosedMenuEntryWithLink(apps.menu, apps.createClosedMenuElement(app)); }); if (data.apps.length < MAX_MINIVIEW_ITEMS) { webStoreMiniEntry = apps.createWebStoreMiniElement(); webStoreMiniEntry.querySelector('a').ping = appsPromoPing; appsMiniview.appendChild(webStoreMiniEntry); addClosedMenuEntryWithLink(apps.menu, apps.createWebStoreClosedMenuElement()); } if (!data.showLauncher) hideSection(Section.APPS); else appsSection.classList.remove('disabled'); addClosedMenuFooter(apps.menu, 'apps', MENU_APPS, Section.APPS); apps.loaded = true; if (appsPromoLink) appsPromoLink.ping = appsPromoPing; maybeDoneLoading(); // Disable the animations when the app launcher is being (re)initailized. apps.layout({disableAnimations:true}); if (isDoneLoading()) { updateMiniviewClipping(appsMiniview); layoutSections(); } } function markNewApps(data) { var oldData = apps.data; data.forEach(function(app) { if (hashParams['app-id'] == app['id']) { delete hashParams['app-id']; app.isNew = true; } else if (oldData && !oldData.some(function(id) { return id == app.id; })) { app.isNew = true; } else { app.isNew = false; } }); } function appsPrefChangeCallback(data) { // Currently the only pref that is watched is the launch type. data.apps.forEach(function(app) { var appLink = document.querySelector('.app a[app-id=' + app['id'] + ']'); if (appLink) appLink.setAttribute('launch-type', app['launch_type']); }); } // Launches the specified app using the APP_LAUNCH_NTP_APP_RE_ENABLE histogram. // This should only be invoked from the AppLauncherHandler. function launchAppAfterEnable(appId) { chrome.send('launchApp', [appId, APP_LAUNCH.NTP_APP_RE_ENABLE]); } var apps = (function() { function createElement(app) { var div = document.createElement('div'); div.className = 'app'; var a = div.appendChild(document.createElement('a')); a.setAttribute('app-id', app['id']); a.setAttribute('launch-type', app['launch_type']); a.draggable = false; a.xtitle = a.textContent = app['name']; a.href = app['launch_url']; return div; } /** * Launches an application. * @param {string} appId Application to launch. * @param {MouseEvent} opt_mouseEvent Mouse event from the click that * triggered the launch, used to detect modifier keys that change * the tab's disposition. */ function launchApp(appId, opt_mouseEvent) { var args = [appId, getAppLaunchType()]; if (opt_mouseEvent) { // Launch came from a click - add details of the click // Otherwise it came from a 'command' event from elsewhere in the UI. args.push(opt_mouseEvent.altKey, opt_mouseEvent.ctrlKey, opt_mouseEvent.metaKey, opt_mouseEvent.shiftKey, opt_mouseEvent.button); } chrome.send('launchApp', args); } function isAppSectionMaximized() { return getAppLaunchType() == APP_LAUNCH.NTP_APPS_MAXIMIZED && !$('apps').classList.contains('disabled'); } function isAppsMenu(node) { return node.id == 'apps-menu'; } function getAppLaunchType() { // We determine if the apps section is maximized, collapsed or in menu mode // based on the class of the apps section. if ($('apps').classList.contains('menu')) return APP_LAUNCH.NTP_APPS_MENU; else if ($('apps').classList.contains('collapsed')) return APP_LAUNCH.NTP_APPS_COLLAPSED; else return APP_LAUNCH.NTP_APPS_MAXIMIZED; } /** * @this {!HTMLAnchorElement} */ function handleClick(e) { var appId = e.currentTarget.getAttribute('app-id'); if (!appDragAndDrop.isDragging()) launchApp(appId, e); return false; } // Keep in sync with LaunchType in extension_prefs.h var LaunchType = { LAUNCH_PINNED: 0, LAUNCH_REGULAR: 1, LAUNCH_FULLSCREEN: 2, LAUNCH_WINDOW: 3 }; // Keep in sync with LaunchContainer in extension_constants.h var LaunchContainer = { LAUNCH_WINDOW: 0, LAUNCH_PANEL: 1, LAUNCH_TAB: 2 }; var currentApp; var promoHasBeenSeen = false; function addContextMenu(el, app) { el.addEventListener('contextmenu', cr.ui.contextMenuHandler); el.addEventListener('keydown', cr.ui.contextMenuHandler); el.addEventListener('keyup', cr.ui.contextMenuHandler); Object.defineProperty(el, 'contextMenu', { get: function() { currentApp = app; $('apps-launch-command').label = app['name']; $('apps-options-command').canExecuteChange(); var launchTypeEl; if (el.getAttribute('app-id') === app['id']) { launchTypeEl = el; } else { appLinkSel = 'a[app-id=' + app['id'] + ']'; launchTypeEl = el.querySelector(appLinkSel); } var launchType = launchTypeEl.getAttribute('launch-type'); var launchContainer = app['launch_container']; var isPanel = launchContainer == LaunchContainer.LAUNCH_PANEL; // Update the commands related to the launch type. var launchTypeIds = ['apps-launch-type-pinned', 'apps-launch-type-regular', 'apps-launch-type-fullscreen', 'apps-launch-type-window']; launchTypeIds.forEach(function(id) { var command = $(id); command.disabled = isPanel; command.checked = !isPanel && launchType == command.getAttribute('launch-type'); }); return $('app-context-menu'); } }); } document.addEventListener('command', function(e) { if (!currentApp) return; var commandId = e.command.id; switch (commandId) { case 'apps-options-command': window.location = currentApp['options_url']; break; case 'apps-launch-command': launchApp(currentApp['id']); break; case 'apps-uninstall-command': chrome.send('uninstallApp', [currentApp['id']]); break; case 'apps-create-shortcut-command': chrome.send('createAppShortcut', [currentApp['id']]); break; case 'apps-launch-type-pinned': case 'apps-launch-type-regular': case 'apps-launch-type-fullscreen': case 'apps-launch-type-window': chrome.send('setLaunchType', [currentApp['id'], Number(e.command.getAttribute('launch-type'))]); break; } }); document.addEventListener('canExecute', function(e) { switch (e.command.id) { case 'apps-options-command': e.canExecute = currentApp && currentApp['options_url']; break; case 'apps-launch-command': e.canExecute = true; break; case 'apps-uninstall-command': e.canExecute = !currentApp['can_uninstall']; break; } }); // Moves the element at position |from| in array |arr| to position |to|. function arrayMove(arr, from, to) { var element = arr.splice(from, 1); arr.splice(to, 0, element[0]); } // The autoscroll rate during drag and drop, in px per second. var APP_AUTOSCROLL_RATE = 400; return { loaded: false, menu: $('apps-menu'), showPromo: false, detachWebstoreEntry: false, scrollMouseXY_: null, scrollListener_: null, // The list of app ids, in order, of each app in the launcher. data_: null, get data() { return this.data_; }, set data(data) { this.data_ = data.map(function(app) { return app.id; }); this.invalidate_(); }, dirty_: true, invalidate_: function() { this.dirty_ = true; }, visible_: true, get visible() { return this.visible_; }, set visible(visible) { this.visible_ = visible; this.invalidate_(); }, maybePingPromoSeen_: function() { if (promoHasBeenSeen || !this.showPromo || !isAppSectionMaximized()) return; promoHasBeenSeen = true; chrome.send('promoSeen', []); }, // DragAndDropDelegate dragContainer: $('apps-content'), transitionsDuration: 200, get dragItem() { return this.dragItem_; }, set dragItem(dragItem) { if (this.dragItem_ != dragItem) { this.dragItem_ = dragItem; this.invalidate_(); } }, // The dimensions of each item in the app launcher. dimensions_: null, get dimensions() { if (this.dimensions_) return this.dimensions_; var width = 124; var height = 136; var marginWidth = 6; var marginHeight = 10; var borderWidth = 0; var borderHeight = 0; this.dimensions_ = { width: width + marginWidth + borderWidth, height: height + marginHeight + borderHeight }; return this.dimensions_; }, // Gets the item under the mouse event |e|. Returns null if there is no // item or if the item is not draggable. getItem: function(e) { var item = findAncestorByClass(e.target, 'app'); // You can't drag the web store launcher. if (item && item.classList.contains('web-store-entry')) return null; return item; }, // Returns true if |coordinates| point to a valid drop location. The // coordinates are relative to the drag container and the object should // have the 'x' and 'y' properties set. canDropOn: function(coordinates) { var cols = MAX_APPS_PER_ROW[layoutMode]; var rows = Math.ceil(this.data.length / cols); var bottom = rows * this.dimensions.height; var right = cols * this.dimensions.width; if (coordinates.x >= right || coordinates.x < 0 || coordinates.y >= bottom || coordinates.y < 0) return false; var position = this.getIndexAt_(coordinates); var appCount = this.data.length; if (!this.detachWebstoreEntry) appCount--; return position >= 0 && position < appCount; }, setDragPlaceholder: function(coordinates) { var position = this.getIndexAt_(coordinates); var appId = this.dragItem.querySelector('a').getAttribute('app-id'); var current = this.data.indexOf(appId); if (current == position || current < 0) return; arrayMove(this.data, current, position); this.invalidate_(); this.layout(); }, getIndexAt_: function(coordinates) { var w = this.dimensions.width; var h = this.dimensions.height; var appsPerRow = MAX_APPS_PER_ROW[layoutMode]; var row = Math.floor(coordinates.y / h); var col = Math.floor(coordinates.x / w); var index = appsPerRow * row + col; var appCount = this.data.length; var rows = Math.ceil(appCount / appsPerRow); // Rather than making the free space on the last row invalid, we // map it to the last valid position. if (index >= appCount && index < appsPerRow * rows) return appCount-1; return index; }, scrollPage: function(xy) { var rect = this.dragContainer.getBoundingClientRect(); // Here, we calculate the visible boundaries of the app launcher, which // are then used to determine when we should auto-scroll. var top = $('apps').getBoundingClientRect().bottom; var bottomFudge = 15; // Fudge factor due to a gradient mask. var bottom = top + maxiviewVisibleHeight - bottomFudge; var left = rect.left + window.scrollX; var right = Math.min(window.innerWidth, rect.left + rect.width); var dy = Math.min(0, xy.y - top) + Math.max(0, xy.y - bottom); var dx = Math.min(0, xy.x - left) + Math.max(0, xy.x - right); if (dx == 0 && dy == 0) { this.stopScroll_(); return; } // If we scroll the page directly from this method, it may be choppy and // inconsistent. Instead, we loop using animation frames, and scroll at a // speed that's independent of how many times this method is called. this.scrollMouseXY_ = {dx: dx, dy: dy}; if (!this.scrollListener_) { this.scrollListener_ = this.scrollImpl_.bind(this); this.scrollStep_(); } }, scrollStep_: function() { this.scrollStart_ = Date.now(); window.webkitRequestAnimationFrame(this.scrollListener_); }, scrollImpl_: function(time) { if (!appDragAndDrop.isDragging()) { this.stopScroll_(); return; } if (!this.scrollMouseXY_) return; var step = time - this.scrollStart_; window.scrollBy( this.calcScroll_(this.scrollMouseXY_.dx, step), this.calcScroll_(this.scrollMouseXY_.dy, step)); this.scrollStep_(); }, calcScroll_: function(delta, step) { if (delta == 0) return 0; // Increase the multiplier for every 50px the mouse is beyond the edge. var sign = delta > 0 ? 1 : -1; var scalar = APP_AUTOSCROLL_RATE * step / 1000; var multiplier = Math.floor(Math.abs(delta) / 50) + 1; return sign * scalar * multiplier; }, stopScroll_: function() { this.scrollListener_ = null; this.scrollMouseXY_ = null; }, saveDrag: function(draggedItem) { this.invalidate_(); this.layout(); var draggedAppId = draggedItem.querySelector('a').getAttribute('app-id'); var appIds = this.data.filter(function(id) { return id != 'web-store-entry'; }); // Wait until the transitions are complete before notifying the browser. // Otherwise, the apps will be re-rendered while still transitioning. setTimeout(function() { chrome.send('reorderApps', [draggedAppId, appIds]); }, this.transitionsDuration + 10); }, layout: function(options) { options = options || {}; if (!this.dirty_ && options.force != true) return; try { var container = this.dragContainer; if (options.disableAnimations) container.setAttribute('launcher-animations', false); var d0 = Date.now(); this.layoutImpl_(); this.dirty_ = false; logEvent('apps.layout: ' + (Date.now() - d0)); } finally { if (options.disableAnimations) { // We need to re-enable animations asynchronously, so that the // animations are still disabled for this layout update. setTimeout(function() { container.setAttribute('launcher-animations', true); }, 0); } } }, layoutImpl_: function() { var apps = this.data || []; var rects = this.getLayoutRects_(apps.length); var appsContent = this.dragContainer; // Ping the PROMO_SEEN histogram only when the promo is maximized, and // maximum once per NTP load. this.maybePingPromoSeen_(); if (!this.visible) return; for (var i = 0; i < apps.length; i++) { var app = appsContent.querySelector('[app-id='+apps[i]+']').parentNode; // If the node is being dragged, don't try to place it in the grid. if (app == this.dragItem) continue; app.style.left = rects[i].left + 'px'; app.style.top = rects[i].top + 'px'; } // We need to set the container's height manually because the apps use // absolute positioning. var rows = Math.ceil(apps.length / MAX_APPS_PER_ROW[layoutMode]); appsContent.style.height = (rows * this.dimensions.height) + 'px'; }, getLayoutRects_: function(appCount) { var availableWidth = this.dragContainer.offsetWidth; var rtl = isRtl(); var rects = []; var w = this.dimensions.width; var h = this.dimensions.height; var appsPerRow = MAX_APPS_PER_ROW[layoutMode]; for (var i = 0; i < appCount; i++) { var top = Math.floor(i / appsPerRow) * h; var left = (i % appsPerRow) * w; // Reflect the X axis if an RTL language is active. if (rtl) left = availableWidth - left - w; rects[i] = {left: left, top: top}; } return rects; }, get loadedImages() { return this.loadedImages_; }, set loadedImages(value) { this.loadedImages_ = value; if (this.loadedImages_ == 0) return; // Each application icon is loaded asynchronously. Here, we display // the icons once they've all been loaded to make it look nicer. if (this.loadedImages_ == this.data.length) { this.showImages(); return; } // We won't actually have the visible height until the sections have // been layed out. if (!maxiviewVisibleHeight) return; // If we know the visible height of the maxiview, then we can don't need // to wait for all the icons. Instead, we wait until the visible portion // have been loaded. var appsPerRow = MAX_APPS_PER_ROW[layoutMode]; var rows = Math.ceil(maxiviewVisibleHeight / this.dimensions.height); var count = Math.min(appsPerRow * rows, this.data.length); if (this.loadedImages_ == count) { this.showImages(); return; } }, showImages: function() { $('apps-content').classList.add('visible'); clearTimeout(this.imageTimer); }, createElement: function(app) { var div = createElement(app); var a = div.firstChild; a.onclick = handleClick; a.ping = getAppPingUrl( 'PING_BY_ID', this.showPromo, 'NTP_APPS_MAXIMIZED'); a.style.backgroundImage = url(app['icon_big']); if (app.isNew) { div.setAttribute('new', 'new'); // Delay changing the attribute a bit to let the page settle down a bit. setTimeout(function() { // Make sure the new icon is scrolled into view. document.body.scrollTop = document.body.scrollHeight; // This will trigger the 'bounce' animation defined in apps.css. div.setAttribute('new', 'installed'); }, 500); div.addEventListener('webkitAnimationEnd', function(e) { div.removeAttribute('new'); }); } // CSS background images don't fire 'load' events, so we use an Image. var img = new Image(); img.onload = function() { this.loadedImages++; }.bind(this); img.src = app['icon_big']; var settingsButton = div.appendChild(new cr.ui.ContextMenuButton); settingsButton.className = 'app-settings'; settingsButton.title = localStrings.getString('appsettings'); addContextMenu(div, app); return div; }, createMiniviewElement: function(app) { var span = document.createElement('span'); var a = span.appendChild(document.createElement('a')); a.setAttribute('app-id', app['id']); a.textContent = app['name']; a.href = app['launch_url']; a.onclick = handleClick; a.ping = getAppPingUrl( 'PING_BY_ID', this.showPromo, 'NTP_APPS_COLLAPSED'); a.style.backgroundImage = url(app['icon_small']); a.className = 'item'; span.appendChild(a); addContextMenu(span, app); return span; }, createClosedMenuElement: function(app) { var a = document.createElement('a'); a.setAttribute('app-id', app['id']); a.textContent = app['name']; a.href = app['launch_url']; a.onclick = handleClick; a.ping = getAppPingUrl( 'PING_BY_ID', this.showPromo, 'NTP_APPS_MENU'); a.style.backgroundImage = url(app['icon_small']); a.className = 'item'; addContextMenu(a, app); return a; }, createWebStoreElement: function() { var elm = createElement({ 'id': 'web-store-entry', 'name': localStrings.getString('web_store_title'), 'launch_url': localStrings.getString('web_store_url') }); elm.classList.add('web-store-entry'); return elm; }, createWebStoreMiniElement: function() { var span = document.createElement('span'); span.appendChild(this.createWebStoreClosedMenuElement()); return span; }, createWebStoreClosedMenuElement: function() { var a = document.createElement('a'); a.textContent = localStrings.getString('web_store_title'); a.href = localStrings.getString('web_store_url'); a.style.backgroundImage = url('chrome://theme/IDR_PRODUCT_LOGO_16'); a.className = 'item'; return a; } }; })(); // Enable drag and drop reordering of the app launcher. var appDragAndDrop = new DragAndDropController(apps);