1 // Copyright (c) 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
6 * @fileoverview PageListView implementation.
7 * PageListView manages page list, dot list, switcher buttons and handles apps
8 * pages callbacks from backend.
10 * Note that you need to have AppLauncherHandler in your WebUI to use this code.
13 cr.define('ntp', function() {
17 * Creates a PageListView object.
21 function PageListView() {
24 PageListView.prototype = {
26 * The CardSlider object to use for changing app pages.
27 * @type {CardSlider|undefined}
29 cardSlider: undefined,
32 * The frame div for this.cardSlider.
33 * @type {!Element|undefined}
35 sliderFrame: undefined,
38 * The 'page-list' element.
39 * @type {!Element|undefined}
44 * A list of all 'tile-page' elements.
45 * @type {!NodeList|undefined}
50 * A list of all 'apps-page' elements.
51 * @type {!NodeList|undefined}
56 * The Suggestions page.
57 * @type {!Element|undefined}
59 suggestionsPage: undefined,
62 * The Most Visited page.
63 * @type {!Element|undefined}
65 mostVisitedPage: undefined,
68 * The 'dots-list' element.
69 * @type {!Element|undefined}
74 * The left and right paging buttons.
75 * @type {!Element|undefined}
77 pageSwitcherStart: undefined,
78 pageSwitcherEnd: undefined,
81 * The 'trash' element. Note that technically this is unnecessary,
82 * JavaScript creates the object for us based on the id. But I don't want
83 * to rely on the ID being the same, and JSCompiler doesn't know about it.
84 * @type {!Element|undefined}
89 * The type of page that is currently shown. The value is a numerical ID.
95 * The index of the page that is currently shown, within the page type.
96 * For example if the third Apps page is showing, this will be 2.
102 * EventTracker for managing event listeners for page events.
103 * @type {!EventTracker}
105 eventTracker: new EventTracker,
108 * If non-null, this is the ID of the app to highlight to the user the next
109 * time getAppsCallback runs. "Highlight" in this case means to switch to
110 * the page and run the new tile animation.
113 highlightAppId: null,
116 * Initializes page list view.
117 * @param {!Element} pageList A DIV element to host all pages.
118 * @param {!Element} dotList An UL element to host nav dots. Each dot
120 * @param {!Element} cardSliderFrame The card slider frame that hosts
121 * pageList and switcher buttons.
122 * @param {!Element|undefined} opt_trash Optional trash element.
123 * @param {!Element|undefined} opt_pageSwitcherStart Optional start page
125 * @param {!Element|undefined} opt_pageSwitcherEnd Optional end page
128 initialize: function(pageList, dotList, cardSliderFrame, opt_trash,
129 opt_pageSwitcherStart, opt_pageSwitcherEnd) {
130 this.pageList = pageList;
132 this.dotList = dotList;
133 cr.ui.decorate(this.dotList, ntp.DotList);
135 this.trash = opt_trash;
137 new ntp.Trash(this.trash);
139 this.pageSwitcherStart = opt_pageSwitcherStart;
140 if (this.pageSwitcherStart)
141 ntp.initializePageSwitcher(this.pageSwitcherStart);
143 this.pageSwitcherEnd = opt_pageSwitcherEnd;
144 if (this.pageSwitcherEnd)
145 ntp.initializePageSwitcher(this.pageSwitcherEnd);
147 this.shownPage = loadTimeData.getInteger('shown_page_type');
148 this.shownPageIndex = loadTimeData.getInteger('shown_page_index');
150 if (loadTimeData.getBoolean('showApps')) {
151 // Request data on the apps so we can fill them in.
152 // Note that this is kicked off asynchronously. 'getAppsCallback' will
153 // be invoked at some point after this function returns.
154 chrome.send('getApps');
157 if (this.shownPage == loadTimeData.getInteger('apps_page_id')) {
159 loadTimeData.getInteger('most_visited_page_id'), 0);
162 document.body.classList.add('bare-minimum');
165 document.addEventListener('keydown', this.onDocKeyDown_.bind(this));
167 this.tilePages = this.pageList.getElementsByClassName('tile-page');
168 this.appsPages = this.pageList.getElementsByClassName('apps-page');
170 // Initialize the cardSlider without any cards at the moment.
171 this.sliderFrame = cardSliderFrame;
172 this.cardSlider = new cr.ui.CardSlider(this.sliderFrame, this.pageList,
173 this.sliderFrame.offsetWidth);
175 // Prevent touch events from triggering any sort of native scrolling if
176 // there are multiple cards in the slider frame.
177 var cardSlider = this.cardSlider;
178 cardSliderFrame.addEventListener('touchmove', function(e) {
179 if (cardSlider.cardCount <= 1)
184 // Handle mousewheel events anywhere in the card slider, so that wheel
185 // events on the page switchers will still scroll the page.
186 // This listener must be added before the card slider is initialized,
187 // because it needs to be called before the card slider's handler.
188 cardSliderFrame.addEventListener('mousewheel', function(e) {
189 if (cardSlider.currentCardValue.handleMouseWheel(e)) {
190 e.preventDefault(); // Prevent default scroll behavior.
191 e.stopImmediatePropagation(); // Prevent horizontal card flipping.
195 this.cardSlider.initialize(
196 loadTimeData.getBoolean('isSwipeTrackingFromScrollEventsEnabled'));
198 // Handle events from the card slider.
199 this.pageList.addEventListener('cardSlider:card_changed',
200 this.onCardChanged_.bind(this));
201 this.pageList.addEventListener('cardSlider:card_added',
202 this.onCardAdded_.bind(this));
203 this.pageList.addEventListener('cardSlider:card_removed',
204 this.onCardRemoved_.bind(this));
206 // Ensure the slider is resized appropriately with the window.
207 window.addEventListener('resize', this.onWindowResize_.bind(this));
209 // Update apps when online state changes.
210 window.addEventListener('online',
211 this.updateOfflineEnabledApps_.bind(this));
212 window.addEventListener('offline',
213 this.updateOfflineEnabledApps_.bind(this));
217 * Appends a tile page.
219 * @param {TilePage} page The page element.
220 * @param {string} title The title of the tile page.
221 * @param {boolean} titleIsEditable If true, the title can be changed.
222 * @param {TilePage} opt_refNode Optional reference node to insert in front
224 * When opt_refNode is falsey, |page| will just be appended to the end of
227 appendTilePage: function(page, title, titleIsEditable, opt_refNode) {
229 var refIndex = this.getTilePageIndex(opt_refNode);
230 this.cardSlider.addCardAtIndex(page, refIndex);
232 this.cardSlider.appendCard(page);
235 // Remember special MostVisitedPage.
236 if (typeof ntp.MostVisitedPage != 'undefined' &&
237 page instanceof ntp.MostVisitedPage) {
238 assert(this.tilePages.length == 1,
239 'MostVisitedPage should be added as first tile page');
240 this.mostVisitedPage = page;
243 if (typeof ntp.SuggestionsPage != 'undefined' &&
244 page instanceof ntp.SuggestionsPage) {
245 this.suggestionsPage = page;
248 // If we're appending an AppsPage and it's a temporary page, animate it.
249 var animate = page instanceof ntp.AppsPage &&
250 page.classList.contains('temporary');
251 // Make a deep copy of the dot template to add a new one.
252 var newDot = new ntp.NavDot(page, title, titleIsEditable, animate);
253 page.navigationDot = newDot;
254 this.dotList.insertBefore(newDot,
255 opt_refNode ? opt_refNode.navigationDot : null);
256 // Set a tab index on the first dot.
257 if (this.dotList.dots.length == 1)
260 this.eventTracker.add(page, 'pagelayout', this.onPageLayout_.bind(this));
264 * Called by chrome when an app has changed positions.
265 * @param {Object} appData The data for the app. This contains page and
268 appMoved: function(appData) {
269 assert(loadTimeData.getBoolean('showApps'));
271 var app = $(appData.id);
272 assert(app, 'trying to move an app that doesn\'t exist');
275 this.appsPages[appData.page_index].insertApp(appData, false);
279 * Called by chrome when an existing app has been disabled or
280 * removed/uninstalled from chrome.
281 * @param {Object} appData A data structure full of relevant information for
283 * @param {boolean} isUninstall True if the app is being uninstalled;
284 * false if the app is being disabled.
285 * @param {boolean} fromPage True if the removal was from the current page.
287 appRemoved: function(appData, isUninstall, fromPage) {
288 assert(loadTimeData.getBoolean('showApps'));
290 var app = $(appData.id);
291 assert(app, 'trying to remove an app that doesn\'t exist');
294 app.replaceAppData(appData);
296 app.remove(!!fromPage);
300 * @return {boolean} If the page is still starting up.
303 isStartingUp_: function() {
304 return document.documentElement.classList.contains('starting-up');
308 * Tracks whether apps have been loaded at least once.
315 * Callback invoked by chrome with the apps available.
317 * Note that calls to this function can occur at any time, not just in
318 * response to a getApps request. For example, when a user
319 * installs/uninstalls an app on another synchronized devices.
320 * @param {Object} data An object with all the data on available
323 getAppsCallback: function(data) {
324 assert(loadTimeData.getBoolean('showApps'));
326 var startTime = Date.now();
328 // Remember this to select the correct card when done rebuilding.
329 var prevCurrentCard = this.cardSlider.currentCard;
331 // Make removal of pages and dots as quick as possible with less DOM
332 // operations, reflows, or repaints. We set currentCard = 0 and remove
333 // from the end to not encounter any auto-magic card selections in the
334 // process and we hide the card slider throughout.
335 this.cardSlider.currentCard = 0;
337 // Clear any existing apps pages and dots.
338 // TODO(rbyers): It might be nice to preserve animation of dots after an
339 // uninstall. Could we re-use the existing page and dot elements? It
340 // seems unfortunate to have Chrome send us the entire apps list after an
342 while (this.appsPages.length > 0)
343 this.removeTilePageAndDot_(this.appsPages[this.appsPages.length - 1]);
345 // Get the array of apps and add any special synthesized entries
346 var apps = data.apps;
348 // Get a list of page names
349 var pageNames = data.appPageNames;
351 function stringListIsEmpty(list) {
352 for (var i = 0; i < list.length; i++) {
359 // Sort by launch ordinal
360 apps.sort(function(a, b) {
361 return a.app_launch_ordinal > b.app_launch_ordinal ? 1 :
362 a.app_launch_ordinal < b.app_launch_ordinal ? -1 : 0;
365 // An app to animate (in case it was just installed).
368 // If there are any pages after the apps, add new pages before them.
369 var lastAppsPage = (this.appsPages.length > 0) ?
370 this.appsPages[this.appsPages.length - 1] : null;
371 var lastAppsPageIndex = (lastAppsPage != null) ?
372 Array.prototype.indexOf.call(this.tilePages, lastAppsPage) : -1;
373 var nextPageAfterApps = lastAppsPageIndex != -1 ?
374 this.tilePages[lastAppsPageIndex + 1] : null;
376 // Add the apps, creating pages as necessary
377 for (var i = 0; i < apps.length; i++) {
379 var pageIndex = app.page_index || 0;
380 while (pageIndex >= this.appsPages.length) {
381 var pageName = loadTimeData.getString('appDefaultPageName');
382 if (this.appsPages.length < pageNames.length)
383 pageName = pageNames[this.appsPages.length];
385 var origPageCount = this.appsPages.length;
386 this.appendTilePage(new ntp.AppsPage(), pageName, true,
388 // Confirm that appsPages is a live object, updated when a new page is
389 // added (otherwise we'd have an infinite loop)
390 assert(this.appsPages.length == origPageCount + 1,
391 'expected new page');
394 if (app.id == this.highlightAppId)
397 this.appsPages[pageIndex].insertApp(app, false);
400 this.cardSlider.currentCard = prevCurrentCard;
403 this.appAdded(highlightApp, true);
405 logEvent('apps.layout: ' + (Date.now() - startTime));
407 // Tell the slider about the pages and mark the current page.
408 this.updateSliderCards();
409 this.cardSlider.currentCardValue.navigationDot.classList.add('selected');
411 if (!this.appsLoaded_) {
412 this.appsLoaded_ = true;
413 cr.dispatchSimpleEvent(document, 'sectionready', true, true);
415 this.updateAppLauncherPromoHiddenState_();
419 * Called by chrome when a new app has been added to chrome or has been
420 * enabled if previously disabled.
421 * @param {Object} appData A data structure full of relevant information for
423 * @param {boolean=} opt_highlight Whether the app about to be added should
426 appAdded: function(appData, opt_highlight) {
427 assert(loadTimeData.getBoolean('showApps'));
429 if (appData.id == this.highlightAppId) {
430 opt_highlight = true;
431 this.highlightAppId = null;
434 var pageIndex = appData.page_index || 0;
436 if (pageIndex >= this.appsPages.length) {
437 while (pageIndex >= this.appsPages.length) {
438 this.appendTilePage(new ntp.AppsPage(),
439 loadTimeData.getString('appDefaultPageName'),
442 this.updateSliderCards();
445 var page = this.appsPages[pageIndex];
446 var app = $(appData.id);
448 app.replaceAppData(appData);
449 } else if (opt_highlight) {
450 page.insertAndHighlightApp(appData);
451 this.setShownPage_(loadTimeData.getInteger('apps_page_id'),
454 page.insertApp(appData, false);
459 * Callback invoked by chrome whenever an app preference changes.
460 * @param {Object} data An object with all the data on available
463 appsPrefChangedCallback: function(data) {
464 assert(loadTimeData.getBoolean('showApps'));
466 for (var i = 0; i < data.apps.length; ++i) {
467 $(data.apps[i].id).appData = data.apps[i];
470 // Set the App dot names. Skip the first dot (Most Visited).
471 var dots = this.dotList.getElementsByClassName('dot');
472 var start = this.mostVisitedPage ? 1 : 0;
473 for (var i = start; i < dots.length; ++i) {
474 dots[i].displayTitle = data.appPageNames[i - start] || '';
479 * Callback invoked by chrome whenever the app launcher promo pref changes.
480 * @param {boolean} show Identifies if we should show or hide the promo.
482 appLauncherPromoPrefChangeCallback: function(show) {
483 loadTimeData.overrideValues({showAppLauncherPromo: show});
484 this.updateAppLauncherPromoHiddenState_();
488 * Updates the hidden state of the app launcher promo based on the page
489 * shown and load data content.
491 updateAppLauncherPromoHiddenState_: function() {
492 $('app-launcher-promo').hidden =
493 !loadTimeData.getBoolean('showAppLauncherPromo') ||
494 this.shownPage != loadTimeData.getInteger('apps_page_id');
498 * Invoked whenever the pages in apps-page-list have changed so that
499 * the Slider knows about the new elements.
501 updateSliderCards: function() {
502 var pageNo = Math.max(0, Math.min(this.cardSlider.currentCard,
503 this.tilePages.length - 1));
504 this.cardSlider.setCards(Array.prototype.slice.call(this.tilePages),
506 // The shownPage property was potentially saved from a previous webui that
507 // didn't have the same set of pages as the current one. So we cascade
508 // from suggestions, to most visited and then to apps because we can have
509 // an page with apps only (e.g., chrome://apps) or one with only the most
510 // visited, but not one with only suggestions. And we alwayd default to
511 // most visited first when previously shown page is not availabel anymore.
512 // If most visited isn't there either, we go to apps.
513 if (this.shownPage == loadTimeData.getInteger('suggestions_page_id')) {
514 if (this.suggestionsPage)
515 this.cardSlider.selectCardByValue(this.suggestionsPage);
517 this.shownPage = loadTimeData.getInteger('most_visited_page_id');
519 if (this.shownPage == loadTimeData.getInteger('most_visited_page_id')) {
520 if (this.mostVisitedPage)
521 this.cardSlider.selectCardByValue(this.mostVisitedPage);
523 this.shownPage = loadTimeData.getInteger('apps_page_id');
525 if (this.shownPage == loadTimeData.getInteger('apps_page_id') &&
526 loadTimeData.getBoolean('showApps')) {
527 this.cardSlider.selectCardByValue(
528 this.appsPages[Math.min(this.shownPageIndex,
529 this.appsPages.length - 1)]);
530 } else if (this.mostVisitedPage) {
531 this.shownPage = loadTimeData.getInteger('most_visited_page_id');
532 this.cardSlider.selectCardByValue(this.mostVisitedPage);
537 * Called whenever tiles should be re-arranging themselves out of the way
538 * of a moving or insert tile.
540 enterRearrangeMode: function() {
541 if (loadTimeData.getBoolean('showApps')) {
542 var tempPage = new ntp.AppsPage();
543 tempPage.classList.add('temporary');
544 var pageName = loadTimeData.getString('appDefaultPageName');
545 this.appendTilePage(tempPage, pageName, true);
548 if (ntp.getCurrentlyDraggingTile().firstChild.canBeRemoved()) {
549 $('footer').classList.add('showing-trash-mode');
550 $('footer-menu-container').style.minWidth = $('trash').offsetWidth -
551 $('chrome-web-store-link').offsetWidth + 'px';
554 document.documentElement.classList.add('dragging-mode');
558 * Invoked whenever some app is released
560 leaveRearrangeMode: function() {
561 var tempPage = document.querySelector('.tile-page.temporary');
563 var dot = tempPage.navigationDot;
564 if (!tempPage.tileCount &&
565 tempPage != this.cardSlider.currentCardValue) {
566 this.removeTilePageAndDot_(tempPage, true);
568 tempPage.classList.remove('temporary');
569 this.saveAppPageName(tempPage,
570 loadTimeData.getString('appDefaultPageName'));
574 $('footer').classList.remove('showing-trash-mode');
575 $('footer-menu-container').style.minWidth = '';
576 document.documentElement.classList.remove('dragging-mode');
580 * Callback for the 'pagelayout' event.
581 * @param {Event} e The event.
583 onPageLayout_: function(e) {
584 if (Array.prototype.indexOf.call(this.tilePages, e.currentTarget) !=
585 this.cardSlider.currentCard) {
589 this.updatePageSwitchers();
593 * Adjusts the size and position of the page switchers according to the
594 * layout of the current card, and updates the aria-label attributes of
595 * the page switchers.
597 updatePageSwitchers: function() {
598 if (!this.pageSwitcherStart || !this.pageSwitcherEnd)
601 var page = this.cardSlider.currentCardValue;
603 this.pageSwitcherStart.hidden = !page ||
604 (this.cardSlider.currentCard == 0);
605 this.pageSwitcherEnd.hidden = !page ||
606 (this.cardSlider.currentCard == this.cardSlider.cardCount - 1);
611 var pageSwitcherLeft = isRTL() ? this.pageSwitcherEnd :
612 this.pageSwitcherStart;
613 var pageSwitcherRight = isRTL() ? this.pageSwitcherStart :
614 this.pageSwitcherEnd;
615 var scrollbarWidth = page.scrollbarWidth;
616 pageSwitcherLeft.style.width =
617 (page.sideMargin + 13) + 'px';
618 pageSwitcherLeft.style.left = '0';
619 pageSwitcherRight.style.width =
620 (page.sideMargin - scrollbarWidth + 13) + 'px';
621 pageSwitcherRight.style.right = scrollbarWidth + 'px';
623 var offsetTop = page.querySelector('.tile-page-content').offsetTop + 'px';
624 pageSwitcherLeft.style.top = offsetTop;
625 pageSwitcherRight.style.top = offsetTop;
626 pageSwitcherLeft.style.paddingBottom = offsetTop;
627 pageSwitcherRight.style.paddingBottom = offsetTop;
629 // Update the aria-label attributes of the two page switchers.
630 this.pageSwitcherStart.updateButtonAccessibleLabel(this.dotList.dots);
631 this.pageSwitcherEnd.updateButtonAccessibleLabel(this.dotList.dots);
635 * Returns the index of the given apps page.
636 * @param {AppsPage} page The AppsPage we wish to find.
637 * @return {number} The index of |page| or -1 if it is not in the
640 getAppsPageIndex: function(page) {
641 return Array.prototype.indexOf.call(this.appsPages, page);
645 * Handler for cardSlider:card_changed events from this.cardSlider.
646 * @param {Event} e The cardSlider:card_changed event.
649 onCardChanged_: function(e) {
650 var page = e.cardSlider.currentCardValue;
652 // Don't change shownPage until startup is done (and page changes actually
653 // reflect user actions).
654 if (!this.isStartingUp_()) {
655 if (page.classList.contains('apps-page')) {
656 this.setShownPage_(loadTimeData.getInteger('apps_page_id'),
657 this.getAppsPageIndex(page));
658 } else if (page.classList.contains('most-visited-page')) {
660 loadTimeData.getInteger('most_visited_page_id'), 0);
661 } else if (page.classList.contains('suggestions-page')) {
662 this.setShownPage_(loadTimeData.getInteger('suggestions_page_id'), 0);
664 console.error('unknown page selected');
668 // Update the active dot
669 var curDot = this.dotList.getElementsByClassName('selected')[0];
671 curDot.classList.remove('selected');
672 page.navigationDot.classList.add('selected');
673 this.updatePageSwitchers();
677 * Saves/updates the newly selected page to open when first loading the NTP.
678 * @type {number} shownPage The new shown page type.
679 * @type {number} shownPageIndex The new shown page index.
682 setShownPage_: function(shownPage, shownPageIndex) {
683 assert(shownPageIndex >= 0);
684 this.shownPage = shownPage;
685 this.shownPageIndex = shownPageIndex;
686 chrome.send('pageSelected', [this.shownPage, this.shownPageIndex]);
687 this.updateAppLauncherPromoHiddenState_();
691 * Listen for card additions to update the page switchers or the current
693 * @param {Event} e A card removed or added event.
695 onCardAdded_: function(e) {
696 // When the second arg passed to insertBefore is falsey, it acts just like
698 this.pageList.insertBefore(e.addedCard, this.tilePages[e.addedIndex]);
699 this.onCardAddedOrRemoved_();
703 * Listen for card removals to update the page switchers or the current card
705 * @param {Event} e A card removed or added event.
707 onCardRemoved_: function(e) {
708 e.removedCard.parentNode.removeChild(e.removedCard);
709 this.onCardAddedOrRemoved_();
713 * Called when a card is removed or added.
716 onCardAddedOrRemoved_: function() {
717 if (this.isStartingUp_())
720 // Without repositioning there were issues - http://crbug.com/133457.
721 this.cardSlider.repositionFrame();
722 this.updatePageSwitchers();
726 * Save the name of an apps page.
727 * Store the apps page name into the preferences store.
728 * @param {AppsPage} appsPage The app page for which we wish to save.
729 * @param {string} name The name of the page.
731 saveAppPageName: function(appPage, name) {
732 var index = this.getAppsPageIndex(appPage);
734 chrome.send('saveAppPageName', [name, index]);
738 * Window resize handler.
741 onWindowResize_: function(e) {
742 this.cardSlider.resize(this.sliderFrame.offsetWidth);
743 this.updatePageSwitchers();
747 * Listener for offline status change events. Updates apps that are
748 * not offline-enabled to be grayscale if the browser is offline.
751 updateOfflineEnabledApps_: function() {
752 var apps = document.querySelectorAll('.app');
753 for (var i = 0; i < apps.length; ++i) {
754 if (apps[i].appData.enabled && !apps[i].appData.offline_enabled) {
762 * Handler for key events on the page. Ctrl-Arrow will switch the visible
764 * @param {Event} e The KeyboardEvent.
767 onDocKeyDown_: function(e) {
768 if (!e.ctrlKey || e.altKey || e.metaKey || e.shiftKey)
772 if (e.keyIdentifier == 'Left')
774 else if (e.keyIdentifier == 'Right')
780 (this.cardSlider.currentCard + direction +
781 this.cardSlider.cardCount) % this.cardSlider.cardCount;
782 this.cardSlider.selectCard(cardIndex, true);
788 * Returns the index of a given tile page.
789 * @param {TilePage} page The TilePage we wish to find.
790 * @return {number} The index of |page| or -1 if it is not in the
793 getTilePageIndex: function(page) {
794 return Array.prototype.indexOf.call(this.tilePages, page);
798 * Removes a page and navigation dot (if the navdot exists).
799 * @param {TilePage} page The page to be removed.
800 * @param {boolean=} opt_animate If the removal should be animated.
802 removeTilePageAndDot_: function(page, opt_animate) {
803 if (page.navigationDot)
804 page.navigationDot.remove(opt_animate);
805 this.cardSlider.removeCard(page);
810 PageListView: PageListView