Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
644 lines
20 KiB
JavaScript
644 lines
20 KiB
JavaScript
/* 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(); // <div.card> -> 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);
|