@angular/animations
Version:
Angular - animations integration with web-animations
1,102 lines • 223 kB
JavaScript
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import { AUTO_STYLE, NoopAnimationPlayer, ɵAnimationGroupPlayer as AnimationGroupPlayer, ɵPRE_STYLE as PRE_STYLE } from '@angular/animations';
import { ElementInstructionMap } from '../dsl/element_instruction_map';
import { copyObj, ENTER_CLASSNAME, eraseStyles, LEAVE_CLASSNAME, NG_ANIMATING_CLASSNAME, NG_ANIMATING_SELECTOR, NG_TRIGGER_CLASSNAME, NG_TRIGGER_SELECTOR, setStyles } from '../util';
import { getOrSetAsInMap, listenOnPlayer, makeAnimationEvent, normalizeKeyframes, optimizeGroupPlayer } from './shared';
const QUEUED_CLASSNAME = 'ng-animate-queued';
const QUEUED_SELECTOR = '.ng-animate-queued';
const DISABLED_CLASSNAME = 'ng-animate-disabled';
const DISABLED_SELECTOR = '.ng-animate-disabled';
const STAR_CLASSNAME = 'ng-star-inserted';
const STAR_SELECTOR = '.ng-star-inserted';
const EMPTY_PLAYER_ARRAY = [];
const NULL_REMOVAL_STATE = {
namespaceId: '',
setForRemoval: false,
setForMove: false,
hasAnimation: false,
removedBeforeQueried: false
};
const NULL_REMOVED_QUERIED_STATE = {
namespaceId: '',
setForMove: false,
setForRemoval: false,
hasAnimation: false,
removedBeforeQueried: true
};
export const REMOVAL_FLAG = '__ng_removed';
export class StateValue {
constructor(input, namespaceId = '') {
this.namespaceId = namespaceId;
const isObj = input && input.hasOwnProperty('value');
const value = isObj ? input['value'] : input;
this.value = normalizeTriggerValue(value);
if (isObj) {
const options = copyObj(input);
delete options['value'];
this.options = options;
}
else {
this.options = {};
}
if (!this.options.params) {
this.options.params = {};
}
}
get params() {
return this.options.params;
}
absorbOptions(options) {
const newParams = options.params;
if (newParams) {
const oldParams = this.options.params;
Object.keys(newParams).forEach(prop => {
if (oldParams[prop] == null) {
oldParams[prop] = newParams[prop];
}
});
}
}
}
export const VOID_VALUE = 'void';
export const DEFAULT_STATE_VALUE = new StateValue(VOID_VALUE);
export class AnimationTransitionNamespace {
constructor(id, hostElement, _engine) {
this.id = id;
this.hostElement = hostElement;
this._engine = _engine;
this.players = [];
this._triggers = {};
this._queue = [];
this._elementListeners = new Map();
this._hostClassName = 'ng-tns-' + id;
addClass(hostElement, this._hostClassName);
}
listen(element, name, phase, callback) {
if (!this._triggers.hasOwnProperty(name)) {
throw new Error(`Unable to listen on the animation trigger event "${phase}" because the animation trigger "${name}" doesn\'t exist!`);
}
if (phase == null || phase.length == 0) {
throw new Error(`Unable to listen on the animation trigger "${name}" because the provided event is undefined!`);
}
if (!isTriggerEventValid(phase)) {
throw new Error(`The provided animation trigger event "${phase}" for the animation trigger "${name}" is not supported!`);
}
const listeners = getOrSetAsInMap(this._elementListeners, element, []);
const data = { name, phase, callback };
listeners.push(data);
const triggersWithStates = getOrSetAsInMap(this._engine.statesByElement, element, {});
if (!triggersWithStates.hasOwnProperty(name)) {
addClass(element, NG_TRIGGER_CLASSNAME);
addClass(element, NG_TRIGGER_CLASSNAME + '-' + name);
triggersWithStates[name] = DEFAULT_STATE_VALUE;
}
return () => {
// the event listener is removed AFTER the flush has occurred such
// that leave animations callbacks can fire (otherwise if the node
// is removed in between then the listeners would be deregistered)
this._engine.afterFlush(() => {
const index = listeners.indexOf(data);
if (index >= 0) {
listeners.splice(index, 1);
}
if (!this._triggers[name]) {
delete triggersWithStates[name];
}
});
};
}
register(name, ast) {
if (this._triggers[name]) {
// throw
return false;
}
else {
this._triggers[name] = ast;
return true;
}
}
_getTrigger(name) {
const trigger = this._triggers[name];
if (!trigger) {
throw new Error(`The provided animation trigger "${name}" has not been registered!`);
}
return trigger;
}
trigger(element, triggerName, value, defaultToFallback = true) {
const trigger = this._getTrigger(triggerName);
const player = new TransitionAnimationPlayer(this.id, triggerName, element);
let triggersWithStates = this._engine.statesByElement.get(element);
if (!triggersWithStates) {
addClass(element, NG_TRIGGER_CLASSNAME);
addClass(element, NG_TRIGGER_CLASSNAME + '-' + triggerName);
this._engine.statesByElement.set(element, triggersWithStates = {});
}
let fromState = triggersWithStates[triggerName];
const toState = new StateValue(value, this.id);
const isObj = value && value.hasOwnProperty('value');
if (!isObj && fromState) {
toState.absorbOptions(fromState.options);
}
triggersWithStates[triggerName] = toState;
if (!fromState) {
fromState = DEFAULT_STATE_VALUE;
}
const isRemoval = toState.value === VOID_VALUE;
// normally this isn't reached by here, however, if an object expression
// is passed in then it may be a new object each time. Comparing the value
// is important since that will stay the same despite there being a new object.
// The removal arc here is special cased because the same element is triggered
// twice in the event that it contains animations on the outer/inner portions
// of the host container
if (!isRemoval && fromState.value === toState.value) {
// this means that despite the value not changing, some inner params
// have changed which means that the animation final styles need to be applied
if (!objEquals(fromState.params, toState.params)) {
const errors = [];
const fromStyles = trigger.matchStyles(fromState.value, fromState.params, errors);
const toStyles = trigger.matchStyles(toState.value, toState.params, errors);
if (errors.length) {
this._engine.reportError(errors);
}
else {
this._engine.afterFlush(() => {
eraseStyles(element, fromStyles);
setStyles(element, toStyles);
});
}
}
return;
}
const playersOnElement = getOrSetAsInMap(this._engine.playersByElement, element, []);
playersOnElement.forEach(player => {
// only remove the player if it is queued on the EXACT same trigger/namespace
// we only also deal with queued players here because if the animation has
// started then we want to keep the player alive until the flush happens
// (which is where the previousPlayers are passed into the new palyer)
if (player.namespaceId == this.id && player.triggerName == triggerName && player.queued) {
player.destroy();
}
});
let transition = trigger.matchTransition(fromState.value, toState.value, element, toState.params);
let isFallbackTransition = false;
if (!transition) {
if (!defaultToFallback)
return;
transition = trigger.fallbackTransition;
isFallbackTransition = true;
}
this._engine.totalQueuedPlayers++;
this._queue.push({ element, triggerName, transition, fromState, toState, player, isFallbackTransition });
if (!isFallbackTransition) {
addClass(element, QUEUED_CLASSNAME);
player.onStart(() => {
removeClass(element, QUEUED_CLASSNAME);
});
}
player.onDone(() => {
let index = this.players.indexOf(player);
if (index >= 0) {
this.players.splice(index, 1);
}
const players = this._engine.playersByElement.get(element);
if (players) {
let index = players.indexOf(player);
if (index >= 0) {
players.splice(index, 1);
}
}
});
this.players.push(player);
playersOnElement.push(player);
return player;
}
deregister(name) {
delete this._triggers[name];
this._engine.statesByElement.forEach((stateMap, element) => {
delete stateMap[name];
});
this._elementListeners.forEach((listeners, element) => {
this._elementListeners.set(element, listeners.filter(entry => {
return entry.name != name;
}));
});
}
clearElementCache(element) {
this._engine.statesByElement.delete(element);
this._elementListeners.delete(element);
const elementPlayers = this._engine.playersByElement.get(element);
if (elementPlayers) {
elementPlayers.forEach(player => player.destroy());
this._engine.playersByElement.delete(element);
}
}
_signalRemovalForInnerTriggers(rootElement, context) {
const elements = this._engine.driver.query(rootElement, NG_TRIGGER_SELECTOR, true);
// emulate a leave animation for all inner nodes within this node.
// If there are no animations found for any of the nodes then clear the cache
// for the element.
elements.forEach(elm => {
// this means that an inner remove() operation has already kicked off
// the animation on this element...
if (elm[REMOVAL_FLAG])
return;
const namespaces = this._engine.fetchNamespacesByElement(elm);
if (namespaces.size) {
namespaces.forEach(ns => ns.triggerLeaveAnimation(elm, context, false, true));
}
else {
this.clearElementCache(elm);
}
});
// If the child elements were removed along with the parent, their animations might not
// have completed. Clear all the elements from the cache so we don't end up with a memory leak.
this._engine.afterFlushAnimationsDone(() => elements.forEach(elm => this.clearElementCache(elm)));
}
triggerLeaveAnimation(element, context, destroyAfterComplete, defaultToFallback) {
const triggerStates = this._engine.statesByElement.get(element);
if (triggerStates) {
const players = [];
Object.keys(triggerStates).forEach(triggerName => {
// this check is here in the event that an element is removed
// twice (both on the host level and the component level)
if (this._triggers[triggerName]) {
const player = this.trigger(element, triggerName, VOID_VALUE, defaultToFallback);
if (player) {
players.push(player);
}
}
});
if (players.length) {
this._engine.markElementAsRemoved(this.id, element, true, context);
if (destroyAfterComplete) {
optimizeGroupPlayer(players).onDone(() => this._engine.processLeaveNode(element));
}
return true;
}
}
return false;
}
prepareLeaveAnimationListeners(element) {
const listeners = this._elementListeners.get(element);
if (listeners) {
const visitedTriggers = new Set();
listeners.forEach(listener => {
const triggerName = listener.name;
if (visitedTriggers.has(triggerName))
return;
visitedTriggers.add(triggerName);
const trigger = this._triggers[triggerName];
const transition = trigger.fallbackTransition;
const elementStates = this._engine.statesByElement.get(element);
const fromState = elementStates[triggerName] || DEFAULT_STATE_VALUE;
const toState = new StateValue(VOID_VALUE);
const player = new TransitionAnimationPlayer(this.id, triggerName, element);
this._engine.totalQueuedPlayers++;
this._queue.push({
element,
triggerName,
transition,
fromState,
toState,
player,
isFallbackTransition: true
});
});
}
}
removeNode(element, context) {
const engine = this._engine;
if (element.childElementCount) {
this._signalRemovalForInnerTriggers(element, context);
}
// this means that a * => VOID animation was detected and kicked off
if (this.triggerLeaveAnimation(element, context, true))
return;
// find the player that is animating and make sure that the
// removal is delayed until that player has completed
let containsPotentialParentTransition = false;
if (engine.totalAnimations) {
const currentPlayers = engine.players.length ? engine.playersByQueriedElement.get(element) : [];
// when this `if statement` does not continue forward it means that
// a previous animation query has selected the current element and
// is animating it. In this situation want to continue forwards and
// allow the element to be queued up for animation later.
if (currentPlayers && currentPlayers.length) {
containsPotentialParentTransition = true;
}
else {
let parent = element;
while (parent = parent.parentNode) {
const triggers = engine.statesByElement.get(parent);
if (triggers) {
containsPotentialParentTransition = true;
break;
}
}
}
}
// at this stage we know that the element will either get removed
// during flush or will be picked up by a parent query. Either way
// we need to fire the listeners for this element when it DOES get
// removed (once the query parent animation is done or after flush)
this.prepareLeaveAnimationListeners(element);
// whether or not a parent has an animation we need to delay the deferral of the leave
// operation until we have more information (which we do after flush() has been called)
if (containsPotentialParentTransition) {
engine.markElementAsRemoved(this.id, element, false, context);
}
else {
const removalFlag = element[REMOVAL_FLAG];
if (!removalFlag || removalFlag === NULL_REMOVAL_STATE) {
// we do this after the flush has occurred such
// that the callbacks can be fired
engine.afterFlush(() => this.clearElementCache(element));
engine.destroyInnerAnimations(element);
engine._onRemovalComplete(element, context);
}
}
}
insertNode(element, parent) {
addClass(element, this._hostClassName);
}
drainQueuedTransitions(microtaskId) {
const instructions = [];
this._queue.forEach(entry => {
const player = entry.player;
if (player.destroyed)
return;
const element = entry.element;
const listeners = this._elementListeners.get(element);
if (listeners) {
listeners.forEach((listener) => {
if (listener.name == entry.triggerName) {
const baseEvent = makeAnimationEvent(element, entry.triggerName, entry.fromState.value, entry.toState.value);
baseEvent['_data'] = microtaskId;
listenOnPlayer(entry.player, listener.phase, baseEvent, listener.callback);
}
});
}
if (player.markedForDestroy) {
this._engine.afterFlush(() => {
// now we can destroy the element properly since the event listeners have
// been bound to the player
player.destroy();
});
}
else {
instructions.push(entry);
}
});
this._queue = [];
return instructions.sort((a, b) => {
// if depCount == 0 them move to front
// otherwise if a contains b then move back
const d0 = a.transition.ast.depCount;
const d1 = b.transition.ast.depCount;
if (d0 == 0 || d1 == 0) {
return d0 - d1;
}
return this._engine.driver.containsElement(a.element, b.element) ? 1 : -1;
});
}
destroy(context) {
this.players.forEach(p => p.destroy());
this._signalRemovalForInnerTriggers(this.hostElement, context);
}
elementContainsData(element) {
let containsData = false;
if (this._elementListeners.has(element))
containsData = true;
containsData =
(this._queue.find(entry => entry.element === element) ? true : false) || containsData;
return containsData;
}
}
export class TransitionAnimationEngine {
constructor(bodyNode, driver, _normalizer) {
this.bodyNode = bodyNode;
this.driver = driver;
this._normalizer = _normalizer;
this.players = [];
this.newHostElements = new Map();
this.playersByElement = new Map();
this.playersByQueriedElement = new Map();
this.statesByElement = new Map();
this.disabledNodes = new Set();
this.totalAnimations = 0;
this.totalQueuedPlayers = 0;
this._namespaceLookup = {};
this._namespaceList = [];
this._flushFns = [];
this._whenQuietFns = [];
this.namespacesByHostElement = new Map();
this.collectedEnterElements = [];
this.collectedLeaveElements = [];
// this method is designed to be overridden by the code that uses this engine
this.onRemovalComplete = (element, context) => { };
}
/** @internal */
_onRemovalComplete(element, context) {
this.onRemovalComplete(element, context);
}
get queuedPlayers() {
const players = [];
this._namespaceList.forEach(ns => {
ns.players.forEach(player => {
if (player.queued) {
players.push(player);
}
});
});
return players;
}
createNamespace(namespaceId, hostElement) {
const ns = new AnimationTransitionNamespace(namespaceId, hostElement, this);
if (hostElement.parentNode) {
this._balanceNamespaceList(ns, hostElement);
}
else {
// defer this later until flush during when the host element has
// been inserted so that we know exactly where to place it in
// the namespace list
this.newHostElements.set(hostElement, ns);
// given that this host element is apart of the animation code, it
// may or may not be inserted by a parent node that is an of an
// animation renderer type. If this happens then we can still have
// access to this item when we query for :enter nodes. If the parent
// is a renderer then the set data-structure will normalize the entry
this.collectEnterElement(hostElement);
}
return this._namespaceLookup[namespaceId] = ns;
}
_balanceNamespaceList(ns, hostElement) {
const limit = this._namespaceList.length - 1;
if (limit >= 0) {
let found = false;
for (let i = limit; i >= 0; i--) {
const nextNamespace = this._namespaceList[i];
if (this.driver.containsElement(nextNamespace.hostElement, hostElement)) {
this._namespaceList.splice(i + 1, 0, ns);
found = true;
break;
}
}
if (!found) {
this._namespaceList.splice(0, 0, ns);
}
}
else {
this._namespaceList.push(ns);
}
this.namespacesByHostElement.set(hostElement, ns);
return ns;
}
register(namespaceId, hostElement) {
let ns = this._namespaceLookup[namespaceId];
if (!ns) {
ns = this.createNamespace(namespaceId, hostElement);
}
return ns;
}
registerTrigger(namespaceId, name, trigger) {
let ns = this._namespaceLookup[namespaceId];
if (ns && ns.register(name, trigger)) {
this.totalAnimations++;
}
}
destroy(namespaceId, context) {
if (!namespaceId)
return;
const ns = this._fetchNamespace(namespaceId);
this.afterFlush(() => {
this.namespacesByHostElement.delete(ns.hostElement);
delete this._namespaceLookup[namespaceId];
const index = this._namespaceList.indexOf(ns);
if (index >= 0) {
this._namespaceList.splice(index, 1);
}
});
this.afterFlushAnimationsDone(() => ns.destroy(context));
}
_fetchNamespace(id) {
return this._namespaceLookup[id];
}
fetchNamespacesByElement(element) {
// normally there should only be one namespace per element, however
// if @triggers are placed on both the component element and then
// its host element (within the component code) then there will be
// two namespaces returned. We use a set here to simply the dedupe
// of namespaces incase there are multiple triggers both the elm and host
const namespaces = new Set();
const elementStates = this.statesByElement.get(element);
if (elementStates) {
const keys = Object.keys(elementStates);
for (let i = 0; i < keys.length; i++) {
const nsId = elementStates[keys[i]].namespaceId;
if (nsId) {
const ns = this._fetchNamespace(nsId);
if (ns) {
namespaces.add(ns);
}
}
}
}
return namespaces;
}
trigger(namespaceId, element, name, value) {
if (isElementNode(element)) {
const ns = this._fetchNamespace(namespaceId);
if (ns) {
ns.trigger(element, name, value);
return true;
}
}
return false;
}
insertNode(namespaceId, element, parent, insertBefore) {
if (!isElementNode(element))
return;
// special case for when an element is removed and reinserted (move operation)
// when this occurs we do not want to use the element for deletion later
const details = element[REMOVAL_FLAG];
if (details && details.setForRemoval) {
details.setForRemoval = false;
details.setForMove = true;
const index = this.collectedLeaveElements.indexOf(element);
if (index >= 0) {
this.collectedLeaveElements.splice(index, 1);
}
}
// in the event that the namespaceId is blank then the caller
// code does not contain any animation code in it, but it is
// just being called so that the node is marked as being inserted
if (namespaceId) {
const ns = this._fetchNamespace(namespaceId);
// This if-statement is a workaround for router issue #21947.
// The router sometimes hits a race condition where while a route
// is being instantiated a new navigation arrives, triggering leave
// animation of DOM that has not been fully initialized, until this
// is resolved, we need to handle the scenario when DOM is not in a
// consistent state during the animation.
if (ns) {
ns.insertNode(element, parent);
}
}
// only *directives and host elements are inserted before
if (insertBefore) {
this.collectEnterElement(element);
}
}
collectEnterElement(element) {
this.collectedEnterElements.push(element);
}
markElementAsDisabled(element, value) {
if (value) {
if (!this.disabledNodes.has(element)) {
this.disabledNodes.add(element);
addClass(element, DISABLED_CLASSNAME);
}
}
else if (this.disabledNodes.has(element)) {
this.disabledNodes.delete(element);
removeClass(element, DISABLED_CLASSNAME);
}
}
removeNode(namespaceId, element, isHostElement, context) {
if (isElementNode(element)) {
const ns = namespaceId ? this._fetchNamespace(namespaceId) : null;
if (ns) {
ns.removeNode(element, context);
}
else {
this.markElementAsRemoved(namespaceId, element, false, context);
}
if (isHostElement) {
const hostNS = this.namespacesByHostElement.get(element);
if (hostNS && hostNS.id !== namespaceId) {
hostNS.removeNode(element, context);
}
}
}
else {
this._onRemovalComplete(element, context);
}
}
markElementAsRemoved(namespaceId, element, hasAnimation, context) {
this.collectedLeaveElements.push(element);
element[REMOVAL_FLAG] =
{ namespaceId, setForRemoval: context, hasAnimation, removedBeforeQueried: false };
}
listen(namespaceId, element, name, phase, callback) {
if (isElementNode(element)) {
return this._fetchNamespace(namespaceId).listen(element, name, phase, callback);
}
return () => { };
}
_buildInstruction(entry, subTimelines, enterClassName, leaveClassName, skipBuildAst) {
return entry.transition.build(this.driver, entry.element, entry.fromState.value, entry.toState.value, enterClassName, leaveClassName, entry.fromState.options, entry.toState.options, subTimelines, skipBuildAst);
}
destroyInnerAnimations(containerElement) {
let elements = this.driver.query(containerElement, NG_TRIGGER_SELECTOR, true);
elements.forEach(element => this.destroyActiveAnimationsForElement(element));
if (this.playersByQueriedElement.size == 0)
return;
elements = this.driver.query(containerElement, NG_ANIMATING_SELECTOR, true);
elements.forEach(element => this.finishActiveQueriedAnimationOnElement(element));
}
destroyActiveAnimationsForElement(element) {
const players = this.playersByElement.get(element);
if (players) {
players.forEach(player => {
// special case for when an element is set for destruction, but hasn't started.
// in this situation we want to delay the destruction until the flush occurs
// so that any event listeners attached to the player are triggered.
if (player.queued) {
player.markedForDestroy = true;
}
else {
player.destroy();
}
});
}
}
finishActiveQueriedAnimationOnElement(element) {
const players = this.playersByQueriedElement.get(element);
if (players) {
players.forEach(player => player.finish());
}
}
whenRenderingDone() {
return new Promise(resolve => {
if (this.players.length) {
return optimizeGroupPlayer(this.players).onDone(() => resolve());
}
else {
resolve();
}
});
}
processLeaveNode(element) {
const details = element[REMOVAL_FLAG];
if (details && details.setForRemoval) {
// this will prevent it from removing it twice
element[REMOVAL_FLAG] = NULL_REMOVAL_STATE;
if (details.namespaceId) {
this.destroyInnerAnimations(element);
const ns = this._fetchNamespace(details.namespaceId);
if (ns) {
ns.clearElementCache(element);
}
}
this._onRemovalComplete(element, details.setForRemoval);
}
if (this.driver.matchesElement(element, DISABLED_SELECTOR)) {
this.markElementAsDisabled(element, false);
}
this.driver.query(element, DISABLED_SELECTOR, true).forEach(node => {
this.markElementAsDisabled(node, false);
});
}
flush(microtaskId = -1) {
let players = [];
if (this.newHostElements.size) {
this.newHostElements.forEach((ns, element) => this._balanceNamespaceList(ns, element));
this.newHostElements.clear();
}
if (this.totalAnimations && this.collectedEnterElements.length) {
for (let i = 0; i < this.collectedEnterElements.length; i++) {
const elm = this.collectedEnterElements[i];
addClass(elm, STAR_CLASSNAME);
}
}
if (this._namespaceList.length &&
(this.totalQueuedPlayers || this.collectedLeaveElements.length)) {
const cleanupFns = [];
try {
players = this._flushAnimations(cleanupFns, microtaskId);
}
finally {
for (let i = 0; i < cleanupFns.length; i++) {
cleanupFns[i]();
}
}
}
else {
for (let i = 0; i < this.collectedLeaveElements.length; i++) {
const element = this.collectedLeaveElements[i];
this.processLeaveNode(element);
}
}
this.totalQueuedPlayers = 0;
this.collectedEnterElements.length = 0;
this.collectedLeaveElements.length = 0;
this._flushFns.forEach(fn => fn());
this._flushFns = [];
if (this._whenQuietFns.length) {
// we move these over to a variable so that
// if any new callbacks are registered in another
// flush they do not populate the existing set
const quietFns = this._whenQuietFns;
this._whenQuietFns = [];
if (players.length) {
optimizeGroupPlayer(players).onDone(() => {
quietFns.forEach(fn => fn());
});
}
else {
quietFns.forEach(fn => fn());
}
}
}
reportError(errors) {
throw new Error(`Unable to process animations due to the following failed trigger transitions\n ${errors.join('\n')}`);
}
_flushAnimations(cleanupFns, microtaskId) {
const subTimelines = new ElementInstructionMap();
const skippedPlayers = [];
const skippedPlayersMap = new Map();
const queuedInstructions = [];
const queriedElements = new Map();
const allPreStyleElements = new Map();
const allPostStyleElements = new Map();
const disabledElementsSet = new Set();
this.disabledNodes.forEach(node => {
disabledElementsSet.add(node);
const nodesThatAreDisabled = this.driver.query(node, QUEUED_SELECTOR, true);
for (let i = 0; i < nodesThatAreDisabled.length; i++) {
disabledElementsSet.add(nodesThatAreDisabled[i]);
}
});
const bodyNode = this.bodyNode;
const allTriggerElements = Array.from(this.statesByElement.keys());
const enterNodeMap = buildRootMap(allTriggerElements, this.collectedEnterElements);
// this must occur before the instructions are built below such that
// the :enter queries match the elements (since the timeline queries
// are fired during instruction building).
const enterNodeMapIds = new Map();
let i = 0;
enterNodeMap.forEach((nodes, root) => {
const className = ENTER_CLASSNAME + i++;
enterNodeMapIds.set(root, className);
nodes.forEach(node => addClass(node, className));
});
const allLeaveNodes = [];
const mergedLeaveNodes = new Set();
const leaveNodesWithoutAnimations = new Set();
for (let i = 0; i < this.collectedLeaveElements.length; i++) {
const element = this.collectedLeaveElements[i];
const details = element[REMOVAL_FLAG];
if (details && details.setForRemoval) {
allLeaveNodes.push(element);
mergedLeaveNodes.add(element);
if (details.hasAnimation) {
this.driver.query(element, STAR_SELECTOR, true).forEach(elm => mergedLeaveNodes.add(elm));
}
else {
leaveNodesWithoutAnimations.add(element);
}
}
}
const leaveNodeMapIds = new Map();
const leaveNodeMap = buildRootMap(allTriggerElements, Array.from(mergedLeaveNodes));
leaveNodeMap.forEach((nodes, root) => {
const className = LEAVE_CLASSNAME + i++;
leaveNodeMapIds.set(root, className);
nodes.forEach(node => addClass(node, className));
});
cleanupFns.push(() => {
enterNodeMap.forEach((nodes, root) => {
const className = enterNodeMapIds.get(root);
nodes.forEach(node => removeClass(node, className));
});
leaveNodeMap.forEach((nodes, root) => {
const className = leaveNodeMapIds.get(root);
nodes.forEach(node => removeClass(node, className));
});
allLeaveNodes.forEach(element => {
this.processLeaveNode(element);
});
});
const allPlayers = [];
const erroneousTransitions = [];
for (let i = this._namespaceList.length - 1; i >= 0; i--) {
const ns = this._namespaceList[i];
ns.drainQueuedTransitions(microtaskId).forEach(entry => {
const player = entry.player;
const element = entry.element;
allPlayers.push(player);
if (this.collectedEnterElements.length) {
const details = element[REMOVAL_FLAG];
// move animations are currently not supported...
if (details && details.setForMove) {
player.destroy();
return;
}
}
const nodeIsOrphaned = !bodyNode || !this.driver.containsElement(bodyNode, element);
const leaveClassName = leaveNodeMapIds.get(element);
const enterClassName = enterNodeMapIds.get(element);
const instruction = this._buildInstruction(entry, subTimelines, enterClassName, leaveClassName, nodeIsOrphaned);
if (instruction.errors && instruction.errors.length) {
erroneousTransitions.push(instruction);
return;
}
// even though the element may not be apart of the DOM, it may
// still be added at a later point (due to the mechanics of content
// projection and/or dynamic component insertion) therefore it's
// important we still style the element.
if (nodeIsOrphaned) {
player.onStart(() => eraseStyles(element, instruction.fromStyles));
player.onDestroy(() => setStyles(element, instruction.toStyles));
skippedPlayers.push(player);
return;
}
// if a unmatched transition is queued to go then it SHOULD NOT render
// an animation and cancel the previously running animations.
if (entry.isFallbackTransition) {
player.onStart(() => eraseStyles(element, instruction.fromStyles));
player.onDestroy(() => setStyles(element, instruction.toStyles));
skippedPlayers.push(player);
return;
}
// this means that if a parent animation uses this animation as a sub trigger
// then it will instruct the timeline builder to not add a player delay, but
// instead stretch the first keyframe gap up until the animation starts. The
// reason this is important is to prevent extra initialization styles from being
// required by the user in the animation.
instruction.timelines.forEach(tl => tl.stretchStartingKeyframe = true);
subTimelines.append(element, instruction.timelines);
const tuple = { instruction, player, element };
queuedInstructions.push(tuple);
instruction.queriedElements.forEach(element => getOrSetAsInMap(queriedElements, element, []).push(player));
instruction.preStyleProps.forEach((stringMap, element) => {
const props = Object.keys(stringMap);
if (props.length) {
let setVal = allPreStyleElements.get(element);
if (!setVal) {
allPreStyleElements.set(element, setVal = new Set());
}
props.forEach(prop => setVal.add(prop));
}
});
instruction.postStyleProps.forEach((stringMap, element) => {
const props = Object.keys(stringMap);
let setVal = allPostStyleElements.get(element);
if (!setVal) {
allPostStyleElements.set(element, setVal = new Set());
}
props.forEach(prop => setVal.add(prop));
});
});
}
if (erroneousTransitions.length) {
const errors = [];
erroneousTransitions.forEach(instruction => {
errors.push(`@${instruction.triggerName} has failed due to:\n`);
instruction.errors.forEach(error => errors.push(`- ${error}\n`));
});
allPlayers.forEach(player => player.destroy());
this.reportError(errors);
}
const allPreviousPlayersMap = new Map();
// this map works to tell which element in the DOM tree is contained by
// which animation. Further down below this map will get populated once
// the players are built and in doing so it can efficiently figure out
// if a sub player is skipped due to a parent player having priority.
const animationElementMap = new Map();
queuedInstructions.forEach(entry => {
const element = entry.element;
if (subTimelines.has(element)) {
animationElementMap.set(element, element);
this._beforeAnimationBuild(entry.player.namespaceId, entry.instruction, allPreviousPlayersMap);
}
});
skippedPlayers.forEach(player => {
const element = player.element;
const previousPlayers = this._getPreviousPlayers(element, false, player.namespaceId, player.triggerName, null);
previousPlayers.forEach(prevPlayer => {
getOrSetAsInMap(allPreviousPlayersMap, element, []).push(prevPlayer);
prevPlayer.destroy();
});
});
// this is a special case for nodes that will be removed (either by)
// having their own leave animations or by being queried in a container
// that will be removed once a parent animation is complete. The idea
// here is that * styles must be identical to ! styles because of
// backwards compatibility (* is also filled in by default in many places).
// Otherwise * styles will return an empty value or auto since the element
// that is being getComputedStyle'd will not be visible (since * = destination)
const replaceNodes = allLeaveNodes.filter(node => {
return replacePostStylesAsPre(node, allPreStyleElements, allPostStyleElements);
});
// POST STAGE: fill the * styles
const postStylesMap = new Map();
const allLeaveQueriedNodes = cloakAndComputeStyles(postStylesMap, this.driver, leaveNodesWithoutAnimations, allPostStyleElements, AUTO_STYLE);
allLeaveQueriedNodes.forEach(node => {
if (replacePostStylesAsPre(node, allPreStyleElements, allPostStyleElements)) {
replaceNodes.push(node);
}
});
// PRE STAGE: fill the ! styles
const preStylesMap = new Map();
enterNodeMap.forEach((nodes, root) => {
cloakAndComputeStyles(preStylesMap, this.driver, new Set(nodes), allPreStyleElements, PRE_STYLE);
});
replaceNodes.forEach(node => {
const post = postStylesMap.get(node);
const pre = preStylesMap.get(node);
postStylesMap.set(node, Object.assign(Object.assign({}, post), pre));
});
const rootPlayers = [];
const subPlayers = [];
const NO_PARENT_ANIMATION_ELEMENT_DETECTED = {};
queuedInstructions.forEach(entry => {
const { element, player, instruction } = entry;
// this means that it was never consumed by a parent animation which
// means that it is independent and therefore should be set for animation
if (subTimelines.has(element)) {
if (disabledElementsSet.has(element)) {
player.onDestroy(() => setStyles(element, instruction.toStyles));
player.disabled = true;
player.overrideTotalTime(instruction.totalTime);
skippedPlayers.push(player);
return;
}
// this will flow up the DOM and query the map to figure out
// if a parent animation has priority over it. In the situation
// that a parent is detected then it will cancel the loop. If
// nothing is detected, or it takes a few hops to find a parent,
// then it will fill in the missing nodes and signal them as having
// a detected parent (or a NO_PARENT value via a special constant).
let parentWithAnimation = NO_PARENT_ANIMATION_ELEMENT_DETECTED;
if (animationElementMap.size > 1) {
let elm = element;
const parentsToAdd = [];
while (elm = elm.parentNode) {
const detectedParent = animationElementMap.get(elm);
if (detectedParent) {
parentWithAnimation = detectedParent;
break;
}
parentsToAdd.push(elm);
}
parentsToAdd.forEach(parent => animationElementMap.set(parent, parentWithAnimation));
}
const innerPlayer = this._buildAnimation(player.namespaceId, instruction, allPreviousPlayersMap, skippedPlayersMap, preStylesMap, postStylesMap);
player.setRealPlayer(innerPlayer);
if (parentWithAnimation === NO_PARENT_ANIMATION_ELEMENT_DETECTED) {
rootPlayers.push(player);
}
else {
const parentPlayers = this.playersByElement.get(parentWithAnimation);
if (parentPlayers && parentPlayers.length) {
player.parentPlayer = optimizeGroupPlayer(parentPlayers);
}
skippedPlayers.push(player);
}
}
else {
eraseStyles(element, instruction.fromStyles);
player.onDestroy(() => setStyles(element, instruction.toStyles));
// there still might be a ancestor player animating this
// element therefore we will still add it as a sub player
// even if its animation may be disabled
subPlayers.push(player);
if (disabledElementsSet.has(element)) {
skippedPlayers.push(player);
}
}
});
// find all of the sub players' corresponding inner animation player
subPlayers.forEach(player => {
// even if any players are not found for a sub animation then it
// will still complete itself after the next tick since it's Noop
const playersForElement = skippedPlayersMap.get(player.element);
if (playersForElement && playersForElement.length) {
const innerPlayer = optimizeGroupPlayer(playersForElement);
player.setRealPlayer(innerPlayer);
}
});
// the reason why we don't actually play the animation is
// because all that a skipped player is designed to do is to
// fire the start/done transition callback events
skippedPlayers.forEach(player => {
if (player.parentPlayer) {
player.syncPlayerEvents(player.parentPlayer);
}
else {
player.destroy();
}
});
// run through all of the queued removals and see if they
// were picked up by a query. If not then perform the removal
// operation right away unless a parent animation is ongoing.
for (let i = 0; i < allLeaveNodes.length; i++) {
const element = allLeaveNodes[i];
const details = element[REMOVAL_FLAG];
removeClass(element, LEAVE_CLASSNAME);
// this means the element has a removal animation that is being
// taken care of and therefore the inner elements will hang around
// until that animation is over (or the parent queried animation)
if (details && details.hasAnimation)
continue;
let players = [];
// if this element is queried or if it contains queried children
// then we want for the element not to be removed from the page
// until the queried animations have finished
if (queriedElements.size) {
let queriedPlayerResults = queriedElements.get(element);
if (queriedPlayerResults && queriedPlayerResults.length) {
players.push(...queriedPlayerResults);
}
let queriedInnerElements = this.driver.query(element, NG_ANIMATING_SELECTOR, true);
for (let j = 0; j < queriedInnerElements.length; j++) {
let queriedPlayers = queriedElements.get(queriedInnerElements[j]);
if (queriedPlayers && queriedPlayers.length) {
players.push(...queriedPlayers);
}
}
}
const activePlayers = players.filter(p => !p.destroyed);
if (activePlayers.length) {
removeNodesAfterAnimationDone(this, element, activePlayers);
}
else {
this.processLeaveNode(element);
}
}
// this is required so the cleanup method doesn't remove them
allLeaveNodes.length = 0;
rootPlayers.forEach(player => {
this.players.push(player);
player.onDone(() => {
player.destroy();
const index = this.players.indexOf(player);
this.players.splice(index, 1);
});
player.play();
});
return rootPlayers;
}
elementContainsData(namespaceId, element) {
let containsData = false;
const details = element[REMOVAL_FLAG];
if (details && details.setForRemoval)
containsData = true;
if (this.playersByElement.has(element))
containsData = true;
if (this.playersByQueriedElement.has(element))
containsData = true;
if (this.statesByElement.has(element))
containsData = true;
return this._fetchNamespace(namespaceId).elementContainsData(element) || containsData;
}
afterFlush(callback) {
this._flushFns.push(callb