VulpesOS/gaia/apps/email/js/cards.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

924 lines
30 KiB
JavaScript
Executable file

'use strict';
define(function(require, exports, module) {
var cardsInit = require('cards_init'),
htmlCache = require('html_cache'),
mozL10n = require('l10n!'),
evt = require('evt'),
toaster = require('toaster'),
transitionEnd = require('transition_end'),
hookupInputAreaResetButtons = require('input_areas');
function addClass(domNode, name) {
if (domNode) {
domNode.classList.add(name);
}
}
function removeClass(domNode, name) {
if (domNode) {
domNode.classList.remove(name);
}
}
/**
* Fairly simple card abstraction with support for simple horizontal animated
* transitions. We are cribbing from deuxdrop's mobile UI's cards.js
* implementation created jrburke.
*/
var cards = {
_cardDefs: {},
/*
* Existing cards, left-to-right, new cards getting pushed onto the right.
*/
_cardStack: [],
activeCardIndex: -1,
/*
* @oneof[null @listof[cardName modeName]]{
* If a lazy load is causing us to have to wait before we push a card, this
* is the type of card we are planning to push. This is used by hasCard
* to avoid returning misleading answers while an async push is happening.
* }
*/
_pendingPush: null,
/**
* Cards can stack on top of each other, make sure the stacked set is
* visible over the lower sets.
*/
_zIndex: 0,
/**
* The DOM node that contains the _containerNode ("#cardContainer") and which
* we inject popup and masking layers into. The choice of doing the popup
* stuff at this layer is arbitrary.
*/
_rootNode: null,
/**
* The "#cardContainer" node which serves as the scroll container for the
* contained _cardsNode ("#cards"). It is as wide as the viewport.
*/
_containerNode: null,
/**
* The "#cards" node that holds the cards; it is as wide as all of the cards
* it contains and has its left offset changed in order to change what card
* is visible.
*/
_cardsNode: null,
/**
* The DOM nodes that should be removed from their parent when our current
* transition ends.
*/
_animatingDeadDomNodes: [],
/**
* Tracks the number of transition events per card animation. Since each
* animation ends up with two transitionend events since two cards are
* moving, need to wait for the last one to be finished before doing
* cleanup, like DOM removal.
*/
_transitionCount: 0,
/**
* Tracks if startup events have been emitted. The events only need to be
* emitted once.
* @type {Boolean}
*/
_startupEventsEmitted: false,
/**
* Is a popup visible, suggesting that any click that is not on the popup
* should be taken as a desire to close the popup? This is not a boolean,
* but rather info on the active popup.
*/
_popupActive: null,
/**
* Are we eating all click events we see until we transition to the next
* card (possibly due to a call to pushCard that has not yet occurred?).
* Set by calling `eatEventsUntilNextCard`.
*/
_eatingEventsUntilNextCard: false,
/**
* Initialize and bind ourselves to the DOM which should now be fully loaded.
*/
init: function() {
this._rootNode = document.body;
this._containerNode = document.getElementById('cardContainer');
this._cardsNode = document.getElementById('cards');
this._statusColorMeta = document.querySelector('meta[name="theme-color"]');
toaster.init(this._containerNode);
this._containerNode.addEventListener('click',
this._onMaybeIntercept.bind(this),
true);
// XXX be more platform detecty. or just add more events. unless the
// prefixes are already gone with webkit and opera?
transitionEnd(this._cardsNode, this._onTransitionEnd.bind(this), false);
// Listen for visibility changes to let current card know of them too.
// Do this here instead of each card needing to listen, and needing to know
// if it is also the current card.
document.addEventListener('visibilitychange', function(evt) {
var card = this._cardStack[this.activeCardIndex];
if (card && card.onCurrentCardDocumentVisibilityChange) {
card.onCurrentCardDocumentVisibilityChange(document.hidden);
}
}.bind(this));
cardsInit(this);
},
/**
* If the tray is active and a click happens in the tray area, transition
* back to the visible thing (which must be to our right currently.)
*/
_onMaybeIntercept: function(event) {
if (this._eatingEventsUntilNextCard) {
event.stopPropagation();
event.preventDefault();
return;
}
if (this._popupActive) {
event.stopPropagation();
event.preventDefault();
this._popupActive.close();
return;
}
// Find the card containing the event target.
var cardNode = event.target;
for (cardNode = event.target; cardNode; cardNode = cardNode.parentElement) {
if (cardNode.classList.contains('card')) {
break;
}
}
},
/**
* Push a card onto the card-stack.
*/
/* @args[
* @param[type]
* @param[showMethod @oneof[
* @case['animate']{
* Perform an animated scrolling transition.
* }
* @case['immediate']{
* Immediately warp to the card without animation.
* }
* @case['none']{
* Don't touch the view at all.
* }
* ]]
* @param[args Object]{
* An arguments object to provide to the card's constructor when
* instantiating.
* }
* @param[placement #:optional @oneof[
* @case[undefined]{
* The card gets pushed onto the end of the stack.
* }
* @case['left']{
* The card gets inserted to the left of the current card.
* }
* @case['right']{
* The card gets inserted to the right of the current card.
* }
* }
* ]
*/
pushCard: function(type, showMethod, args, placement) {
var cardDef = this._cardDefs[type];
args = args || {};
if (!cardDef) {
var cbArgs = Array.slice(arguments);
this._pendingPush = [type];
// Only eat clicks if the card will be visibly displayed.
if (showMethod !== 'none') {
this.eatEventsUntilNextCard();
}
require(['element!cards/' + type], function(Ctor) {
this._cardDefs[type] = Ctor;
this.pushCard.apply(this, cbArgs);
}.bind(this));
return;
}
this._pendingPush = null;
console.log('pushCard for type: ' + type);
var domNode = args.cachedNode || new cardDef();
if (args && domNode.onArgs) {
domNode.onArgs(args);
}
var cardIndex, insertBuddy;
if (!placement) {
cardIndex = this._cardStack.length;
insertBuddy = null;
domNode.classList.add(cardIndex === 0 ? 'before' : 'after');
}
else if (placement === 'left') {
cardIndex = this.activeCardIndex++;
insertBuddy = this._cardsNode.children[cardIndex];
domNode.classList.add('before');
}
else if (placement === 'right') {
cardIndex = this.activeCardIndex + 1;
if (cardIndex >= this._cardStack.length) {
insertBuddy = null;
} else {
insertBuddy = this._cardsNode.children[cardIndex];
}
domNode.classList.add('after');
}
this._cardStack.splice(cardIndex, 0, domNode);
if (!args.cachedNode) {
this._cardsNode.insertBefore(domNode, insertBuddy);
}
// If the card has any <button type="reset"> buttons,
// make them clear the field they're next to and not the entire form.
// See input_areas.js and shared/style/input_areas.css.
hookupInputAreaResetButtons(domNode);
// Only do auto font size watching for cards that do not have more
// complicated needs, like message_list, which modifies children contents
// that are not caught by the font_size_util.
if (!domNode.callHeaderFontSize) {
// We're appending new elements to DOM so to make sure headers are
// properly resized and centered, we emit a lazyload event.
// This will be removed when the gaia-header web component lands.
window.dispatchEvent(new CustomEvent('lazyload', {
detail: domNode
}));
}
if ('postInsert' in domNode) {
domNode.postInsert();
}
if (showMethod !== 'none') {
// make sure the reflow sees the new node so that the animation
// later is smooth.
if (!args.cachedNode) {
domNode.clientWidth;
}
this._showCard(cardIndex, showMethod, 'forward');
}
if (args.onPushed) {
args.onPushed(domNode);
}
},
/**
* Pushes a new card if none exists, otherwise, uses existing
* card and passes args to that card via tellCard. Arguments
* are the same as pushCard.
* @return {Boolean} true if card was pushed.
*/
pushOrTellCard: function(type, showMethod, args, placement) {
var query = type;
if (this.hasCard(query)) {
this.tellCard(query, args);
return false;
} else {
this.pushCard.apply(this, Array.slice(arguments));
return true;
}
},
/**
* Sets the status bar color. The element, or any of its children, can specify
* the color by setting data-statuscolor to one of the following values:
* - default: uses the default data-statuscolor set on the meta theme-color
* tag is used.
* - background: the CSS background color, via getComputedStyle, is used. This
* is useful if the background that is desired is not the one from the element
* itself, but from one of its children.
* - a specific color value.
*
* If no data-statuscolor attribute is found, then the background color for
* the element, via getComputedStyle, is used. If that value is not a valid
* color value, then the default statuscolor on the meta tag is used.
*
* Note that this method uses getComputedStyle. This could be expensive
* depending on when it is called. For the card infrastructure, since it is
* done as part of a card transition, and done before the card transition code
* applies transition styles, the target element should not be visible at the
* time of the query. In practice no negligble end user effect has been seen,
* and that query is much more desirable than hardcoding colors in JS or HTML.
*
* @param {Element} [element] the card element of interest. If no element is
* passed, the the current card is used.
*/
setStatusColor: function(element) {
var color;
// Some use cases, like dialogs, are outside the card stack, so they may
// not know what element to use for a baseline. In those cases, Cards
// decides the target element.
if (!element) {
element = this._cardStack[this.activeCardIndex];
}
// Try first for specific color override. Do a node query, since for custom
// elements, the custom elment tag may not set its color, but the template
// used inside the tag may.
var statusElement = element.dataset.statuscolor ? element :
element.querySelector('[data-statuscolor]');
if (statusElement) {
color = statusElement.dataset.statuscolor;
// Allow cards to just indicate they want the default.
if (color === 'default') {
color = null;
} else if (color === 'background') {
color = getComputedStyle(statusElement).backgroundColor;
}
} else {
// Just use the background color of the original element.
color = getComputedStyle(element).backgroundColor;
}
// Only use specific color values, not values like 'transparent'.
if (color && color.indexOf('rgb') !== 0 && color.indexOf('#') !== 0) {
color = null;
}
color = color || this._statusColorMeta.dataset.statuscolor;
var existingColor = this._statusColorMeta.getAttribute('content');
if (color !== existingColor) {
this._statusColorMeta.setAttribute('content', color);
}
},
_findCardUsingType: function(type) {
for (var i = 0; i < this._cardStack.length; i++) {
var domNode = this._cardStack[i];
if (htmlCache.nodeToKey(domNode) === type) {
return i;
}
}
},
_findCard: function(query, skipFail) {
var result;
if (typeof query === 'string') {
result = this._findCardUsingType(query, skipFail);
} else if (typeof(query) === 'number') { // index number
result = query;
} else {
// query is a DOM node in this case
result = this._cardStack.indexOf(query);
}
if (result > -1) {
return result;
} else if (!skipFail) {
throw new Error('Unable to find card with query:', query);
} else {
// Returning undefined explicitly so that index comparisons, like
// the one in hasCard, are correct.
return undefined;
}
},
hasCard: function(query) {
if (this._pendingPush && this._pendingPush === query) {
return true;
}
return this._findCard(query, true) > -1;
},
isVisible: function(domNode) {
return !!(domNode &&
domNode.classList.contains('center'));
},
findCardObject: function(query) {
return this._cardStack[this._findCard(query)];
},
getCurrentCardType: function() {
var result = null,
card = this._cardStack[this.activeCardIndex];
// Favor any _pendingPush value as it is about to
// become current, just waiting on an async cycle
// to finish. Otherwise use current card value.
if (this._pendingPush) {
result = this._pendingPush;
} else if (card) {
result = htmlCache.nodeToKey(card);
}
return result;
},
// Filter is an optional paramater. It is a function that returns
// true if the folder passed to it should be included in the selector
folderSelector: function(model, callback, filter) {
var self = this;
require(['value_selector'], function(ValueSelector) {
// XXX: Unified folders will require us to make sure we get the folder
// list for the account the message originates from.
if (!self.folderPrompt) {
var selectorTitle = mozL10n.get('messages-folder-select');
self.folderPrompt = new ValueSelector(selectorTitle);
}
model.latestOnce('foldersSlice', function(foldersSlice) {
var folders = foldersSlice.items;
folders.forEach(function(folder) {
var isMatch = !filter || filter(folder);
if (folder.neededForHierarchy || isMatch) {
self.folderPrompt.addToList(folder.name, folder.depth,
isMatch,
function(folder) {
return function() {
self.folderPrompt.hide();
callback(folder);
};
}(folder));
}
});
self.folderPrompt.show();
});
});
},
moveToCard: function(query, showMethod) {
this._showCard(this._findCard(query), showMethod || 'animate');
},
tellCard: function(query, what) {
var cardIndex = this._findCard(query),
domNode = this._cardStack[cardIndex];
if (!('told' in domNode)) {
console.warn('Tried to tell a card that\'s not listening!', query, what);
} else {
domNode.told(what);
}
},
/**
* Remove the card identified by its DOM node and all the cards to its right.
* Pass null to remove all of the cards! If cardDomNode passed, but there
* are no cards before it, cards.getDefaultCard is called to set up a before
* card.
*/
/* @args[
* @param[cardDomNode]{
* The DOM node that is the first card to remove; all of the cards to its
* right will also be removed. If null is passed it is understood you
* want to remove all cards.
* }
* @param[showMethod @oneof[
* @case['animate']{
* Perform an animated scrolling transition.
* }
* @case['immediate']{
* Immediately warp to the card without animation.
* }
* @case['none']{
* Remove the nodes immediately, don't do anything about the view
* position. You only want to do this if you are going to push one
* or more cards and the last card will use a transition of 'immediate'.
* }
* ]]
* @param[numCards #:optional Number]{
* The number of cards to remove. If omitted, all the cards to the right
* of this card are removed as well.
* }
* @param[nextCardSpec #:optional]{
* If a showMethod is not 'none', the card to show after removal.
* }
* @param[skipDefault #:optional Boolean]{
* Skips the default pushCard if the removal ends up with no more
* cards in the stack.
* }
* ]
*/
removeCardAndSuccessors: function(cardDomNode, showMethod, numCards,
nextCardSpec, skipDefault) {
if (!this._cardStack.length) {
return;
}
if (cardDomNode && this._cardStack.length === 1 && !skipDefault) {
// No card to go to when done, so ask for a default
// card and continue work once it exists.
return cards.pushDefaultCard(function() {
this.removeCardAndSuccessors(cardDomNode, showMethod, numCards,
nextCardSpec);
}.bind(this));
}
var firstIndex, iCard, domNode;
if (cardDomNode === undefined) {
throw new Error('undefined is not a valid card spec!');
}
else if (cardDomNode === null) {
firstIndex = 0;
// reset the z-index to 0 since we may have cards in the stack that
// adjusted the z-index (and we are definitively clearing all cards).
this._zIndex = 0;
}
else {
for (iCard = this._cardStack.length - 1; iCard >= 0; iCard--) {
domNode = this._cardStack[iCard];
if (domNode === cardDomNode) {
firstIndex = iCard;
break;
}
}
if (firstIndex === undefined) {
throw new Error('No card represented by that DOM node');
}
}
if (!numCards) {
numCards = this._cardStack.length - firstIndex;
}
if (showMethod === 'none') {
// If a 'none' remove, and the remove is for a DOM node that used
// anim-overlay, which would have increased the _zIndex when added, adjust
// the zIndex appropriately.
if (cardDomNode && cardDomNode.classList.contains('anim-overlay')) {
this._zIndex -= 10;
}
} else {
var nextCardIndex = -1;
if (nextCardSpec) {
nextCardIndex = this._findCard(nextCardSpec);
} else if (this._cardStack.length) {
nextCardIndex = Math.min(firstIndex - 1, this._cardStack.length - 1);
}
if (nextCardIndex > -1) {
this._showCard(nextCardIndex, showMethod, 'back');
}
}
// Update activeCardIndex if nodes were removed that would affect its
// value.
if (firstIndex <= this.activeCardIndex) {
this.activeCardIndex -= numCards;
if (this.activeCardIndex < -1) {
this.activeCardIndex = -1;
}
}
var deadDomNodes = this._cardStack.splice(
firstIndex, numCards);
for (iCard = 0; iCard < deadDomNodes.length; iCard++) {
domNode = deadDomNodes[iCard];
try {
domNode.die();
}
catch (ex) {
console.warn('Problem cleaning up card:', ex, '\n', ex.stack);
}
switch (showMethod) {
case 'animate':
case 'immediate': // XXX handle properly
this._animatingDeadDomNodes.push(domNode);
break;
case 'none':
domNode.parentNode.removeChild(domNode);
break;
}
}
// Reset aria-hidden attributes to handle cards visibility.
this._setScreenReaderVisibility();
},
/**
* Shortcut for removing all the cards
*/
removeAllCards: function() {
return this.removeCardAndSuccessors(null, 'none');
},
_showCard: function(cardIndex, showMethod, navDirection) {
// Do not do anything if this is a show card for the current card.
if (cardIndex === this.activeCardIndex) {
return;
}
// If the active element is one that can have focus, blur it so that the
// keyboard goes away.
var activeElement = document.activeElement;
if (activeElement && activeElement.blur) {
activeElement.blur();
}
if (cardIndex > this._cardStack.length - 1) {
// Some cards were removed, adjust.
cardIndex = this._cardStack.length - 1;
}
if (this.activeCardIndex > this._cardStack.length - 1) {
this.activeCardIndex = -1;
}
if (this.activeCardIndex === -1) {
this.activeCardIndex = cardIndex === 0 ? cardIndex : cardIndex - 1;
}
var domNode = (cardIndex !== null) ? this._cardStack[cardIndex] : null;
var beginNode = this._cardStack[this.activeCardIndex];
var endNode = this._cardStack[cardIndex];
var isForward = navDirection === 'forward';
if (this._cardStack.length === 1) {
// Reset zIndex so that it does not grow ever higher when all but
// one card are removed
this._zIndex = 0;
}
// If going forward and it is an overlay node, then do not animate the
// beginning node, it will just sit under the overlay.
if (isForward && endNode.classList.contains('anim-overlay')) {
beginNode = null;
// anim-overlays are the transitions to new layers in the stack. If
// starting a new one, it is forward movement and needs a new zIndex.
// Otherwise, going back to
this._zIndex += 10;
}
// If going back and the beginning node was an overlay, do not animate
// the end node, since it should just be hidden under the overlay.
if (beginNode && beginNode.classList.contains('anim-overlay')) {
if (isForward) {
// If a forward animation and overlay had a vertical transition,
// disable it, use normal horizontal transition.
if (showMethod !== 'immediate') {
if (beginNode.classList.contains('anim-vertical')) {
removeClass(beginNode, 'anim-vertical');
addClass(beginNode, 'disabled-anim-vertical');
} else if (beginNode.classList.contains('anim-fade')) {
removeClass(beginNode, 'anim-fade');
addClass(beginNode, 'disabled-anim-fade');
}
}
} else {
this.setStatusColor(endNode);
endNode = null;
this._zIndex -= 10;
}
}
// If the zindex is not zero, then in an overlay stack, adjust zindex
// accordingly.
if (endNode && isForward && this._zIndex) {
endNode.style.zIndex = this._zIndex;
}
var cardsNode = this._cardsNode;
// Do the status bar color work before triggering transitions, otherwise
// we lose some animation frames on the card transitions.
if (endNode) {
this.setStatusColor(endNode);
}
if (showMethod === 'immediate') {
addClass(beginNode, 'no-anim');
addClass(endNode, 'no-anim');
// make sure the reflow sees the transition is turned off.
cardsNode.clientWidth;
// explicitly clear since there will be no animation
this._eatingEventsUntilNextCard = false;
}
else if (showMethod === 'none') {
// do not set _eatingEventsUntilNextCard, but don't clear it either.
}
else {
this._transitionCount = (beginNode && endNode) ? 2 : 1;
this._eatingEventsUntilNextCard = true;
}
if (this.activeCardIndex === cardIndex) {
// same node, no transition, just bootstrapping UI.
removeClass(beginNode, 'before');
removeClass(beginNode, 'after');
addClass(beginNode, 'center');
} else if (this.activeCardIndex > cardIndex) {
// back
removeClass(beginNode, 'center');
addClass(beginNode, 'after');
removeClass(endNode, 'before');
addClass(endNode, 'center');
} else {
// forward
removeClass(beginNode, 'center');
addClass(beginNode, 'before');
removeClass(endNode, 'after');
addClass(endNode, 'center');
}
if (showMethod === 'immediate') {
// make sure the instantaneous transition is seen before we turn
// transitions back on.
cardsNode.clientWidth;
removeClass(beginNode, 'no-anim');
removeClass(endNode, 'no-anim');
this._onCardVisible(domNode);
}
// Hide toaster while active card index changed:
toaster.hide();
this.activeCardIndex = cardIndex;
// Reset aria-hidden attributes to handle cards visibility.
this._setScreenReaderVisibility();
},
_setScreenReaderVisibility: function() {
// We use aria-hidden to handle visibility instead of CSS because there are
// semi-transparent cards, such as folder picker.
this._cardStack.forEach(function(card, index) {
card.setAttribute('aria-hidden', index !== this.activeCardIndex);
}, this);
},
_onTransitionEnd: function(event) {
// Avoid other transitions except ones on cards as a whole.
if (!event.target.classList.contains('card')) {
return;
}
var activeCard = this._cardStack[this.activeCardIndex];
// If no current card, this could be initial setup from cache, no valid
// cards yet, so bail.
if (!activeCard) {
return;
}
// Multiple cards can animate, so there can be multiple transitionend
// events. Only do the end work when all have finished animating.
if (this._transitionCount > 0) {
this._transitionCount -= 1;
}
if (this._transitionCount === 0) {
if (this._eatingEventsUntilNextCard) {
this._eatingEventsUntilNextCard = false;
}
if (this._animatingDeadDomNodes.length) {
// Use a setTimeout to give the animation some space to settle.
setTimeout(function() {
this._animatingDeadDomNodes.forEach(function(domNode) {
if (domNode.parentNode) {
domNode.parentNode.removeChild(domNode);
}
});
this._animatingDeadDomNodes = [];
}.bind(this), 100);
}
// If an vertical overlay transition was was disabled, if
// current node index is an overlay, enable it again.
var endNode = activeCard;
if (endNode.classList.contains('disabled-anim-vertical')) {
removeClass(endNode, 'disabled-anim-vertical');
addClass(endNode, 'anim-vertical');
} else if (endNode.classList.contains('disabled-anim-fade')) {
removeClass(endNode, 'disabled-anim-fade');
addClass(endNode, 'anim-fade');
}
// If any action to do at the end of transition trigger now.
if (this._afterTransitionAction) {
var afterTransitionAction = this._afterTransitionAction;
this._afterTransitionAction = null;
afterTransitionAction();
}
this._onCardVisible(activeCard);
// If the card has next cards that can be preloaded, load them now.
// Use of nextCards should be balanced with startup performance.
// nextCards can result in smoother transitions to new cards on first
// navigation to that new card type, but loading the extra module may
// also compete with current card and data model performance.
var nextCards = activeCard.nextCards;
if (nextCards) {
console.log('Preloading cards: ' + nextCards);
require(nextCards.map(function(id) {
return 'cards/' + id;
}));
}
}
},
/**
* Handles final notification of card visibility in the stack.
* @param {Card} domNode the card instance.
*/
_onCardVisible: function(domNode) {
if (domNode.onCardVisible) {
domNode.onCardVisible();
}
this._emitStartupEvents(domNode.skipEmitContentEvents);
},
/**
* Handles emitting startup events used for performance tracking.
* @param {Boolean} skipEmitContentEvents if content events should be skipped
* because the card itself handles it.
*/
_emitStartupEvents: function(skipEmitContentEvents) {
if (!this._startupEventsEmitted) {
if (window.startupCacheEventsSent) {
// Cache already loaded, so at this point the content shown is wired
// to event handlers.
window.performance.mark('contentInteractive');
} else {
// Cache was not used, so only now is the chrome dom loaded.
window.performance.mark('navigationLoaded');
}
window.performance.mark('navigationInteractive');
// If a card that has a simple static content DOM, content is complete.
// Otherwise, like message_list, need backend data to call complete.
if (!skipEmitContentEvents) {
evt.emit('metrics:contentDone');
}
this._startupEventsEmitted = true;
}
},
/**
* Helper that causes (some) events targeted at our cards to be eaten until
* we get to the next card. The idea is to avoid bugs caused by the user
* still being able to click things while our cards are transitioning or
* while we are performing a (reliable) async wait before we actually initiate
* a pushCard in response to user stimulus.
*
* This is automatically triggered when performing an animated transition;
* other code should only call this in the async wait case mentioned above.
*
* For example, we don't want the user to have 2 message readers happening
* at the same time because they managed to click on a second message before
* the first reader got displayed.
*/
eatEventsUntilNextCard: function() {
this._eatingEventsUntilNextCard = true;
},
/**
* Stop eating events, presumably because eatEventsUntilNextCard was used
* as a hack for a known-fast async operation to avoid bugs (where we knew
* full well that we weren't going to show a card).
*/
stopEatingEvents: function() {
this._eatingEventsUntilNextCard = false;
},
/**
* If there are any cards on the deck right now, log an error and clear them
* all out. Our caller is strongly asserting that there should be no cards
* and the presence of any indicates a bug.
*/
assertNoCards: function() {
if (this._cardStack.length) {
throw new Error('There are ' + this._cardStack.length + ' cards but' +
' there should be ZERO');
}
}
};
return cards;
});