diff options
author | dhollowa@chromium.org <dhollowa@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2012-06-26 00:34:20 +0000 |
---|---|---|
committer | dhollowa@chromium.org <dhollowa@chromium.org@0039d316-1c4b-4281-b951-d872f2087c98> | 2012-06-26 00:34:20 +0000 |
commit | 4a44f6610eb14a728a22a9dea00d6707d2e616e1 (patch) | |
tree | 0e76609c4c38a88943cb2f9dd4bf7b81644f140b | |
parent | 50b6601e6fbe1862226c072952d52eae1556a6ae (diff) | |
download | chromium_src-4a44f6610eb14a728a22a9dea00d6707d2e616e1.zip chromium_src-4a44f6610eb14a728a22a9dea00d6707d2e616e1.tar.gz chromium_src-4a44f6610eb14a728a22a9dea00d6707d2e616e1.tar.bz2 |
Add alternate New Tab Page for Instant Extended work
Adds alternate New Tab Page for Instant Extended work. This is a duplicate
of the chrome/browser/resources/ntp4/ directory. This alternate page is
enabled with the --enable-instant-extended-api flag.
BUG=133529
TEST=Manual
R=estade@chromium.org
Review URL: https://chromiumcodereview.appspot.com/10636031
git-svn-id: svn://svn.chromium.org/chrome/trunk/src@144073 0039d316-1c4b-4281-b951-d872f2087c98
29 files changed, 7329 insertions, 7 deletions
diff --git a/chrome/browser/browser_resources.grd b/chrome/browser/browser_resources.grd index 227f2ab..3f6ffb1 100644 --- a/chrome/browser/browser_resources.grd +++ b/chrome/browser/browser_resources.grd @@ -89,6 +89,8 @@ <include name="IDR_NEW_INCOGNITO_TAB_THEME_CSS" file="resources\new_incognito_tab_theme.css" flattenhtml="true" type="BINDATA" /> <include name="IDR_NEW_TAB_4_HTML" file="resources\ntp4\new_tab.html" flattenhtml="true" type="BINDATA" /> <include name="IDR_NEW_TAB_4_THEME_CSS" file="resources\ntp4\new_tab_theme.css" flattenhtml="true" type="BINDATA" /> + <include name="IDR_NEW_TAB_SEARCH_HTML" file="resources\ntp_search\new_tab.html" flattenhtml="true" type="BINDATA" /> + <include name="IDR_NEW_TAB_SEARCH_THEME_CSS" file="resources\ntp_search\new_tab_theme.css" flattenhtml="true" type="BINDATA" /> <include name="IDR_NOTIFICATION_1LINE_HTML" file="resources\notification_1line.html" flattenhtml="true" type="BINDATA" /> <include name="IDR_NOTIFICATION_2LINE_HTML" file="resources\notification_2line.html" flattenhtml="true" type="BINDATA" /> <include name="IDR_NOTIFICATION_ICON_HTML" file="resources\notification_icon.html" type="BINDATA" /> @@ -138,6 +140,8 @@ <include name="IDR_SUGGESTIONS_INTERNALS_JS" file="resources\suggestions_internals\suggestions_internals.js" type="BINDATA" /> <include name="IDR_SUGGESTIONS_PAGE_CSS" file="resources\ntp4\suggestions_page.css" type="BINDATA" /> <include name="IDR_SUGGESTIONS_PAGE_JS" file="resources\ntp4\suggestions_page.js" type="BINDATA" /> + <include name="IDR_SUGGESTIONS_PAGE_SEARCH_CSS" file="resources\ntp_search\suggestions_page.css" type="BINDATA" /> + <include name="IDR_SUGGESTIONS_PAGE_SEARCH_JS" file="resources\ntp_search\suggestions_page.js" type="BINDATA" /> <include name="IDR_SSL_ERROR_HTML" file="resources\ssl_error.html" flattenhtml="true" type="BINDATA" /> <include name="IDR_SSL_ROAD_BLOCK_HTML" file="resources\ssl_roadblock.html" flattenhtml="true" type="BINDATA" /> <include name="IDR_TRACING_HTML" file="..\..\third_party\trace-viewer\src\about_tracing.html" flattenhtml="true" allowexternalscript="true" type="BINDATA" /> diff --git a/chrome/browser/resources/ntp_search/OWNERS b/chrome/browser/resources/ntp_search/OWNERS new file mode 100644 index 0000000..a295d1b --- /dev/null +++ b/chrome/browser/resources/ntp_search/OWNERS @@ -0,0 +1,4 @@ +set noparent +estade@chromium.org +rbyers@chromium.org +dbeam@chromium.org diff --git a/chrome/browser/resources/ntp_search/apps_page.css b/chrome/browser/resources/ntp_search/apps_page.css new file mode 100644 index 0000000..b0f7c79 --- /dev/null +++ b/chrome/browser/resources/ntp_search/apps_page.css @@ -0,0 +1,176 @@ +/* Copyright (c) 2012 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. */ + +.app { + outline: none; + position: absolute; + text-align: center; +} + +.app-contents { + -webkit-transition: -webkit-transform 100ms; +} + +.app-contents:active:not(.suppress-active), +.app:not(.click-focus):focus .app-contents:not(.suppress-active), +.drag-representation:not(.placing) .app-contents { + -webkit-transform: scale(1.1); +} + +/* Don't animate the initial scaling. */ +.app-contents:active:not(.suppress-active), +/* Active gets applied right before .suppress-active, so to avoid flicker + * we need to make the scale go back to normal without an animation. */ +.app-contents.suppress-active { + -webkit-transition-duration: 0; +} + +.app-contents > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-img-container { + /* -webkit-mask-image set by JavaScript to the image source. */ + -webkit-mask-size: 100% 100%; + margin-left: auto; + margin-right: auto; +} + +.app-img-container > * { + height: 100%; + width: 100%; +} + +.app-icon-div { + -webkit-box-align: center; + -webkit-box-pack: center; + background-color: white; + border: 1px solid #d5d5d5; + border-radius: 5px; + display: -webkit-box; + margin-left: auto; + margin-right: auto; + position: relative; + vertical-align: middle; + z-index: 0; +} + +.app-icon-div .app-img-container { + bottom: 10px; + left: 10px; + position: absolute; +} + +.app-icon-div .color-stripe { + border-bottom-left-radius: 5px 5px; + border-bottom-right-radius: 5px 5px; + bottom: 0; + height: 3px; + opacity: 1.0; + position: absolute; + width: 100%; + z-index: 100; +} + +.app-context-menu > button:first-child { + font-weight: bold; +} + +.app-context-menu { + z-index: 1000; +} + +.launch-click-target { + cursor: pointer; +} + +/* Notifications */ + +.app-notification { + -webkit-transition: color 150ms linear; + color: #999; + display: block; + font-size: 0.9em; + white-space: nowrap; +} + +.app-notification:hover { + text-decoration: underline; +} + +/* Promo */ +/* Show the promo if the webstore has a promo and is the only app on the page. + */ +.tile:only-of-type > .has-promo > .app-contents > span { + display: none; +} + +.tile:only-of-type > .has-promo .app-img-container > .apps-promo-logo { + display: block; +} + +.apps-promo-logo { + display: none; + height: 128px; + width: 128px; +} + +.tile:only-of-type > .has-promo .app-img-container > img:first-child { + display: none; +} + +.app-img-container > img:first-child { + display: block; +} + +/* TODO(estade): animation? */ +.tile:only-of-type > .has-promo > .apps-promo-extras { + display: block; +} + +.apps-promo-extras { + display: none; + /* 128 * 5/4 */ + left: 160px; + position: absolute; + text-align: left; + top: 0; +} + +html[dir='rtl'] .apps-promo-extras { + left: auto; + right: 160px; +} + +.apps-promo-heading { + -webkit-margin-start: 3px; + font-weight: bold; + margin-bottom: 5px; +} + +.g-button-basic { + -webkit-border-image: url('images/app_promo_button.png') 6 10 12 6; + border-width: 6px 10px 12px 6px; + color: #fff !important; + display: inline-block; + font-size: 1.3em; + font-weight: bold; + padding: 2px 10px; + text-align: center; + text-decoration: none; + white-space: nowrap; +} + +.app .invisible { + visibility: hidden; +} + +/* Move the notification lower on apps pages to account for the 16px of + * transparency each app icon should have. */ +.apps-page #notification-container { + bottom: 15px; +} diff --git a/chrome/browser/resources/ntp_search/apps_page.js b/chrome/browser/resources/ntp_search/apps_page.js new file mode 100644 index 0000000..6a41162 --- /dev/null +++ b/chrome/browser/resources/ntp_search/apps_page.js @@ -0,0 +1,917 @@ +// Copyright (c) 2012 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. + +cr.define('ntp', function() { + 'use strict'; + + var APP_LAUNCH = { + // The histogram buckets (keep in sync with extension_constants.h). + NTP_APPS_MAXIMIZED: 0, + NTP_APPS_COLLAPSED: 1, + NTP_APPS_MENU: 2, + NTP_MOST_VISITED: 3, + NTP_RECENTLY_CLOSED: 4, + NTP_APP_RE_ENABLE: 16, + NTP_WEBSTORE_FOOTER: 18, + NTP_WEBSTORE_PLUS_ICON: 19, + }; + + // Histogram buckets for UMA tracking of where a DnD drop came from. + var DRAG_SOURCE = { + SAME_APPS_PANE: 0, + OTHER_APPS_PANE: 1, + MOST_VISITED_PANE: 2, + BOOKMARKS_PANE: 3, + OUTSIDE_NTP: 4 + }; + var DRAG_SOURCE_LIMIT = DRAG_SOURCE.OUTSIDE_NTP + 1; + + /** + * App context menu. The class is designed to be used as a singleton with + * the app that is currently showing a context menu stored in this.app_. + * @constructor + */ + function AppContextMenu() { + this.__proto__ = AppContextMenu.prototype; + this.initialize(); + } + cr.addSingletonGetter(AppContextMenu); + + AppContextMenu.prototype = { + initialize: function() { + var menu = new cr.ui.Menu; + cr.ui.decorate(menu, cr.ui.Menu); + menu.classList.add('app-context-menu'); + this.menu = menu; + + this.launch_ = this.appendMenuItem_(); + this.launch_.addEventListener('activate', this.onLaunch_.bind(this)); + + menu.appendChild(cr.ui.MenuItem.createSeparator()); + this.launchRegularTab_ = this.appendMenuItem_('applaunchtyperegular'); + this.launchPinnedTab_ = this.appendMenuItem_('applaunchtypepinned'); + if (!cr.isMac) + this.launchNewWindow_ = this.appendMenuItem_('applaunchtypewindow'); + this.launchFullscreen_ = this.appendMenuItem_('applaunchtypefullscreen'); + + var self = this; + this.forAllLaunchTypes_(function(launchTypeButton, id) { + launchTypeButton.addEventListener('activate', + self.onLaunchTypeChanged_.bind(self)); + }); + + menu.appendChild(cr.ui.MenuItem.createSeparator()); + this.options_ = this.appendMenuItem_('appoptions'); + this.disableNotifications_ = + this.appendMenuItem_('appdisablenotifications'); + this.uninstall_ = this.appendMenuItem_('appuninstall'); + this.options_.addEventListener('activate', + this.onShowOptions_.bind(this)); + this.disableNotifications_.addEventListener( + 'activate', this.onDisableNotifications_.bind(this)); + this.uninstall_.addEventListener('activate', + this.onUninstall_.bind(this)); + + if (!cr.isMac && !cr.isChromeOS) { + menu.appendChild(cr.ui.MenuItem.createSeparator()); + this.createShortcut_ = this.appendMenuItem_('appcreateshortcut'); + this.createShortcut_.addEventListener( + 'activate', this.onCreateShortcut_.bind(this)); + } + + document.body.appendChild(menu); + }, + + /** + * Appends a menu item to |this.menu|. + * @param {?String} textId If non-null, the ID for the localized string + * that acts as the item's label. + */ + appendMenuItem_: function(textId) { + var button = cr.doc.createElement('button'); + this.menu.appendChild(button); + cr.ui.decorate(button, cr.ui.MenuItem); + if (textId) + button.textContent = loadTimeData.getString(textId); + return button; + }, + + /** + * Iterates over all the launch type menu items. + * @param {function(cr.ui.MenuItem, number)} f The function to call for each + * menu item. The parameters to the function include the menu item and + * the associated launch ID. + */ + forAllLaunchTypes_: function(f) { + // Order matters: index matches launchType id. + var launchTypes = [this.launchPinnedTab_, + this.launchRegularTab_, + this.launchFullscreen_, + this.launchNewWindow_]; + + for (var i = 0; i < launchTypes.length; ++i) { + if (!launchTypes[i]) + continue; + + f(launchTypes[i], i); + } + }, + + /** + * Does all the necessary setup to show the menu for the given app. + * @param {App} app The App object that will be showing a context menu. + */ + setupForApp: function(app) { + this.app_ = app; + + this.launch_.textContent = app.appData.title; + + this.forAllLaunchTypes_(function(launchTypeButton, id) { + launchTypeButton.disabled = false; + launchTypeButton.checked = app.appData.launch_type == id; + }); + + this.options_.disabled = !app.appData.optionsUrl || !app.appData.enabled; + this.uninstall_.disabled = !app.appData.mayDisable; + + this.disableNotifications_.hidden = true; + var notificationsDisabled = app.appData.notifications_disabled; + if (typeof notificationsDisabled != 'undefined') { + this.disableNotifications_.hidden = false; + this.disableNotifications_.checked = notificationsDisabled; + } + }, + + /** + * Handlers for menu item activation. + * @param {Event} e The activation event. + * @private + */ + onLaunch_: function(e) { + chrome.send('launchApp', [this.app_.appId, APP_LAUNCH.NTP_APPS_MENU]); + }, + onLaunchTypeChanged_: function(e) { + var pressed = e.currentTarget; + var app = this.app_; + this.forAllLaunchTypes_(function(launchTypeButton, id) { + if (launchTypeButton == pressed) { + chrome.send('setLaunchType', [app.appId, id]); + // Manually update the launch type. We will only get + // appsPrefChangeCallback calls after changes to other NTP instances. + app.appData.launch_type = id; + } + }); + }, + onShowOptions_: function(e) { + window.location = this.app_.appData.optionsUrl; + }, + onDisableNotifications_: function(e) { + var app = this.app_; + app.removeBubble(); + // Toggle the current disable setting. + var newSetting = !this.disableNotifications_.checked; + app.appData.notifications_disabled = newSetting; + chrome.send('setNotificationsDisabled', [app.appData.id, newSetting]); + }, + onUninstall_: function(e) { + chrome.send('uninstallApp', [this.app_.appData.id]); + }, + onCreateShortcut_: function(e) { + chrome.send('createAppShortcut', [this.app_.appData.id]); + }, + }; + + /** + * Creates a new App object. + * @param {Object} appData The data object that describes the app. + * @constructor + * @extends {HTMLDivElement} + */ + function App(appData) { + var el = cr.doc.createElement('div'); + el.__proto__ = App.prototype; + el.initialize(appData); + + return el; + } + + App.prototype = { + __proto__: HTMLDivElement.prototype, + + /** + * Initialize the app object. + * @param {Object} appData The data object that describes the app. + */ + initialize: function(appData) { + this.appData = appData; + assert(this.appData_.id, 'Got an app without an ID'); + this.id = this.appData_.id; + + this.className = 'app focusable'; + + if (!this.appData_.icon_big_exists && this.appData_.icon_small_exists) + this.useSmallIcon_ = true; + + this.appContents_ = this.useSmallIcon_ ? + $('app-small-icon-template').cloneNode(true) : + $('app-large-icon-template').cloneNode(true); + this.appContents_.id = ''; + this.appendChild(this.appContents_); + + this.appImgContainer_ = this.querySelector('.app-img-container'); + this.appImg_ = this.appImgContainer_.querySelector('img'); + this.setIcon(); + + if (this.useSmallIcon_) { + this.imgDiv_ = this.querySelector('.app-icon-div'); + this.addLaunchClickTarget_(this.imgDiv_); + this.imgDiv_.title = this.appData_.title; + chrome.send('getAppIconDominantColor', [this.id]); + } else { + this.addLaunchClickTarget_(this.appImgContainer_); + this.appImgContainer_.title = this.appData_.title; + } + + var appSpan = this.appContents_.querySelector('.title'); + appSpan.textContent = appSpan.title = this.appData_.title; + this.addLaunchClickTarget_(appSpan); + + var notification = this.appData_.notification; + var hasNotification = typeof notification != 'undefined' && + typeof notification['title'] != 'undefined' && + typeof notification['body'] != 'undefined' && + !this.appData_.notifications_disabled; + if (hasNotification) + this.setupNotification_(notification); + + this.addEventListener('keydown', cr.ui.contextMenuHandler); + this.addEventListener('keyup', cr.ui.contextMenuHandler); + + // This hack is here so that appContents.contextMenu will be the same as + // this.contextMenu. + var self = this; + this.appContents_.__defineGetter__('contextMenu', function() { + return self.contextMenu; + }); + this.appContents_.addEventListener('contextmenu', + cr.ui.contextMenuHandler); + + if (this.appData_.is_webstore) + this.createAppsPromoExtras_(); + + this.addEventListener('mousedown', this.onMousedown_, true); + this.addEventListener('keydown', this.onKeydown_); + this.addEventListener('blur', this.onBlur_, true); + }, + + /** + * Sets the color of the favicon dominant color bar. + * @param {string} color The css-parsable value for the color. + */ + set stripeColor(color) { + this.querySelector('.color-stripe').style.backgroundColor = color; + }, + + /** + * Removes the app tile from the page. Should be called after the app has + * been uninstalled. + */ + remove: function(opt_animate) { + // Unset the ID immediately, because the app is already gone. But leave + // the tile on the page as it animates out. + this.id = ''; + this.tile.doRemove(opt_animate); + }, + + /** + * Set the URL of the icon from |appData_|. This won't actually show the + * icon until loadIcon() is called (for performance reasons; we don't want + * to load icons until we have to). + */ + setIcon: function() { + var src = this.useSmallIcon_ ? this.appData_.icon_small : + this.appData_.icon_big; + if (!this.appData_.enabled || + (!this.appData_.offlineEnabled && !navigator.onLine)) { + src += '?grayscale=true'; + } + + this.appImgSrc_ = src; + this.classList.add('icon-loading'); + }, + + /** + * Shows the icon for the app. That is, it causes chrome to load the app + * icon resource. + */ + loadIcon: function() { + if (this.appImgSrc_) { + this.appImg_.src = this.appImgSrc_; + this.appImg_.classList.remove('invisible'); + this.appImgSrc_ = null; + } + + this.classList.remove('icon-loading'); + }, + + /** + * Creates a bubble node. + * @param {Object} notification The notification to show in the bubble. + * @param {boolean} full Whether we want the headline or just the content. + * @private + */ + createBubbleNode_: function(notification, full) { + if (!full) { + var titleItem = this.ownerDocument.createElement('span'); + titleItem.textContent = notification['title']; + return titleItem; + } else { + var container = this.ownerDocument.createElement('div'); + + var messageItem = this.ownerDocument.createElement('div'); + messageItem.textContent = notification['body']; + container.appendChild(messageItem); + + if (notification['linkUrl'] && notification['linkText']) { + var anchor = this.ownerDocument.createElement('a'); + anchor.href = notification['linkUrl']; + anchor.textContent = notification['linkText']; + container.appendChild(anchor); + } + + return container; + } + }, + + /** + * Sets up a notification for the app icon. + * @param {Object} notification The notification to show in the bubble. + * @private + */ + setupNotification_: function(notification) { + if (notification) { + var infoBubble; + if (!this.currentBubbleShowing_) { + // Create a new bubble. + infoBubble = new cr.ui.ExpandableBubble; + infoBubble.anchorNode = this; + infoBubble.appId = this.appData_.id; + infoBubble.handleCloseEvent = function() { + chrome.send('closeNotification', [this.appId]); + infoBubble.hide(); + }; + } else { + // Reuse the old bubble instead of popping up a new bubble over + // the old one. + infoBubble = this.currentBubbleShowing_; + infoBubble.collapseBubble_(); + } + infoBubble.contentTitle = this.createBubbleNode_(notification, false); + infoBubble.content = this.createBubbleNode_(notification, true); + infoBubble.show(); + infoBubble.resizeAndReposition(); + + this.currentBubbleShowing_ = infoBubble; + } + }, + + /** + * Removes the info bubble if there is one. + */ + removeBubble: function() { + if (this.currentBubbleShowing_) { + this.currentBubbleShowing_.hide(); + this.currentBubbleShowing_ = null; + } + }, + + /** + * Creates the apps-promo section of the app (should only be called for the + * webstore app). + * @private + */ + createAppsPromoExtras_: function() { + this.classList.add('webstore'); + + this.appsPromoExtras_ = $('apps-promo-extras-template').cloneNode(true); + this.appsPromoExtras_.id = ''; + this.appsPromoHeading_ = + this.appsPromoExtras_.querySelector('.apps-promo-heading'); + this.appsPromoLink_ = + this.appsPromoExtras_.querySelector('.apps-promo-link'); + this.appsPromoLink_.addEventListener('click', this.onClick_.bind(this)); + + this.appsPromoLogo_ = this.ownerDocument.createElement('img'); + this.appsPromoLogo_.className = 'apps-promo-logo'; + this.appImgContainer_.appendChild(this.appsPromoLogo_); + + this.appendChild(this.appsPromoExtras_); + }, + + /** + * Sets the apps promo appearance. If |data| is null, there is no promo. If + * |data| is non-null, it contains strings to be shown for the promo. The + * promo is only shown when the webstore app icon is alone on a page. + * @param {Object} data A dictionary that contains apps promo strings. + */ + setAppsPromoData: function(data) { + if (data) { + this.classList.add('has-promo'); + } else { + this.classList.remove('has-promo'); + return; + } + + this.appsPromoHeading_.textContent = data.promoHeader; + this.appsPromoLink_.href = data.promoLink; + this.appsPromoLink_.textContent = data.promoButton; + this.appsPromoLogo_.src = data.promoLogo; + }, + + /** + * Set the size and position of the app tile. + * @param {number} size The total size of |this|. + * @param {number} x The x-position. + * @param {number} y The y-position. + * animate. + */ + setBounds: function(size, x, y) { + var imgSize = size * APP_IMG_SIZE_FRACTION; + this.appImgContainer_.style.width = this.appImgContainer_.style.height = + toCssPx(this.useSmallIcon_ ? 16 : imgSize); + if (this.useSmallIcon_) { + // 3/4 is the ratio of 96px to 128px (the used height and full height + // of icons in apps). + var iconSize = imgSize * 3 / 4; + // The -2 is for the div border to improve the visual alignment for the + // icon div. + this.imgDiv_.style.width = this.imgDiv_.style.height = + toCssPx(iconSize - 2); + // Margins set to get the icon placement right and the text to line up. + this.imgDiv_.style.marginTop = this.imgDiv_.style.marginBottom = + toCssPx((imgSize - iconSize) / 2); + } + + this.style.width = this.style.height = toCssPx(size); + this.style.left = toCssPx(x); + this.style.right = toCssPx(x); + this.style.top = toCssPx(y); + + if (this.currentBubbleShowing_) + this.currentBubbleShowing_.resizeAndReposition(); + }, + + /** + * Invoked when an app is clicked. + * @param {Event} e The click event. + * @private + */ + onClick_: function(e) { + var is_promo = this.appsPromoExtras_ && + window.getComputedStyle(this.appsPromoExtras_).display != 'none'; + var url = !this.appData_.is_webstore ? '' : + is_promo ? this.appsPromoLink_.href : + appendParam(this.appData_.url, + 'utm_source', + 'chrome-ntp-icon'); + + chrome.send('launchApp', + [this.appId, APP_LAUNCH.NTP_APPS_MAXIMIZED, url, + e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]); + + // Don't allow the click to trigger a link or anything + e.preventDefault(); + }, + + /** + * Invoked when the user presses a key while the app is focused. + * @param {Event} e The key event. + * @private + */ + onKeydown_: function(e) { + if (e.keyIdentifier == 'Enter') { + chrome.send('launchApp', + [this.appId, APP_LAUNCH.NTP_APPS_MAXIMIZED, '', + 0, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]); + e.preventDefault(); + e.stopPropagation(); + } + }, + + /** + * Adds a node to the list of targets that will launch the app. This list + * is also used in onMousedown to determine whether the app contents should + * be shown as active (if we don't do this, then clicking anywhere in + * appContents, even a part that is outside the ideally clickable region, + * will cause the app icon to look active). + * @param {HTMLElement} node The node that should be clickable. + */ + addLaunchClickTarget_: function(node) { + node.classList.add('launch-click-target'); + node.addEventListener('click', this.onClick_.bind(this)); + }, + + /** + * Handler for mousedown on the App. Adds a class that allows us to + * not display as :active for right clicks and clicks on app notifications + * (specifically, don't pulse on these occasions). Also, we don't pulse + * for clicks that aren't within the clickable regions. + * @param {Event} e The mousedown event. + */ + onMousedown_: function(e) { + if (e.button == 2 || + !findAncestorByClass(e.target, 'launch-click-target')) { + this.appContents_.classList.add('suppress-active'); + } else { + this.appContents_.classList.remove('suppress-active'); + } + + // This class is here so we don't show the focus state for apps that + // gain keyboard focus via mouse clicking. + this.classList.add('click-focus'); + }, + + /** + * This app is losing keyboard focus. + * @param {Event} e The event. + */ + onBlur_: function(e) { + this.classList.remove('click-focus'); + }, + + /** + * Change the appData and update the appearance of the app. + * @param {Object} appData The new data object that describes the app. + */ + replaceAppData: function(appData) { + this.appData_ = appData; + this.setIcon(); + this.loadIcon(); + }, + + /** + * The data and preferences for this app. + * @type {Object} + */ + set appData(data) { + this.appData_ = data; + }, + get appData() { + return this.appData_; + }, + + get appId() { + return this.appData_.id; + }, + + /** + * Returns a pointer to the context menu for this app. All apps share the + * singleton AppContextMenu. This function is called by the + * ContextMenuHandler in response to the 'contextmenu' event. + * @type {cr.ui.Menu} + */ + get contextMenu() { + var menu = AppContextMenu.getInstance(); + menu.setupForApp(this); + return menu.menu; + }, + + /** + * Returns whether this element can be 'removed' from chrome (i.e. whether + * the user can drag it onto the trash and expect something to happen). + * @return {boolean} True if the app can be uninstalled. + */ + canBeRemoved: function() { + return this.appData_.mayDisable; + }, + + /** + * Uninstalls the app after it's been dropped on the trash. + */ + removeFromChrome: function() { + chrome.send('uninstallApp', [this.appData_.id, true]); + this.tile.tilePage.removeTile(this.tile, true); + if (this.currentBubbleShowing_) + currentBubbleShowing_.hide(); + }, + + /** + * Called when a drag is starting on the tile. Updates dataTransfer with + * data for this tile. + */ + setDragData: function(dataTransfer) { + dataTransfer.setData('Text', this.appData_.title); + dataTransfer.setData('URL', this.appData_.url); + }, + }; + + var TilePage = ntp.TilePage; + + // The fraction of the app tile size that the icon uses. + var APP_IMG_SIZE_FRACTION = 4 / 5; + + var appsPageGridValues = { + // The fewest tiles we will show in a row. + minColCount: 3, + // The most tiles we will show in a row. + maxColCount: 6, + + // The smallest a tile can be. + minTileWidth: 64 / APP_IMG_SIZE_FRACTION, + // The biggest a tile can be. + maxTileWidth: 128 / APP_IMG_SIZE_FRACTION, + + // The padding between tiles, as a fraction of the tile width. + tileSpacingFraction: 1 / 8, + }; + TilePage.initGridValues(appsPageGridValues); + + /** + * Creates a new AppsPage object. + * @constructor + * @extends {TilePage} + */ + function AppsPage() { + var el = new TilePage(appsPageGridValues); + el.__proto__ = AppsPage.prototype; + el.initialize(); + + return el; + } + + AppsPage.prototype = { + __proto__: TilePage.prototype, + + initialize: function() { + this.classList.add('apps-page'); + + this.addEventListener('cardselected', this.onCardSelected_); + // Add event listeners for two events, so we can temporarily suppress + // the app notification bubbles when the app card slides in and out of + // view. + this.addEventListener('carddeselected', this.onCardDeselected_); + this.addEventListener('cardSlider:card_change_ended', + this.onCardChangeEnded_); + + this.addEventListener('tilePage:tile_added', this.onTileAdded_); + + this.content_.addEventListener('scroll', this.onScroll_.bind(this)); + }, + + /** + * Highlight a newly installed app as it's added to the NTP. + * @param {Object} appData The data object that describes the app. + */ + insertAndHighlightApp: function(appData) { + ntp.getCardSlider().selectCardByValue(this); + this.content_.scrollTop = this.content_.scrollHeight; + this.insertApp(appData, true); + }, + + /** + * Similar to appendApp, but it respects the app_launch_ordinal field of + * |appData|. + * @param {Object} appData The data that describes the app. + * @param {boolean} animate Whether to animate the insertion. + */ + insertApp: function(appData, animate) { + var index = this.tileElements_.length; + for (var i = 0; i < this.tileElements_.length; i++) { + if (appData.app_launch_ordinal < + this.tileElements_[i].firstChild.appData.app_launch_ordinal) { + index = i; + break; + } + } + + this.addTileAt(new App(appData), index, animate); + }, + + /** + * Handler for 'cardselected' event, fired when |this| is selected. The + * first time this is called, we load all the app icons. + * @private + */ + onCardSelected_: function(e) { + var apps = this.querySelectorAll('.app.icon-loading'); + for (var i = 0; i < apps.length; i++) { + apps[i].loadIcon(); + if (apps[i].currentBubbleShowing_) + apps[i].currentBubbleShowing_.suppressed = false; + } + }, + + /** + * Handler for tile additions to this page. + * @param {Event} e The tilePage:tile_added event. + */ + onTileAdded_: function(e) { + assert(e.currentTarget == this); + assert(e.addedTile.firstChild instanceof App); + if (this.classList.contains('selected-card')) + e.addedTile.firstChild.loadIcon(); + }, + + /** + * Handler for the when this.cardSlider ends change its card. If animated, + * this happens when the -webkit-transition is done, otherwise happens + * immediately (but after cardSlider:card_changed). + * @private + */ + onCardChangeEnded_: function(e) { + for (var i = 0; i < this.tileElements_.length; i++) { + var app = this.tileElements_[i].firstChild; + assert(app instanceof App); + if (app.currentBubbleShowing_) + app.currentBubbleShowing_.suppressed = false; + } + }, + + /** + * Handler for the 'carddeselected' event, fired when the user switches + * to another 'card' than the App 'card' on the NTP (|this| gets + * deselected). + * @private + */ + onCardDeselected_: function(e) { + for (var i = 0; i < this.tileElements_.length; i++) { + var app = this.tileElements_[i].firstChild; + assert(app instanceof App); + if (app.currentBubbleShowing_) + app.currentBubbleShowing_.suppressed = true; + } + }, + + /** + * A handler for when the apps page is scrolled (then we need to reposition + * the bubbles. + * @private + */ + onScroll_: function(e) { + if (!this.selected) + return; + for (var i = 0; i < this.tileElements_.length; i++) { + var app = this.tileElements_[i].firstChild; + assert(app instanceof App); + if (app.currentBubbleShowing_) + app.currentBubbleShowing_.resizeAndReposition(); + } + }, + + /** @inheritDoc */ + doDragOver: function(e) { + // Only animatedly re-arrange if the user is currently dragging an app. + var tile = ntp.getCurrentlyDraggingTile(); + if (tile && tile.querySelector('.app')) { + TilePage.prototype.doDragOver.call(this, e); + } else { + e.preventDefault(); + this.setDropEffect(e.dataTransfer); + } + }, + + /** @inheritDoc */ + shouldAcceptDrag: function(e) { + if (ntp.getCurrentlyDraggingTile()) + return true; + if (!e.dataTransfer || !e.dataTransfer.types) + return false; + return Array.prototype.indexOf.call(e.dataTransfer.types, + 'text/uri-list') != -1; + }, + + /** @inheritDoc */ + addDragData: function(dataTransfer, index) { + var sourceId = -1; + var currentlyDraggingTile = ntp.getCurrentlyDraggingTile(); + if (currentlyDraggingTile) { + var tileContents = currentlyDraggingTile.firstChild; + if (tileContents.classList.contains('app')) { + var originalPage = currentlyDraggingTile.tilePage; + var samePageDrag = originalPage == this; + sourceId = samePageDrag ? DRAG_SOURCE.SAME_APPS_PANE : + DRAG_SOURCE.OTHER_APPS_PANE; + this.tileGrid_.insertBefore(currentlyDraggingTile, + this.tileElements_[index]); + this.tileMoved(currentlyDraggingTile); + if (!samePageDrag) { + originalPage.fireRemovedEvent(currentlyDraggingTile, index, true); + this.fireAddedEvent(currentlyDraggingTile, index, true); + } + } else if (currentlyDraggingTile.querySelector('.most-visited')) { + this.generateAppForLink(tileContents.data); + sourceId = DRAG_SOURCE.MOST_VISITED_PANE; + } + } else { + this.addOutsideData_(dataTransfer); + sourceId = DRAG_SOURCE.OUTSIDE_NTP; + } + + assert(sourceId != -1); + chrome.send('metricsHandler:recordInHistogram', + ['NewTabPage.AppsPageDragSource', sourceId, DRAG_SOURCE_LIMIT]); + }, + + /** + * Adds drag data that has been dropped from a source that is not a tile. + * @param {Object} dataTransfer The data transfer object that holds drop + * data. + * @private + */ + addOutsideData_: function(dataTransfer) { + var url = dataTransfer.getData('url'); + assert(url); + + // If the dataTransfer has html data, use that html's text contents as the + // title of the new link. + var html = dataTransfer.getData('text/html'); + var title; + if (html) { + // It's important that we don't attach this node to the document + // because it might contain scripts. + var node = this.ownerDocument.createElement('div'); + node.innerHTML = html; + title = node.textContent; + } + + // Make sure title is >=1 and <=45 characters for Chrome app limits. + if (!title) + title = url; + if (title.length > 45) + title = title.substring(0, 45); + var data = {url: url, title: title}; + + // Synthesize an app. + this.generateAppForLink(data); + }, + + /** + * Creates a new crx-less app manifest and installs it. + * @param {Object} data The data object describing the link. Must have |url| + * and |title| members. + */ + generateAppForLink: function(data) { + assert(data.url != undefined); + assert(data.title != undefined); + var pageIndex = ntp.getAppsPageIndex(this); + chrome.send('generateAppForLink', [data.url, data.title, pageIndex]); + }, + + /** @inheritDoc */ + tileMoved: function(draggedTile) { + if (!(draggedTile.firstChild instanceof App)) + return; + + var pageIndex = ntp.getAppsPageIndex(this); + chrome.send('setPageIndex', [draggedTile.firstChild.appId, pageIndex]); + + var appIds = []; + for (var i = 0; i < this.tileElements_.length; i++) { + var tileContents = this.tileElements_[i].firstChild; + if (tileContents instanceof App) + appIds.push(tileContents.appId); + } + + chrome.send('reorderApps', [draggedTile.firstChild.appId, appIds]); + }, + + /** @inheritDoc */ + setDropEffect: function(dataTransfer) { + var tile = ntp.getCurrentlyDraggingTile(); + if (tile && tile.querySelector('.app')) + ntp.setCurrentDropEffect(dataTransfer, 'move'); + else + ntp.setCurrentDropEffect(dataTransfer, 'copy'); + }, + }; + + AppsPage.setPromo = function(data) { + var store = document.querySelector('.webstore'); + if (store) + store.setAppsPromoData(data); + }; + + /** + * Launches the specified app using the APP_LAUNCH_NTP_APP_RE_ENABLE + * histogram. This should only be invoked from the AppLauncherHandler. + * @param {String} appID The ID of the app. + */ + function launchAppAfterEnable(appId) { + chrome.send('launchApp', [appId, APP_LAUNCH.NTP_APP_RE_ENABLE]); + } + + function appNotificationChanged(id, notification) { + var app = $(id); + // The app might have been uninstalled, or notifications might be disabled. + if (app && !app.appData.notifications_disabled) + app.setupNotification_(notification); + } + + return { + APP_LAUNCH: APP_LAUNCH, + appNotificationChanged: appNotificationChanged, + AppsPage: AppsPage, + launchAppAfterEnable: launchAppAfterEnable, + }; +}); diff --git a/chrome/browser/resources/ntp_search/dot_list.js b/chrome/browser/resources/ntp_search/dot_list.js new file mode 100644 index 0000000..b8e0f11 --- /dev/null +++ b/chrome/browser/resources/ntp_search/dot_list.js @@ -0,0 +1,80 @@ +// Copyright (c) 2012 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. + +/** + * @fileoverview DotList implementation + */ + +cr.define('ntp', function() { + 'use strict'; + + /** + * Live list of the navigation dots. + * @type {!NodeList|undefined} + */ + var navDots; + + /** + * Creates a new DotList object. + * @constructor + * @extends {HTMLUListElement} + */ + var DotList = cr.ui.define('ul'); + + DotList.prototype = { + __proto__: HTMLUListElement.prototype, + + /** @inheritDoc */ + decorate: function() { + this.addEventListener('keydown', this.onKeyDown_.bind(this)); + navDots = this.getElementsByClassName('dot'); + }, + + /** + * Live list of the navigation dots. + * @type {!NodeList|undefined} + */ + get dots() { + return navDots; + }, + + /** + * Handler for key events on the dot list. These keys will change the focus + * element. + * @param {Event} e The KeyboardEvent. + */ + onKeyDown_: function(e) { + if (e.metaKey || e.shiftKey || e.altKey || e.ctrlKey) + return; + + var direction = 0; + if (e.keyIdentifier == 'Left') + direction = -1; + else if (e.keyIdentifier == 'Right') + direction = 1; + else + return; + + var focusDot = this.querySelector('.dot:focus'); + if (!focusDot) + return; + var focusIndex = Array.prototype.indexOf.call(navDots, focusDot); + var newFocusIndex = focusIndex + direction; + if (focusIndex == newFocusIndex) + return; + + newFocusIndex = (newFocusIndex + navDots.length) % navDots.length; + navDots[newFocusIndex].tabIndex = 3; + navDots[newFocusIndex].focus(); + focusDot.tabIndex = -1; + + e.stopPropagation(); + e.preventDefault(); + } + }; + + return { + DotList: DotList + }; +}); diff --git a/chrome/browser/resources/ntp_search/footer_menu.css b/chrome/browser/resources/ntp_search/footer_menu.css new file mode 100644 index 0000000..96c6fae --- /dev/null +++ b/chrome/browser/resources/ntp_search/footer_menu.css @@ -0,0 +1,160 @@ +/* Copyright (c) 2012 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. */ + +.footer-menu-button { + -webkit-appearance: none; + -webkit-padding-end: 15px; + -webkit-padding-start: 9px; + -webkit-transition: opacity 200ms; + -webkit-transition-delay: 100ms; + background: none; + border: 0; + color: inherit; + cursor: pointer; + display: block; + font: inherit; + height: 100%; + margin: 0; + /* The padding increases the clickable area. */ + padding-bottom: 0; + padding-top: 0; + white-space: nowrap; +} + +.footer-menu-button:hover:not([menu-shown]) { + color: #666; +} + +.footer-menu-button:hover:not([menu-shown]) .disclosure-triangle { + background-color: #666; +} + +.footer-menu-button[menu-shown] { + color: #555; +} + +.footer-menu-button[menu-shown] .disclosure-triangle { + background-color: #555; +} + +.footer-menu { + -webkit-margin-end: 10px; + max-height: 400px; + overflow: auto; + padding: 6px 8px; + /* Needs to be above #footer. */ + z-index: 10; +} + +.footer-menu, +.footer-menu-context-menu { + min-width: 150px; +} + +/* TODO(dubroy): Unify this with tile page scrollbar. */ +.footer-menu::-webkit-scrollbar-button { + display: none; +} + +.footer-menu::-webkit-scrollbar { + width: 8px; +} + +.footer-menu::-webkit-scrollbar-thumb { + background-color: #D9D9D9; + border: 2px solid white; +} + +.footer-menu-item { + -webkit-margin-end: 8px; + -webkit-margin-start: 0; + -webkit-padding-end: 0; + -webkit-padding-start: 22px; + background: no-repeat 0 50%; + background-color: transparent !important; + background-size: 16px 16px; + box-sizing: border-box; + display: block; + line-height: 1.5em; + margin-bottom: 0.5em; + margin-top: 0.5em; + max-width: 450px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.footer-menu-item:not(:hover) { + text-decoration: none; +} + +.footer-menu-item:first-of-type { + margin-top: 0.25em; +} + +.footer-menu-item:last-of-type { + margin-bottom: 0.25em; +} + +.footer-menu section { + padding: 0; +} + +.footer-menu section h3 { + color: black; + font-size: 1em; + font-weight: bold; + margin-bottom: 0.5em; +} + +.footer-menu section:first-of-type h3 { + margin-top: 0.25em; +} + +/* Used to add additional details to a section header */ +.footer-menu section h3 .details { + color: rgb(151, 156, 160); + font-style: italic; + font-weight: normal; +} + +.footer-menu section h3 .details:before { + content: '\2013'; /* En-dash character. */ + margin: 0 0.5em; +} + +html[dir='rtl'] .footer-menu-item { + background: no-repeat 100% 50%; +} + +.recent-window { + background-image: url('images/closed_window.png'); +} + +/* TODO(estade): find a better color for active. */ +.footer-menu-item:active, +.footer-menu-item:visited, +.footer-menu-item:link { + color: hsl(213, 90%, 24%) !important; +} + +.disclosure-triangle { + -webkit-margin-start: 2px; + -webkit-mask-image: url('images/disclosure_triangle_mask.png'); + background-color: #7F7F7F; + display: inline-block; + height: 9px; + width: 9px; +} + +.footer-menu-context-menu { + /* Needs to be above .footer-menu. */ + z-index: 11; +} + +.footer-menu hr { + background-color: rgb(217, 217, 217); + border: 0; + height: 1px; +} diff --git a/chrome/browser/resources/ntp_search/images/app_promo_button.png b/chrome/browser/resources/ntp_search/images/app_promo_button.png Binary files differnew file mode 100644 index 0000000..9625a7f --- /dev/null +++ b/chrome/browser/resources/ntp_search/images/app_promo_button.png diff --git a/chrome/browser/resources/ntp_search/images/closed_window.png b/chrome/browser/resources/ntp_search/images/closed_window.png Binary files differnew file mode 100644 index 0000000..ba0c05a --- /dev/null +++ b/chrome/browser/resources/ntp_search/images/closed_window.png diff --git a/chrome/browser/resources/ntp_search/images/disclosure_triangle_mask.png b/chrome/browser/resources/ntp_search/images/disclosure_triangle_mask.png Binary files differnew file mode 100644 index 0000000..b034d57 --- /dev/null +++ b/chrome/browser/resources/ntp_search/images/disclosure_triangle_mask.png diff --git a/chrome/browser/resources/ntp_search/logging.js b/chrome/browser/resources/ntp_search/logging.js new file mode 100644 index 0000000..dfd0b14 --- /dev/null +++ b/chrome/browser/resources/ntp_search/logging.js @@ -0,0 +1,32 @@ +// Copyright (c) 2012 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. + +/** + * @fileoverview + * Logging info for benchmarking purposes. Should be the first js file included. + */ + +/* Stack of events that has been logged. */ +var eventLog = []; + +/** + * Logs an event. + * @param {String} name The name of the event (can be any string). + * @param {boolean} shouldLogTime If true, the event is used for benchmarking + * and the time is logged. Otherwise, just push the event on the event + * stack. + */ +function logEvent(name, shouldLogTime) { + if (shouldLogTime) + chrome.send('metricsHandler:logEventTime', [name]); + eventLog.push([name, Date.now()]); +} + +logEvent('Tab.NewTabScriptStart', true); +window.addEventListener('load', function(e) { + logEvent('Tab.NewTabOnload', true); +}); +document.addEventListener('DOMContentLoaded', function(e) { + logEvent('Tab.NewTabDOMContentLoaded', true); +}); diff --git a/chrome/browser/resources/ntp_search/most_visited_page.css b/chrome/browser/resources/ntp_search/most_visited_page.css new file mode 100644 index 0000000..a8c39f9 --- /dev/null +++ b/chrome/browser/resources/ntp_search/most_visited_page.css @@ -0,0 +1,185 @@ +/* Copyright (c) 2012 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. */ + +.most-visited { + position: absolute; + z-index: 0; +} + +.most-visited { + -webkit-box-orient: vertical; + display: -webkit-box; + position: absolute; + text-decoration: none; +} + +.most-visited:focus { + outline: none; +} + +.fills-parent { + bottom: 0; + display: -webkit-box; + left: 0; + position: absolute; + right: 0; + top: 0; +} + +/* filler mode: hide everything except the thumbnail --- leave a grey rectangle + * in its place. */ +.filler * { + visibility: hidden; +} + +.filler { + pointer-events: none; +} + +.most-visited .close-button { + -webkit-transition: opacity 150ms; + opacity: 0; + position: absolute; + right: 0; + top: 0; + z-index: 5; +} + +html[dir=rtl] .most-visited .close-button { + left: 0; + right: auto; +} + +.most-visited:hover .close-button { + -webkit-transition-delay: 500ms; + opacity: 1; +} + +.most-visited .close-button:hover { + -webkit-transition: none; +} + +.most-visited .favicon { + -webkit-margin-start: 5px; + background: no-repeat left 50%; + bottom: 7px; + box-sizing: border-box; + display: block; + height: 16px; + position: absolute; + width: 16px; +} + +html[dir='rtl'] .most-visited .favicon { + background-position-x: right; +} + +.most-visited .color-stripe { + border-bottom-left-radius: 3px 3px; + border-bottom-right-radius: 3px 3px; + /* Matches height of title. */ + bottom: 23px; + height: 3px; + /* Matches padding-top of the title. */ + margin-bottom: 8px; + position: absolute; + width: 100%; + z-index: 10; +} + +.most-visited .title { + display: block; + height: 23px; + overflow: hidden; + padding-top: 8px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.thumbnail { + -webkit-transition: opacity 150ms; + background: no-repeat; + /* This shows for missing thumbnails. */ + background-color: #eee; + background-size: 100%; + border-radius: 3px; + /* These max dimensions are not necessary, as the sizing logic in the .js + * should be sufficient, but they're here for extra insurance. We never want + * to scale a thumbnail larger than this size. */ + max-height: 132px; + max-width: 212px; +} + +.filler .thumbnail { + /* TODO(estade): there seems to be a webkit bug where this border is not + * always removed when it should be. Investigate. */ + border: 1px solid; + visibility: visible; +} + +.thumbnail-shield { + background: -webkit-linear-gradient(rgba(255, 255, 255, 0), + rgba(255, 255, 255, 0) 50%, + rgba(255, 255, 255, 0.9)); + border-radius: 3px; +} + +/* TODO(dbeam): Remove this when printing of -webkit-linear-gradient() works. */ +@media print { + .thumbnail-shield { + background: none; + } +} + +.most-visited:focus .thumbnail, +.most-visited:hover .thumbnail { + opacity: 0.95; +} + +.most-visited:focus .thumbnail-shield, +.most-visited:hover .thumbnail-shield, +.most-visited:active .thumbnail-shield { + background: -webkit-linear-gradient(rgba(255, 255, 255, 0), + rgba(255, 255, 255, 0) 80%, + rgba(255, 255, 255, 0.9)); +} + +/* The thumbnail gets lighter when clicked, but not when the click is on the + * close button. */ +.most-visited:active .close-button:not(:active) + .thumbnail { + opacity: 0.9; +} + +/* The thumbnail gets a shadow when clicked, but not when the click is on the + * close button. */ +.most-visited:active .close-button:not(:active) + .thumbnail .thumbnail-shield { + -webkit-box-shadow: inset 0 1px 10px rgba(0, 0, 0, 0.2); +} + +.thumbnail-wrapper { + -webkit-box-flex: 1; + -webkit-transition: background-color 150ms; + border: 1px solid transparent; + border-radius: 3px; + display: block; + position: relative; + z-index: 5; +} + +.filler .thumbnail-wrapper { + visibility: visible; +} + +/* 'finishing-drag' is the state we are in after dropping on the trash can. + * Override opacity of the tile to 1, so that the new tile animation + * occurs simultaneously with the trash animation. */ +.tile.dragging.finishing-drag { + opacity: 1; +} + +/* Don't display the new tile until there's something to show. */ +.blacklisted { + opacity: 0; +} diff --git a/chrome/browser/resources/ntp_search/most_visited_page.js b/chrome/browser/resources/ntp_search/most_visited_page.js new file mode 100644 index 0000000..8b005b9 --- /dev/null +++ b/chrome/browser/resources/ntp_search/most_visited_page.js @@ -0,0 +1,470 @@ +// Copyright (c) 2012 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. + +cr.define('ntp', function() { + 'use strict'; + + var TilePage = ntp.TilePage; + + /** + * A counter for generating unique tile IDs. + */ + var tileID = 0; + + /** + * Creates a new Most Visited object for tiling. + * @constructor + * @extends {HTMLAnchorElement} + */ + function MostVisited() { + var el = cr.doc.createElement('a'); + el.__proto__ = MostVisited.prototype; + el.initialize(); + + return el; + } + + MostVisited.prototype = { + __proto__: HTMLAnchorElement.prototype, + + initialize: function() { + this.reset(); + + this.addEventListener('click', this.handleClick_); + this.addEventListener('keydown', this.handleKeyDown_); + }, + + get index() { + assert(this.tile); + return this.tile.index; + }, + + get data() { + return this.data_; + }, + + /** + * Clears the DOM hierarchy for this node, setting it back to the default + * for a blank thumbnail. + */ + reset: function() { + this.className = 'most-visited filler real'; + this.innerHTML = + '<span class="thumbnail-wrapper fills-parent">' + + '<div class="close-button"></div>' + + '<span class="thumbnail fills-parent">' + + // thumbnail-shield provides a gradient fade effect. + '<div class="thumbnail-shield fills-parent"></div>' + + '</span>' + + '<span class="favicon"></span>' + + '</span>' + + '<div class="color-stripe"></div>' + + '<span class="title"></span>'; + + this.querySelector('.close-button').title = + loadTimeData.getString('removethumbnailtooltip'); + + this.tabIndex = -1; + this.data_ = null; + this.removeAttribute('id'); + this.title = ''; + }, + + /** + * Update the appearance of this tile according to |data|. + * @param {Object} data A dictionary of relevant data for the page. + */ + updateForData: function(data) { + if (this.classList.contains('blacklisted') && data) { + // Animate appearance of new tile. + this.classList.add('new-tile-contents'); + } + this.classList.remove('blacklisted'); + + if (!data || data.filler) { + if (this.data_) + this.reset(); + return; + } + + var id = tileID++; + this.id = 'most-visited-tile-' + id; + this.data_ = data; + this.classList.add('focusable'); + + var faviconDiv = this.querySelector('.favicon'); + var faviconUrl = 'chrome://favicon/size/16/' + data.url; + faviconDiv.style.backgroundImage = url(faviconUrl); + chrome.send('getFaviconDominantColor', [faviconUrl, this.id]); + + var title = this.querySelector('.title'); + title.textContent = data.title; + title.dir = data.direction; + + // Sets the tooltip. + this.title = data.title; + + var thumbnailUrl = 'chrome://thumb/' + data.url; + this.querySelector('.thumbnail').style.backgroundImage = + url(thumbnailUrl); + + this.href = data.url; + + this.classList.remove('filler'); + }, + + /** + * Sets the color of the favicon dominant color bar. + * @param {string} color The css-parsable value for the color. + */ + set stripeColor(color) { + this.querySelector('.color-stripe').style.backgroundColor = color; + }, + + /** + * Handles a click on the tile. + * @param {Event} e The click event. + */ + handleClick_: function(e) { + if (e.target.classList.contains('close-button')) { + this.blacklist_(); + e.preventDefault(); + } else { + // Records an app launch from the most visited page (Chrome will decide + // whether the url is an app). TODO(estade): this only works for clicks; + // other actions like "open in new tab" from the context menu won't be + // recorded. Can this be fixed? + chrome.send('recordAppLaunchByURL', + [encodeURIComponent(this.href), + ntp.APP_LAUNCH.NTP_MOST_VISITED]); + // Records the index of this tile. + chrome.send('metricsHandler:recordInHistogram', + ['NewTabPage.MostVisited', this.index, 8]); + chrome.send('mostVisitedAction', + [ntp.NtpFollowAction.CLICKED_TILE]); + } + }, + + /** + * Allow blacklisting most visited site using the keyboard. + */ + handleKeyDown_: function(e) { + if (!cr.isMac && e.keyCode == 46 || // Del + cr.isMac && e.metaKey && e.keyCode == 8) { // Cmd + Backspace + this.blacklist_(); + } + }, + + /** + * Permanently removes a page from Most Visited. + */ + blacklist_: function() { + this.showUndoNotification_(); + chrome.send('blacklistURLFromMostVisited', [this.data_.url]); + this.reset(); + chrome.send('getMostVisited'); + this.classList.add('blacklisted'); + }, + + showUndoNotification_: function() { + var data = this.data_; + var self = this; + var doUndo = function() { + chrome.send('removeURLsFromMostVisitedBlacklist', [data.url]); + self.updateForData(data); + } + + var undo = { + action: doUndo, + text: loadTimeData.getString('undothumbnailremove'), + }; + + var undoAll = { + action: function() { + chrome.send('clearMostVisitedURLsBlacklist'); + }, + text: loadTimeData.getString('restoreThumbnailsShort'), + }; + + ntp.showNotification( + loadTimeData.getString('thumbnailremovednotification'), + [undo, undoAll]); + }, + + /** + * Set the size and position of the most visited tile. + * @param {number} size The total size of |this|. + * @param {number} x The x-position. + * @param {number} y The y-position. + * animate. + */ + setBounds: function(size, x, y) { + this.style.width = toCssPx(size); + this.style.height = toCssPx(heightForWidth(size)); + + this.style.left = toCssPx(x); + this.style.right = toCssPx(x); + this.style.top = toCssPx(y); + }, + + /** + * Returns whether this element can be 'removed' from chrome (i.e. whether + * the user can drag it onto the trash and expect something to happen). + * @return {boolean} True, since most visited pages can always be + * blacklisted. + */ + canBeRemoved: function() { + return true; + }, + + /** + * Removes this element from chrome, i.e. blacklists it. + */ + removeFromChrome: function() { + this.blacklist_(); + this.parentNode.classList.add('finishing-drag'); + }, + + /** + * Called when a drag of this tile has ended (after all animations have + * finished). + */ + finalizeDrag: function() { + this.parentNode.classList.remove('finishing-drag'); + }, + + /** + * Called when a drag is starting on the tile. Updates dataTransfer with + * data for this tile (for dragging outside of the NTP). + */ + setDragData: function(dataTransfer) { + dataTransfer.setData('Text', this.data_.title); + dataTransfer.setData('URL', this.data_.url); + }, + }; + + var mostVisitedPageGridValues = { + // The fewest tiles we will show in a row. + minColCount: 2, + // The most tiles we will show in a row. + maxColCount: 4, + + // The smallest a tile can be. + minTileWidth: 122, + // The biggest a tile can be. 212 (max thumbnail width) + 2. + maxTileWidth: 214, + + // The padding between tiles, as a fraction of the tile width. + tileSpacingFraction: 1 / 8, + }; + TilePage.initGridValues(mostVisitedPageGridValues); + + /** + * Calculates the height for a Most Visited tile for a given width. The size + * is based on the thumbnail, which should have a 212:132 ratio. + * @return {number} The height. + */ + function heightForWidth(width) { + // The 2s are for borders, the 31 is for the title. + return (width - 2) * 132 / 212 + 2 + 31; + } + + var THUMBNAIL_COUNT = 8; + + /** + * Creates a new MostVisitedPage object. + * @constructor + * @extends {TilePage} + */ + function MostVisitedPage() { + var el = new TilePage(mostVisitedPageGridValues); + el.__proto__ = MostVisitedPage.prototype; + el.initialize(); + + return el; + } + + MostVisitedPage.prototype = { + __proto__: TilePage.prototype, + + initialize: function() { + this.classList.add('most-visited-page'); + this.data_ = null; + this.mostVisitedTiles_ = this.getElementsByClassName('most-visited real'); + + this.addEventListener('carddeselected', this.handleCardDeselected_); + this.addEventListener('cardselected', this.handleCardSelected_); + }, + + /** + * Create blank (filler) tiles. + * @private + */ + createTiles_: function() { + for (var i = 0; i < THUMBNAIL_COUNT; i++) { + this.appendTile(new MostVisited()); + } + }, + + /** + * Update the tiles after a change to |data_|. + */ + updateTiles_: function() { + for (var i = 0; i < THUMBNAIL_COUNT; i++) { + var page = this.data_[i]; + var tile = this.mostVisitedTiles_[i]; + + if (i >= this.data_.length) + tile.reset(); + else + tile.updateForData(page); + } + }, + + /** + * Handles the 'card deselected' event (i.e. the user clicked to another + * pane). + * @param {Event} e The CardChanged event. + */ + handleCardDeselected_: function(e) { + if (!document.documentElement.classList.contains('starting-up')) { + chrome.send('mostVisitedAction', + [ntp.NtpFollowAction.CLICKED_OTHER_NTP_PANE]); + } + }, + + /** + * Handles the 'card selected' event (i.e. the user clicked to select the + * Most Visited pane). + * @param {Event} e The CardChanged event. + */ + handleCardSelected_: function(e) { + if (!document.documentElement.classList.contains('starting-up')) + chrome.send('mostVisitedSelected'); + }, + + /** + * Array of most visited data objects. + * @type {Array} + */ + get data() { + return this.data_; + }, + set data(data) { + var startTime = Date.now(); + + // The first time data is set, create the tiles. + if (!this.data_) { + this.createTiles_(); + this.data_ = data.slice(0, THUMBNAIL_COUNT); + } else { + this.data_ = refreshData(this.data_, data); + } + + this.updateTiles_(); + logEvent('mostVisited.layout: ' + (Date.now() - startTime)); + }, + + /** @inheritDoc */ + shouldAcceptDrag: function(e) { + return false; + }, + + /** @inheritDoc */ + heightForWidth: heightForWidth, + }; + + /** + * Executed once the NTP has loaded. Checks if the Most Visited pane is + * shown or not. If it is shown, the 'mostVisitedSelected' message is sent + * to the C++ code, to record the fact that the user has seen this pane. + */ + MostVisitedPage.onLoaded = function() { + if (ntp.getCardSlider() && + ntp.getCardSlider().currentCardValue && + ntp.getCardSlider().currentCardValue.classList + .contains('most-visited-page')) { + chrome.send('mostVisitedSelected'); + } + } + + /** + * We've gotten additional Most Visited data. Update our old data with the + * new data. The ordering of the new data is not important, except when a + * page is pinned. Thus we try to minimize re-ordering. + * @param {Array} oldData The current Most Visited page list. + * @param {Array} newData The new Most Visited page list. + * @return {Array} The merged page list that should replace the current page + * list. + */ + function refreshData(oldData, newData) { + oldData = oldData.slice(0, THUMBNAIL_COUNT); + newData = newData.slice(0, THUMBNAIL_COUNT); + + // Copy over pinned sites directly. + for (var j = 0; j < newData.length; j++) { + if (newData[j].pinned) { + oldData[j] = newData[j]; + // Mark the entry as 'updated' so we don't try to update again. + oldData[j].updated = true; + // Mark the newData page as 'used' so we don't try to re-use it. + newData[j].used = true; + } + } + + // Look through old pages; if they exist in the newData list, keep them + // where they are. + for (var i = 0; i < oldData.length; i++) { + if (!oldData[i] || oldData[i].updated) + continue; + + for (var j = 0; j < newData.length; j++) { + if (newData[j].used) + continue; + + if (newData[j].url == oldData[i].url) { + // The background image and other data may have changed. + oldData[i] = newData[j]; + oldData[i].updated = true; + newData[j].used = true; + break; + } + } + } + + // Look through old pages that haven't been updated yet; replace them. + for (var i = 0; i < oldData.length; i++) { + if (oldData[i] && oldData[i].updated) + continue; + + for (var j = 0; j < newData.length; j++) { + if (newData[j].used) + continue; + + oldData[i] = newData[j]; + oldData[i].updated = true; + newData[j].used = true; + break; + } + + if (oldData[i] && !oldData[i].updated) + oldData[i] = null; + } + + // Clear 'updated' flags so this function will work next time it's called. + for (var i = 0; i < THUMBNAIL_COUNT; i++) { + if (oldData[i]) + oldData[i].updated = false; + } + + return oldData; + }; + + return { + MostVisitedPage: MostVisitedPage, + refreshData: refreshData, + }; +}); + +document.addEventListener('ntpLoaded', ntp.MostVisitedPage.onLoaded); diff --git a/chrome/browser/resources/ntp_search/nav_dot.css b/chrome/browser/resources/ntp_search/nav_dot.css new file mode 100644 index 0000000..820aec3 --- /dev/null +++ b/chrome/browser/resources/ntp_search/nav_dot.css @@ -0,0 +1,90 @@ +/* Copyright (c) 2012 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. */ + +/* TODO(estade): handle overflow better? I tried overflow-x: hidden and + overflow-y: visible (for the new dot animation), but this makes a scroll + bar appear */ +#dot-list { + /* Expand to take up all available horizontal space. */ + -webkit-box-flex: 1; + /* Center child dots. */ + -webkit-box-pack: center; + display: -webkit-box; + height: 100%; + list-style-type: none; + margin: 0; + padding: 0; +} + +html.starting-up #dot-list { + display: none; +} + +.dot { + -webkit-box-flex: 1; + -webkit-margin-end: 10px; + -webkit-padding-start: 2px; + -webkit-transition: max-width 250ms, -webkit-margin-end 250ms; + box-sizing: border-box; + cursor: pointer; + /* max-width: Set in new_tab.js. See measureNavDots() */ + outline: none; + text-align: left; +} + +.dot:last-child { + -webkit-margin-end: 0; +} + +.dot.small { + -webkit-margin-end: 0; + max-width: 0; +} + +.dot .selection-bar { + -webkit-transition: border-color 200ms; + border-bottom: 5px solid; + border-color: rgba(0, 0, 0, 0.1); + height: 10px; +} + +.dot input { + -webkit-appearance: caret; + -webkit-margin-start: 2px; + -webkit-transition: color 200ms; + background-color: transparent; + cursor: inherit; + font: inherit; + height: auto; + margin-top: 2px; + padding: 1px 0; + width: 90%; +} + +.dot input:focus { + cursor: auto; +} + +/* Everything below here should be themed but we don't have appropriate colors + * yet. + */ +.dot input { + color: #b2b2b2; +} + +.dot:focus input, +.dot:hover input, +.dot.selected input { + color: #7f7f7f; +} + +.dot:focus .selection-bar, +.dot:hover .selection-bar, +.dot.drag-target .selection-bar { + border-color: #b2b2b2; +} + +.dot.selected .selection-bar { + border-color: #7f7f7f; +} diff --git a/chrome/browser/resources/ntp_search/nav_dot.js b/chrome/browser/resources/ntp_search/nav_dot.js new file mode 100644 index 0000000..72a2fbc --- /dev/null +++ b/chrome/browser/resources/ntp_search/nav_dot.js @@ -0,0 +1,276 @@ +// Copyright (c) 2012 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. + +/** + * @fileoverview Nav dot + * This is the class for the navigation controls that appear along the bottom + * of the NTP. + */ + +cr.define('ntp', function() { + 'use strict'; + + /** + * Creates a new navigation dot. + * @param {TilePage} page The associated TilePage. + * @param {string} title The title of the navigation dot. + * @param {bool} titleIsEditable If true, the title can be changed. + * @param {bool} animate If true, animates into existence. + * @constructor + * @extends {HTMLLIElement} + */ + function NavDot(page, title, titleIsEditable, animate) { + var dot = cr.doc.createElement('li'); + dot.__proto__ = NavDot.prototype; + dot.initialize(page, title, titleIsEditable, animate); + + return dot; + } + + NavDot.prototype = { + __proto__: HTMLLIElement.prototype, + + initialize: function(page, title, titleIsEditable, animate) { + this.className = 'dot'; + this.setAttribute('role', 'button'); + + this.page_ = page; + + var selectionBar = this.ownerDocument.createElement('div'); + selectionBar.className = 'selection-bar'; + this.appendChild(selectionBar); + + // TODO(estade): should there be some limit to the number of characters? + this.input_ = this.ownerDocument.createElement('input'); + this.input_.setAttribute('spellcheck', false); + this.input_.value = title; + // Take the input out of the tab-traversal focus order. + this.input_.disabled = true; + this.appendChild(this.input_); + + this.displayTitle = title; + this.titleIsEditable_ = titleIsEditable; + + this.addEventListener('keydown', this.onKeyDown_); + this.addEventListener('click', this.onClick_); + this.addEventListener('dblclick', this.onDoubleClick_); + this.dragWrapper_ = new cr.ui.DragWrapper(this, this); + this.addEventListener('webkitTransitionEnd', this.onTransitionEnd_); + + this.input_.addEventListener('blur', this.onInputBlur_.bind(this)); + this.input_.addEventListener('mousedown', + this.onInputMouseDown_.bind(this)); + this.input_.addEventListener('keydown', this.onInputKeyDown_.bind(this)); + + if (animate) { + this.classList.add('small'); + var self = this; + window.setTimeout(function() { + self.classList.remove('small'); + }, 0); + } + }, + + /** + * @return {TilePage} The associated TilePage. + */ + get page() { + return this.page_; + }, + + /** + * Sets/gets the display title. + * @type {String} title The display name for this nav dot. + */ + get displayTitle() { + return this.title; + }, + set displayTitle(title) { + this.title = this.input_.value = title; + }, + + /** + * Removes the dot from the page. If |opt_animate| is truthy, we first + * transition the element to 0 width. + * @param {boolean=} opt_animate Whether to animate the removal or not. + */ + remove: function(opt_animate) { + if (opt_animate) + this.classList.add('small'); + else + this.parentNode.removeChild(this); + }, + + /** + * Navigates the card slider to the page for this dot. + */ + switchToPage: function() { + ntp.getCardSlider().selectCardByValue(this.page_, true); + }, + + /** + * Handler for keydown event on the dot. + * @param {Event} e The KeyboardEvent. + */ + onKeyDown_: function(e) { + if (e.keyIdentifier == 'Enter') { + this.onClick_(e); + e.stopPropagation(); + } + }, + + /** + * Clicking causes the associated page to show. + * @param {Event} e The click event. + * @private + */ + onClick_: function(e) { + this.switchToPage(); + // The explicit focus call is necessary because of overriding the default + // handling in onInputMouseDown_. + if (this.ownerDocument.activeElement != this.input_) + this.focus(); + + chrome.send('introMessageDismissed'); + e.stopPropagation(); + }, + + /** + * Double clicks allow the user to edit the page title. + * @param {Event} e The click event. + * @private + */ + onDoubleClick_: function(e) { + if (this.titleIsEditable_) { + this.input_.disabled = false; + this.input_.focus(); + this.input_.select(); + } + }, + + /** + * Prevent mouse down on the input from selecting it. + * @param {Event} e The click event. + * @private + */ + onInputMouseDown_: function(e) { + if (this.ownerDocument.activeElement != this.input_) + e.preventDefault(); + }, + + /** + * Handle keypresses on the input. + * @param {Event} e The click event. + * @private + */ + onInputKeyDown_: function(e) { + switch (e.keyIdentifier) { + case 'U+001B': // Escape cancels edits. + this.input_.value = this.displayTitle; + case 'Enter': // Fall through. + this.input_.blur(); + break; + } + }, + + /** + * When the input blurs, commit the edited changes. + * @param {Event} e The blur event. + * @private + */ + onInputBlur_: function(e) { + window.getSelection().removeAllRanges(); + this.displayTitle = this.input_.value; + ntp.saveAppPageName(this.page_, this.displayTitle); + this.input_.disabled = true; + }, + + shouldAcceptDrag: function(e) { + return this.page_.shouldAcceptDrag(e); + }, + + /** + * A drag has entered the navigation dot. If the user hovers long enough, + * we will navigate to the relevant page. + * @param {Event} e The MouseOver event for the drag. + * @private + */ + doDragEnter: function(e) { + var self = this; + function navPageClearTimeout() { + self.switchToPage(); + self.dragNavTimeout = null; + } + this.dragNavTimeout = window.setTimeout(navPageClearTimeout, 500); + + this.doDragOver(e); + }, + + /** + * A dragged element has moved over the navigation dot. Show the correct + * indicator and prevent default handling so the <input> won't act as a drag + * target. + * @param {Event} e The MouseOver event for the drag. + * @private + */ + doDragOver: function(e) { + e.preventDefault(); + + if (!this.dragWrapper_.isCurrentDragTarget) + ntp.setCurrentDropEffect(e.dataTransfer, 'none'); + else + this.page_.setDropEffect(e.dataTransfer); + }, + + /** + * A dragged element has been dropped on the navigation dot. Tell the page + * to append it. + * @param {Event} e The MouseOver event for the drag. + * @private + */ + doDrop: function(e) { + e.stopPropagation(); + var tile = ntp.getCurrentlyDraggingTile(); + if (tile && tile.tilePage != this.page_) + this.page_.appendDraggingTile(); + // TODO(estade): handle non-tile drags. + + this.cancelDelayedSwitch_(); + }, + + /** + * The drag has left the navigation dot. + * @param {Event} e The MouseOver event for the drag. + * @private + */ + doDragLeave: function(e) { + this.cancelDelayedSwitch_(); + }, + + /** + * Cancels the timer for page switching. + * @private + */ + cancelDelayedSwitch_: function() { + if (this.dragNavTimeout) { + window.clearTimeout(this.dragNavTimeout); + this.dragNavTimeout = null; + } + }, + + /** + * A transition has ended. + * @param {Event} e The transition end event. + * @private + */ + onTransitionEnd_: function(e) { + if (e.propertyName === 'max-width' && this.classList.contains('small')) + this.parentNode.removeChild(this); + }, + }; + + return { + NavDot: NavDot, + }; +}); diff --git a/chrome/browser/resources/ntp_search/new_tab.css b/chrome/browser/resources/ntp_search/new_tab.css new file mode 100644 index 0000000..cdaabae --- /dev/null +++ b/chrome/browser/resources/ntp_search/new_tab.css @@ -0,0 +1,447 @@ +/* Copyright (c) 2012 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. */ + +html { + /* It's necessary to put this here instead of in body in order to get the + background-size of 100% to work properly */ + height: 100%; + overflow: hidden; +} + +body { + /* Don't highlight links when they're tapped. Safari has bugs here that + show up as flicker when dragging in some situations */ + -webkit-tap-highlight-color: transparent; + /* Don't allow selecting text - can occur when dragging */ + -webkit-user-select: none; + background-size: auto 100%; + margin: 0; +} + +/* [hidden] does display:none, but its priority is too low in some cases. */ +[hidden] { + display: none !important; +} + +#notification-container { + -webkit-transition: opacity 200ms, margin-left 200ms; + bottom: 31px; + display: block; + float: left; + position: relative; + text-align: start; + z-index: 15; +} + +html[dir='rtl'] #notification-container { + float: right; +} + +#notification-container.card-changed { + -webkit-transition: none; + opacity: 0; +} + +#notification-container.inactive { + -webkit-transition: opacity 200ms; + opacity: 0; +} + +#notification { + display: inline-block; + font-weight: bold; +} + +#notification > div > div, +#notification > div { + display: inline-block; +} + +/* NOTE: This is in the probable case that we start stuffing 16x16 data URI'd + * icons in the promo notification responses. */ +#notification > span > img { + margin-bottom: -3px; +} + +#notification .close-button { + -webkit-margin-start: 0.5em; + vertical-align: middle; +} + +.close-button { + background: no-repeat; + background-color: transparent; + /* TODO(estade): this should animate between states. */ + background-image: -webkit-image-set( + url('../../../../ui/resources/default_100_percent/close_bar.png') 1x, + url('../../../../ui/resources/default_200_percent/close_bar.png') 2x); + border: 0; + cursor: default; + display: inline-block; + height: 16px; + padding: 0; + width: 16px; +} + +.close-button:hover, +.close-button:focus { + background-image: -webkit-image-set( + url('../../../../ui/resources/default_100_percent/close_bar_hover.png') + 1x, + url('../../../../ui/resources/default_200_percent/close_bar_hover.png') + 2x); +} + +.close-button:active { + background-image: -webkit-image-set( + url('../../../../ui/resources/default_100_percent/close_bar_pressed.png') + 1x, + url('../../../../ui/resources/default_200_percent/close_bar_pressed.png') + 2x); +} + +.link-button { + -webkit-margin-start: 0.5em; +} + +#card-slider-frame { + /* Must match #footer height. */ + bottom: 50px; + overflow: hidden; + /* We want this to fill the window except for the region used + by footer */ + position: fixed; + top: 0; + width: 100%; +} + +body.bare-minimum #card-slider-frame { + bottom: 0; +} + +#page-list { + /* fill the apps-frame */ + display: -webkit-box; + height: 100%; +} + +#attribution { + bottom: 0; + left: auto; + margin-left: 8px; + /* Leave room for the scrollbar. */ + margin-right: 13px; + position: absolute; + right: 0; + text-align: left; + z-index: -5; +} + +/* For themes that right-align their images, we flip the attribution to the + * left to avoid conflicts. We also do this for bare-minimum mode since there + * can be conflicts with the recently closed menu. */ +html[themegravity='right'] #attribution, +body.bare-minimum #attribution, +html[dir='rtl'] #attribution { + left: 0; + right: auto; + text-align: right; +} + +#attribution > span { + display: block; +} + +#footer { + background-image: -webkit-linear-gradient( + rgba(242, 242, 242, 0.9), rgba(222, 222, 222, 0.9)); + bottom: 0; + color: #7F7F7F; + font-size: 0.9em; + font-weight: bold; + overflow: hidden; + position: fixed; + width: 100%; + z-index: 5; +} + +/* TODO(estade): remove this border hack and replace with a webkit-gradient + * border-image on #footer once WebKit supports border-image-slice. + * See https://bugs.webkit.org/show_bug.cgi?id=20127 */ +#footer-border { + height: 1px; +} + +#footer-content { + -webkit-box-align: center; + display: -webkit-box; + height: 49px; +} + +#footer-content > * { + margin: 0 9px; +} + +#logo-img { + margin-top: 4px; +} + +body.bare-minimum #footer { + background: transparent; + bottom: auto; + font-weight: normal; + position: absolute; + right: 0; +} + +html[dir='rtl'] body.bare-minimum #footer { + left: 0; + right: auto; +} + +body.bare-minimum #footer-border, +body.bare-minimum #logo-img, +body.bare-minimum #dot-list { + visibility: hidden; +} + +.starting-up * { + -webkit-transition: none !important; +} + +/* Login Status. **************************************************************/ + +#login-container { + -webkit-box-shadow: none; + background: transparent none; + border: none; + color: inherit; + cursor: pointer; + font: inherit; + /* Leave room for the scrollbar. */ + margin-left: 13px; + margin-right: 13px; + margin-top: 5px; + padding: 0; + position: fixed; + right: 0; + text-align: right; + top: 0; + z-index: 10; +} + +html[dir='rtl'] #login-container { + left: 0; + right: auto; +} + +.login-status-icon { + -webkit-padding-end: 37px; + background-position: right center; + background-repeat: no-repeat; + min-height: 27px; +} + +html[dir='rtl'] .login-status-icon { + background-position-x: left; +} + +.profile-name:hover, +.link-span { + text-decoration: underline; +} + +#login-status-bubble-contents { + font-size: 1.1em; +} + +#login-status-message-container { + margin-bottom: 13px; +} + +#login-status-learn-more { + display: inline-block; +} + +.login-status-row { + -webkit-box-align: center; + -webkit-box-orient: horizontal; + -webkit-box-pack: end; + display: -webkit-box; +} + +#login-status-advanced-container { + -webkit-box-flex: 1; +} + +#login-status-dismiss { + min-width: 6em; +} + +/* Trash. *********************************************************************/ + +#trash { + -webkit-transition: top 200ms, opacity 0; + -webkit-transition-delay: 0, 200ms; + color: #222; + height: 100%; + opacity: 0; + position: absolute; + right: 0; + top: 50px; + width: auto; +} + +html[dir='rtl'] #trash { + left: 0; + right: auto; +} + +#footer.showing-trash-mode #trash { + -webkit-transition-delay: 0, 0; + -webkit-transition-duration: 0, 200ms; + opacity: 0.75; + top: 0; +} + +#footer.showing-trash-mode #trash.drag-target { + opacity: 1; +} + +#trash > .trash-text { + -webkit-padding-end: 7px; + -webkit-padding-start: 30px; + border: 1px dashed #7f7f7f; + border-radius: 4px; + display: inline-block; + padding-bottom: 9px; + padding-top: 10px; + position: relative; + top: 7px; +} + +#trash > .lid, +#trash > .can { + top: 18px; +} + +#footer.showing-trash-mode #trash.drag-target .lid { + -webkit-transform: rotate(-45deg); +} + +html[dir='rtl'] #footer.showing-trash-mode #trash.drag-target .lid { + -webkit-transform: rotate(45deg); +} + +#fontMeasuringDiv { + /* The font attributes match the nav inputs. */ + font-size: 0.9em; + font-weight: bold; + pointer-events: none; + position: absolute; + visibility: hidden; +} + +/* Page switcher buttons. *****************************************************/ + +.page-switcher { + -webkit-transition: width 150ms, right 150ms, background-color 150ms; + background-color: transparent; + border: none; + bottom: 0; + font-size: 40px; + margin: 0; + max-width: 150px; + min-width: 90px; + outline: none; + padding: 0; + position: absolute; + top: 0; + z-index: 5; +} + +#chrome-web-store-link { + -webkit-padding-end: 12px; + /* Match transition delay of recently closed button. */ + -webkit-transition-delay: 100ms; + color: inherit; + cursor: pointer; + display: inline-block; + margin: 0; + text-decoration: none; + white-space: nowrap; +} + +#chrome-web-store-title { + -webkit-padding-end: 36px; + -webkit-padding-start: 15px; + background: url('chrome://theme/IDR_WEBSTORE_ICON_24') right 50% no-repeat; + display: inline-block; + line-height: 49px; +} + +#chrome-web-store-link:hover { + color: #666; +} + +html[dir='rtl'] #chrome-web-store-title { + background-position-x: left; +} + +#vertical-separator { + -webkit-box-ordinal-group: 3; + background-color: rgb(178, 178, 178); + display: none; + height: 20px; + margin: 0; + vertical-align: middle; + width: 1px; +} + +/* Show the separator only if one of the menus is visible. */ +.footer-menu-button:not(.invisible) ~ #vertical-separator { + display: block; +} + +/* In trash mode, hide the menus and web store link. */ +#footer.showing-trash-mode #chrome-web-store-link, +#footer.showing-trash-mode .menu-container { + -webkit-transition-delay: 0; + opacity: 0; + visibility: hidden; +} + +#footer .menu-container { + -webkit-box-align: center; + /* Put menus in a box so the order can easily be swapped. */ + display: -webkit-box; + height: 100%; + margin: 0; +} + +#recently-closed-menu-button.invisible { + visibility: hidden; +} + +#recently-closed-menu-button:not(.invisible) { + /* Put the recently closed menu to the right when it's visible. */ + -webkit-box-ordinal-group: 2; +} + +#other-sessions-menu-button.invisible { + display: none; +} + +.other-sessions-promo-message { + display: none; + padding: 0; +} + +.other-sessions-promo-message:only-child { + display: block; +} + +.other-sessions-promo-message p { + margin: 0; +} diff --git a/chrome/browser/resources/ntp_search/new_tab.html b/chrome/browser/resources/ntp_search/new_tab.html new file mode 100644 index 0000000..3b31e19 --- /dev/null +++ b/chrome/browser/resources/ntp_search/new_tab.html @@ -0,0 +1,206 @@ +<!DOCTYPE html> +<html i18n-values=" + dir:textdirection; + hasattribution:hasattribution; + themegravity:themegravity; + bookmarkbarattached:bookmarkbarattached;" + class="starting-up"> +<head> +<meta charset="utf-8"> +<title i18n-content="title"></title> +<!-- Don't scale the viewport in either portrait or landscape mode. + Note that this means apps will be reflowed when rotated (like iPad). + If we wanted to maintain position we could remove 'maximum-scale' so + that we'd zoom out in portrait mode, but then there would be a bunch + of unusable space at the bottom. +--> +<meta name="viewport" + content="user-scalable=no, width=device-width, maximum-scale=1.0"> + +<!-- It's important that this be the first script loaded. --> +<script src="logging.js"></script> + +<link rel="stylesheet" href="../shared/css/bubble.css"> +<link rel="stylesheet" href="../shared/css/expandable_bubble.css"> +<link rel="stylesheet" href="../shared/css/menu.css"> +<link rel="stylesheet" href="../shared/css/menu_button.css"> +<link rel="stylesheet" href="../shared/css/trash.css"> +<link rel="stylesheet" href="../shared/css/widgets.css"> +<link rel="stylesheet" href="apps_page.css"> +<link rel="stylesheet" href="chrome://newtab/suggestions_page.css"> +<link rel="stylesheet" href="most_visited_page.css"> +<link rel="stylesheet" href="nav_dot.css"> +<link rel="stylesheet" href="new_tab.css"> +<link rel="stylesheet" href="footer_menu.css"> +<link rel="stylesheet" href="tile_page.css"> +<link id="themecss" rel="stylesheet"> + +<script src="../shared/js/event_tracker.js"></script> +<script src="../shared/js/load_time_data.js"></script> +<script src="../shared/js/parse_html_subset.js"></script> +<script src="../shared/js/util.js"></script> + +<script src="../shared/js/cr.js"></script> +<script src="../shared/js/cr/ui.js"></script> +<script src="../shared/js/cr/ui/bubble.js"></script> +<script src="../shared/js/cr/ui/card_slider.js"></script> +<script src="../shared/js/cr/ui/context_menu_handler.js"></script> +<script src="../shared/js/cr/ui/drag_wrapper.js"></script> +<script src="../shared/js/cr/ui/expandable_bubble.js"></script> +<script src="../shared/js/cr/ui/menu.js"></script> +<script src="../shared/js/cr/ui/menu_item.js"></script> +<script src="../shared/js/cr/ui/position_util.js"></script> +<script src="../shared/js/cr/ui/menu_button.js"></script> +<script src="../shared/js/cr/ui/context_menu_button.js"></script> +<script src="../shared/js/cr/ui/touch_handler.js"></script> + +<script src="tile_page.js"></script> +<script src="apps_page.js"></script> +<script src="dot_list.js"></script> +<script src="most_visited_page.js"></script> +<script src="trash.js"></script> +<script src="page_list_view.js"></script> +<script src="page_switcher.js"></script> + +<script src="nav_dot.js"></script> +<script src="new_tab.js"></script> +<script src="recently_closed.js"></script> +<script src="other_sessions.js"></script> +</head> + +<body i18n-values=".style.fontFamily:fontfamily;.style.fontSize:fontsize"> + <button id="login-container" class="custom-appearance" hidden> + <div id="login-status-header-container" class="login-status-row"> + <div id="login-status-header"></div> + </div> + <div id="login-status-sub-header"></div> + </button> + + <div id="notification-container" class="inactive" hidden> + <div id="notification"> + <span></span> + <div id="notificationLinks"></div> + <button class="close-button custom-appearance" class="custom-appearance"> + </button> + </div> + </div> + + <div id="card-slider-frame"> + <button id="page-switcher-start" class="page-switcher custom-appearance" + tabindex="2" hidden>‹ + </button> + <div id="page-list"></div> + <button id="page-switcher-end" class="page-switcher custom-appearance" + tabindex="2" hidden>› + </button> + <div id="attribution"> + <span i18n-content="attributionintro"></span> + <img id="attribution-img"> + </div> + </div> + + <div id="footer"> + <div id="footer-border"></div> + <div id="footer-content"> + <img id="logo-img" src="chrome://theme/IDR_PRODUCT_LOGO"> + + <ul id="dot-list"> + </ul> + + <div class="menu-container"> + <button id="recently-closed-menu-button" + class="footer-menu-button custom-appearance"> + <span i18n-content="recentlyclosed"></span> + <div class="disclosure-triangle"></div> + </button> + + <button id="other-sessions-menu-button" + class="footer-menu-button custom-appearance invisible"> + <span i18n-content="otherSessions"></span> + <div class="disclosure-triangle"></div> + </button> + + <div id="vertical-separator"></div> + </div> + + <a id="chrome-web-store-link"> + <span id="chrome-web-store-title" i18n-content="webStoreTitleShort"> + </span> + </a> + + <div id="trash" class="trash"> + <span class="lid"></span> + <span class="can"></span> + <span class="trash-text" i18n-content="appuninstall"></span> + </div> + </div> + </div> +</body> + +<!-- A div to hold all the templates, and in the darkness bind them. --> +<div hidden> + +<!-- NTP4 intro bubble --> +<div id="ntp4-intro-bubble-contents"> + <div></div> + <a i18n-content="learn_more" target="_blank"></a> +</div> + +<!-- Login status bubble --> +<div id="login-status-bubble-contents"> + <div id="login-status-message-container"> + <span i18n-content="login_status_message"></span> + <a id="login-status-learn-more" i18n-content="learn_more" + i18n-values="href:login_status_url" target="_blank"></a> + </div> + <div class="login-status-row"> + <div id="login-status-advanced-container"> + <a id="login-status-advanced" + i18n-content="login_status_advanced" href="#"></a> + </div> + <button id="login-status-dismiss" i18n-content="login_status_dismiss"> + </button> + </div> +</div> + +<!-- Apps promo. --> +<div id="apps-promo-extras-template" class="apps-promo-extras"> + <h3 class="apps-promo-heading"></h3> + <a class="apps-promo-link g-button-basic"></a> +</div> + +<!-- App Contents w/ Large Icon --> +<div id="app-large-icon-template" class="app-contents"> + <div class="app-img-container"> + <img class="invisible"> + </div> + <span class="title"></span> +</div> + +<!-- App Contents w/ Small Icon --> +<div id="app-small-icon-template" class="app-contents"> + <div class="app-icon-div"> + <div class="app-img-container"> + <img class="invisible"> + </div> + <div class="color-stripe"></div> + </div> + <span class="title"></span> +</div> + +<!-- Message shown in the other sessions menu when the user is signed in but + there is no session data (e.g. they have tab sync turned off). --> +<div id="other-sessions-promo-template" class="other-sessions-promo-message"> + <span i18n-content="otherSessionsEmpty"></span> + <p> + <a i18n-values="href:otherSessionsLearnMoreUrl" i18n-content="learnMore"> + </a> + </p> +</div> + +</div> + +<!-- This is used to measure text in the current locale. It is not visible. --> +<div id="fontMeasuringDiv"></div> + +</html> diff --git a/chrome/browser/resources/ntp_search/new_tab.js b/chrome/browser/resources/ntp_search/new_tab.js new file mode 100644 index 0000000..57c2a68 --- /dev/null +++ b/chrome/browser/resources/ntp_search/new_tab.js @@ -0,0 +1,595 @@ +// Copyright (c) 2012 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. + +/** + * @fileoverview New tab page + * This is the main code for the new tab page used by touch-enabled Chrome + * browsers. For now this is still a prototype. + */ + +// Use an anonymous function to enable strict mode just for this file (which +// will be concatenated with other files when embedded in Chrome +cr.define('ntp', function() { + 'use strict'; + + /** + * NewTabView instance. + * @type {!Object|undefined} + */ + var newTabView; + + /** + * The 'notification-container' element. + * @type {!Element|undefined} + */ + var notificationContainer; + + /** + * If non-null, an info bubble for showing messages to the user. It points at + * the Most Visited label, and is used to draw more attention to the + * navigation dot UI. + * @type {!Element|undefined} + */ + var infoBubble; + + /** + * If non-null, an bubble confirming that the user has signed into sync. It + * points at the login status at the top of the page. + * @type {!Element|undefined} + */ + var loginBubble; + + /** + * true if |loginBubble| should be shown. + * @type {Boolean} + */ + var shouldShowLoginBubble = false; + + /** + * The 'other-sessions-menu-button' element. + * @type {!Element|undefined} + */ + var otherSessionsButton; + + /** + * The time in milliseconds for most transitions. This should match what's + * in new_tab.css. Unfortunately there's no better way to try to time + * something to occur until after a transition has completed. + * @type {number} + * @const + */ + var DEFAULT_TRANSITION_TIME = 500; + + /** + * See description for these values in ntp_stats.h. + * @enum {number} + */ + var NtpFollowAction = { + CLICKED_TILE: 11, + CLICKED_OTHER_NTP_PANE: 12, + OTHER: 13 + }; + + /** + * Creates a NewTabView object. NewTabView extends PageListView with + * new tab UI specific logics. + * @constructor + * @extends {PageListView} + */ + function NewTabView() { + var pageSwitcherStart = null; + var pageSwitcherEnd = null; + if (loadTimeData.getValue('showApps')) { + pageSwitcherStart = getRequiredElement('page-switcher-start'); + pageSwitcherEnd = getRequiredElement('page-switcher-end'); + } + this.initialize(getRequiredElement('page-list'), + getRequiredElement('dot-list'), + getRequiredElement('card-slider-frame'), + getRequiredElement('trash'), + pageSwitcherStart, pageSwitcherEnd); + } + + NewTabView.prototype = { + __proto__: ntp.PageListView.prototype, + + /** @inheritDoc */ + appendTilePage: function(page, title, titleIsEditable, opt_refNode) { + ntp.PageListView.prototype.appendTilePage.apply(this, arguments); + + if (infoBubble) + window.setTimeout(infoBubble.reposition.bind(infoBubble), 0); + } + }; + + /** + * Invoked at startup once the DOM is available to initialize the app. + */ + function onLoad() { + sectionsToWaitFor = loadTimeData.getBoolean('showApps') ? 2 : 1; + if (loadTimeData.getBoolean('isSuggestionsPageEnabled')) + sectionsToWaitFor++; + measureNavDots(); + + // Load the current theme colors. + themeChanged(); + + newTabView = new NewTabView(); + + notificationContainer = getRequiredElement('notification-container'); + notificationContainer.addEventListener( + 'webkitTransitionEnd', onNotificationTransitionEnd); + + cr.ui.decorate($('recently-closed-menu-button'), ntp.RecentMenuButton); + chrome.send('getRecentlyClosedTabs'); + + if (loadTimeData.getBoolean('showOtherSessionsMenu')) { + otherSessionsButton = getRequiredElement('other-sessions-menu-button'); + cr.ui.decorate(otherSessionsButton, ntp.OtherSessionsMenuButton); + otherSessionsButton.initialize(loadTimeData.getBoolean('isUserSignedIn')); + } + + var mostVisited = new ntp.MostVisitedPage(); + // Move the footer into the most visited page if we are in "bare minimum" + // mode. + if (document.body.classList.contains('bare-minimum')) + mostVisited.appendFooter(getRequiredElement('footer')); + newTabView.appendTilePage(mostVisited, + loadTimeData.getString('mostvisited'), + false); + chrome.send('getMostVisited'); + + if (loadTimeData.getBoolean('isSuggestionsPageEnabled')) { + var suggestions_script = document.createElement('script'); + suggestions_script.src = 'suggestions_page.js'; + suggestions_script.onload = function() { + newTabView.appendTilePage(new ntp.SuggestionsPage(), + loadTimeData.getString('suggestions'), + false, + (newTabView.appsPages.length > 0) ? + newTabView.appsPages[0] : null); + chrome.send('getSuggestions'); + cr.dispatchSimpleEvent(document, 'sectionready', true, true); + }; + document.querySelector('head').appendChild(suggestions_script); + } + + var webStoreLink = loadTimeData.getString('webStoreLink'); + var url = appendParam(webStoreLink, 'utm_source', 'chrome-ntp-launcher'); + $('chrome-web-store-link').href = url; + $('chrome-web-store-link').addEventListener('click', + onChromeWebStoreButtonClick); + + if (loadTimeData.getString('login_status_message')) { + loginBubble = new cr.ui.Bubble; + loginBubble.anchorNode = $('login-container'); + loginBubble.setArrowLocation(cr.ui.ArrowLocation.TOP_END); + loginBubble.bubbleAlignment = + cr.ui.BubbleAlignment.BUBBLE_EDGE_TO_ANCHOR_EDGE; + loginBubble.deactivateToDismissDelay = 2000; + loginBubble.setCloseButtonVisible(false); + + $('login-status-advanced').onclick = function() { + chrome.send('showAdvancedLoginUI'); + }; + $('login-status-dismiss').onclick = loginBubble.hide.bind(loginBubble); + + var bubbleContent = $('login-status-bubble-contents'); + loginBubble.content = bubbleContent; + + // The anchor node won't be updated until updateLogin is called so don't + // show the bubble yet. + shouldShowLoginBubble = true; + } + + var loginContainer = getRequiredElement('login-container'); + loginContainer.addEventListener('click', showSyncLoginUI); + chrome.send('initializeSyncLogin'); + + doWhenAllSectionsReady(function() { + // Tell the slider about the pages. + newTabView.updateSliderCards(); + // Mark the current page. + newTabView.cardSlider.currentCardValue.navigationDot.classList.add( + 'selected'); + + if (loadTimeData.valueExists('serverpromo')) { + var promo = loadTimeData.getString('serverpromo'); + var tags = ['IMG']; + var attrs = { + src: function(node, value) { + return node.tagName == 'IMG' && + /^data\:image\/(?:png|gif|jpe?g)/.test(value); + }, + }; + showNotification(parseHtmlSubset(promo, tags, attrs), [], function() { + chrome.send('closeNotificationPromo'); + }, 60000); + chrome.send('notificationPromoViewed'); + } + + cr.dispatchSimpleEvent(document, 'ntpLoaded', true, true); + document.documentElement.classList.remove('starting-up'); + }); + } + + /** + * Launches the chrome web store app with the chrome-ntp-launcher + * source. + * @param {Event} e The click event. + */ + function onChromeWebStoreButtonClick(e) { + chrome.send('recordAppLaunchByURL', + [encodeURIComponent(this.href), + ntp.APP_LAUNCH.NTP_WEBSTORE_FOOTER]); + } + + /* + * The number of sections to wait on. + * @type {number} + */ + var sectionsToWaitFor = -1; + + /** + * Queued callbacks which lie in wait for all sections to be ready. + * @type {array} + */ + var readyCallbacks = []; + + /** + * Fired as each section of pages becomes ready. + * @param {Event} e Each page's synthetic DOM event. + */ + document.addEventListener('sectionready', function(e) { + if (--sectionsToWaitFor <= 0) { + while (readyCallbacks.length) { + readyCallbacks.shift()(); + } + } + }); + + /** + * This is used to simulate a fire-once event (i.e. $(document).ready() in + * jQuery or Y.on('domready') in YUI. If all sections are ready, the callback + * is fired right away. If all pages are not ready yet, the function is queued + * for later execution. + * @param {function} callback The work to be done when ready. + */ + function doWhenAllSectionsReady(callback) { + assert(typeof callback == 'function'); + if (sectionsToWaitFor > 0) + readyCallbacks.push(callback); + else + window.setTimeout(callback, 0); // Do soon after, but asynchronously. + } + + /** + * Fills in an invisible div with the 'Most Visited' string so that + * its length may be measured and the nav dots sized accordingly. + */ + function measureNavDots() { + var measuringDiv = $('fontMeasuringDiv'); + measuringDiv.textContent = loadTimeData.getString('mostvisited'); + // The 4 is for border and padding. + var pxWidth = Math.max(measuringDiv.clientWidth * 1.15 + 4, 80); + + var styleElement = document.createElement('style'); + styleElement.type = 'text/css'; + // max-width is used because if we run out of space, the nav dots will be + // shrunk. + styleElement.textContent = '.dot { max-width: ' + pxWidth + 'px; }'; + document.querySelector('head').appendChild(styleElement); + } + + function themeChanged(opt_hasAttribution) { + $('themecss').href = 'chrome://theme/css/new_tab_theme.css?' + Date.now(); + + if (typeof opt_hasAttribution != 'undefined') { + document.documentElement.setAttribute('hasattribution', + opt_hasAttribution); + } + + updateAttribution(); + } + + function setBookmarkBarAttached(attached) { + document.documentElement.setAttribute('bookmarkbarattached', attached); + } + + /** + * Attributes the attribution image at the bottom left. + */ + function updateAttribution() { + var attribution = $('attribution'); + if (document.documentElement.getAttribute('hasattribution') == 'true') { + $('attribution-img').src = + 'chrome://theme/IDR_THEME_NTP_ATTRIBUTION?' + Date.now(); + attribution.hidden = false; + } else { + attribution.hidden = true; + } + } + + /** + * Timeout ID. + * @type {number} + */ + var notificationTimeout = 0; + + /** + * Shows the notification bubble. + * @param {string|Node} message The notification message or node to use as + * message. + * @param {Array.<{text: string, action: function()}>} links An array of + * records describing the links in the notification. Each record should + * have a 'text' attribute (the display string) and an 'action' attribute + * (a function to run when the link is activated). + * @param {Function} opt_closeHandler The callback invoked if the user + * manually dismisses the notification. + */ + function showNotification(message, links, opt_closeHandler, opt_timeout) { + window.clearTimeout(notificationTimeout); + + var span = document.querySelector('#notification > span'); + if (typeof message == 'string') { + span.textContent = message; + } else { + span.textContent = ''; // Remove all children. + span.appendChild(message); + } + + var linksBin = $('notificationLinks'); + linksBin.textContent = ''; + for (var i = 0; i < links.length; i++) { + var link = linksBin.ownerDocument.createElement('div'); + link.textContent = links[i].text; + link.action = links[i].action; + link.onclick = function() { + this.action(); + hideNotification(); + }; + link.setAttribute('role', 'button'); + link.setAttribute('tabindex', 0); + link.className = 'link-button'; + linksBin.appendChild(link); + } + + function closeFunc(e) { + if (opt_closeHandler) + opt_closeHandler(); + hideNotification(); + } + + document.querySelector('#notification button').onclick = closeFunc; + document.addEventListener('dragstart', closeFunc); + + notificationContainer.hidden = false; + showNotificationOnCurrentPage(); + + newTabView.cardSlider.frame.addEventListener( + 'cardSlider:card_change_ended', onCardChangeEnded); + + var timeout = opt_timeout || 10000; + notificationTimeout = window.setTimeout(hideNotification, timeout); + } + + /** + * Hide the notification bubble. + */ + function hideNotification() { + notificationContainer.classList.add('inactive'); + + newTabView.cardSlider.frame.removeEventListener( + 'cardSlider:card_change_ended', onCardChangeEnded); + } + + /** + * Happens when 1 or more consecutive card changes end. + * @param {Event} e The cardSlider:card_change_ended event. + */ + function onCardChangeEnded(e) { + // If we ended on the same page as we started, ignore. + if (newTabView.cardSlider.currentCardValue.notification) + return; + + // Hide the notification the old page. + notificationContainer.classList.add('card-changed'); + + showNotificationOnCurrentPage(); + } + + /** + * Move and show the notification on the current page. + */ + function showNotificationOnCurrentPage() { + var page = newTabView.cardSlider.currentCardValue; + doWhenAllSectionsReady(function() { + if (page != newTabView.cardSlider.currentCardValue) + return; + + // NOTE: This moves the notification to inside of the current page. + page.notification = notificationContainer; + + // Reveal the notification and instruct it to hide itself if ignored. + notificationContainer.classList.remove('inactive'); + + // Gives the browser time to apply this rule before we remove it (causing + // a transition). + window.setTimeout(function() { + notificationContainer.classList.remove('card-changed'); + }, 0); + }); + } + + /** + * When done fading out, set hidden to true so the notification can't be + * tabbed to or clicked. + * @param {Event} e The webkitTransitionEnd event. + */ + function onNotificationTransitionEnd(e) { + if (notificationContainer.classList.contains('inactive')) + notificationContainer.hidden = true; + } + + function setRecentlyClosedTabs(dataItems) { + $('recently-closed-menu-button').dataItems = dataItems; + } + + function setMostVisitedPages(data, hasBlacklistedUrls) { + newTabView.mostVisitedPage.data = data; + cr.dispatchSimpleEvent(document, 'sectionready', true, true); + } + + function setSuggestionsPages(data, hasBlacklistedUrls) { + newTabView.suggestionsPage.data = data; + } + + /** + * Set the dominant color for a node. This will be called in response to + * getFaviconDominantColor. The node represented by |id| better have a setter + * for stripeColor. + * @param {string} id The ID of a node. + * @param {string} color The color represented as a CSS string. + */ + function setStripeColor(id, color) { + var node = $(id); + if (node) + node.stripeColor = color; + } + + /** + * Updates the text displayed in the login container. If there is no text then + * the login container is hidden. + * @param {string} loginHeader The first line of text. + * @param {string} loginSubHeader The second line of text. + * @param {string} iconURL The url for the login status icon. If this is null + then the login status icon is hidden. + * @param {boolean} isUserSignedIn Indicates if the user is signed in or not. + */ + function updateLogin(loginHeader, loginSubHeader, iconURL, isUserSignedIn) { + if (loginHeader || loginSubHeader) { + $('login-container').hidden = false; + $('login-status-header').innerHTML = loginHeader; + $('login-status-sub-header').innerHTML = loginSubHeader; + $('card-slider-frame').classList.add('showing-login-area'); + + if (iconURL) { + $('login-status-header-container').style.backgroundImage = url(iconURL); + $('login-status-header-container').classList.add('login-status-icon'); + } else { + $('login-status-header-container').style.backgroundImage = 'none'; + $('login-status-header-container').classList.remove( + 'login-status-icon'); + } + } else { + $('login-container').hidden = true; + $('card-slider-frame').classList.remove('showing-login-area'); + } + if (shouldShowLoginBubble) { + window.setTimeout(loginBubble.show.bind(loginBubble), 0); + chrome.send('loginMessageSeen'); + shouldShowLoginBubble = false; + } else if (loginBubble) { + loginBubble.reposition(); + } + if (otherSessionsButton) + otherSessionsButton.updateSignInState(isUserSignedIn); + } + + /** + * Show the sync login UI. + * @param {Event} e The click event. + */ + function showSyncLoginUI(e) { + var rect = e.currentTarget.getBoundingClientRect(); + chrome.send('showSyncLoginUI', + [rect.left, rect.top, rect.width, rect.height]); + } + + /** + * Wrappers to forward the callback to corresponding PageListView member. + */ + function appAdded() { + return newTabView.appAdded.apply(newTabView, arguments); + } + + function appMoved() { + return newTabView.appMoved.apply(newTabView, arguments); + } + + function appRemoved() { + return newTabView.appRemoved.apply(newTabView, arguments); + } + + function appsPrefChangeCallback() { + return newTabView.appsPrefChangedCallback.apply(newTabView, arguments); + } + + function appsReordered() { + return newTabView.appsReordered.apply(newTabView, arguments); + } + + function enterRearrangeMode() { + return newTabView.enterRearrangeMode.apply(newTabView, arguments); + } + + function setForeignSessions(sessionList, isTabSyncEnabled) { + if (otherSessionsButton) + otherSessionsButton.setForeignSessions(sessionList, isTabSyncEnabled); + } + + function getAppsCallback() { + return newTabView.getAppsCallback.apply(newTabView, arguments); + } + + function getAppsPageIndex() { + return newTabView.getAppsPageIndex.apply(newTabView, arguments); + } + + function getCardSlider() { + return newTabView.cardSlider; + } + + function leaveRearrangeMode() { + return newTabView.leaveRearrangeMode.apply(newTabView, arguments); + } + + function saveAppPageName() { + return newTabView.saveAppPageName.apply(newTabView, arguments); + } + + function setAppToBeHighlighted(appId) { + newTabView.highlightAppId = appId; + } + + // Return an object with all the exports + return { + appAdded: appAdded, + appMoved: appMoved, + appRemoved: appRemoved, + appsPrefChangeCallback: appsPrefChangeCallback, + enterRearrangeMode: enterRearrangeMode, + getAppsCallback: getAppsCallback, + getAppsPageIndex: getAppsPageIndex, + getCardSlider: getCardSlider, + onLoad: onLoad, + leaveRearrangeMode: leaveRearrangeMode, + NtpFollowAction: NtpFollowAction, + saveAppPageName: saveAppPageName, + setAppToBeHighlighted: setAppToBeHighlighted, + setBookmarkBarAttached: setBookmarkBarAttached, + setForeignSessions: setForeignSessions, + setMostVisitedPages: setMostVisitedPages, + setSuggestionsPages: setSuggestionsPages, + setRecentlyClosedTabs: setRecentlyClosedTabs, + setStripeColor: setStripeColor, + showNotification: showNotification, + themeChanged: themeChanged, + updateLogin: updateLogin + }; +}); + +document.addEventListener('DOMContentLoaded', ntp.onLoad); + +var toCssPx = cr.ui.toCssPx; diff --git a/chrome/browser/resources/ntp_search/new_tab_theme.css b/chrome/browser/resources/ntp_search/new_tab_theme.css new file mode 100644 index 0000000..8f268f3 --- /dev/null +++ b/chrome/browser/resources/ntp_search/new_tab_theme.css @@ -0,0 +1,109 @@ +/* Copyright (c) 2012 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. */ + +html { + background-attachment: fixed; + background-color: $2; /* COLOR_NTP_BACKGROUND */ + background-image: url(chrome://theme/IDR_THEME_NTP_BACKGROUND?$1); + background-position: $3; + background-repeat: $5; +} + +html[bookmarkbarattached='true'] { + background-position: $4; +} + +body { + color: $8; /* COLOR_NTP_TEXT */ + height: 100%; + overflow: auto; +} + +#attribution, +.link-button, +.link-span { + color: $21; /* COLOR_NTP_TEXT_LIGHT */ +} + +.link-button:active { + color: $8; /* COLOR_NTP_TEXT */ +} + +.page-switcher { + color: rgba($23, 0.5); /* COLOR_NTP_TEXT */ +} + +.page-switcher:hover, +.page-switcher:focus, +.page-switcher.drag-target { + background-color: rgba($23, 0.06); /* COLOR_NTP_TEXT */ +} + +/* Only change the background to a gradient when a promo is showing. */ +.showing-login-area #page-switcher-end:hover, +.showing-login-area #page-switcher-end:focus, +.showing-login-area #page-switcher-end.drag-target { + background: + -webkit-linear-gradient(top, rgba($23, 0) 0, + rgba($23, .01) 60px, + rgba($23, .06) 183px); /* COLOR_NTP_TEXT */ +} + +.tile-page-scrollbar { + background-color: $21; /* COLOR_NTP_TEXT_LIGHT */ +} + +/* Footer *********************************************************************/ + +#footer-border { + background: -webkit-linear-gradient(left, + rgba($22, 0.2), /* COLOR_NTP_SECTION_BORDER */ + rgba($22, 0.3) 20%, /* COLOR_NTP_SECTION_BORDER */ + rgba($22, 0.3) 80%, /* COLOR_NTP_SECTION_BORDER */ + rgba($22, 0.2)); /* COLOR_NTP_SECTION_BORDER */ +} + +.dot input:focus { + background-color: $2; /* COLOR_NTP_BACKGROUND */ +} + +.bare-minimum #footer { + color: $8; /* COLOR_NTP_TEXT */ +} + +.bare-minimum .disclosure-triangle { + background-color: $8; /* COLOR_NTP_TEXT */ +} + +.bare-minimum .footer-menu-button:hover, +.bare-minimum .footer-menu-button[menu-shown], +.bare-minimum #chrome-web-store-link:hover { + color: rgba($23, 0.85); /* COLOR_NTP_TEXT */ +} + +.bare-minimum .footer-menu-button:hover .disclosure-triangle, +.bare-minimum .footer-menu-button[menu-shown] .disclosure-triangle { + background-color: rgba($23, 0.85); /* COLOR_NTP_TEXT */ +} + +/* Most Visited ***************************************************************/ + +.most-visited, +.most-visited > .title { + color: $8; /* COLOR_NTP_TEXT */ +} + +.most-visited:focus:not(.filler) .thumbnail-wrapper, +.most-visited:hover:not(.filler) .thumbnail-wrapper { + background-color: $6; /* COLOR_NTP_HEADER */ +} + +.thumbnail-wrapper { + /* This shows through at the (rounded) thumbnail's corners. */ + background-color: $11; /* COLOR_NTP_SECTION_BORDER */ +} + +.filler .thumbnail { + border-color: $2; /* COLOR_NTP_BACKGROUND */ +} diff --git a/chrome/browser/resources/ntp_search/other_sessions.js b/chrome/browser/resources/ntp_search/other_sessions.js new file mode 100644 index 0000000..1a0f60d --- /dev/null +++ b/chrome/browser/resources/ntp_search/other_sessions.js @@ -0,0 +1,361 @@ +// Copyright (c) 2012 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. + +/** + * @fileoverview The menu that shows tabs from sessions on other devices. + */ + +cr.define('ntp', function() { + 'use strict'; + + /** @const */ var ContextMenuButton = cr.ui.ContextMenuButton; + /** @const */ var Menu = cr.ui.Menu; + /** @const */ var MenuItem = cr.ui.MenuItem; + /** @const */ var MenuButton = cr.ui.MenuButton; + /** @const */ var OtherSessionsMenuButton = cr.ui.define('button'); + + // Histogram buckets for UMA tracking of menu usage. + /** @const */ var HISTOGRAM_EVENT = { + INITIALIZED: 0, + SHOW_MENU: 1, + LINK_CLICKED: 2, + LINK_RIGHT_CLICKED: 3, + SESSION_NAME_RIGHT_CLICKED: 4, + SHOW_SESSION_MENU: 5, + COLLAPSE_SESSION: 6, + EXPAND_SESSION: 7, + OPEN_ALL: 8 + }; + /** @const */ var HISTOGRAM_EVENT_LIMIT = + HISTOGRAM_EVENT.OPEN_ALL + 1; + + /** + * Record an event in the UMA histogram. + * @param {Number} eventId The id of the event to be recorded. + * @private + */ + function recordUmaEvent_(eventId) { + chrome.send('metricsHandler:recordInHistogram', + ['NewTabPage.OtherSessionsMenu', eventId, HISTOGRAM_EVENT_LIMIT]); + } + + OtherSessionsMenuButton.prototype = { + __proto__: MenuButton.prototype, + + decorate: function() { + MenuButton.prototype.decorate.call(this); + this.menu = new Menu; + cr.ui.decorate(this.menu, Menu); + this.menu.classList.add('footer-menu'); + this.menu.addEventListener('contextmenu', + this.onContextMenu_.bind(this), true); + document.body.appendChild(this.menu); + + // Create the context menu that appears when the user right clicks + // on a device name. + this.deviceContextMenu_ = DeviceContextMenuController.getInstance().menu; + document.body.appendChild(this.deviceContextMenu_); + + this.promoMessage_ = $('other-sessions-promo-template').cloneNode(true); + this.promoMessage_.removeAttribute('id'); // Prevent a duplicate id. + + this.sessions_ = []; + this.anchorType = cr.ui.AnchorType.ABOVE; + this.invertLeftRight = true; + + // Initialize the images for the drop-down buttons that appear beside the + // session names. + MenuButton.createDropDownArrows(); + + recordUmaEvent_(HISTOGRAM_EVENT.INITIALIZED); + }, + + /** + * Initialize this element. + * @param {boolean} signedIn Is the current user signed in? + */ + initialize: function(signedIn) { + this.updateSignInState(signedIn); + }, + + /** + * Handle a context menu event for an object in the menu's DOM subtree. + */ + onContextMenu_: function(e) { + // Only record the action if it occurred in one of the menu items or + // on one of the session headings. + if (findAncestorByClass(e.target, 'footer-menu-item')) { + recordUmaEvent_(HISTOGRAM_EVENT.LINK_RIGHT_CLICKED); + } else { + var heading = findAncestorByClass(e.target, 'session-heading'); + if (heading) { + recordUmaEvent_(HISTOGRAM_EVENT.SESSION_NAME_RIGHT_CLICKED); + + // Let the context menu know which session it was invoked on, + // since they all share the same instance of the menu. + DeviceContextMenuController.getInstance().setSession( + heading.sessionData_); + } + } + }, + + /** + * Hides the menu. + * @override + */ + hideMenu: function() { + // Don't hide if the device context menu is currently showing. + if (this.deviceContextMenu_.hidden) + MenuButton.prototype.hideMenu.call(this); + }, + + /** + * Shows the menu, first rebuilding it if necessary. + * TODO(estade): the right of the menu should align with the right of the + * button. + * @override + */ + showMenu: function() { + if (this.sessions_.length == 0) + chrome.send('getForeignSessions'); + recordUmaEvent_(HISTOGRAM_EVENT.SHOW_MENU); + MenuButton.prototype.showMenu.call(this); + + // Work around https://bugs.webkit.org/show_bug.cgi?id=85884. + this.menu.scrollTop = 0; + }, + + /** + * Reset the menu contents to the default state. + * @private + */ + resetMenuContents_: function() { + this.menu.innerHTML = ''; + this.menu.appendChild(this.promoMessage_); + }, + + /** + * Create a custom click handler for a link, so that clicking on a link + * restores the session (including back stack) rather than just opening + * the URL. + */ + makeClickHandler_: function(sessionTag, windowId, tabId) { + var self = this; + return function(e) { + recordUmaEvent_(HISTOGRAM_EVENT.LINK_CLICKED); + chrome.send('openForeignSession', [sessionTag, windowId, tabId, + e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]); + e.preventDefault(); + }; + }, + + /** + * Add the UI for a foreign session to the menu. + * @param {Object} session Object describing the foreign session. + */ + addSession_: function(session) { + var doc = this.ownerDocument; + + var section = doc.createElement('section'); + this.menu.appendChild(section); + + var heading = doc.createElement('h3'); + heading.className = 'session-heading'; + heading.textContent = session.name; + heading.sessionData_ = session; + section.appendChild(heading); + + var dropDownButton = new ContextMenuButton; + dropDownButton.classList.add('drop-down'); + // Keep track of the drop down that triggered the menu, so we know + // which element to apply the command to. + function handleDropDownFocus(e) { + DeviceContextMenuController.getInstance().setSession(session); + } + dropDownButton.addEventListener('mousedown', handleDropDownFocus); + dropDownButton.addEventListener('focus', handleDropDownFocus); + heading.appendChild(dropDownButton); + + var timeSpan = doc.createElement('span'); + timeSpan.className = 'details'; + timeSpan.textContent = session.modifiedTime; + heading.appendChild(timeSpan); + + cr.ui.contextMenuHandler.setContextMenu(heading, + this.deviceContextMenu_); + + if (!session.collapsed) + section.appendChild(this.createSessionContents_(session)); + }, + + /** + * Create the DOM tree representing the tabs and windows in a session. + * @param {Object} session The session model object. + * @return {Element} A single div containing the list of tabs & windows. + * @private + */ + createSessionContents_: function(session) { + var doc = this.ownerDocument; + var contents = doc.createElement('div'); + + for (var i = 0; i < session.windows.length; i++) { + var window = session.windows[i]; + + // Show a separator between multiple windows in the same session. + if (i > 0) + contents.appendChild(doc.createElement('hr')); + + for (var j = 0; j < window.tabs.length; j++) { + var tab = window.tabs[j]; + var a = doc.createElement('a'); + a.className = 'footer-menu-item'; + a.textContent = tab.title; + a.href = tab.url; + a.style.backgroundImage = url('chrome://session-favicon/' + tab.url); + + var clickHandler = this.makeClickHandler_( + session.tag, String(window.sessionId), String(tab.sessionId)); + a.addEventListener('click', clickHandler); + contents.appendChild(a); + } + } + + return contents; + }, + + /** + * Sets the menu model data. An empty list means that either there are no + * foreign sessions, or tab sync is disabled for this profile. + * |isTabSyncEnabled| makes it possible to distinguish between the cases. + * + * @param {Array} sessionList Array of objects describing the sessions + * from other devices. + * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile? + */ + setForeignSessions: function(sessionList, isTabSyncEnabled) { + this.sessions_ = sessionList; + this.resetMenuContents_(); + if (sessionList.length > 0) { + // Rebuild the menu with the new data. + for (var i = 0; i < sessionList.length; i++) { + this.addSession_(sessionList[i]); + } + } + + // The menu button is shown iff tab sync is enabled. + if (isTabSyncEnabled) + this.classList.remove('invisible'); + else + this.classList.add('invisible'); + }, + + /** + * Called when this element is initialized, and from the new tab page when + * the user's signed in state changes, + * @param {boolean} signedIn Is the user currently signed in? + */ + updateSignInState: function(signedIn) { + if (signedIn) + chrome.send('getForeignSessions'); + else + this.classList.add('invisible'); + }, + }; + + /** + * Controller for the context menu for device names in the list of sessions. + * This class is designed to be used as a singleton. + * + * @constructor + */ + function DeviceContextMenuController() { + this.__proto__ = DeviceContextMenuController.prototype; + this.initialize(); + } + cr.addSingletonGetter(DeviceContextMenuController); + + DeviceContextMenuController.prototype = { + + initialize: function() { + var menu = new cr.ui.Menu; + cr.ui.decorate(menu, cr.ui.Menu); + menu.classList.add('device-context-menu'); + menu.classList.add('footer-menu-context-menu'); + this.menu = menu; + this.collapseItem_ = this.appendMenuItem_('collapseSessionMenuItemText'); + this.collapseItem_.addEventListener('activate', + this.onCollapseOrExpand_.bind(this)); + this.expandItem_ = this.appendMenuItem_('expandSessionMenuItemText'); + this.expandItem_.addEventListener('activate', + this.onCollapseOrExpand_.bind(this)); + this.openAllItem_ = this.appendMenuItem_('restoreSessionMenuItemText'); + this.openAllItem_.addEventListener('activate', + this.onOpenAll_.bind(this)); + }, + + /** + * Appends a menu item to |this.menu|. + * @param {String} textId The ID for the localized string that acts as + * the item's label. + */ + appendMenuItem_: function(textId) { + var button = cr.doc.createElement('button'); + this.menu.appendChild(button); + cr.ui.decorate(button, cr.ui.MenuItem); + button.textContent = loadTimeData.getString(textId); + return button; + }, + + /** + * Handler for the 'Collapse' and 'Expand' menu items. + * @param {Event} e The activation event. + * @private + */ + onCollapseOrExpand_: function(e) { + this.session_.collapsed = !this.session_.collapsed; + this.updateMenuItems_(); + chrome.send('setForeignSessionCollapsed', + [this.session_.tag, this.session_.collapsed]); + chrome.send('getForeignSessions'); // Refresh the list. + + var eventId = this.session_.collapsed ? + HISTOGRAM_EVENT.COLLAPSE_SESSION : HISTOGRAM_EVENT.EXPAND_SESSION; + recordUmaEvent_(eventId); + }, + + /** + * Handler for the 'Open all' menu item. + * @param {Event} e The activation event. + * @private + */ + onOpenAll_: function(e) { + chrome.send('openForeignSession', [this.session_.tag]); + recordUmaEvent_(HISTOGRAM_EVENT.OPEN_ALL); + }, + + /** + * Set the session data for the session the context menu was invoked on. + * This should never be called when the menu is visible. + * @param {Object} session The model object for the session. + */ + setSession: function(session) { + this.session_ = session; + this.updateMenuItems_(); + }, + + /** + * Set the visibility of the Expand/Collapse menu items based on the state + * of the session that this menu is currently associated with. + * @private + */ + updateMenuItems_: function() { + this.collapseItem_.hidden = this.session_.collapsed; + this.expandItem_.hidden = !this.session_.collapsed; + } + }; + + return { + OtherSessionsMenuButton: OtherSessionsMenuButton, + }; +}); diff --git a/chrome/browser/resources/ntp_search/page_list_view.js b/chrome/browser/resources/ntp_search/page_list_view.js new file mode 100644 index 0000000..0eb37be --- /dev/null +++ b/chrome/browser/resources/ntp_search/page_list_view.js @@ -0,0 +1,767 @@ +// Copyright (c) 2012 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. + +/** + * @fileoverview PageListView implementation. + * PageListView manages page list, dot list, switcher buttons and handles apps + * pages callbacks from backend. + * + * Note that you need to have AppLauncherHandler in your WebUI to use this code. + */ + +cr.define('ntp', function() { + 'use strict'; + + /** + * Creates a PageListView object. + * @constructor + * @extends {Object} + */ + function PageListView() { + } + + PageListView.prototype = { + /** + * The CardSlider object to use for changing app pages. + * @type {CardSlider|undefined} + */ + cardSlider: undefined, + + /** + * The frame div for this.cardSlider. + * @type {!Element|undefined} + */ + sliderFrame: undefined, + + /** + * The 'page-list' element. + * @type {!Element|undefined} + */ + pageList: undefined, + + /** + * A list of all 'tile-page' elements. + * @type {!NodeList|undefined} + */ + tilePages: undefined, + + /** + * A list of all 'apps-page' elements. + * @type {!NodeList|undefined} + */ + appsPages: undefined, + + /** + * The Suggestions page. + * @type {!Element|undefined} + */ + suggestionsPage: undefined, + + /** + * The Most Visited page. + * @type {!Element|undefined} + */ + mostVisitedPage: undefined, + + /** + * The 'dots-list' element. + * @type {!Element|undefined} + */ + dotList: undefined, + + /** + * The left and right paging buttons. + * @type {!Element|undefined} + */ + pageSwitcherStart: undefined, + pageSwitcherEnd: undefined, + + /** + * The 'trash' element. Note that technically this is unnecessary, + * JavaScript creates the object for us based on the id. But I don't want + * to rely on the ID being the same, and JSCompiler doesn't know about it. + * @type {!Element|undefined} + */ + trash: undefined, + + /** + * The type of page that is currently shown. The value is a numerical ID. + * @type {number} + */ + shownPage: 0, + + /** + * The index of the page that is currently shown, within the page type. + * For example if the third Apps page is showing, this will be 2. + * @type {number} + */ + shownPageIndex: 0, + + /** + * EventTracker for managing event listeners for page events. + * @type {!EventTracker} + */ + eventTracker: new EventTracker, + + /** + * If non-null, this is the ID of the app to highlight to the user the next + * time getAppsCallback runs. "Highlight" in this case means to switch to + * the page and run the new tile animation. + * @type {?string} + */ + highlightAppId: null, + + /** + * Initializes page list view. + * @param {!Element} pageList A DIV element to host all pages. + * @param {!Element} dotList An UL element to host nav dots. Each dot + * represents a page. + * @param {!Element} cardSliderFrame The card slider frame that hosts + * pageList and switcher buttons. + * @param {!Element|undefined} opt_trash Optional trash element. + * @param {!Element|undefined} opt_pageSwitcherStart Optional start page + * switcher button. + * @param {!Element|undefined} opt_pageSwitcherEnd Optional end page + * switcher button. + */ + initialize: function(pageList, dotList, cardSliderFrame, opt_trash, + opt_pageSwitcherStart, opt_pageSwitcherEnd) { + this.pageList = pageList; + + this.dotList = dotList; + cr.ui.decorate(this.dotList, ntp.DotList); + + this.trash = opt_trash; + if (this.trash) + new ntp.Trash(this.trash); + + this.pageSwitcherStart = opt_pageSwitcherStart; + if (this.pageSwitcherStart) + ntp.initializePageSwitcher(this.pageSwitcherStart); + + this.pageSwitcherEnd = opt_pageSwitcherEnd; + if (this.pageSwitcherEnd) + ntp.initializePageSwitcher(this.pageSwitcherEnd); + + this.shownPage = loadTimeData.getInteger('shown_page_type'); + this.shownPageIndex = loadTimeData.getInteger('shown_page_index'); + + if (loadTimeData.getBoolean('showApps')) { + // Request data on the apps so we can fill them in. + // Note that this is kicked off asynchronously. 'getAppsCallback' will + // be invoked at some point after this function returns. + chrome.send('getApps'); + } else { + // No apps page. + if (this.shownPage == loadTimeData.getInteger('apps_page_id')) { + this.setShownPage_( + loadTimeData.getInteger('most_visited_page_id'), 0); + } + + document.body.classList.add('bare-minimum'); + } + + document.addEventListener('keydown', this.onDocKeyDown_.bind(this)); + // Prevent touch events from triggering any sort of native scrolling. + document.addEventListener('touchmove', function(e) { + e.preventDefault(); + }, true); + + this.tilePages = this.pageList.getElementsByClassName('tile-page'); + this.appsPages = this.pageList.getElementsByClassName('apps-page'); + + // Initialize the cardSlider without any cards at the moment. + this.sliderFrame = cardSliderFrame; + this.cardSlider = new cr.ui.CardSlider(this.sliderFrame, this.pageList, + this.sliderFrame.offsetWidth); + + // Handle mousewheel events anywhere in the card slider, so that wheel + // events on the page switchers will still scroll the page. + // This listener must be added before the card slider is initialized, + // because it needs to be called before the card slider's handler. + var cardSlider = this.cardSlider; + cardSliderFrame.addEventListener('mousewheel', function(e) { + if (cardSlider.currentCardValue.handleMouseWheel(e)) { + e.preventDefault(); // Prevent default scroll behavior. + e.stopImmediatePropagation(); // Prevent horizontal card flipping. + } + }); + + this.cardSlider.initialize( + loadTimeData.getBoolean('isSwipeTrackingFromScrollEventsEnabled')); + + // Handle events from the card slider. + this.pageList.addEventListener('cardSlider:card_changed', + this.onCardChanged_.bind(this)); + this.pageList.addEventListener('cardSlider:card_added', + this.onCardAdded_.bind(this)); + this.pageList.addEventListener('cardSlider:card_removed', + this.onCardRemoved_.bind(this)); + + // Ensure the slider is resized appropriately with the window. + window.addEventListener('resize', this.onWindowResize_.bind(this)); + + // Update apps when online state changes. + window.addEventListener('online', + this.updateOfflineEnabledApps_.bind(this)); + window.addEventListener('offline', + this.updateOfflineEnabledApps_.bind(this)); + }, + + /** + * Appends a tile page. + * + * @param {TilePage} page The page element. + * @param {string} title The title of the tile page. + * @param {bool} titleIsEditable If true, the title can be changed. + * @param {TilePage} opt_refNode Optional reference node to insert in front + * of. + * When opt_refNode is falsey, |page| will just be appended to the end of + * the page list. + */ + appendTilePage: function(page, title, titleIsEditable, opt_refNode) { + if (opt_refNode) { + var refIndex = this.getTilePageIndex(opt_refNode); + this.cardSlider.addCardAtIndex(page, refIndex); + } else { + this.cardSlider.appendCard(page); + } + + // Remember special MostVisitedPage. + if (typeof ntp.MostVisitedPage != 'undefined' && + page instanceof ntp.MostVisitedPage) { + assert(this.tilePages.length == 1, + 'MostVisitedPage should be added as first tile page'); + this.mostVisitedPage = page; + } + + if (typeof ntp.SuggestionsPage != 'undefined' && + page instanceof ntp.SuggestionsPage) { + this.suggestionsPage = page; + } + + // If we're appending an AppsPage and it's a temporary page, animate it. + var animate = page instanceof ntp.AppsPage && + page.classList.contains('temporary'); + // Make a deep copy of the dot template to add a new one. + var newDot = new ntp.NavDot(page, title, titleIsEditable, animate); + page.navigationDot = newDot; + this.dotList.insertBefore(newDot, + opt_refNode ? opt_refNode.navigationDot : null); + // Set a tab index on the first dot. + if (this.dotList.dots.length == 1) + newDot.tabIndex = 3; + + this.eventTracker.add(page, 'pagelayout', this.onPageLayout_.bind(this)); + }, + + /** + * Called by chrome when an app has changed positions. + * @param {Object} appData The data for the app. This contains page and + * position indices. + */ + appMoved: function(appData) { + assert(loadTimeData.getBoolean('showApps')); + + var app = $(appData.id); + assert(app, 'trying to move an app that doesn\'t exist'); + app.remove(false); + + this.appsPages[appData.page_index].insertApp(appData, false); + }, + + /** + * Called by chrome when an existing app has been disabled or + * removed/uninstalled from chrome. + * @param {Object} appData A data structure full of relevant information for + * the app. + * @param {boolean} isUninstall True if the app is being uninstalled; + * false if the app is being disabled. + * @param {boolean} fromPage True if the removal was from the current page. + */ + appRemoved: function(appData, isUninstall, fromPage) { + assert(loadTimeData.getBoolean('showApps')); + + var app = $(appData.id); + assert(app, 'trying to remove an app that doesn\'t exist'); + + if (!isUninstall) + app.replaceAppData(appData); + else + app.remove(!!fromPage); + }, + + /** + * @return {boolean} If the page is still starting up. + * @private + */ + isStartingUp_: function() { + return document.documentElement.classList.contains('starting-up'); + }, + + /** + * Tracks whether apps have been loaded at least once. + * @type {boolean} + * @private + */ + appsLoaded_: false, + + /** + * Callback invoked by chrome with the apps available. + * + * Note that calls to this function can occur at any time, not just in + * response to a getApps request. For example, when a user + * installs/uninstalls an app on another synchronized devices. + * @param {Object} data An object with all the data on available + * applications. + */ + getAppsCallback: function(data) { + assert(loadTimeData.getBoolean('showApps')); + + var startTime = Date.now(); + + // Remember this to select the correct card when done rebuilding. + var prevCurrentCard = this.cardSlider.currentCard; + + // Make removal of pages and dots as quick as possible with less DOM + // operations, reflows, or repaints. We set currentCard = 0 and remove + // from the end to not encounter any auto-magic card selections in the + // process and we hide the card slider throughout. + this.cardSlider.currentCard = 0; + + // Clear any existing apps pages and dots. + // TODO(rbyers): It might be nice to preserve animation of dots after an + // uninstall. Could we re-use the existing page and dot elements? It + // seems unfortunate to have Chrome send us the entire apps list after an + // uninstall. + while (this.appsPages.length > 0) + this.removeTilePageAndDot_(this.appsPages[this.appsPages.length - 1]); + + // Get the array of apps and add any special synthesized entries + var apps = data.apps; + + // Get a list of page names + var pageNames = data.appPageNames; + + function stringListIsEmpty(list) { + for (var i = 0; i < list.length; i++) { + if (list[i]) + return false; + } + return true; + } + + // Sort by launch ordinal + apps.sort(function(a, b) { + return a.app_launch_ordinal > b.app_launch_ordinal ? 1 : + a.app_launch_ordinal < b.app_launch_ordinal ? -1 : 0; + }); + + // An app to animate (in case it was just installed). + var highlightApp; + + // If there are any pages after the apps, add new pages before them. + var lastAppsPage = (this.appsPages.length > 0) ? + this.appsPages[this.appsPages.length - 1] : null; + var lastAppsPageIndex = (lastAppsPage != null) ? + Array.prototype.indexOf.call(this.tilePages, lastAppsPage) : -1; + var nextPageAfterApps = lastAppsPageIndex != -1 ? + this.tilePages[lastAppsPageIndex + 1] : null; + + // Add the apps, creating pages as necessary + for (var i = 0; i < apps.length; i++) { + var app = apps[i]; + var pageIndex = app.page_index || 0; + while (pageIndex >= this.appsPages.length) { + var pageName = loadTimeData.getString('appDefaultPageName'); + if (this.appsPages.length < pageNames.length) + pageName = pageNames[this.appsPages.length]; + + var origPageCount = this.appsPages.length; + this.appendTilePage(new ntp.AppsPage(), pageName, true, + nextPageAfterApps); + // Confirm that appsPages is a live object, updated when a new page is + // added (otherwise we'd have an infinite loop) + assert(this.appsPages.length == origPageCount + 1, + 'expected new page'); + } + + if (app.id == this.highlightAppId) + highlightApp = app; + else + this.appsPages[pageIndex].insertApp(app, false); + } + + ntp.AppsPage.setPromo(data.showPromo ? data : null); + + this.cardSlider.currentCard = prevCurrentCard; + + if (highlightApp) + this.appAdded(highlightApp, true); + + logEvent('apps.layout: ' + (Date.now() - startTime)); + + // Tell the slider about the pages and mark the current page. + this.updateSliderCards(); + this.cardSlider.currentCardValue.navigationDot.classList.add('selected'); + + if (!this.appsLoaded_) { + this.appsLoaded_ = true; + cr.dispatchSimpleEvent(document, 'sectionready', true, true); + } + }, + + /** + * Called by chrome when a new app has been added to chrome or has been + * enabled if previously disabled. + * @param {Object} appData A data structure full of relevant information for + * the app. + * @param {boolean=} opt_highlight Whether the app about to be added should + * be highlighted. + */ + appAdded: function(appData, opt_highlight) { + assert(loadTimeData.getBoolean('showApps')); + + if (appData.id == this.highlightAppId) { + opt_highlight = true; + this.highlightAppId = null; + } + + var pageIndex = appData.page_index || 0; + + if (pageIndex >= this.appsPages.length) { + while (pageIndex >= this.appsPages.length) { + this.appendTilePage(new ntp.AppsPage(), + loadTimeData.getString('appDefaultPageName'), + true); + } + this.updateSliderCards(); + } + + var page = this.appsPages[pageIndex]; + var app = $(appData.id); + if (app) { + app.replaceAppData(appData); + } else if (opt_highlight) { + page.insertAndHighlightApp(appData); + this.setShownPage_(loadTimeData.getInteger('apps_page_id'), + appData.page_index); + } else { + page.insertApp(appData, false); + } + }, + + /** + * Callback invoked by chrome whenever an app preference changes. + * @param {Object} data An object with all the data on available + * applications. + */ + appsPrefChangedCallback: function(data) { + assert(loadTimeData.getBoolean('showApps')); + + for (var i = 0; i < data.apps.length; ++i) { + $(data.apps[i].id).appData = data.apps[i]; + } + + // Set the App dot names. Skip the first dot (Most Visited). + var dots = this.dotList.getElementsByClassName('dot'); + var start = this.mostVisitedPage ? 1 : 0; + for (var i = start; i < dots.length; ++i) { + dots[i].displayTitle = data.appPageNames[i - start] || ''; + } + }, + + /** + * Invoked whenever the pages in apps-page-list have changed so that + * the Slider knows about the new elements. + */ + updateSliderCards: function() { + var pageNo = Math.max(0, Math.min(this.cardSlider.currentCard, + this.tilePages.length - 1)); + this.cardSlider.setCards(Array.prototype.slice.call(this.tilePages), + pageNo); + switch (this.shownPage) { + case loadTimeData.getInteger('apps_page_id'): + this.cardSlider.selectCardByValue( + this.appsPages[Math.min(this.shownPageIndex, + this.appsPages.length - 1)]); + break; + case loadTimeData.getInteger('most_visited_page_id'): + if (this.mostVisitedPage) + this.cardSlider.selectCardByValue(this.mostVisitedPage); + break; + case loadTimeData.getInteger('suggestions_page_id'): + if (this.suggestionsPage) + this.cardSlider.selectCardByValue(this.suggestionsPage); + break; + } + }, + + /** + * Called whenever tiles should be re-arranging themselves out of the way + * of a moving or insert tile. + */ + enterRearrangeMode: function() { + if (loadTimeData.getBoolean('showApps')) { + var tempPage = new ntp.AppsPage(); + tempPage.classList.add('temporary'); + var pageName = loadTimeData.getString('appDefaultPageName'); + this.appendTilePage(tempPage, pageName, true); + } + + if (ntp.getCurrentlyDraggingTile().firstChild.canBeRemoved()) + $('footer').classList.add('showing-trash-mode'); + + document.documentElement.classList.add('dragging-mode'); + }, + + /** + * Invoked whenever some app is released + */ + leaveRearrangeMode: function() { + var tempPage = document.querySelector('.tile-page.temporary'); + if (tempPage) { + var dot = tempPage.navigationDot; + if (!tempPage.tileCount && + tempPage != this.cardSlider.currentCardValue) { + this.removeTilePageAndDot_(tempPage, true); + } else { + tempPage.classList.remove('temporary'); + this.saveAppPageName(tempPage, + loadTimeData.getString('appDefaultPageName')); + } + } + + $('footer').classList.remove('showing-trash-mode'); + document.documentElement.classList.remove('dragging-mode'); + }, + + /** + * Callback for the 'pagelayout' event. + * @param {Event} e The event. + */ + onPageLayout_: function(e) { + if (Array.prototype.indexOf.call(this.tilePages, e.currentTarget) != + this.cardSlider.currentCard) { + return; + } + + this.updatePageSwitchers(); + }, + + /** + * Adjusts the size and position of the page switchers according to the + * layout of the current card. + */ + updatePageSwitchers: function() { + if (!this.pageSwitcherStart || !this.pageSwitcherEnd) + return; + + var page = this.cardSlider.currentCardValue; + + this.pageSwitcherStart.hidden = !page || + (this.cardSlider.currentCard == 0); + this.pageSwitcherEnd.hidden = !page || + (this.cardSlider.currentCard == this.cardSlider.cardCount - 1); + + if (!page) + return; + + var pageSwitcherLeft = isRTL() ? this.pageSwitcherEnd : + this.pageSwitcherStart; + var pageSwitcherRight = isRTL() ? this.pageSwitcherStart : + this.pageSwitcherEnd; + var scrollbarWidth = page.scrollbarWidth; + pageSwitcherLeft.style.width = + (page.sideMargin + 13) + 'px'; + pageSwitcherLeft.style.left = '0'; + pageSwitcherRight.style.width = + (page.sideMargin - scrollbarWidth + 13) + 'px'; + pageSwitcherRight.style.right = scrollbarWidth + 'px'; + + var offsetTop = page.querySelector('.tile-page-content').offsetTop + 'px'; + pageSwitcherLeft.style.top = offsetTop; + pageSwitcherRight.style.top = offsetTop; + pageSwitcherLeft.style.paddingBottom = offsetTop; + pageSwitcherRight.style.paddingBottom = offsetTop; + }, + + /** + * Returns the index of the given apps page. + * @param {AppsPage} page The AppsPage we wish to find. + * @return {number} The index of |page| or -1 if it is not in the + * collection. + */ + getAppsPageIndex: function(page) { + return Array.prototype.indexOf.call(this.appsPages, page); + }, + + /** + * Handler for cardSlider:card_changed events from this.cardSlider. + * @param {Event} e The cardSlider:card_changed event. + * @private + */ + onCardChanged_: function(e) { + var page = e.cardSlider.currentCardValue; + + // Don't change shownPage until startup is done (and page changes actually + // reflect user actions). + if (!this.isStartingUp_()) { + if (page.classList.contains('apps-page')) { + this.setShownPage_(loadTimeData.getInteger('apps_page_id'), + this.getAppsPageIndex(page)); + } else if (page.classList.contains('most-visited-page')) { + this.setShownPage_( + loadTimeData.getInteger('most_visited_page_id'), 0); + } else if (page.classList.contains('suggestions-page')) { + this.setShownPage_(loadTimeData.getInteger('suggestions_page_id'), 0); + } else { + console.error('unknown page selected'); + } + } + + // Update the active dot + var curDot = this.dotList.getElementsByClassName('selected')[0]; + if (curDot) + curDot.classList.remove('selected'); + page.navigationDot.classList.add('selected'); + this.updatePageSwitchers(); + }, + + /** + * Saves/updates the newly selected page to open when first loading the NTP. + * @type {number} shownPage The new shown page type. + * @type {number} shownPageIndex The new shown page index. + * @private + */ + setShownPage_: function(shownPage, shownPageIndex) { + assert(shownPageIndex >= 0); + this.shownPage = shownPage; + this.shownPageIndex = shownPageIndex; + chrome.send('pageSelected', [this.shownPage, this.shownPageIndex]); + }, + + /** + * Listen for card additions to update the page switchers or the current + * card accordingly. + * @param {Event} e A card removed or added event. + */ + onCardAdded_: function(e) { + // When the second arg passed to insertBefore is falsey, it acts just like + // appendChild. + this.pageList.insertBefore(e.addedCard, this.tilePages[e.addedIndex]); + this.onCardAddedOrRemoved_(); + }, + + /** + * Listen for card removals to update the page switchers or the current card + * accordingly. + * @param {Event} e A card removed or added event. + */ + onCardRemoved_: function(e) { + e.removedCard.parentNode.removeChild(e.removedCard); + this.onCardAddedOrRemoved_(); + }, + + /** + * Called when a card is removed or added. + * @private + */ + onCardAddedOrRemoved_: function() { + if (this.isStartingUp_()) + return; + + // Without repositioning there were issues - http://crbug.com/133457. + this.cardSlider.repositionFrame(); + this.updatePageSwitchers(); + }, + + /** + * Save the name of an apps page. + * Store the apps page name into the preferences store. + * @param {AppsPage} appsPage The app page for which we wish to save. + * @param {string} name The name of the page. + */ + saveAppPageName: function(appPage, name) { + var index = this.getAppsPageIndex(appPage); + assert(index != -1); + chrome.send('saveAppPageName', [name, index]); + }, + + /** + * Window resize handler. + * @private + */ + onWindowResize_: function(e) { + this.cardSlider.resize(this.sliderFrame.offsetWidth); + this.updatePageSwitchers(); + }, + + /** + * Listener for offline status change events. Updates apps that are + * not offline-enabled to be grayscale if the browser is offline. + * @private + */ + updateOfflineEnabledApps_: function() { + var apps = document.querySelectorAll('.app'); + for (var i = 0; i < apps.length; ++i) { + if (apps[i].appData.enabled && !apps[i].appData.offline_enabled) { + apps[i].setIcon(); + apps[i].loadIcon(); + } + } + }, + + /** + * Handler for key events on the page. Ctrl-Arrow will switch the visible + * page. + * @param {Event} e The KeyboardEvent. + * @private + */ + onDocKeyDown_: function(e) { + if (!e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) + return; + + var direction = 0; + if (e.keyIdentifier == 'Left') + direction = -1; + else if (e.keyIdentifier == 'Right') + direction = 1; + else + return; + + var cardIndex = + (this.cardSlider.currentCard + direction + + this.cardSlider.cardCount) % this.cardSlider.cardCount; + this.cardSlider.selectCard(cardIndex, true); + + e.stopPropagation(); + }, + + /** + * Returns the index of a given tile page. + * @param {TilePage} page The TilePage we wish to find. + * @return {number} The index of |page| or -1 if it is not in the + * collection. + */ + getTilePageIndex: function(page) { + return Array.prototype.indexOf.call(this.tilePages, page); + }, + + /** + * Removes a page and navigation dot (if the navdot exists). + * @param {TilePage} page The page to be removed. + * @param {boolean=} opt_animate If the removal should be animated. + */ + removeTilePageAndDot_: function(page, opt_animate) { + if (page.navigationDot) + page.navigationDot.remove(opt_animate); + this.cardSlider.removeCard(page); + }, + }; + + return { + PageListView: PageListView + }; +}); diff --git a/chrome/browser/resources/ntp_search/page_switcher.js b/chrome/browser/resources/ntp_search/page_switcher.js new file mode 100644 index 0000000..2ae7f75 --- /dev/null +++ b/chrome/browser/resources/ntp_search/page_switcher.js @@ -0,0 +1,107 @@ +// Copyright (c) 2012 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. + +/** + * @fileoverview Page switcher + * This is the class for the left and right navigation arrows that switch + * between pages. + */ +cr.define('ntp', function() { + + function PageSwitcher() { + } + + PageSwitcher.template = { + __proto__: HTMLButtonElement.prototype, + + decorate: function(el) { + el.__proto__ = PageSwitcher.template; + + el.addEventListener('click', el.activate_); + + el.direction_ = el.id == 'page-switcher-start' ? -1 : 1; + + el.dragWrapper_ = new cr.ui.DragWrapper(el, el); + }, + + /** + * Activate the switcher (go to the next card). + * @private + */ + activate_: function() { + var cardSlider = ntp.getCardSlider(); + var index = cardSlider.currentCard + this.direction_; + var numCards = cardSlider.cardCount - 1; + cardSlider.selectCard(Math.max(0, Math.min(index, numCards)), true); + }, + + shouldAcceptDrag: function(e) { + // We allow all drags to trigger the page switching effect. + return true; + }, + + doDragEnter: function(e) { + this.scheduleDelayedSwitch_(); + this.doDragOver(e); + }, + + doDragLeave: function(e) { + this.cancelDelayedSwitch_(); + }, + + doDragOver: function(e) { + e.preventDefault(); + var targetPage = ntp.getCardSlider().currentCardValue; + if (targetPage.shouldAcceptDrag(e)) + targetPage.setDropEffect(e.dataTransfer); + }, + + doDrop: function(e) { + e.stopPropagation(); + this.cancelDelayedSwitch_(); + + var tile = ntp.getCurrentlyDraggingTile(); + if (!tile) + return; + + var sourcePage = tile.tilePage; + var targetPage = ntp.getCardSlider().currentCardValue; + if (targetPage == sourcePage || !targetPage.shouldAcceptDrag(e)) + return; + + targetPage.appendDraggingTile(); + }, + + /** + * Starts a timer to activate the switcher. The timer repeats until + * cancelled by cancelDelayedSwitch_. + * @private + */ + scheduleDelayedSwitch_: function() { + var self = this; + function navPageClearTimeout() { + self.activate_(); + self.dragNavTimeout_ = null; + self.scheduleDelayedSwitch_(); + } + this.dragNavTimeout_ = window.setTimeout(navPageClearTimeout, 500); + }, + + /** + * Cancels the timer that activates the switcher while dragging. + * @private + */ + cancelDelayedSwitch_: function() { + if (this.dragNavTimeout_) { + window.clearTimeout(this.dragNavTimeout_); + this.dragNavTimeout_ = null; + } + }, + + }; + + return { + initializePageSwitcher: PageSwitcher.template.decorate + }; +}); diff --git a/chrome/browser/resources/ntp_search/recently_closed.js b/chrome/browser/resources/ntp_search/recently_closed.js new file mode 100644 index 0000000..6ece9de --- /dev/null +++ b/chrome/browser/resources/ntp_search/recently_closed.js @@ -0,0 +1,114 @@ +// Copyright (c) 2012 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. + +/** + * @fileoverview The recently closed menu: button, model data, and menu. + */ + +cr.define('ntp', function() { + 'use strict'; + + /** + * Returns the text used for a recently closed window. + * @param {number} numTabs Number of tabs in the window. + * @return {string} The text to use. + */ + function formatTabsText(numTabs) { + if (numTabs == 1) + return loadTimeData.getString('closedwindowsingle'); + return loadTimeData.getStringF('closedwindowmultiple', numTabs); + } + + var Menu = cr.ui.Menu; + var MenuItem = cr.ui.MenuItem; + var MenuButton = cr.ui.MenuButton; + var RecentMenuButton = cr.ui.define('button'); + + RecentMenuButton.prototype = { + __proto__: MenuButton.prototype, + + decorate: function() { + MenuButton.prototype.decorate.call(this); + this.menu = new Menu; + cr.ui.decorate(this.menu, Menu); + this.menu.classList.add('footer-menu'); + document.body.appendChild(this.menu); + + this.needsRebuild_ = true; + this.classList.add('invisible'); + this.anchorType = cr.ui.AnchorType.ABOVE; + this.invertLeftRight = true; + }, + + /** + * Shows the menu, first rebuilding it if necessary. + * TODO(estade): the right of the menu should align with the right of the + * button. + * @override + */ + showMenu: function() { + if (this.needsRebuild_) { + this.menu.textContent = ''; + this.dataItems_.forEach(this.addItem_, this); + this.needsRebuild_ = false; + } + + MenuButton.prototype.showMenu.call(this); + }, + + /** + * Sets the menu model data. + * @param {Array} dataItems Array of objects that describe the apps. + */ + set dataItems(dataItems) { + this.dataItems_ = dataItems; + this.needsRebuild_ = true; + if (dataItems.length) + this.classList.remove('invisible'); + else + this.classList.add('invisible'); + }, + + /** + * Adds an app to the menu. + * @param {Object} data An object encapsulating all data about the app. + * @private + */ + addItem_: function(data) { + var isWindow = data.type == 'window'; + var a = this.ownerDocument.createElement('a'); + a.className = 'footer-menu-item'; + if (isWindow) { + a.href = ''; + a.classList.add('recent-window'); + a.textContent = formatTabsText(data.tabs.length); + a.title = data.tabs.map(function(tab) { return tab.title; }).join('\n'); + } else { + a.href = data.url; + a.style.backgroundImage = 'url(chrome://favicon/' + data.url + ')'; + a.textContent = data.title; + } + + function onClick(e) { + chrome.send('recordAppLaunchByURL', + [encodeURIComponent(data.url), + ntp.APP_LAUNCH.NTP_RECENTLY_CLOSED]); + var index = Array.prototype.indexOf.call(a.parentNode.children, a); + chrome.send('reopenTab', [data.sessionId, index, + e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]); + // We are likely deleted by this point! + + e.preventDefault(); + } + a.addEventListener('click', onClick); + + this.menu.appendChild(a); + cr.ui.decorate(a, MenuItem); + }, + }; + + return { + RecentMenuButton: RecentMenuButton, + }; +}); diff --git a/chrome/browser/resources/ntp_search/suggestions_page.css b/chrome/browser/resources/ntp_search/suggestions_page.css new file mode 100644 index 0000000..747d206 --- /dev/null +++ b/chrome/browser/resources/ntp_search/suggestions_page.css @@ -0,0 +1,109 @@ +/* Copyright (c) 2012 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. */ + +.suggestions { + position: absolute; + z-index: 0; +} + +.suggestions { + -webkit-box-orient: vertical; + display: -webkit-box; + position: absolute; + text-decoration: none; +} + +.suggestions:focus { + outline: none; +} + +.suggestions .close-button { + -webkit-transition: opacity 150ms; + opacity: 0; + position: absolute; + right: 0; + top: 0; + z-index: 5; +} + +html[dir=rtl] .suggestions .close-button { + left: 0; + right: auto; +} + +.suggestions:hover .close-button { + -webkit-transition-delay: 500ms; + opacity: 1; +} + +.suggestions .close-button:hover { + -webkit-transition: none; +} + +.suggestions .favicon { + -webkit-margin-start: 5px; + background: no-repeat left 50%; + bottom: 7px; + box-sizing: border-box; + display: block; + height: 16px; + position: absolute; + width: 16px; +} + +html[dir='rtl'] .suggestions .favicon { + background-position-x: right; +} + +.suggestions .color-stripe { + border-bottom-left-radius: 3px 3px; + border-bottom-right-radius: 3px 3px; + /* Matches height of title plus height of score. */ + bottom: 36px; + height: 3px; + position: absolute; + width: 100%; + z-index: 10; +} + +.suggestions .title { + display: block; + height: 18px; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.suggestions .score { + display: block; + height: 18px; + overflow: hidden; + text-align: center; +} + +.suggestions:focus .thumbnail, +.suggestions:hover .thumbnail { + opacity: 0.95; +} + +.suggestions:focus .thumbnail-shield, +.suggestions:hover .thumbnail-shield, +.suggestions:active .thumbnail-shield { + background: -webkit-linear-gradient(rgba(255, 255, 255, 0), + rgba(255, 255, 255, 0) 80%, + rgba(255, 255, 255, 0.9)); +} + +/* The thumbnail gets lighter when clicked, but not when the click is on the + * close button. */ +.suggestions:active .close-button:not(:active) + .thumbnail { + opacity: 0.9; +} + +/* The thumbnail gets a shadow when clicked, but not when the click is on the + * close button. */ +.suggestions:active .close-button:not(:active) + .thumbnail .thumbnail-shield { + -webkit-box-shadow: inset 0 1px 10px rgba(0, 0, 0, 0.2); +} diff --git a/chrome/browser/resources/ntp_search/suggestions_page.js b/chrome/browser/resources/ntp_search/suggestions_page.js new file mode 100644 index 0000000..4e4f0d6 --- /dev/null +++ b/chrome/browser/resources/ntp_search/suggestions_page.js @@ -0,0 +1,471 @@ +// Copyright (c) 2012 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. + +cr.define('ntp', function() { + 'use strict'; + + var TilePage = ntp.TilePage; + + /** + * A counter for generating unique tile IDs. + */ + var tileID = 0; + + /** + * Creates a new Suggestions page object for tiling. + * @constructor + * @extends {HTMLAnchorElement} + */ + function Suggestion() { + var el = cr.doc.createElement('a'); + el.__proto__ = Suggestion.prototype; + el.initialize(); + + return el; + } + + Suggestion.prototype = { + __proto__: HTMLAnchorElement.prototype, + + initialize: function() { + this.reset(); + + this.addEventListener('click', this.handleClick_); + this.addEventListener('keydown', this.handleKeyDown_); + }, + + get index() { + assert(this.tile); + return this.tile.index; + }, + + get data() { + return this.data_; + }, + + /** + * Clears the DOM hierarchy for this node, setting it back to the default + * for a blank thumbnail. TODO(georgey) make it a template. + */ + reset: function() { + this.className = 'suggestions filler real'; + this.innerHTML = + '<span class="thumbnail-wrapper fills-parent">' + + '<div class="close-button"></div>' + + '<span class="thumbnail fills-parent">' + + // thumbnail-shield provides a gradient fade effect. + '<div class="thumbnail-shield fills-parent"></div>' + + '</span>' + + '<span class="favicon"></span>' + + '</span>' + + '<div class="color-stripe"></div>' + + '<span class="title"></span>' + + '<span class="score"></span>'; + + this.querySelector('.close-button').title = + loadTimeData.getString('removethumbnailtooltip'); + + this.tabIndex = -1; + this.data_ = null; + this.removeAttribute('id'); + this.title = ''; + }, + + /** + * Update the appearance of this tile according to |data|. + * @param {Object} data A dictionary of relevant data for the page. + */ + updateForData: function(data) { + if (this.classList.contains('blacklisted') && data) { + // Animate appearance of new tile. + this.classList.add('new-tile-contents'); + } + this.classList.remove('blacklisted'); + + if (!data || data.filler) { + if (this.data_) + this.reset(); + return; + } + + var id = tileID++; + this.id = 'suggestions-tile-' + id; + this.data_ = data; + this.classList.add('focusable'); + + var faviconDiv = this.querySelector('.favicon'); + var faviconUrl = 'chrome://favicon/size/16/' + data.url; + faviconDiv.style.backgroundImage = url(faviconUrl); + chrome.send('getFaviconDominantColor', [faviconUrl, this.id]); + + var title = this.querySelector('.title'); + title.textContent = data.title; + title.dir = data.direction; + + var score = this.querySelector('.score'); + score.textContent = data.score; + + // Sets the tooltip. + this.title = data.title; + + var thumbnailUrl = 'chrome://thumb/' + data.url; + this.querySelector('.thumbnail').style.backgroundImage = + url(thumbnailUrl); + + this.href = data.url; + + this.classList.remove('filler'); + }, + + /** + * Sets the color of the favicon dominant color bar. + * @param {string} color The css-parsable value for the color. + */ + set stripeColor(color) { + this.querySelector('.color-stripe').style.backgroundColor = color; + }, + + /** + * Handles a click on the tile. + * @param {Event} e The click event. + */ + handleClick_: function(e) { + if (e.target.classList.contains('close-button')) { + this.blacklist_(); + e.preventDefault(); + } else { + // Records the index of this tile. + chrome.send('metricsHandler:recordInHistogram', + ['NewTabPage.SuggestedSite', this.index, 8]); + chrome.send('suggestedSitesAction', + [ntp.NtpFollowAction.CLICKED_TILE]); + } + }, + + /** + * Allow blacklisting suggestions site using the keyboard. + * @param {Event} e The keydown event. + */ + handleKeyDown_: function(e) { + if (!cr.isMac && e.keyCode == 46 || // Del + cr.isMac && e.metaKey && e.keyCode == 8) { // Cmd + Backspace + this.blacklist_(); + } + }, + + /** + * Permanently removes a page from Suggestions. + */ + blacklist_: function() { + this.showUndoNotification_(); + chrome.send('blacklistURLFromSuggestions', [this.data_.url]); + this.reset(); + chrome.send('getSuggestions'); + this.classList.add('blacklisted'); + }, + + /** + * Shows notification that you can undo blacklisting. + */ + showUndoNotification_: function() { + var data = this.data_; + var self = this; + var doUndo = function() { + chrome.send('removeURLsFromSuggestionsBlacklist', [data.url]); + self.updateForData(data); + }; + + var undo = { + action: doUndo, + text: loadTimeData.getString('undothumbnailremove'), + }; + + var undoAll = { + action: function() { + chrome.send('clearSuggestionsURLsBlacklist'); + }, + text: loadTimeData.getString('restoreThumbnailsShort'), + }; + + ntp.showNotification( + loadTimeData.getString('thumbnailremovednotification'), + [undo, undoAll]); + }, + + /** + * Set the size and position of the suggestions tile. + * @param {number} size The total size of |this|. + * @param {number} x The x-position. + * @param {number} y The y-position. + */ + setBounds: function(size, x, y) { + this.style.width = size + 'px'; + this.style.height = heightForWidth(size) + 'px'; + + this.style.left = x + 'px'; + this.style.right = x + 'px'; + this.style.top = y + 'px'; + }, + + /** + * Returns whether this element can be 'removed' from chrome (i.e. whether + * the user can drag it onto the trash and expect something to happen). + * @return {boolean} True, since suggestions pages can always be + * blacklisted. + */ + canBeRemoved: function() { + return true; + }, + + /** + * Removes this element from chrome, i.e. blacklists it. + */ + removeFromChrome: function() { + this.blacklist_(); + this.parentNode.classList.add('finishing-drag'); + }, + + /** + * Called when a drag of this tile has ended (after all animations have + * finished). + */ + finalizeDrag: function() { + this.parentNode.classList.remove('finishing-drag'); + }, + + /** + * Called when a drag is starting on the tile. Updates dataTransfer with + * data for this tile (for dragging outside of the NTP). + * @param {Event.DataTransfer} dataTransfer The drag event data store. + */ + setDragData: function(dataTransfer) { + dataTransfer.setData('Text', this.data_.title); + dataTransfer.setData('URL', this.data_.url); + }, + }; + + var suggestionsPageGridValues = { + // The fewest tiles we will show in a row. + minColCount: 2, + // The suggestions we will show in a row. + maxColCount: 4, + + // The smallest a tile can be. + minTileWidth: 122, + // The biggest a tile can be. 212 (max thumbnail width) + 2. + maxTileWidth: 214, + + // The padding between tiles, as a fraction of the tile width. + tileSpacingFraction: 1 / 8, + }; + TilePage.initGridValues(suggestionsPageGridValues); + + /** + * Calculates the height for a Suggestion tile for a given width. The size + * is based on the thumbnail, which should have a 212:132 ratio. + * @return {number} The height. + */ + function heightForWidth(width) { + // The 2s are for borders, the 36 is for the title and score. + return (width - 2) * 132 / 212 + 2 + 36; + } + + var THUMBNAIL_COUNT = 8; + + /** + * Creates a new SuggestionsPage object. + * @constructor + * @extends {TilePage} + */ + function SuggestionsPage() { + var el = new TilePage(suggestionsPageGridValues); + el.__proto__ = SuggestionsPage.prototype; + el.initialize(); + + return el; + } + + SuggestionsPage.prototype = { + __proto__: TilePage.prototype, + + initialize: function() { + this.classList.add('suggestions-page'); + this.data_ = null; + this.suggestionsTiles_ = this.getElementsByClassName('suggestions real'); + + this.addEventListener('carddeselected', this.handleCardDeselected_); + this.addEventListener('cardselected', this.handleCardSelected_); + }, + + /** + * Create blank (filler) tiles. + * @private + */ + createTiles_: function() { + for (var i = 0; i < THUMBNAIL_COUNT; i++) { + this.appendTile(new Suggestion()); + } + }, + + /** + * Update the tiles after a change to |this.data_|. + */ + updateTiles_: function() { + for (var i = 0; i < THUMBNAIL_COUNT; i++) { + var page = this.data_[i]; + var tile = this.suggestionsTiles_[i]; + + if (i >= this.data_.length) + tile.reset(); + else + tile.updateForData(page); + } + }, + + /** + * Handles the 'card deselected' event (i.e. the user clicked to another + * pane). + * @param {Event} e The CardChanged event. + */ + handleCardDeselected_: function(e) { + if (!document.documentElement.classList.contains('starting-up')) { + chrome.send('suggestedSitesAction', + [ntp.NtpFollowAction.CLICKED_OTHER_NTP_PANE]); + } + }, + + /** + * Handles the 'card selected' event (i.e. the user clicked to select the + * Suggested pane). + * @param {Event} e The CardChanged event. + */ + handleCardSelected_: function(e) { + if (!document.documentElement.classList.contains('starting-up')) + chrome.send('suggestedSitesSelected'); + }, + + /** + * Array of suggestions data objects. + * @type {Array} + */ + get data() { + return this.data_; + }, + set data(data) { + var startTime = Date.now(); + + // The first time data is set, create the tiles. + if (!this.data_) { + this.createTiles_(); + this.data_ = data.slice(0, THUMBNAIL_COUNT); + } else { + this.data_ = refreshData(this.data_, data); + } + + this.updateTiles_(); + logEvent('suggestions.layout: ' + (Date.now() - startTime)); + }, + + /** @inheritDoc */ + shouldAcceptDrag: function(e) { + return false; + }, + + /** @inheritDoc */ + heightForWidth: heightForWidth, + }; + + /** + * Executed once the NTP has loaded. Checks if the Suggested pane is + * shown or not. If it is shown, the 'suggestedSitesSelected' message is sent + * to the C++ code, to record the fact that the user has seen this pane. + */ + SuggestionsPage.onLoaded = function() { + if (ntp.getCardSlider() && + ntp.getCardSlider().currentCardValue && + ntp.getCardSlider().currentCardValue.classList + .contains('suggestions-page')) { + chrome.send('suggestedSitesSelected'); + } + } + + /** + * We've gotten additional data for Suggestions page. Update our old data with + * the new data. The ordering of the new data is not important, except when a + * page is pinned. Thus we try to minimize re-ordering. + * @param {Array} oldData The current Suggestions page list. + * @param {Array} newData The new Suggestions page list. + * @return {Array} The merged page list that should replace the current page + * list. + */ + function refreshData(oldData, newData) { + oldData = oldData.slice(0, THUMBNAIL_COUNT); + newData = newData.slice(0, THUMBNAIL_COUNT); + + // Copy over pinned sites directly. + for (var i = 0; i < newData.length; i++) { + if (newData[i].pinned) { + oldData[i] = newData[i]; + // Mark the entry as 'updated' so we don't try to update again. + oldData[i].updated = true; + // Mark the newData page as 'used' so we don't try to re-use it. + newData[i].used = true; + } + } + + // Look through old pages; if they exist in the newData list, keep them + // where they are. + for (var i = 0; i < oldData.length; i++) { + if (!oldData[i] || oldData[i].updated) + continue; + + for (var j = 0; j < newData.length; j++) { + if (newData[j].used) + continue; + + if (newData[j].url == oldData[i].url) { + // The background image and other data may have changed. + oldData[i] = newData[j]; + oldData[i].updated = true; + newData[j].used = true; + break; + } + } + } + + // Look through old pages that haven't been updated yet; replace them. + for (var i = 0; i < oldData.length; i++) { + if (oldData[i] && oldData[i].updated) + continue; + + for (var j = 0; j < newData.length; j++) { + if (newData[j].used) + continue; + + oldData[i] = newData[j]; + oldData[i].updated = true; + newData[j].used = true; + break; + } + + if (oldData[i] && !oldData[i].updated) + oldData[i] = null; + } + + // Clear 'updated' flags so this function will work next time it's called. + for (var i = 0; i < THUMBNAIL_COUNT; i++) { + if (oldData[i]) + oldData[i].updated = false; + } + + return oldData; + } + + return { + SuggestionsPage: SuggestionsPage, + refreshData: refreshData, + }; +}); + +document.addEventListener('ntpLoaded', ntp.SuggestionsPage.onLoaded); diff --git a/chrome/browser/resources/ntp_search/tile_page.css b/chrome/browser/resources/ntp_search/tile_page.css new file mode 100644 index 0000000..08ff22b --- /dev/null +++ b/chrome/browser/resources/ntp_search/tile_page.css @@ -0,0 +1,194 @@ +/* Copyright (c) 2012 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. */ + +.tile-page { + -webkit-box-orient: vertical; + display: -webkit-box; + height: 100%; + position: relative; + width: 100%; +} + +.tile-page-scrollbar { + -webkit-box-sizing: border-box; + margin: 0 4px; + pointer-events: none; + position: absolute; + right: 0; + width: 5px; + z-index: 5; +} + +.tile-page-content { + -webkit-box-flex: 1; + /* Don't apply clip mask to padding. */ + -webkit-mask-clip: content-box; + /* TODO(estade): this mask is disabled for technical reasons. It negatively + * impacts performance of page switching, also it causes problems with Mac + * text: http://crbug.com/86955 + -webkit-mask-image: -webkit-linear-gradient(bottom, transparent, black 30px); + */ + /* The following four properties are necessary so that the mask won't clip + * the scrollbar. */ + box-sizing: border-box; + overflow-y: scroll; + /* Scrollbar width(13px) + balance right padding. */ + padding-left: 93px; + padding-right: 80px; + /* This value is mirrored in TilePage.updateTopMargin_ */ + padding-top: 60px; + position: relative; + text-align: center; + width: 100%; +} + +.top-margin { + /* The only reason height is set to 1px, rather than left at 0, is that + * otherwise webkit collapses the top and bottom margins. */ + height: 1px; +} + +.tile-grid { + position: relative; + width: 100%; +} + +.tile { + -webkit-print-color-adjust: exact; + /* Don't offer the context menu on long-press. */ + -webkit-touch-callout: none; + -webkit-user-drag: element; + display: inline-block; + position: absolute; +} + +/* NOTE: Dopplegangers nest themselves inside of other tiles, so don't + * accidentally double apply font-size to them. */ +.tile:not(.doppleganger) { + font-size: 1.2em; +} + +/* Not real but not a doppleganger: show nothing. This state exists for a + * webstore tile that's on the same page as a [+]. */ +.tile:not(.real):not(.doppleganger) { + display: none; +} + +/* I don't know why this is necessary. -webkit-user-drag: element on .tile + * should be enough. If we don't do this, we get 2 drag representations for + * the image. */ +.tile img { + -webkit-user-drag: none; +} + +.doppleganger { + left: 0 !important; + right: 0 !important; + top: 0 !important; +} + +.tile.dragging { + opacity: 0; +} + +.tile.drag-representation { + -webkit-transition: opacity 200ms; + pointer-events: none; + position: fixed; + z-index: 3; +} + +.tile.drag-representation.placing > * { + -webkit-transition: -webkit-transform 200ms; +} + +/* When a drag finishes while we're not showing the page where the tile + * belongs, the tile shrinks to a dot. */ +.tile.drag-representation.dropped-on-other-page > * { + -webkit-transform: scale(0) rotate(0); +} + +.tile.drag-representation.deleting > * { + -webkit-transform: scale(0) rotate(360deg); + -webkit-transition: -webkit-transform 600ms; +} + +.animating-tile-page .tile, +.tile.drag-representation.placing { + -webkit-transition: left 200ms, right 200ms, top 200ms; +} + +.hovering-on-trash { + opacity: 0.6; +} + +.animating-tile-page .top-margin { + -webkit-transition: margin-bottom 200ms; +} + +.animating-tile-page #notification-container { + -webkit-transition: margin 200ms, opacity 200ms; +} + +@-webkit-keyframes bounce { + 0% { + -webkit-transform: scale(0, 0); + } + + 60% { + -webkit-transform: scale(1.2, 1.2); + } + + 100% { + -webkit-transform: scale(1, 1); + } +} + +.tile > .new-tile-contents { + -webkit-animation: bounce 500ms ease-in-out; +} + +@-webkit-keyframes blipout { + 0% { + -webkit-transform: scale(1, 1); + } + + 60% { + -webkit-animation-timing-function: ease-in; + -webkit-transform: scale(1.3, 0.02); + opacity: 1; + } + + 90% { + -webkit-animation-timing-function: default; + -webkit-transform: scale(0.3, 0.02); + opacity: 0.7; + } + + 100% { + -webkit-animation-timing-function: linear; + -webkit-transform: scale(0.3, 0.02); + opacity: 0; + } +} + +.tile > .removing-tile-contents { + -webkit-animation: blipout 300ms; + -webkit-animation-fill-mode: forwards; + pointer-events: none; +} + +.tile-page:not(.selected-card) * { + -webkit-transition: none !important; +} + +/** Scrollbars ****************************************************************/ + +.tile-page-content::-webkit-scrollbar { + width: 13px; +} + +.tile-page-content::-webkit-scrollbar-button { + display: none; +} diff --git a/chrome/browser/resources/ntp_search/tile_page.js b/chrome/browser/resources/ntp_search/tile_page.js new file mode 100644 index 0000000..33baa7f --- /dev/null +++ b/chrome/browser/resources/ntp_search/tile_page.js @@ -0,0 +1,1347 @@ +// Copyright (c) 2012 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. + +cr.define('ntp', function() { + 'use strict'; + + // We can't pass the currently dragging tile via dataTransfer because of + // http://crbug.com/31037 + var currentlyDraggingTile = null; + function getCurrentlyDraggingTile() { + return currentlyDraggingTile; + } + function setCurrentlyDraggingTile(tile) { + currentlyDraggingTile = tile; + if (tile) + ntp.enterRearrangeMode(); + else + ntp.leaveRearrangeMode(); + } + + /** + * Changes the current dropEffect of a drag. This modifies the native cursor + * and serves as an indicator of what we should do at the end of the drag as + * well as give indication to the user if a drop would succeed if they let go. + * @param {DataTransfer} dataTransfer A dataTransfer object from a drag event. + * @param {string} effect A drop effect to change to (i.e. copy, move, none). + */ + function setCurrentDropEffect(dataTransfer, effect) { + dataTransfer.dropEffect = effect; + if (currentlyDraggingTile) + currentlyDraggingTile.lastDropEffect = dataTransfer.dropEffect; + } + + /** + * Creates a new Tile object. Tiles wrap content on a TilePage, providing + * some styling and drag functionality. + * @constructor + * @extends {HTMLDivElement} + */ + function Tile(contents) { + var tile = cr.doc.createElement('div'); + tile.__proto__ = Tile.prototype; + tile.initialize(contents); + + return tile; + } + + Tile.prototype = { + __proto__: HTMLDivElement.prototype, + + initialize: function(contents) { + // 'real' as opposed to doppleganger. + this.className = 'tile real'; + this.appendChild(contents); + contents.tile = this; + + this.addEventListener('dragstart', this.onDragStart_); + this.addEventListener('drag', this.onDragMove_); + this.addEventListener('dragend', this.onDragEnd_); + + this.firstChild.addEventListener( + 'webkitAnimationEnd', this.onContentsAnimationEnd_.bind(this)); + + this.eventTracker = new EventTracker(); + }, + + get index() { + return Array.prototype.indexOf.call(this.tilePage.tileElements_, this); + }, + + get tilePage() { + return findAncestorByClass(this, 'tile-page'); + }, + + /** + * Position the tile at |x, y|, and store this as the grid location, i.e. + * where the tile 'belongs' when it's not being dragged. + * @param {number} x The x coordinate, in pixels. + * @param {number} y The y coordinate, in pixels. + */ + setGridPosition: function(x, y) { + this.gridX = x; + this.gridY = y; + this.moveTo(x, y); + }, + + /** + * Position the tile at |x, y|. + * @param {number} x The x coordinate, in pixels. + * @param {number} y The y coordinate, in pixels. + */ + moveTo: function(x, y) { + // left overrides right in LTR, and right takes precedence in RTL. + this.style.left = toCssPx(x); + this.style.right = toCssPx(x); + this.style.top = toCssPx(y); + }, + + /** + * The handler for dragstart events fired on |this|. + * @param {Event} e The event for the drag. + * @private + */ + onDragStart_: function(e) { + // The user may start dragging again during a previous drag's finishing + // animation. + if (this.classList.contains('dragging')) + this.finalizeDrag_(); + + setCurrentlyDraggingTile(this); + + e.dataTransfer.effectAllowed = 'copyMove'; + this.firstChild.setDragData(e.dataTransfer); + + // The drag clone is the node we use as a representation during the drag. + // It's attached to the top level document element so that it floats above + // image masks. + this.dragClone = this.cloneNode(true); + this.dragClone.style.right = ''; + this.dragClone.classList.add('drag-representation'); + $('card-slider-frame').appendChild(this.dragClone); + this.eventTracker.add(this.dragClone, 'webkitTransitionEnd', + this.onDragCloneTransitionEnd_.bind(this)); + + this.classList.add('dragging'); + // offsetLeft is mirrored in RTL. Un-mirror it. + var offsetLeft = isRTL() ? + this.parentNode.clientWidth - this.offsetLeft : + this.offsetLeft; + this.dragOffsetX = e.x - offsetLeft - this.parentNode.offsetLeft; + this.dragOffsetY = e.y - this.offsetTop - + // Unlike offsetTop, this value takes scroll position into account. + this.parentNode.getBoundingClientRect().top; + + this.onDragMove_(e); + }, + + /** + * The handler for drag events fired on |this|. + * @param {Event} e The event for the drag. + * @private + */ + onDragMove_: function(e) { + if (e.view != window || (e.x == 0 && e.y == 0)) { + this.dragClone.hidden = true; + return; + } + + this.dragClone.hidden = false; + this.dragClone.style.left = toCssPx(e.x - this.dragOffsetX); + this.dragClone.style.top = toCssPx(e.y - this.dragOffsetY); + }, + + /** + * The handler for dragend events fired on |this|. + * @param {Event} e The event for the drag. + * @private + */ + onDragEnd_: function(e) { + this.dragClone.hidden = false; + this.dragClone.classList.add('placing'); + + setCurrentlyDraggingTile(null); + + // tilePage will be null if we've already been removed. + var tilePage = this.tilePage; + if (tilePage) + tilePage.positionTile_(this.index); + + // Take an appropriate action with the drag clone. + if (this.landedOnTrash) { + this.dragClone.classList.add('deleting'); + } else if (tilePage) { + // TODO(dbeam): Until we fix dropEffect to the correct behavior it will + // differ on windows - crbug.com/39399. That's why we use the custom + // this.lastDropEffect instead of e.dataTransfer.dropEffect. + if (tilePage.selected && this.lastDropEffect != 'copy') { + // The drag clone can still be hidden from the last drag move event. + this.dragClone.hidden = false; + // The tile's contents may have moved following the respositioning; + // adjust for that. + var contentDiffX = this.dragClone.firstChild.offsetLeft - + this.firstChild.offsetLeft; + var contentDiffY = this.dragClone.firstChild.offsetTop - + this.firstChild.offsetTop; + this.dragClone.style.left = + toCssPx(this.gridX + this.parentNode.offsetLeft - + contentDiffX); + this.dragClone.style.top = + toCssPx(this.gridY + + this.parentNode.getBoundingClientRect().top - + contentDiffY); + } else if (this.dragClone.hidden) { + this.finalizeDrag_(); + } else { + // The CSS3 transitions spec intentionally leaves it up to individual + // user agents to determine when styles should be applied. On some + // platforms (at the moment, Windows), when you apply both classes + // immediately a transition may not occur correctly. That's why we're + // using a setTimeout here to queue adding the class until the + // previous class (currently: .placing) sets up a transition. + // http://dev.w3.org/csswg/css3-transitions/#starting + window.setTimeout(function() { + if (this.dragClone) + this.dragClone.classList.add('dropped-on-other-page'); + }.bind(this), 0); + } + } + + delete this.lastDropEffect; + this.landedOnTrash = false; + }, + + /** + * Creates a clone of this node offset by the coordinates. Used for the + * dragging effect where a tile appears to float off one side of the grid + * and re-appear on the other. + * @param {number} x x-axis offset, in pixels. + * @param {number} y y-axis offset, in pixels. + */ + showDoppleganger: function(x, y) { + // We always have to clear the previous doppleganger to make sure we get + // style updates for the contents of this tile. + this.clearDoppleganger(); + + var clone = this.cloneNode(true); + clone.classList.remove('real'); + clone.classList.add('doppleganger'); + var clonelets = clone.querySelectorAll('.real'); + for (var i = 0; i < clonelets.length; i++) { + clonelets[i].classList.remove('real'); + } + + this.appendChild(clone); + this.doppleganger_ = clone; + + if (isRTL()) + x *= -1; + + this.doppleganger_.style.WebkitTransform = 'translate(' + x + 'px, ' + + y + 'px)'; + }, + + /** + * Destroys the current doppleganger. + */ + clearDoppleganger: function() { + if (this.doppleganger_) { + this.removeChild(this.doppleganger_); + this.doppleganger_ = null; + } + }, + + /** + * Returns status of doppleganger. + * @return {boolean} True if there is a doppleganger showing for |this|. + */ + hasDoppleganger: function() { + return !!this.doppleganger_; + }, + + /** + * Cleans up after the drag is over. This is either called when the + * drag representation finishes animating to the final position, or when + * the next drag starts (if the user starts a 2nd drag very quickly). + * @private + */ + finalizeDrag_: function() { + assert(this.classList.contains('dragging')); + + var clone = this.dragClone; + this.dragClone = null; + + clone.parentNode.removeChild(clone); + this.eventTracker.remove(clone, 'webkitTransitionEnd'); + this.classList.remove('dragging'); + if (this.firstChild.finalizeDrag) + this.firstChild.finalizeDrag(); + }, + + /** + * Called when the drag representation node is done migrating to its final + * resting spot. + * @param {Event} e The transition end event. + */ + onDragCloneTransitionEnd_: function(e) { + if (this.classList.contains('dragging') && + (e.propertyName == 'left' || e.propertyName == 'top' || + e.propertyName == '-webkit-transform')) { + this.finalizeDrag_(); + } + }, + + /** + * Called when an app is removed from Chrome. Animates its disappearance. + * @param {boolean=} opt_animate Whether the animation should be animated. + */ + doRemove: function(opt_animate) { + if (opt_animate) + this.firstChild.classList.add('removing-tile-contents'); + else + this.tilePage.removeTile(this, false); + }, + + /** + * Callback for the webkitAnimationEnd event on the tile's contents. + * @param {Event} e The event object. + */ + onContentsAnimationEnd_: function(e) { + if (this.firstChild.classList.contains('new-tile-contents')) + this.firstChild.classList.remove('new-tile-contents'); + if (this.firstChild.classList.contains('removing-tile-contents')) + this.tilePage.removeTile(this, true); + }, + }; + + /** + * Gives the proportion of the row width that is devoted to a single icon. + * @param {number} rowTileCount The number of tiles in a row. + * @param {number} tileSpacingFraction The proportion of the tile width which + * will be used as spacing between tiles. + * @return {number} The ratio between icon width and row width. + */ + function tileWidthFraction(rowTileCount, tileSpacingFraction) { + return rowTileCount + (rowTileCount - 1) * tileSpacingFraction; + } + + /** + * Calculates an assortment of tile-related values for a grid with the + * given dimensions. + * @param {number} width The pixel width of the grid. + * @param {number} numRowTiles The number of tiles in a row. + * @param {number} tileSpacingFraction The proportion of the tile width which + * will be used as spacing between tiles. + * @return {Object} A mapping of pixel values. + */ + function tileValuesForGrid(width, numRowTiles, tileSpacingFraction) { + var tileWidth = width / tileWidthFraction(numRowTiles, tileSpacingFraction); + var offsetX = tileWidth * (1 + tileSpacingFraction); + var interTileSpacing = offsetX - tileWidth; + + return { + tileWidth: tileWidth, + offsetX: offsetX, + interTileSpacing: interTileSpacing, + }; + } + + // The smallest amount of horizontal blank space to display on the sides when + // displaying a wide arrangement. There is an additional 26px of margin from + // the tile page padding. + var MIN_WIDE_MARGIN = 18; + + /** + * Creates a new TilePage object. This object contains tiles and controls + * their layout. + * @param {Object} gridValues Pixel values that define the size and layout + * of the tile grid. + * @constructor + * @extends {HTMLDivElement} + */ + function TilePage(gridValues) { + var el = cr.doc.createElement('div'); + el.gridValues_ = gridValues; + el.__proto__ = TilePage.prototype; + el.initialize(); + + return el; + } + + /** + * Takes a collection of grid layout pixel values and updates them with + * additional tiling values that are calculated from TilePage constants. + * @param {Object} grid The grid layout pixel values to update. + */ + TilePage.initGridValues = function(grid) { + // The amount of space we need to display a narrow grid (all narrow grids + // are this size). + grid.narrowWidth = + grid.minTileWidth * tileWidthFraction(grid.minColCount, + grid.tileSpacingFraction); + // The minimum amount of space we need to display a wide grid. + grid.minWideWidth = + grid.minTileWidth * tileWidthFraction(grid.maxColCount, + grid.tileSpacingFraction); + // The largest we will ever display a wide grid. + grid.maxWideWidth = + grid.maxTileWidth * tileWidthFraction(grid.maxColCount, + grid.tileSpacingFraction); + // Tile-related pixel values for the narrow display. + grid.narrowTileValues = tileValuesForGrid(grid.narrowWidth, + grid.minColCount, + grid.tileSpacingFraction); + // Tile-related pixel values for the minimum narrow display. + grid.wideTileValues = tileValuesForGrid(grid.minWideWidth, + grid.maxColCount, + grid.tileSpacingFraction); + }; + + TilePage.prototype = { + __proto__: HTMLDivElement.prototype, + + initialize: function() { + this.className = 'tile-page'; + + // Div that acts as a custom scrollbar. The scrollbar has to live + // outside the content div so it doesn't flicker when scrolling (due to + // repainting after the scroll, then repainting again when moved in the + // onScroll handler). |scrollbar_| is only aesthetic, and it only + // represents the thumb. Actual events are still handled by the invisible + // native scrollbars. This div gives us more flexibility with the visuals. + this.scrollbar_ = this.ownerDocument.createElement('div'); + this.scrollbar_.className = 'tile-page-scrollbar'; + this.scrollbar_.hidden = true; + this.appendChild(this.scrollbar_); + + // This contains everything but the scrollbar. + this.content_ = this.ownerDocument.createElement('div'); + this.content_.className = 'tile-page-content'; + this.appendChild(this.content_); + + // Div that sets the vertical position of the tile grid. + this.topMargin_ = this.ownerDocument.createElement('div'); + this.topMargin_.className = 'top-margin'; + this.content_.appendChild(this.topMargin_); + + // Div that holds the tiles. + this.tileGrid_ = this.ownerDocument.createElement('div'); + this.tileGrid_.className = 'tile-grid'; + this.tileGrid_.style.minWidth = this.gridValues_.narrowWidth + 'px'; + this.content_.appendChild(this.tileGrid_); + + // Ordered list of our tiles. + this.tileElements_ = this.tileGrid_.getElementsByClassName('tile real'); + // Ordered list of the elements which want to accept keyboard focus. These + // elements will not be a part of the normal tab order; the tile grid + // initially gets focused and then these elements can be focused via the + // arrow keys. + this.focusableElements_ = + this.tileGrid_.getElementsByClassName('focusable'); + + // These are properties used in updateTopMargin. + this.animatedTopMarginPx_ = 0; + this.topMarginPx_ = 0; + + this.eventTracker = new EventTracker(); + this.eventTracker.add(window, 'resize', this.onResize_.bind(this)); + + this.addEventListener('DOMNodeInsertedIntoDocument', + this.onNodeInsertedIntoDocument_); + + this.content_.addEventListener('scroll', this.onScroll_.bind(this)); + + this.dragWrapper_ = new cr.ui.DragWrapper(this.tileGrid_, this); + + this.addEventListener('cardselected', this.handleCardSelection_); + this.addEventListener('carddeselected', this.handleCardDeselection_); + this.addEventListener('focus', this.handleFocus_); + this.addEventListener('keydown', this.handleKeyDown_); + this.addEventListener('mousedown', this.handleMouseDown_); + + this.focusElementIndex_ = -1; + }, + + get tiles() { + return this.tileElements_; + }, + + get tileCount() { + return this.tileElements_.length; + }, + + get selected() { + return Array.prototype.indexOf.call(this.parentNode.children, this) == + ntp.getCardSlider().currentCard; + }, + + /** + * The size of the margin (unused space) on the sides of the tile grid, in + * pixels. + * @type {number} + */ + get sideMargin() { + return this.layoutValues_.leftMargin; + }, + + /** + * Returns the width of the scrollbar, in pixels, if it is active, or 0 + * otherwise. + * @type {number} + */ + get scrollbarWidth() { + return this.scrollbar_.hidden ? 0 : 13; + }, + + /** + * Returns any extra padding to insert to the bottom of a tile page. By + * default there is none, but subclasses can override. + * @type {number} + */ + get extraBottomPadding() { + return 0; + }, + + /** + * The notification content of this tile (if any, otherwise null). + * @type {!HTMLElement} + */ + get notification() { + return this.topMargin_.nextElementSibling.id == 'notification-container' ? + this.topMargin_.nextElementSibling : null; + }, + /** + * The notification content of this tile (if any, otherwise null). + * @type {!HTMLElement} + */ + set notification(node) { + assert(node instanceof HTMLElement, '|node| isn\'t an HTMLElement!'); + // NOTE: Implicitly removes from DOM if |node| is inside it. + this.content_.insertBefore(node, this.topMargin_.nextElementSibling); + this.positionNotification_(); + }, + + /** + * Fetches the size, in pixels, of the padding-top of the tile contents. + * @type {number} + */ + get contentPadding() { + if (typeof this.contentPadding_ == 'undefined') { + this.contentPadding_ = + parseInt(getComputedStyle(this.content_).paddingTop, 10); + } + return this.contentPadding_; + }, + + /** + * Removes the tilePage from the DOM and cleans up event handlers. + */ + remove: function() { + // This checks arguments.length as most remove functions have a boolean + // |opt_animate| argument, but that's not necesarilly applicable to + // removing a tilePage. Selecting a different card in an animated way and + // deleting the card afterward is probably a better choice. + assert(typeof arguments[0] != 'boolean', + 'This function takes no |opt_animate| argument.'); + this.tearDown_(); + this.parentNode.removeChild(this); + }, + + /** + * Cleans up resources that are no longer needed after this TilePage + * instance is removed from the DOM. + * @private + */ + tearDown_: function() { + this.eventTracker.removeAll(); + }, + + /** + * Appends a tile to the end of the tile grid. + * @param {HTMLElement} tileElement The contents of the tile. + * @param {boolean} animate If true, the append will be animated. + * @protected + */ + appendTile: function(tileElement, animate) { + this.addTileAt(tileElement, this.tileElements_.length, animate); + }, + + /** + * Adds the given element to the tile grid. + * @param {Node} tileElement The tile object/node to insert. + * @param {number} index The location in the tile grid to insert it at. + * @param {boolean} animate If true, the tile in question will be + * animated (other tiles, if they must reposition, do not animate). + * @protected + */ + addTileAt: function(tileElement, index, animate) { + this.classList.remove('animating-tile-page'); + if (animate) + tileElement.classList.add('new-tile-contents'); + + // Make sure the index is positive and either in the the bounds of + // this.tileElements_ or at the end (meaning append). + assert(index >= 0 && index <= this.tileElements_.length); + + var wrapperDiv = new Tile(tileElement); + // If is out of the bounds of the tile element list, .insertBefore() will + // act just like appendChild(). + this.tileGrid_.insertBefore(wrapperDiv, this.tileElements_[index]); + this.calculateLayoutValues_(); + this.heightChanged_(); + + this.repositionTiles_(); + this.fireAddedEvent(wrapperDiv, index, animate); + }, + + /** + * Notify interested subscribers that a tile has been removed from this + * page. + * @param {Tile} tile The newly added tile. + * @param {number} index The index of the tile that was added. + * @param {boolean} wasAnimated Whether the removal was animated. + */ + fireAddedEvent: function(tile, index, wasAnimated) { + var e = document.createEvent('Event'); + e.initEvent('tilePage:tile_added', true, true); + e.addedIndex = index; + e.addedTile = tile; + e.wasAnimated = wasAnimated; + this.dispatchEvent(e); + }, + + /** + * Removes the given tile and animates the repositioning of the other tiles. + * @param {boolean=} opt_animate Whether the removal should be animated. + * @param {boolean=} opt_dontNotify Whether a page should be removed if the + * last tile is removed from it. + */ + removeTile: function(tile, opt_animate, opt_dontNotify) { + if (opt_animate) + this.classList.add('animating-tile-page'); + var index = tile.index; + tile.parentNode.removeChild(tile); + this.calculateLayoutValues_(); + this.cleanupDrag(); + + if (!opt_dontNotify) + this.fireRemovedEvent(tile, index, !!opt_animate); + }, + + /** + * Notify interested subscribers that a tile has been removed from this + * page. + * @param {Tile} tile The tile that was removed. + * @param {number} oldIndex Where the tile was positioned before removal. + * @param {boolean} wasAnimated Whether the removal was animated. + */ + fireRemovedEvent: function(tile, oldIndex, wasAnimated) { + var e = document.createEvent('Event'); + e.initEvent('tilePage:tile_removed', true, true); + e.removedIndex = oldIndex; + e.removedTile = tile; + e.wasAnimated = wasAnimated; + this.dispatchEvent(e); + }, + + /** + * Removes all tiles from the page. + */ + removeAllTiles: function() { + this.tileGrid_.innerHTML = ''; + }, + + /** + * Called when the page is selected (in the card selector). + * @param {Event} e A custom cardselected event. + * @private + */ + handleCardSelection_: function(e) { + this.tabIndex = 1; + + // When we are selected, we re-calculate the layout values. (See comment + // in doDrop.) + this.calculateLayoutValues_(); + }, + + /** + * Called when the page loses selection (in the card selector). + * @param {Event} e A custom carddeselected event. + * @private + */ + handleCardDeselection_: function(e) { + this.tabIndex = -1; + if (this.currentFocusElement_) + this.currentFocusElement_.tabIndex = -1; + }, + + /** + * When we get focus, pass it on to the focus element. + * @param {Event} e The focus event. + * @private + */ + handleFocus_: function(e) { + if (this.focusableElements_.length == 0) + return; + + this.updateFocusElement_(); + }, + + /** + * Since we are doing custom focus handling, we have to manually + * set focusability on click (as well as keyboard nav above). + * @param {Event} e The focus event. + * @private + */ + handleMouseDown_: function(e) { + var focusable = findAncestorByClass(e.target, 'focusable'); + if (focusable) { + this.focusElementIndex_ = + Array.prototype.indexOf.call(this.focusableElements_, + focusable); + this.updateFocusElement_(); + } else { + // This prevents the tile page from getting focus when the user clicks + // inside the grid but outside of any tile. + e.preventDefault(); + } + }, + + /** + * Handle arrow key focus nav. + * @param {Event} e The focus event. + * @private + */ + handleKeyDown_: function(e) { + // We only handle up, down, left, right without control keys. + if (e.metaKey || e.shiftKey || e.altKey || e.ctrlKey) + return; + + // Wrap the given index to |this.focusableElements_|. + var wrap = function(idx) { + return (idx + this.focusableElements_.length) % + this.focusableElements_.length; + }.bind(this); + + switch (e.keyIdentifier) { + case 'Right': + case 'Left': + var direction = e.keyIdentifier == 'Right' ? 1 : -1; + this.focusElementIndex_ = wrap(this.focusElementIndex_ + direction); + break; + case 'Up': + case 'Down': + // Look through all focusable elements. Find the first one that is + // in the same column. + var direction = e.keyIdentifier == 'Up' ? -1 : 1; + var currentIndex = + Array.prototype.indexOf.call(this.focusableElements_, + this.currentFocusElement_); + var newFocusIdx = wrap(currentIndex + direction); + var tile = this.currentFocusElement_.parentNode; + for (;; newFocusIdx = wrap(newFocusIdx + direction)) { + var newTile = this.focusableElements_[newFocusIdx].parentNode; + var rowTiles = this.layoutValues_.numRowTiles; + if ((newTile.index - tile.index) % rowTiles == 0) + break; + } + + this.focusElementIndex_ = newFocusIdx; + break; + + default: + return; + } + + this.updateFocusElement_(); + + e.preventDefault(); + e.stopPropagation(); + }, + + /** + * Focuses the element for |this.focusElementIndex_|. Makes the current + * focus element, if any, no longer eligible for focus. + * @private + */ + updateFocusElement_: function() { + this.focusElementIndex_ = Math.min(this.focusableElements_.length - 1, + this.focusElementIndex_); + this.focusElementIndex_ = Math.max(0, this.focusElementIndex_); + + var newFocusElement = this.focusableElements_[this.focusElementIndex_]; + var lastFocusElement = this.currentFocusElement_; + if (lastFocusElement && lastFocusElement != newFocusElement) + lastFocusElement.tabIndex = -1; + + newFocusElement.tabIndex = 1; + newFocusElement.focus(); + this.tabIndex = -1; + }, + + /** + * The current focus element is that element which is eligible for focus. + * @type {HTMLElement} The node. + * @private + */ + get currentFocusElement_() { + return this.querySelector('.focusable[tabindex="1"]'); + }, + + /** + * Makes some calculations for tile layout. These change depending on + * height, width, and the number of tiles. + * TODO(estade): optimize calls to this function. Do nothing if the page is + * hidden, but call before being shown. + * @private + */ + calculateLayoutValues_: function() { + var grid = this.gridValues_; + var availableSpace = this.tileGrid_.clientWidth - 2 * MIN_WIDE_MARGIN; + var wide = availableSpace >= grid.minWideWidth; + var numRowTiles = wide ? grid.maxColCount : grid.minColCount; + + var effectiveGridWidth = wide ? + Math.min(Math.max(availableSpace, grid.minWideWidth), + grid.maxWideWidth) : + grid.narrowWidth; + var realTileValues = tileValuesForGrid(effectiveGridWidth, numRowTiles, + grid.tileSpacingFraction); + + // leftMargin centers the grid within the avaiable space. + var minMargin = wide ? MIN_WIDE_MARGIN : 0; + var leftMargin = + Math.max(minMargin, + (this.tileGrid_.clientWidth - effectiveGridWidth) / 2); + + var rowHeight = this.heightForWidth(realTileValues.tileWidth) + + realTileValues.interTileSpacing; + + this.layoutValues_ = { + numRowTiles: numRowTiles, + leftMargin: leftMargin, + colWidth: realTileValues.offsetX, + rowHeight: rowHeight, + tileWidth: realTileValues.tileWidth, + wide: wide, + }; + + // We need to update the top margin as well. + this.updateTopMargin_(); + + this.firePageLayoutEvent_(); + }, + + /** + * Dispatches the custom pagelayout event. + * @private + */ + firePageLayoutEvent_: function() { + cr.dispatchSimpleEvent(this, 'pagelayout', true, true); + }, + + /** + * Calculates the x/y coordinates for an element and moves it there. + * @param {number} index The index of the element to be positioned. + * @param {number} indexOffset If provided, this is added to |index| when + * positioning the tile. The effect is that the tile will be positioned + * in a non-default location. + * @private + */ + positionTile_: function(index, indexOffset) { + var grid = this.gridValues_; + var layout = this.layoutValues_; + + indexOffset = typeof indexOffset != 'undefined' ? indexOffset : 0; + // Add the offset _after_ the modulus division. We might want to show the + // tile off the side of the grid. + var col = index % layout.numRowTiles + indexOffset; + var row = Math.floor(index / layout.numRowTiles); + // Calculate the final on-screen position for the tile. + var realX = col * layout.colWidth + layout.leftMargin; + var realY = row * layout.rowHeight; + + // Calculate the portion of the tile's position that should be animated. + var animatedTileValues = layout.wide ? + grid.wideTileValues : grid.narrowTileValues; + // Animate the difference between three-wide and six-wide. + var animatedLeftMargin = layout.wide ? + 0 : (grid.minWideWidth - MIN_WIDE_MARGIN - grid.narrowWidth) / 2; + var animatedX = col * animatedTileValues.offsetX + animatedLeftMargin; + var animatedY = row * (this.heightForWidth(animatedTileValues.tileWidth) + + animatedTileValues.interTileSpacing); + + var tile = this.tileElements_[index]; + tile.setGridPosition(animatedX, animatedY); + tile.firstChild.setBounds(layout.tileWidth, + realX - animatedX, + realY - animatedY); + + // This code calculates whether the tile needs to show a clone of itself + // wrapped around the other side of the tile grid. + var offTheRight = col == layout.numRowTiles || + (col == layout.numRowTiles - 1 && tile.hasDoppleganger()); + var offTheLeft = col == -1 || (col == 0 && tile.hasDoppleganger()); + if (this.isCurrentDragTarget && (offTheRight || offTheLeft)) { + var sign = offTheRight ? 1 : -1; + tile.showDoppleganger(-layout.numRowTiles * layout.colWidth * sign, + layout.rowHeight * sign); + } else { + tile.clearDoppleganger(); + } + + if (index == this.tileElements_.length - 1) { + this.tileGrid_.style.height = (realY + layout.rowHeight) + 'px'; + this.queueUpdateScrollbars_(); + } + }, + + /** + * Gets the index of the tile that should occupy coordinate (x, y). Note + * that this function doesn't care where the tiles actually are, and will + * return an index even for the space between two tiles. This function is + * effectively the inverse of |positionTile_|. + * @param {number} x The x coordinate, in pixels, relative to the left of + * |this|. + * @param {number} y The y coordinate, in pixels, relative to the top of + * |this|. + * @private + */ + getWouldBeIndexForPoint_: function(x, y) { + var grid = this.gridValues_; + var layout = this.layoutValues_; + + var gridClientRect = this.tileGrid_.getBoundingClientRect(); + var col = Math.floor((x - gridClientRect.left - layout.leftMargin) / + layout.colWidth); + if (col < 0 || col >= layout.numRowTiles) + return -1; + + if (isRTL()) + col = layout.numRowTiles - 1 - col; + + var row = Math.floor((y - gridClientRect.top) / layout.rowHeight); + return row * layout.numRowTiles + col; + }, + + /** + * Window resize event handler. Window resizes may trigger re-layouts. + * @param {Object} e The resize event. + */ + onResize_: function(e) { + if (this.lastWidth_ == this.clientWidth && + this.lastHeight_ == this.clientHeight) { + return; + } + + this.calculateLayoutValues_(); + + this.lastWidth_ = this.clientWidth; + this.lastHeight_ = this.clientHeight; + this.classList.add('animating-tile-page'); + this.heightChanged_(); + + this.positionNotification_(); + this.repositionTiles_(); + }, + + /** + * The tile grid has an image mask which fades at the edges. We only show + * the mask when there is an active drag; it obscures doppleganger tiles + * as they enter or exit the grid. + * @private + */ + updateMask_: function() { + if (!this.isCurrentDragTarget) { + this.tileGrid_.style.WebkitMaskBoxImage = ''; + return; + } + + var leftMargin = this.layoutValues_.leftMargin; + // The fade distance is the space between tiles. + var fadeDistance = (this.gridValues_.tileSpacingFraction * + this.layoutValues_.tileWidth); + fadeDistance = Math.min(leftMargin, fadeDistance); + // On Skia we don't use any fade because it works very poorly. See + // http://crbug.com/99373 + if (!cr.isMac) + fadeDistance = 1; + var gradient = + '-webkit-linear-gradient(left,' + + 'transparent, ' + + 'transparent ' + (leftMargin - fadeDistance) + 'px, ' + + 'black ' + leftMargin + 'px, ' + + 'black ' + (this.tileGrid_.clientWidth - leftMargin) + 'px, ' + + 'transparent ' + (this.tileGrid_.clientWidth - leftMargin + + fadeDistance) + 'px, ' + + 'transparent)'; + this.tileGrid_.style.WebkitMaskBoxImage = gradient; + }, + + updateTopMargin_: function() { + var layout = this.layoutValues_; + + // The top margin is set so that the vertical midpoint of the grid will + // be 1/3 down the page. + var numTiles = this.tileCount + + (this.isCurrentDragTarget && !this.withinPageDrag_ ? 1 : 0); + var numRows = Math.max(1, Math.ceil(numTiles / layout.numRowTiles)); + var usedHeight = layout.rowHeight * numRows; + var newMargin = document.documentElement.clientHeight / 3 - + usedHeight / 3 - this.contentPadding; + // The 'height' style attribute of topMargin is non-zero to work around + // webkit's collapsing margin behavior, so we have to factor that into + // our calculations here. + newMargin = Math.max(newMargin, 0) - this.topMargin_.offsetHeight; + + // |newMargin| is the final margin we actually want to show. However, + // part of that should be animated and part should not (for the same + // reason as with leftMargin). The approach is to consider differences + // when the layout changes from wide to narrow or vice versa as + // 'animatable'. These differences accumulate in animatedTopMarginPx_, + // while topMarginPx_ caches the real (total) margin. Either of these + // calculations may come out to be negative, so we use margins as the + // css property. + + if (typeof this.topMarginIsForWide_ == 'undefined') + this.topMarginIsForWide_ = layout.wide; + if (this.topMarginIsForWide_ != layout.wide) { + this.animatedTopMarginPx_ += newMargin - this.topMarginPx_; + this.topMargin_.style.marginBottom = toCssPx(this.animatedTopMarginPx_); + } + + this.topMarginIsForWide_ = layout.wide; + this.topMarginPx_ = newMargin; + this.topMargin_.style.marginTop = + toCssPx(this.topMarginPx_ - this.animatedTopMarginPx_); + }, + + /** + * Position the notification if there's one showing. + */ + positionNotification_: function() { + if (this.notification && !this.notification.hidden) { + this.notification.style.margin = + -this.notification.offsetHeight + 'px ' + + this.layoutValues_.leftMargin + 'px 0'; + } + }, + + /** + * Handles final setup that can only happen after |this| is inserted into + * the page. + * @private + */ + onNodeInsertedIntoDocument_: function(e) { + this.calculateLayoutValues_(); + this.heightChanged_(); + }, + + /** + * Called when the height of |this| has changed: update the size of + * tileGrid. + * @private + */ + heightChanged_: function() { + // The tile grid will expand to the bottom footer, or enough to hold all + // the tiles, whichever is greater. It would be nicer if tilePage were + // a flex box, and the tile grid could be box-flex: 1, but this exposes a + // bug where repositioning tiles will cause the scroll position to reset. + this.tileGrid_.style.minHeight = (this.clientHeight - + this.tileGrid_.offsetTop - this.content_.offsetTop - + this.extraBottomPadding - + (this.footerNode_ ? this.footerNode_.clientHeight : 0)) + 'px'; + }, + + /** + * Places an element at the bottom of the content div. Used in bare-minimum + * mode to hold #footer. + * @param {HTMLElement} footerNode The node to append to content. + */ + appendFooter: function(footerNode) { + this.footerNode_ = footerNode; + this.content_.appendChild(footerNode); + }, + + /** + * Scrolls the page in response to an mousewheel event, although the event + * may have been triggered on a different element. Return true if the + * event triggered scrolling, and false otherwise. + * This is called explicitly, which allows a consistent experience whether + * the user scrolls on the page or on the page switcher, because this + * function provides a common conversion factor between wheel delta and + * scroll delta. + * @param {Event} e The mousewheel event. + */ + handleMouseWheel: function(e) { + if (e.wheelDeltaY == 0) + return false; + + this.content_.scrollTop -= e.wheelDeltaY / 3; + return true; + }, + + /** + * Handler for the 'scroll' event on |content_|. + * @param {Event} e The scroll event. + * @private + */ + onScroll_: function(e) { + this.queueUpdateScrollbars_(); + }, + + /** + * ID of scrollbar update timer. If 0, there's no scrollbar re-calc queued. + * @private + */ + scrollbarUpdate_: 0, + + /** + * Queues an update on the custom scrollbar. Used for two reasons: first, + * coalescing of multiple updates, and second, because action like + * repositioning a tile can require a delay before they affect values + * like clientHeight. + * @private + */ + queueUpdateScrollbars_: function() { + if (this.scrollbarUpdate_) + return; + + this.scrollbarUpdate_ = window.setTimeout( + this.doUpdateScrollbars_.bind(this), 0); + }, + + /** + * Does the work of calculating the visibility, height and position of the + * scrollbar thumb (there is no track or buttons). + * @private + */ + doUpdateScrollbars_: function() { + this.scrollbarUpdate_ = 0; + + var content = this.content_; + + // Adjust scroll-height to account for possible header-bar. + var adjustedScrollHeight = content.scrollHeight - content.offsetTop; + + if (adjustedScrollHeight <= content.clientHeight) { + this.scrollbar_.hidden = true; + return; + } else { + this.scrollbar_.hidden = false; + } + + var thumbTop = content.offsetTop + + content.scrollTop / adjustedScrollHeight * content.clientHeight; + var thumbHeight = content.clientHeight / adjustedScrollHeight * + this.clientHeight; + + this.scrollbar_.style.top = thumbTop + 'px'; + this.scrollbar_.style.height = thumbHeight + 'px'; + this.firePageLayoutEvent_(); + }, + + /** + * Get the height for a tile of a certain width. Override this function to + * get non-square tiles. + * @param {number} width The pixel width of a tile. + * @return {number} The height for |width|. + */ + heightForWidth: function(width) { + return width; + }, + + /** Dragging **/ + + get isCurrentDragTarget() { + return this.dragWrapper_.isCurrentDragTarget; + }, + + /** + * Thunk for dragleave events fired on |tileGrid_|. + * @param {Event} e A MouseEvent for the drag. + */ + doDragLeave: function(e) { + this.cleanupDrag(); + }, + + /** + * Performs all actions necessary when a drag enters the tile page. + * @param {Event} e A mouseover event for the drag enter. + */ + doDragEnter: function(e) { + // Applies the mask so doppleganger tiles disappear into the fog. + this.updateMask_(); + + this.classList.add('animating-tile-page'); + this.withinPageDrag_ = this.contains(currentlyDraggingTile); + this.dragItemIndex_ = this.withinPageDrag_ ? + currentlyDraggingTile.index : this.tileElements_.length; + this.currentDropIndex_ = this.dragItemIndex_; + + // The new tile may change the number of rows, hence the top margin + // will change. + if (!this.withinPageDrag_) + this.updateTopMargin_(); + + this.doDragOver(e); + }, + + /** + * Performs all actions necessary when the user moves the cursor during + * a drag over the tile page. + * @param {Event} e A mouseover event for the drag over. + */ + doDragOver: function(e) { + e.preventDefault(); + + this.setDropEffect(e.dataTransfer); + var newDragIndex = this.getWouldBeIndexForPoint_(e.pageX, e.pageY); + if (newDragIndex < 0 || newDragIndex >= this.tileElements_.length) + newDragIndex = this.dragItemIndex_; + this.updateDropIndicator_(newDragIndex); + }, + + /** + * Performs all actions necessary when the user completes a drop. + * @param {Event} e A mouseover event for the drag drop. + */ + doDrop: function(e) { + e.stopPropagation(); + e.preventDefault(); + + var index = this.currentDropIndex_; + // Only change data if this was not a 'null drag'. + if (!((index == this.dragItemIndex_) && this.withinPageDrag_)) { + var adjustedIndex = this.currentDropIndex_ + + (index > this.dragItemIndex_ ? 1 : 0); + if (this.withinPageDrag_) { + this.tileGrid_.insertBefore( + currentlyDraggingTile, + this.tileElements_[adjustedIndex]); + this.tileMoved(currentlyDraggingTile, this.dragItemIndex_); + } else { + var originalPage = currentlyDraggingTile ? + currentlyDraggingTile.tilePage : null; + this.addDragData(e.dataTransfer, adjustedIndex); + if (originalPage) + originalPage.cleanupDrag(); + } + + // Dropping the icon may cause topMargin to change, but changing it + // now would cause everything to move (annoying), so we leave it + // alone. The top margin will be re-calculated next time the window is + // resized or the page is selected. + } + + this.classList.remove('animating-tile-page'); + this.cleanupDrag(); + }, + + /** + * Appends the currently dragged tile to the end of the page. Called + * from outside the page, e.g. when dropping on a nav dot. + */ + appendDraggingTile: function() { + var originalPage = currentlyDraggingTile.tilePage; + if (originalPage == this) + return; + + this.addDragData(null, this.tileElements_.length); + if (originalPage) + originalPage.cleanupDrag(); + }, + + /** + * Makes sure all the tiles are in the right place after a drag is over. + */ + cleanupDrag: function() { + this.repositionTiles_(currentlyDraggingTile); + // Remove the drag mask. + this.updateMask_(); + }, + + /** + * Reposition all the tiles (possibly ignoring one). + * @param {?Node} ignoreNode An optional node to ignore. + * @private + */ + repositionTiles_: function(ignoreNode) { + for (var i = 0; i < this.tileElements_.length; i++) { + if (!ignoreNode || ignoreNode !== this.tileElements_[i]) + this.positionTile_(i); + } + }, + + /** + * Updates the visual indicator for the drop location for the active drag. + * @param {Event} e A MouseEvent for the drag. + * @private + */ + updateDropIndicator_: function(newDragIndex) { + var oldDragIndex = this.currentDropIndex_; + if (newDragIndex == oldDragIndex) + return; + + var repositionStart = Math.min(newDragIndex, oldDragIndex); + var repositionEnd = Math.max(newDragIndex, oldDragIndex); + + for (var i = repositionStart; i <= repositionEnd; i++) { + if (i == this.dragItemIndex_) + continue; + else if (i > this.dragItemIndex_) + var adjustment = i <= newDragIndex ? -1 : 0; + else + var adjustment = i >= newDragIndex ? 1 : 0; + + this.positionTile_(i, adjustment); + } + this.currentDropIndex_ = newDragIndex; + }, + + /** + * Checks if a page can accept a drag with the given data. + * @param {Event} e The drag event if the drag object. Implementations will + * likely want to check |e.dataTransfer|. + * @return {boolean} True if this page can handle the drag. + */ + shouldAcceptDrag: function(e) { + return false; + }, + + /** + * Called to accept a drag drop. Will not be called for in-page drops. + * @param {Object} dataTransfer The data transfer object that holds the drop + * data. This should only be used if currentlyDraggingTile is null. + * @param {number} index The tile index at which the drop occurred. + */ + addDragData: function(dataTransfer, index) { + assert(false); + }, + + /** + * Called when a tile has been moved (via dragging). Override this to make + * backend updates. + * @param {Node} draggedTile The tile that was dropped. + * @param {number} prevIndex The previous index of the tile. + */ + tileMoved: function(draggedTile, prevIndex) { + }, + + /** + * Sets the drop effect on |dataTransfer| to the desired value (e.g. + * 'copy'). + * @param {Object} dataTransfer The drag event dataTransfer object. + */ + setDropEffect: function(dataTransfer) { + assert(false); + }, + }; + + return { + getCurrentlyDraggingTile: getCurrentlyDraggingTile, + setCurrentDropEffect: setCurrentDropEffect, + TilePage: TilePage, + }; +}); diff --git a/chrome/browser/resources/ntp_search/trash.js b/chrome/browser/resources/ntp_search/trash.js new file mode 100644 index 0000000..c15eb93 --- /dev/null +++ b/chrome/browser/resources/ntp_search/trash.js @@ -0,0 +1,83 @@ +// Copyright (c) 2012 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. + +/** + * @fileoverview Trash + * This is the class for the trash can that appears when dragging an app. + */ + +cr.define('ntp', function() { + 'use strict'; + + function Trash(trash) { + trash.__proto__ = Trash.prototype; + trash.initialize(); + return trash; + } + + Trash.prototype = { + __proto__: HTMLDivElement.prototype, + + initialize: function(element) { + this.dragWrapper_ = new cr.ui.DragWrapper(this, this); + }, + + /** + * Determines whether we are interested in the drag data for |e|. + * @param {Event} e The event from drag enter. + * @return {boolean} True if we are interested in the drag data for |e|. + */ + shouldAcceptDrag: function(e) { + var tile = ntp.getCurrentlyDraggingTile(); + if (!tile) + return false; + + return tile.firstChild.canBeRemoved(); + }, + + /** + * Drag over handler. + * @param {Event} e The drag event. + */ + doDragOver: function(e) { + ntp.getCurrentlyDraggingTile().dragClone.classList.add( + 'hovering-on-trash'); + ntp.setCurrentDropEffect(e.dataTransfer, 'move'); + e.preventDefault(); + }, + + /** + * Drag enter handler. + * @param {Event} e The drag event. + */ + doDragEnter: function(e) { + this.doDragOver(e); + }, + + /** + * Drop handler. + * @param {Event} e The drag event. + */ + doDrop: function(e) { + e.preventDefault(); + + var tile = ntp.getCurrentlyDraggingTile(); + tile.firstChild.removeFromChrome(); + tile.landedOnTrash = true; + }, + + /** + * Drag leave handler. + * @param {Event} e The drag event. + */ + doDragLeave: function(e) { + ntp.getCurrentlyDraggingTile().dragClone.classList.remove( + 'hovering-on-trash'); + }, + }; + + return { + Trash: Trash, + }; +}); diff --git a/chrome/browser/ui/webui/ntp/new_tab_ui.cc b/chrome/browser/ui/webui/ntp/new_tab_ui.cc index ef71746..49bf9ff 100644 --- a/chrome/browser/ui/webui/ntp/new_tab_ui.cc +++ b/chrome/browser/ui/webui/ntp/new_tab_ui.cc @@ -26,6 +26,7 @@ #include "chrome/browser/themes/theme_service.h" #include "chrome/browser/themes/theme_service_factory.h" #include "chrome/browser/ui/browser.h" +#include "chrome/browser/ui/search/search.h" #include "chrome/browser/ui/webui/chrome_url_data_manager.h" #include "chrome/browser/ui/webui/metrics_handler.h" #include "chrome/browser/ui/webui/ntp/favicon_webui_handler.h" @@ -139,13 +140,26 @@ NewTabUI::NewTabUI(content::WebUI* web_ui) InitializeCSSCaches(); NewTabHTMLSource* html_source = new NewTabHTMLSource(GetProfile()->GetOriginalProfile()); + // These two resources should be loaded only if suggestions NTP is enabled. - html_source->AddResource("suggestions_page.css", "text/css", - NewTabUI::IsSuggestionsPageEnabled() ? IDR_SUGGESTIONS_PAGE_CSS : 0); - if (NewTabUI::IsSuggestionsPageEnabled()) { - html_source->AddResource("suggestions_page.js", "application/javascript", - IDR_SUGGESTIONS_PAGE_JS); + const bool kSuggestEnabled = NewTabUI::IsSuggestionsPageEnabled(); + const bool kInstantEnabled = + chrome::search::IsInstantExtendedAPIEnabled(GetProfile()); + html_source->AddResource("suggestions_page.css", + "text/css", + kSuggestEnabled ? + kInstantEnabled ? + IDR_SUGGESTIONS_PAGE_SEARCH_CSS : + IDR_SUGGESTIONS_PAGE_CSS : + 0); + if (kSuggestEnabled) { + html_source->AddResource("suggestions_page.js", + "application/javascript", + kInstantEnabled ? + IDR_SUGGESTIONS_PAGE_SEARCH_JS : + IDR_SUGGESTIONS_PAGE_JS); } + // ChromeURLDataManager assumes the ownership of the html_source and in some // tests immediately deletes it, so html_source should not be accessed after // this call. diff --git a/chrome/browser/ui/webui/ntp/ntp_resource_cache.cc b/chrome/browser/ui/webui/ntp/ntp_resource_cache.cc index da04a88..8b49198 100644 --- a/chrome/browser/ui/webui/ntp/ntp_resource_cache.cc +++ b/chrome/browser/ui/webui/ntp/ntp_resource_cache.cc @@ -22,6 +22,7 @@ #include "chrome/browser/sync/profile_sync_service_factory.h" #include "chrome/browser/themes/theme_service.h" #include "chrome/browser/themes/theme_service_factory.h" +#include "chrome/browser/ui/search/search.h" #include "chrome/browser/ui/webui/chrome_url_data_manager.h" #include "chrome/browser/ui/webui/ntp/new_tab_page_handler.h" #include "chrome/browser/ui/webui/ntp/new_tab_ui.h" @@ -425,7 +426,8 @@ void NTPResourceCache::CreateNewTabHTML() { // Load the new tab page appropriate for this build base::StringPiece new_tab_html(ResourceBundle::GetSharedInstance(). - GetRawDataResource(IDR_NEW_TAB_4_HTML, + GetRawDataResource(chrome::search::IsInstantExtendedAPIEnabled(profile_) ? + IDR_NEW_TAB_SEARCH_HTML : IDR_NEW_TAB_4_HTML, ui::SCALE_FACTOR_NONE)); jstemplate_builder::UseVersion2 version2; std::string full_html = @@ -552,7 +554,9 @@ void NTPResourceCache::CreateNewTabCSS() { // Get our template. static const base::StringPiece new_tab_theme_css( ResourceBundle::GetSharedInstance().GetRawDataResource( - IDR_NEW_TAB_4_THEME_CSS, ui::SCALE_FACTOR_NONE)); + chrome::search::IsInstantExtendedAPIEnabled(profile_) ? + IDR_NEW_TAB_SEARCH_THEME_CSS : IDR_NEW_TAB_4_THEME_CSS, + ui::SCALE_FACTOR_NONE)); // Create the string from our template and the replacements. std::string css_string; |