/* globals TaskManagerUtils, Sanitizer */ 'use strict'; (function(exports) { /** * A `Card` represents an individual app in the Task Manager. * * The `Card` class itself is inert; because TaskManager only updates its * display on launch, Card doesn't need to update its state in real time. * Card's swipe-up-to-delete behavior is handled by `SwipeToKillMotion`, * also included in this file. * * @param {AppWindow} app * @param {boolean} opts.disableScreenshots * Legacy option. If true, we will use a small icon preview on the card * rather than showing a full screenshot and/or moz-element of the app. * Originally introduced on 128MB Tarako devices to save memory. * @param {boolean} opts.stayInvisible * If true (because we're just using this card as a placeholder since we're * immediately launching the app), don't actually render the card. */ function Card(app, { disableScreenshots, stayInvisible } = {}) { var el = document.createElement('li'); this.app = app; this.element = el; if (stayInvisible) { this.element.style.display = 'none'; return; } this.title = (app.isBrowser() && app.title) ? app.title : app.name; this.subTitle = TaskManagerUtils.getDisplayUrlForApp(app); this.titleId = 'card-title-' + app.instanceID; el.classList.add('card'); el.classList.toggle('private', !!app.isPrivate); el.classList.toggle('show-subtitle', !!this.subTitle); el.classList.toggle('browser', !!app.isBrowser()); el.dataset.appInstanceId = app.instanceID; el.dataset.origin = app.origin; // for ease of testing el.dataset.ssl = app.getSSLState() || ''; // indiciate security when available el.setAttribute('aria-labelledby', this.titleId); el.setAttribute('role', 'presentation'); // The card is not semantic. el.innerHTML = Sanitizer.unwrapSafeHTML(this.getHtmlTemplate()); var topMostWindow = app.getTopMostWindow(); if (topMostWindow && topMostWindow.CLASS_NAME === 'TrustedWindow') { this.title = topMostWindow.name || ''; el.classList.add('trustedui'); } else if (!app.killable()) { el.querySelector('.close-button').style.visibility = 'hidden'; } if (!disableScreenshots) { el.querySelector('.screenshotView').style.backgroundImage = `-moz-element(#${app.instanceID})`; } else { el.classList.add('appIconPreview'); var iconId = 'card-appIcon-' + app.instanceID; el.querySelector('.appIcon').id = iconId; el.querySelector('.appIconView').style.backgroundImage = `-moz-element(#${iconId})`; } this.swipeAction = new SwipeToKillMotion(this.element, { setTranslateY: (y) => { this.translate({ y: y }); } }); } exports.Card = Card; Card.prototype = { /** * To ensure animations run smoothly, cards are positioned exclusively with * CSS transforms. Unfortunately, since translateX and translateY must be set * simultaneously, we need a little additional boilerplate here: TaskManager * itself positions the cards on the x-axis, but each card's SwipeToKillMotion * maintains the y-axis positioning. * * @param {object} props * An object with 'x' and/or 'y' properties. Each property MUST include * units, e.g. 'px'. */ translate(props) { if ('x' in props) { this._translateX = props.x; } if ('y' in props) { this._translateY = props.y; } // XXX: Due to a Flame-specific Gecko layout/rendering bug, we cannot use // "top: 25%" to render the card at the appropriate Y position; we must // instead include the desired Y offset here until that bug is fixed. // In the metrics of this calculation, that's calc(50% + y). // See ; we can probably relocate the offset // when that bug is fixed or when we don't care about Flame any more. var newTransform = `translate(${this._translateX}, calc(50% + ${this._translateY}))`; // Preventing an extra restyle when the value doesn't need to change if (this.element.style.transform !== newTransform) { this.element.style.transform = newTransform; } }, loadIcon() { const ICON_SIZE = 40; TaskManagerUtils.loadAppIcon( this.app, this.element.querySelector('.appIcon'), ICON_SIZE); }, _translateX: '0px', _translateY: '0px', getHtmlTemplate() { return Sanitizer.createSafeHTML `

${this.title}

${this.subTitle}

`; }, }; /** * Track vertical swipes across this element. If the element is flung with * enough upward velocity, make the element fly offscreen and emit an event * that indicates that the app should be killed. * * Extra care is taken here to allow a potential swipe-up to be interrupted. * For instance, if the parent container is scrolling horizontally, we must * prohibit this element from being flung (i.e. poor-man's axis lock). * * Emits: * - "card-will-drag" when the element is grabbed and wants to be flung * upward; this event is cancelable. * - "card-dropped" with detail: { willKill: Boolean } * * @param {Element} el * The element (i.e. card.element) to be made draggable. * @param {function} opts.setTranslateY * This class will call setTranslateY whenever the Y-translation changes. * See `Card.prototype.translate` for rationale on this unfortunate grossness. */ function SwipeToKillMotion(el, { setTranslateY }) { el.addEventListener('touchstart', this); el.addEventListener('touchmove', this); el.addEventListener('touchend', this); el.addEventListener('click', this); this.setTranslateY = setTranslateY; this.el = el; } exports.SwipeToKillMotion = SwipeToKillMotion; // Anything less than MIN_DISTANCE will be ignored for swiping purposes. const MIN_DISTANCE = 4; // Track MAX_DRAG_DELTA_COUNT latest drag events, so that we can compute the // current fling velocity. const MAX_DRAG_DELTA_COUNT = 3; // Even if the user flings upward, if they only fling a tiny amound, ignore it. // Note that velocity is more important than swipe distance. const MIN_SWIPE_DISTANCE = 10; SwipeToKillMotion.prototype = { handleEvent(evt) { if (/touch/.test(evt.type)) { return this.handleTouchEvent(evt); } else if (evt.type === 'click') { // Ignore clicks if the element is already being dragged upward. if (this.activelyDragging) { evt.preventDefault(); evt.stopPropagation(); } } }, beginSwipe(evt) { this.firstTouch = evt.targetTouches[0]; this.firstTouchTimestamp = Date.now(); this.dragDeltas = []; this.activelyDragging = false; this.prohibitDragging = false; this.el.style.transition = 'none'; }, endSwipe(evt) { this.el.style.removeProperty('transition'); // If we haven't moved far enough to cause a drag, there's nothing to do. if (!this.activelyDragging) { return; } var willKill = false; if (this.dragDeltas.length > 2) { var dy1 = this.dragDeltas[0]; var dy2 = this.dragDeltas[this.dragDeltas.length - 1]; // If the newer delta is substantially more negative than the older one, // it's time to kill the card. if (dy2 < dy1 - MIN_SWIPE_DISTANCE) { willKill = true; } } this.activelyDragging = false; this.el.dispatchEvent(new CustomEvent('card-dropped', { bubbles: true, detail: { willKill } })); if (willKill) { this.setTranslateY('-200%'); } else { this.setTranslateY('0px'); } }, dragSwipe(evt) { var currentTouch = evt.targetTouches[0]; var dy = (currentTouch.clientY - this.firstTouch.clientY); var dx = (currentTouch.clientX - this.firstTouch.clientX); // If they're swiping downward, clamp the card to the bottom. if (dy > 0) { dy = 0; } // If we're dragging, track the most recent few swipes so that we can // calculate a rough estimate of velocity, which is important in deciding // if we should swipe up to kill the app or return the app to y=0. if (this.activelyDragging) { this.dragDeltas.push(dy); if (this.dragDeltas.length > MAX_DRAG_DELTA_COUNT) { this.dragDeltas.shift(); } } // If we're not actively dragging yet... else { // If we swiped along the X axis, scroll sideways instead. if (Math.abs(dx) > MIN_DISTANCE) { dy = 0; this.prohibitDragging = true; } // If we swiped along the Y axis, check to see if the scrollable container // thinks we should be allowed to swipe up right now. (If the container // has been scrolling recently, it'll tell us to hold off by canceling // the "card-will-drag" event). else if (Math.abs(dy) > MIN_DISTANCE) { if (this.el.dispatchEvent(new CustomEvent('card-will-drag', { bubbles: true, cancelable: true, detail: { firstTouchTimestamp: this.firstTouchTimestamp }}))) { this.activelyDragging = true; } // If the container told us we can't swipe, give up for now. else { dy = 0; this.prohibitDragging = true; } } // If we aren't sure that we should be swiping, clamp 'dy' to zero to // avoid looking janky if we decide to scroll. else if (Math.abs(dy) < MIN_DISTANCE) { dy = 0; } } this.setTranslateY(dy + 'px'); }, handleTouchEvent(evt) { evt.stopPropagation(); // 'touchstart' and 'touchend' events aren't entirely relevant here; // we're touching the element if and only if evt.targetTouches.length > 0. var wasTouching = this.isTouching; this.isTouching = evt.targetTouches.length > 0; if (!wasTouching && this.isTouching) { this.beginSwipe(evt); } else if (wasTouching && !this.isTouching) { this.endSwipe(evt); } if (this.isTouching && !this.prohibitDragging) { this.dragSwipe(evt); } } }; })(window);