/* global Card, TaskManagerUtils, LazyLoader, Service, StackManager, eventSafety, SettingsListener, AppWindow, BrowserConfigHelper */ 'use strict'; (function(exports) { /** * The TaskManager shows running windows as a list of cards. * * When TaskManager is shown, it grabs a copy of StackManager's stack of apps, * and instantiates one Card for each AppWindow in the stack. * * State Management * ---------------- * * State updates are handled, waterfall-style, with the following functions: * * - updateStack() * '- updateLayout() * '- updateScrollPosition() * * For instance, when the stack changes, we must perform layout; when layout * changes, we must update our scroll position. Each function calls the * dependent functions itself, to make state updates easy. Simply call the * appropriate function corresponding to the highest-level changes needed, * and we'll update the appropriate dependent state accordingly: * * - Call updateStack() when StackManager's stack has changed. * - Call updateLayout() when window dimensions change. * - Call updateScrollPosition() when the scroll position has changed. */ function TaskManager() { this.appToCardMap = new Map(); // AppWindow -> Card this.elementToCardMap = new Map(); // -> Card this.stack = []; // a copy of StackManager's stack of AppWindow instances this.currentCard = null; this._active = false; this.disableScreenshots = false; } TaskManager.prototype = { CARD_GUTTER: 25, // (horizontal margin between cards, in pixels) name: 'TaskManager', // (needed by HierarchyManager) USE_SCREENSHOTS_SETTING: 'app.cards_view.screenshots.enabled', /** * Start the TaskManager module; this occurs once on startup. * * @return {Promise} */ start() { this.element = document.getElementById('task-manager'), this.scrollElement = document.getElementById('cards-view'), this.cardsList = document.getElementById('cards-list'); this.screenElement = document.getElementById('screen'); this.noRecentWindowsEl = document.getElementById('cards-no-recent-windows'); this.newSheetButton = document.getElementById('task-manager-new-sheet-button'); this.newPrivateSheetButton = document.getElementById('task-manager-new-private-sheet-button'); window.addEventListener('taskmanagershow', this); Service.request('registerHierarchy', this); // Listen for changes to the "show screenshots" preference. this.setScreenshotSetting = this.setScreenshotSetting.bind(this); SettingsListener.observe( this.USE_SCREENSHOTS_SETTING, !this.disableScreenshots, this.setScreenshotSetting); return LazyLoader.load([ 'js/card.js', 'js/task_manager_utils.js' ]); }, /** * Stop the TaskManager module. * * @return {Promise} */ stop() { window.removeEventListener('taskmanagershow', this); Service.request('unregisterHierarchy', this); SettingsListener.unobserve( this.USE_SCREENSHOTS_SETTING, this.setScreenshotSetting); }, /** * Update the USE_SCREENSHOTS_SETTING value. See `Card` for more details. */ setScreenshotSetting(useScreenshots) { this.disableScreenshots = !useScreenshots; }, /** * Create a new Card representing the given app, and add it to the DOM. * (This does not deal with stack order.) */ _addApp(app, container, { stayInvisible }) { var card = new Card(app, { disableScreenshots: this.disableScreenshots, stayInvisible: stayInvisible }); container.appendChild(card.element); this.elementToCardMap.set(card.element, card); this.appToCardMap.set(app, card); app.enterTaskManager(); return card; }, /** * Remove the card representing `app` from the DOM. This only occurs in * direct response to stack changes or when the task manager is hidden. */ _removeApp(app) { var card = this.appToCardMap.get(app); if (card) { this.appToCardMap.delete(app); this.elementToCardMap.delete(card.element); this.cardsList.removeChild(card.element); app.leaveTaskManager(); } }, /** * Update our stack and list of cards to match StackManager. * Call this whenever we think the stack has changed; this function will * take care of updating the layout and card positions. */ updateStack({ currentlyLaunchingApp } = {}) { this.stack = StackManager.snapshot(); if (this._browserOnly) { this.stack = this.stack.filter((app) => { return app.isBrowser() || (app.manifest && app.manifest.role === 'search'); }); } navigator.mozL10n.setAttributes(this.noRecentWindowsEl, this._browserOnly ? 'no-recent-browser-windows' : 'no-recent-app-windows'); this.element.classList.toggle('filtered', !!this._browserOnly); this.element.classList.toggle('empty', this.stack.length === 0); var latestAppSet = new Set(this.stack); this.appToCardMap.forEach((card, app) => { if (!latestAppSet.has(app)) { this._removeApp(app); } }); var toAdd = this.stack.filter((app) => { return !this.appToCardMap.get(app); }); if (toAdd.length) { var fragment = document.createDocumentFragment(); toAdd.forEach((app) => { this._addApp(app, fragment, { stayInvisible: (app === currentlyLaunchingApp) }); }); this.cardsList.appendChild(fragment); } this.updateLayout(toAdd.length === this.stack.length); }, /** * Update the card positions and layout. * Called in response to window resize and stack changes. */ updateLayout(initial) { this.cardWidth = window.innerWidth / 2; this.cardHeight = window.innerHeight / 2; var count = this.stack.length; var margins = window.innerWidth - this.cardWidth; var cardStripWidth = this.cardWidth * count + this.CARD_GUTTER * (count - 1); var contentWidth = margins + Math.max(this.cardWidth, cardStripWidth); this.stack.forEach((app, index) => { var card = this.appToCardMap.get(app); var left = (margins / 2) + this.indexToOffset(index); card.translate({ x: left + 'px' }); }); this.cardsList.style.width = contentWidth + 'px'; this.updateScrollPosition(initial); }, /** * Update our bookkeeping for when the scroll position changes, used * primarily for updating cards' accessibility attributes. */ updateScrollPosition(initial) { // Skipping the layout flush inducing getCurrentIndex on the first rendering var index = initial ? StackManager.position : this.getCurrentIndex(); var currentCard = this.appToCardMap.get(this.stack[index]); if (this.currentCard !== currentCard) { this.currentCard = currentCard; this.stack.forEach((app, idx) => { var card = this.appToCardMap.get(app); // Hide non-current apps from the screen reader. card.element.setAttribute('aria-hidden', card !== currentCard); // Update the screen reader card list size. card.element.setAttribute('aria-setsize', this.stack.length); // Update the screen reader card index. card.element.setAttribute('aria-posinset', idx + 1); }); } }, /** * Return the current index into `this.stack`, taking current scroll position * into account. (Extracted for unit test purposes.) */ getCurrentIndex() { return Math.min( this.stack.length - 1, Math.floor(this.scrollElement.scrollLeft / this.cardWidth)); }, /** * Show the Task Manager if it is not already visible. * * @param {boolean} opts.browserOnly * If true, only include browser apps. (Legacy feature) */ show(opts) { if (this.isShown() || this._isTransitioning) { return Promise.resolve(); } // To prevent race conditions when opening or closing (since both opening // and closing are asynchronous), set this._isTransitioning when we're // either showing or hiding. The flag is cleared during this.setActive(). this._isTransitioning = true; this._browserOnly = opts && opts.browserOnly; this.publish('cardviewprepare'); // Will hide the keyboard if needed // Wait for the screen to rotate into portrait orientation, // and for the keyboard to dismiss completely, if applicable. return TaskManagerUtils.waitForScreenToBeReady().then(() => { // Will update and display app screenshots this.publish('cardviewbeforeshow'); window.addEventListener('lockscreen-appopened', this); window.addEventListener('attentionopened', this); window.addEventListener('appopen', this); window.addEventListener('appterminated', this); window.addEventListener('wheel', this); window.addEventListener('resize', this); this.element.addEventListener('click', this); this.element.addEventListener('card-will-drag', this); this.element.addEventListener('card-dropped', this); this.scrollElement.addEventListener('scroll', this); // We only want to paint to critical viewport for now // not the full APZ pre-rendering window this.scrollElement.style.overflowX = 'hidden'; this.updateStack(); this.panToApp(StackManager.getCurrent(), true); this.setActive(true); var activeApp = Service.query('AppWindowManager.getActiveWindow'); if (activeApp && activeApp.isHomescreen) { activeApp.close('home-to-cardview'); this.element.classList.add('from-home'); } // Wait for the current app to signal that it has closed in preparation // for showing the task manager before cleaning things up. return TaskManagerUtils.waitForAppToClose(activeApp); }).then(() => { // Load app icons asynchronously this.appToCardMap.forEach((card, app) => { card.loadIcon(); }); this.publish('cardviewshown'); this.screenElement.classList.add('cards-view'); this.element.classList.remove('from-home'); this.scrollElement.style.overflowX = 'scroll'; this._isTransitioning = false; }); }, /** * Hide the Task Manager and return to the AppWindow specified, or the * homescreen otherwise. */ hide(newApp, animation) { if (!this.isShown() || this._isTransitioning) { return Promise.resolve(); } this._isTransitioning = true; window.removeEventListener('lockscreen-appopened', this); window.removeEventListener('attentionopened', this); window.removeEventListener('appopen', this); window.removeEventListener('appterminated', this); window.removeEventListener('wheel', this); window.removeEventListener('resize', this); this.element.removeEventListener('click', this); this.element.removeEventListener('card-will-drag', this); this.element.removeEventListener('card-dropped', this); this.scrollElement.removeEventListener('scroll', this); newApp = newApp || Service.query('AppWindowManager.getActiveWindow') || Service.query('getHomescreen', true); // Remove '.cards-view' now, so that the incoming app animation begins its // transition at the proper scale. this.screenElement.classList.remove('cards-view'); // Set the proper transition... if (newApp.isHomescreen) { this.element.classList.add('to-home'); newApp.open('home-from-cardview'); } else { newApp.open(animation || 'from-cardview'); } // ... and when the transition has finished, clean up. return eventSafety(newApp.element, 'animationend', (e) => { this.setActive(false); this.publish('cardviewclosed', { detail: newApp }); this.element.classList.remove('to-home'); this.element.classList.remove('filtered'); this.stack.forEach((app) => { this._removeApp(app); }); this._isTransitioning = false; }, 2000); }, /** * LTR/RTL notes: * Given a stack of views [0, 1, 2], cards-list will display: * [0][1][2] in LTR * [2][1][0] in RTL * cards-list's direction is forced to LTR, so cards can be positionned using * left and the view centering is done using scrollTo. */ mirrorIndexIfRTL(idx) { if (document.documentElement.dir === 'rtl') { return (this.stack.length - 1) - idx; } else { return idx; } }, indexToOffset(idx) { var index = this.mirrorIndexIfRTL(idx); return (this.cardWidth + this.CARD_GUTTER) * index; }, /** * Perform a user-initiated action on the given card. */ cardAction(card, actionName) { switch (actionName) { case 'close': var index = this.stack.indexOf(card.app); var maxIdx = this.stack.length - 1; // If this is the right-most card on the stack, we must scroll to the // left before killing the app otherwise we can stay where we are. if (maxIdx > 0 && this.mirrorIndexIfRTL(index) === maxIdx) { // Select its left neighbour var panToIdx = this.mirrorIndexIfRTL(maxIdx - 1); this.panToApp(this.stack[panToIdx]).then(() => { card.app.kill(); }); } else { card.app.kill(); } break; case 'favorite' : console.log('cardAction: TODO: favorite ' + card.element.dataset.origin); break; case 'select': this.panToApp(card.app).then(() => { this.hide(card.app); }); break; } }, /** * Scroll smoothly to the given app. * * @param {AppWindow} app * If this window is not found, we'll scroll to the most-recent app. * @param {boolean} [immediately] * If true, scroll without animation. */ panToApp(app, immediately) { // TODO: better way to know we've arrived at the target card // and make the delay proportional to the distance panned // See: bug 1172171 var idx = this.stack.indexOf(app); if (idx === -1) { idx = this.stack.length - 1; } var desiredPosition = this.indexToOffset(idx); if (immediately) { this.scrollElement.scrollTo({ left: desiredPosition, top: 0, behavior: 'auto' }); return Promise.resolve(); } var currentPosition = this.scrollElement.scrollLeft; return new Promise((resolve, reject) => { if (currentPosition === desiredPosition) { resolve(); } else { this.scrollElement.scrollTo({ left: desiredPosition, top: 0, behavior: 'smooth' }); setTimeout(resolve, 200); } }); }, handleEvent(evt) { var card; switch (evt.type) { case 'click': if (evt.target === this.newSheetButton) { this.openNewSheet({ isPrivate: false }); } else if (evt.target === this.newPrivateSheetButton) { this.openNewSheet({ isPrivate: true }); } else if (this.element.classList.contains('empty')) { this.hide(Service.query('getHomescreen', true)); } else { card = this.elementToCardMap.get(evt.target.closest('.card')); if (card) { this.cardAction(card, evt.target.dataset.buttonAction || 'select'); } } break; case 'resize': this.updateLayout(); break; case 'scroll': if (this.scrollElement.style.overflowX === 'hidden') { // Believe it or not, you will receive scroll events even when // overflow is hidden. We don't care about those, because we're // dragging a card; the user won't see scroll events. break; } this._lastScrollTimestamp = Date.now(); this.updateScrollPosition(); break; case 'card-will-drag': // To provide an axis-lock effect, we track the time of the last scroll // event. If the list has scrolled after the user touched the screen, // we must prevent the drag from resulting in a swipe-up. if (evt.detail.firstTouchTimestamp < (this._lastScrollTimestamp || 0)) { evt.preventDefault(); } // If we're not going to prevent the drag, we should disable scrolling // while the card is dragging; we'll reenable it on "card-dropped". else { this.scrollElement.style.overflowX = 'hidden'; } break; case 'card-dropped': // The user has stopped touching the card; it will either bounce back // into position, or we'll need to kill the app if they swiped upward. // Regardless, reenable scrolling: this.scrollElement.style.overflowX = 'scroll'; // And kill the app after the swipe-up transition has finished. if (evt.detail.willKill) { card = this.elementToCardMap.get(evt.target); // Give the fling-up animation time to finish. eventSafety(card.element, 'transitionend', () => { this.cardAction(card, 'close'); }, 400); } break; case 'wheel': // Screen readers send a 'wheel' event for a two-finger swipe up. // Kill the current app if the user so desires. if (evt.deltaMode === evt.DOM_DELTA_PAGE && this.currentCard) { var currentIndex = this.stack.indexOf(this.currentCard.app); if (evt.deltaY > 0 && this.currentCard.app.killable()) { this.currentCard.app.kill(); } else if (evt.deltaX < 0 && currentIndex >= 1) { this.panToApp(this.stack[currentIndex - 1]); } else if (evt.deltaX > 0 && currentIndex < this.stack.length - 1) { this.panToApp(this.stack[currentIndex + 1]); } } this.updateScrollPosition(); break; case 'lockscreen-appopened': case 'attentionopened': case 'appopen': this.hide(); break; case 'taskmanagershow': this.show({ browserOnly: evt.detail && evt.detail.filter === 'browser-only' }); break; case 'appterminated': this.updateStack(); if (this.stack.length === 0) { this.hide(); } break; } }, // Lifecycle Methods respondToHierarchyEvent(evt) { if (!this._isTransitioning) { if (evt.type === 'home') { if (this.isShown()) { this.hide(Service.query('getHomescreen', true)); return false; // stop the event } } else if (evt.type === 'holdhome') { if (!this.isShown()) { this.show(); return false; // stop the event } } } return true; // keep the event flowing }, publish(type, detail) { var event = new CustomEvent(type, detail || null); window.dispatchEvent(event); }, setFocus() { return true; }, isShown() { return this.isActive(); }, isActive() { return this._active; }, setActive(active) { if (this._active === active) { return; } this._active = active; if (active) { this.publish('taskmanager-activated'); } else { this.publish('taskmanager-deactivated'); } this.element.classList.toggle('active', active); }, /** * Launch a new browser sheet at the end of the stack, pan to its position, * and exit into the new sheet. */ openNewSheet({ isPrivate } = {}) { var config; if (isPrivate) { config = new BrowserConfigHelper({ manifestURL: 'app://search.gaiamobile.org/manifest.webapp', url: 'app://search.gaiamobile.org/newtab.html?private=1', }); config.isPrivate = true; config.isMockPrivate = true; config.oop = true; } else { config = new BrowserConfigHelper({ manifestURL: 'app://search.gaiamobile.org/manifest.webapp', url: 'app://search.gaiamobile.org/newtab.html?private=0' }); } var appWindow = new AppWindow(config); this.updateStack({ currentlyLaunchingApp: appWindow }); // NOTE: These two actions (panToApp and hide) are triggered simultaneously, // to attempt to give the appWindow some time to load before animating // onscreen. The 'from-new-card' transition includes a bit of dead time // at the beginning of the animation to allow 'panToApp' to complete. this.panToApp(appWindow); this.hide(appWindow, 'from-new-card'); } }; exports.TaskManager = TaskManager; })(window);