// 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)); 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); // Prevent touch events from triggering any sort of native scrolling if // there are multiple cards in the slider frame. var cardSlider = this.cardSlider; cardSliderFrame.addEventListener('touchmove', function(e) { if (cardSlider.cardCount <= 1) return; e.preventDefault(); }, true); // 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. 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 {boolean} 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); } 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); } this.updateAppLauncherPromoHiddenState_(); }, /** * 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] || ''; } }, /** * Callback invoked by chrome whenever the app launcher promo pref changes. * @param {boolean} show Identifies if we should show or hide the promo. */ appLauncherPromoPrefChangeCallback: function(show) { loadTimeData.overrideValues({showAppLauncherPromo: show}); this.updateAppLauncherPromoHiddenState_(); }, /** * Updates the hidden state of the app launcher promo based on the page * shown and load data content. */ updateAppLauncherPromoHiddenState_: function() { $('app-launcher-promo').hidden = !loadTimeData.getBoolean('showAppLauncherPromo') || this.shownPage != loadTimeData.getInteger('apps_page_id'); }, /** * 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); // The shownPage property was potentially saved from a previous webui that // didn't have the same set of pages as the current one. So we cascade // from suggestions, to most visited and then to apps because we can have // an page with apps only (e.g., chrome://apps) or one with only the most // visited, but not one with only suggestions. And we alwayd default to // most visited first when previously shown page is not availabel anymore. // If most visited isn't there either, we go to apps. if (this.shownPage == loadTimeData.getInteger('suggestions_page_id')) { if (this.suggestionsPage) this.cardSlider.selectCardByValue(this.suggestionsPage); else this.shownPage = loadTimeData.getInteger('most_visited_page_id'); } if (this.shownPage == loadTimeData.getInteger('most_visited_page_id')) { if (this.mostVisitedPage) this.cardSlider.selectCardByValue(this.mostVisitedPage); else this.shownPage = loadTimeData.getInteger('apps_page_id'); } if (this.shownPage == loadTimeData.getInteger('apps_page_id') && loadTimeData.getBoolean('showApps')) { this.cardSlider.selectCardByValue( this.appsPages[Math.min(this.shownPageIndex, this.appsPages.length - 1)]); } else if (this.mostVisitedPage) { this.shownPage = loadTimeData.getInteger('most_visited_page_id'); this.cardSlider.selectCardByValue(this.mostVisitedPage); } }, /** * 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'); $('footer-menu-container').style.minWidth = $('trash').offsetWidth - $('chrome-web-store-link').offsetWidth + 'px'; } 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'); $('footer-menu-container').style.minWidth = ''; 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, and updates the aria-label attributes of * the page switchers. */ 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; // Update the aria-label attributes of the two page switchers. this.pageSwitcherStart.updateButtonAccessibleLabel(this.dotList.dots); this.pageSwitcherEnd.updateButtonAccessibleLabel(this.dotList.dots); }, /** * 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]); this.updateAppLauncherPromoHiddenState_(); }, /** * 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 }; });