// Copyright (c) 2011 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview Touch-based 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
var ntp = (function() {
'use strict';
/**
* The Slider object to use for changing app pages.
* @type {Slider|undefined}
*/
var slider;
/**
* Template to use for creating new 'apps-page' elements
* @type {!Element|undefined}
*/
var appsPageTemplate;
/**
* Template to use for creating new 'app-container' elements
* @type {!Element|undefined}
*/
var appTemplate;
/**
* Template to use for creating new 'dot' elements
* @type {!Element|undefined}
*/
var dotTemplate;
/**
* The 'apps-page-list' element.
* @type {!Element}
*/
var appsPageList = getRequiredElement('apps-page-list');
/**
* A list of all 'apps-page' elements.
* @type {!NodeList|undefined}
*/
var appsPages;
/**
* The 'dots-list' element.
* @type {!Element}
*/
var dotList = getRequiredElement('dot-list');
/**
* A list of all 'dots' elements.
* @type {!NodeList|undefined}
*/
var dots;
/**
* 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}
*/
var trash = getRequiredElement('trash');
/**
* The time in milliseconds for most transitions. This should match what's
* in newtab.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;
/**
* All the Grabber objects currently in use on the page
* @type {Array.<Grabber>}
*/
var grabbers = [];
/**
* Holds all event handlers tied to apps (and so subject to removal when the
* app list is refreshed)
* @type {!EventTracker}
*/
var appEvents = new EventTracker();
/**
* Invoked at startup once the DOM is available to initialize the app.
*/
function initializeNtp() {
// 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');
// Prevent touch events from triggering any sort of native scrolling
document.addEventListener('touchmove', function(e) {
e.preventDefault();
}, true);
// Get the template elements and remove them from the DOM. Things are
// simpler if we start with 0 pages and 0 apps and don't leave hidden
// template elements behind in the DOM.
appTemplate = getRequiredElement('app-template');
appTemplate.id = null;
appsPages = appsPageList.getElementsByClassName('apps-page');
assert(appsPages.length == 1,
'Expected exactly one apps-page in the apps-page-list.');
appsPageTemplate = appsPages[0];
appsPageList.removeChild(appsPages[0]);
dots = dotList.getElementsByClassName('dot');
assert(dots.length == 1,
'Expected exactly one dot in the dots-list.');
dotTemplate = dots[0];
dotList.removeChild(dots[0]);
// Initialize the slider without any cards at the moment
var appsFrame = getRequiredElement('apps-frame');
slider = new Slider(appsFrame, appsPageList, [], 0, appsFrame.offsetWidth);
slider.initialize();
// Ensure the slider is resized appropriately with the window
window.addEventListener('resize', function() {
slider.resize(appsFrame.offsetWidth);
});
// Handle the page being changed
appsPageList.addEventListener(
Slider.EventType.CARD_CHANGED,
function(e) {
// Update the active dot
var curDot = dotList.getElementsByClassName('selected')[0];
if (curDot)
curDot.classList.remove('selected');
var newPageIndex = e.slider.currentCard;
dots[newPageIndex].classList.add('selected');
// If an app was being dragged, move it to the end of the new page
if (draggingAppContainer)
appsPages[newPageIndex].appendChild(draggingAppContainer);
});
// Add a drag handler to the body (for drags that don't land on an existing
// app)
document.addEventListener(Grabber.EventType.DRAG_ENTER, appDragEnter);
// Handle dropping an app anywhere other than on the trash
document.addEventListener(Grabber.EventType.DROP, appDrop);
// Add handles to manage the transition into/out-of rearrange mode
// Note that we assume here that we only use a Grabber for moving apps,
// so ANY GRAB event means we're enterring rearrange mode.
appsFrame.addEventListener(Grabber.EventType.GRAB, enterRearrangeMode);
appsFrame.addEventListener(Grabber.EventType.RELEASE, leaveRearrangeMode);
// Add handlers for the tash can
trash.addEventListener(Grabber.EventType.DRAG_ENTER, function(e) {
trash.classList.add('hover');
e.grabbedElement.classList.add('trashing');
e.stopPropagation();
});
trash.addEventListener(Grabber.EventType.DRAG_LEAVE, function(e) {
e.grabbedElement.classList.remove('trashing');
trash.classList.remove('hover');
});
trash.addEventListener(Grabber.EventType.DROP, appTrash);
}
/**
* Simple common assertion API
* @param {*} condition The condition to test. Note that this may be used to
* test whether a value is defined or not, and we don't want to force a
* cast to Boolean.
* @param {string=} opt_message A message to use in any error.
*/
function assert(condition, opt_message) {
'use strict';
if (!condition) {
var msg = 'Assertion failed';
if (opt_message)
msg = msg + ': ' + opt_message;
throw new Error(msg);
}
}
/**
* Get an element that's known to exist by its ID. We use this instead of just
* calling getElementById and not checking the result because this lets us
* satisfy the JSCompiler type system.
* @param {string} id The identifier name.
* @return {!Element} the Element.
*/
function getRequiredElement(id) {
var element = document.getElementById(id);
assert(element, 'Missing required element: ' + id);
return element;
}
/**
* Remove all children of an element which have a given class in
* their classList.
* @param {!Element} element The parent element to examine.
* @param {string} className The class to look for.
*/
function removeChildrenByClassName(element, className) {
for (var child = element.firstElementChild; child;) {
var prev = child;
child = child.nextElementSibling;
if (prev.classList.contains(className))
element.removeChild(prev);
}
}
/**
* 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.
*/
function getAppsCallback(data)
{
// Clean up any existing grabber objects - cancelling any outstanding drag.
// Ideally an async app update wouldn't disrupt an active drag but
// that would require us to re-use existing elements and detect how the apps
// have changed, which would be a lot of work.
// Note that we have to explicitly clean up the grabber objects so they stop
// listening to events and break the DOM<->JS cycles necessary to enable
// collection of all these objects.
grabbers.forEach(function(g) {
// Note that this may raise DRAG_END/RELEASE events to clean up an
// oustanding drag.
g.dispose();
});
assert(!draggingAppContainer && !draggingAppOriginalPosition &&
!draggingAppOriginalPage);
grabbers = [];
appEvents.removeAll();
// 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.
removeChildrenByClassName(appsPageList, 'apps-page');
removeChildrenByClassName(dotList, 'dot');
// Get the array of apps and add any special synthesized entries
var apps = data.apps;
apps.push(makeWebstoreApp());
// Sort by launch index
apps.sort(function(a, b) {
return a.app_launch_index - b.app_launch_index;
});
// 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 >= appsPages.length) {
var origPageCount = appsPages.length;
createAppPage();
// Confirm that appsPages is a live object, updated when a new page is
// added (otherwise we'd have an infinite loop)
assert(appsPages.length == origPageCount + 1, 'expected new page');
}
appendApp(appsPages[pageIndex], app);
}
// Tell the slider about the pages
updateSliderCards();
// Mark the current page
dots[slider.currentCard].classList.add('selected');
}
/**
* Make a synthesized app object representing the chrome web store. It seems
* like this could just as easily come from the back-end, and then would
* support being rearranged, etc.
* @return {Object} The app object as would be sent from the webui back-end.
*/
function makeWebstoreApp() {
return {
id: '', // Empty ID signifies this is a special synthesized app
page_index: 0,
app_launch_index: -1, // always first
name: templateData.web_store_title,
launch_url: templateData.web_store_url,
icon_big: getThemeUrl('IDR_WEBSTORE_ICON')
};
}
/**
* Given a theme resource name, construct a URL for it.
* @param {string} resourceName The name of the resource.
* @return {string} A url which can be used to load the resource.
*/
function getThemeUrl(resourceName) {
// Allow standalone_hack.js to hook this mapping (since chrome:// URLs
// won't work for a standalone page)
if (typeof themeUrlMapper == 'function') {
var u = themeUrlMapper(resourceName);
if (u)
return u;
}
return 'chrome://theme/' + resourceName;
}
/**
* Callback invoked by chrome whenever an app preference changes.
* The normal NTP uses this to keep track of the current launch-type of an
* app, updating the choices in the context menu. We don't have such a menu
* so don't use this at all (but it still needs to be here for chrome to
* call).
* @param {Object} data An object with all the data on available
* applications.
*/
function appsPrefChangeCallback(data) {
}
/**
* Invoked whenever the pages in apps-page-list have changed so that
* the Slider knows about the new elements.
*/
function updateSliderCards() {
var pageNo = slider.currentCard;
if (pageNo >= appsPages.length)
pageNo = appsPages.length - 1;
var pageArray = [];
for (var i = 0; i < appsPages.length; i++)
pageArray[i] = appsPages[i];
slider.setCards(pageArray, pageNo);
}
/**
* Create a new app element and attach it to the end of the specified app
* page.
* @param {!Element} parent The element where the app should be inserted.
* @param {!Object} app The application object to create an app for.
*/
function appendApp(parent, app) {
// Make a deep copy of the template and clear its ID
var containerElement = appTemplate.cloneNode(true);
var appElement = containerElement.getElementsByClassName('app')[0];
assert(appElement, 'Expected app-template to have an app child');
assert(typeof(app.id) == 'string',
'Expected every app to have an ID or empty string');
appElement.setAttribute('app-id', app.id);
// Find the span element (if any) and fill it in with the app name
var span = appElement.querySelector('span');
if (span)
span.textContent = app.name;
// Fill in the image
// We use a mask of the same image so CSS rules can highlight just the image
// when it's touched.
var appImg = appElement.querySelector('img');
if (appImg) {
appImg.src = app.icon_big;
appImg.style.webkitMaskImage = url(app.icon_big);
// We put a click handler just on the app image - so clicking on the
// margins between apps doesn't do anything
if (app.id) {
appEvents.add(appImg, 'click', appClick, false);
} else {
// Special case of synthesized apps - can't launch directly so just
// change the URL as if we clicked a link. We may want to eventually
// support tracking clicks with ping messages, but really it seems it
// would be better for the back-end to just create virtual apps for such
// cases.
appEvents.add(appImg, 'click', function(e) {
window.location = app.launch_url;
}, false);
}
}
// Only real apps with back-end storage (for their launch index, etc.) can
// be rearranged.
if (app.id) {
// Create a grabber to support moving apps around
// Note that we move the app rather than the container. This is so that an
// element remains in the original position so we can detect when an app
// is dropped in its starting location.
var grabber = new Grabber(appElement);
grabbers.push(grabber);
// Register to be made aware of when we are dragged
appEvents.add(appElement, Grabber.EventType.DRAG_START, appDragStart,
false);
appEvents.add(appElement, Grabber.EventType.DRAG_END, appDragEnd,
false);
// Register to be made aware of any app drags on top of our container
appEvents.add(containerElement, Grabber.EventType.DRAG_ENTER,
appDragEnter, false);
} else {
// Prevent any built-in drag-and-drop support from activating for the
// element.
appEvents.add(appElement, 'dragstart', function(e) {
e.preventDefault();
}, true);
}
// Insert at the end of the provided page
parent.appendChild(containerElement);
}
/**
* Creates a new page for apps
*
* @return {!Element} The apps-page element created.
* @param {boolean=} opt_animate If true, add the class 'new' to the created
* dot.
*/
function createAppPage(opt_animate)
{
// Make a shallow copy of the app page template.
var newPage = appsPageTemplate.cloneNode(false);
appsPageList.appendChild(newPage);
// Make a deep copy of the dot template to add a new one.
var dotCount = dots.length;
var newDot = dotTemplate.cloneNode(true);
if (opt_animate)
newDot.classList.add('new');
dotList.appendChild(newDot);
// Add click handler to the dot to change the page.
// TODO(rbyers): Perhaps this should be TouchHandler.START_EVENT_ (so we
// don't rely on synthesized click events, and the change takes effect
// before releasing). However, click events seems to be synthesized for a
// region outside the border, and a 10px box is too small to require touch
// events to fall inside of. We could get around this by adding a box around
// the dot for accepting the touch events.
function switchPage(e) {
slider.selectCard(dotCount, true);
e.stopPropagation();
}
appEvents.add(newDot, 'click', switchPage, false);
// Change pages whenever an app is dragged over a dot.
appEvents.add(newDot, Grabber.EventType.DRAG_ENTER, switchPage, false);
return newPage;
}
/**
* Invoked when an app is clicked
* @param {Event} e The click event.
*/
function appClick(e) {
var target = e.currentTarget;
var app = getParentByClassName(target, 'app');
assert(app, 'appClick should have been on a descendant of an app');
var appId = app.getAttribute('app-id');
assert(appId, 'unexpected app without appId');
// Tell chrome to launch the app.
var NTP_APPS_MAXIMIZED = 0;
chrome.send('launchApp', [appId, NTP_APPS_MAXIMIZED]);
// Don't allow the click to trigger a link or anything
e.preventDefault();
}
/**
* Search an elements ancestor chain for the nearest element that is a member
* of the specified class.
* @param {!Element} element The element to start searching from.
* @param {string} className The name of the class to locate.
* @return {Element} The first ancestor of the specified class or null.
*/
function getParentByClassName(element, className)
{
for (var e = element; e; e = e.parentElement) {
if (e.classList.contains(className))
return e;
}
return null;
}
/**
* The container where the app currently being dragged came from.
* @type {!Element|undefined}
*/
var draggingAppContainer;
/**
* The apps-page that the app currently being dragged camed from.
* @type {!Element|undefined}
*/
var draggingAppOriginalPage;
/**
* The element that was originally after the app currently being dragged (or
* null if it was the last on the page).
* @type {!Element|undefined}
*/
var draggingAppOriginalPosition;
/**
* Invoked when app dragging begins.
* @param {Grabber.Event} e The event from the Grabber indicating the drag.
*/
function appDragStart(e) {
// Pull the element out to the appsFrame using fixed positioning. This
// ensures that the app is not affected (remains under the finger) if the
// slider changes cards and is translated. An alternate approach would be
// to use fixed positioning for the slider (so that changes to its position
// don't affect children that aren't positioned relative to it), but we
// don't yet have GPU acceleration for this. Note that we use the appsFrame
var element = e.grabbedElement;
var pos = element.getBoundingClientRect();
element.style.webkitTransform = '';
element.style.position = 'fixed';
// Don't want to zoom around the middle since the left/top co-ordinates
// are post-transform values.
element.style.webkitTransformOrigin = 'left top';
element.style.left = pos.left + 'px';
element.style.top = pos.top + 'px';
// Keep track of what app is being dragged and where it came from
assert(!draggingAppContainer, 'got DRAG_START without DRAG_END');
draggingAppContainer = element.parentNode;
assert(draggingAppContainer.classList.contains('app-container'));
draggingAppOriginalPosition = draggingAppContainer.nextSibling;
draggingAppOriginalPage = draggingAppContainer.parentNode;
// Move the app out of the container
// Note that appendChild also removes the element from its current parent.
getRequiredElement('apps-frame').appendChild(element);
}
/**
* Invoked when app dragging terminates (either successfully or not)
* @param {Grabber.Event} e The event from the Grabber.
*/
function appDragEnd(e) {
// Stop floating the app
var appBeingDragged = e.grabbedElement;
assert(appBeingDragged.classList.contains('app'));
appBeingDragged.style.position = '';
appBeingDragged.style.webkitTransformOrigin = '';
appBeingDragged.style.left = '';
appBeingDragged.style.top = '';
// Ensure the trash can is not active (we won't necessarily get a DRAG_LEAVE
// for it - eg. if we drop on it, or the drag is cancelled)
trash.classList.remove('hover');
appBeingDragged.classList.remove('trashing');
// If we have an active drag (i.e. it wasn't aborted by an app update)
if (draggingAppContainer) {
// Put the app back into it's container
if (appBeingDragged.parentNode != draggingAppContainer)
draggingAppContainer.appendChild(appBeingDragged);
// If we care about the container's original position
if (draggingAppOriginalPage)
{
// Then put the container back where it came from
if (draggingAppOriginalPosition) {
draggingAppOriginalPage.insertBefore(draggingAppContainer,
draggingAppOriginalPosition);
} else {
draggingAppOriginalPage.appendChild(draggingAppContainer);
}
}
}
draggingAppContainer = undefined;
draggingAppOriginalPage = undefined;
draggingAppOriginalPosition = undefined;
}
/**
* Invoked when an app is dragged over another app. Updates the DOM to affect
* the rearrangement (but doesn't commit the change until the app is dropped).
* @param {Grabber.Event} e The event from the Grabber indicating the drag.
*/
function appDragEnter(e)
{
assert(draggingAppContainer, 'expected stored container');
var sourceContainer = draggingAppContainer;
// Ensure enter events delivered to an app-container don't also get
// delivered to the document.
e.stopPropagation();
var curPage = appsPages[slider.currentCard];
var followingContainer = null;
// If we dragged over a specific app, determine which one to insert before
if (e.currentTarget != document) {
// Start by assuming we'll insert the app before the one dragged over
followingContainer = e.currentTarget;
assert(followingContainer.classList.contains('app-container'),
'expected drag over container');
assert(followingContainer.parentNode == curPage);
if (followingContainer == draggingAppContainer)
return;
// But if it's after the current container position then we'll need to
// move ahead by one to account for the container being removed.
if (curPage == draggingAppContainer.parentNode) {
for (var c = draggingAppContainer; c; c = c.nextElementSibling) {
if (c == followingContainer) {
followingContainer = followingContainer.nextElementSibling;
break;
}
}
}
}
// Move the container to the appropriate place on the page
curPage.insertBefore(draggingAppContainer, followingContainer);
}
/**
* Invoked when an app is dropped on the trash
* @param {Grabber.Event} e The event from the Grabber indicating the drop.
*/
function appTrash(e) {
var appElement = e.grabbedElement;
assert(appElement.classList.contains('app'));
var appId = appElement.getAttribute('app-id');
assert(appId);
// Mark this drop as handled so that the catch-all drop handler
// on the document doesn't see this event.
e.stopPropagation();
// Tell chrome to uninstall the app (prompting the user)
chrome.send('uninstallApp', [appId]);
}
/**
* Called when an app is dropped anywhere other than the trash can. Commits
* any movement that has occurred.
* @param {Grabber.Event} e The event from the Grabber indicating the drop.
*/
function appDrop(e) {
if (!draggingAppContainer)
// Drag was aborted (eg. due to an app update) - do nothing
return;
// If the app is dropped back into it's original position then do nothing
assert(draggingAppOriginalPage);
if (draggingAppContainer.parentNode == draggingAppOriginalPage &&
draggingAppContainer.nextSibling == draggingAppOriginalPosition)
return;
// Determine which app was being dragged
var appElement = e.grabbedElement;
assert(appElement.classList.contains('app'));
var appId = appElement.getAttribute('app-id');
assert(appId);
// Update the page index for the app if it's changed. This doesn't trigger
// a call to getAppsCallback so we want to do it before reorderApps
var pageIndex = slider.currentCard;
assert(pageIndex >= 0 && pageIndex < appsPages.length,
'page number out of range');
if (appsPages[pageIndex] != draggingAppOriginalPage)
chrome.send('setPageIndex', [appId, pageIndex]);
// Put the app being dragged back into it's container
draggingAppContainer.appendChild(appElement);
// Create a list of all appIds in the order now present in the DOM
var appIds = [];
for (var page = 0; page < appsPages.length; page++) {
var appsOnPage = appsPages[page].getElementsByClassName('app');
for (var i = 0; i < appsOnPage.length; i++) {
var id = appsOnPage[i].getAttribute('app-id');
if (id)
appIds.push(id);
}
}
// We are going to commit this repositioning - clear the original position
draggingAppOriginalPage = undefined;
draggingAppOriginalPosition = undefined;
// Tell chrome to update its database to persist this new order of apps This
// will cause getAppsCallback to be invoked and the apps to be redrawn.
chrome.send('reorderApps', [appId, appIds]);
appMoved = true;
}
/**
* Set to true if we're currently in rearrange mode and an app has
* been successfully dropped to a new location. This indicates that
* a getAppsCallback call is pending and we can rely on the DOM being
* updated by that.
* @type {boolean}
*/
var appMoved = false;
/**
* Invoked whenever some app is grabbed
* @param {Grabber.Event} e The Grabber Grab event.
*/
function enterRearrangeMode(e)
{
// Stop the slider from sliding for this touch
slider.cancelTouch();
// Add an extra blank page in case the user wants to create a new page
createAppPage(true);
var pageAdded = appsPages.length - 1;
window.setTimeout(function() {
dots[pageAdded].classList.remove('new');
}, 0);
updateSliderCards();
// Cause the dot-list to grow
getRequiredElement('footer').classList.add('rearrange-mode');
assert(!appMoved, 'appMoved should not be set yet');
}
/**
* Invoked whenever some app is released
* @param {Grabber.Event} e The Grabber RELEASE event.
*/
function leaveRearrangeMode(e)
{
// Return the dot-list to normal
getRequiredElement('footer').classList.remove('rearrange-mode');
// If we didn't successfully re-arrange an app, then we won't be
// refreshing the app view in getAppCallback and need to explicitly remove
// the extra empty page we added. We don't want to do this in the normal
// case because if we did actually drop an app there, we want to retain that
// page as our current page number.
if (!appMoved) {
assert(appsPages[appsPages.length - 1].
getElementsByClassName('app-container').length == 0,
'Last app page should be empty');
removePage(appsPages.length - 1);
}
appMoved = false;
}
/**
* Remove the page with the specified index and update the slider.
* @param {number} pageNo The index of the page to remove.
*/
function removePage(pageNo)
{
var page = appsPages[pageNo];
// Remove the page from the DOM
page.parentNode.removeChild(page);
// Remove the corresponding dot
// Need to give it a chance to animate though
var dot = dots[pageNo];
dot.classList.add('new');
window.setTimeout(function() {
// If we've re-created the apps (eg. because an app was uninstalled) then
// we will have removed the old dots from the document already, so skip.
if (dot.parentNode)
dot.parentNode.removeChild(dot);
}, DEFAULT_TRANSITION_TIME);
updateSliderCards();
}
// Return an object with all the exports
return {
assert: assert,
appsPrefChangeCallback: appsPrefChangeCallback,
getAppsCallback: getAppsCallback,
initialize: initializeNtp
};
})();
// publish ntp globals
var assert = ntp.assert;
var getAppsCallback = ntp.getAppsCallback;
var appsPrefChangeCallback = ntp.appsPrefChangeCallback;
// Initialize immediately once globals are published (there doesn't seem to be
// any need to wait for DOMContentLoaded)
ntp.initialize();