@angular/animations
Version:
Angular - animations integration with web-animations
1,084 lines • 222 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 { missingEvent, missingTrigger, transitionFailed, triggerTransitionsFailed, unregisteredTrigger, unsupportedTriggerEvent } from '../error_helpers';
import { copyObj, ENTER_CLASSNAME, eraseStyles, LEAVE_CLASSNAME, NG_ANIMATING_CLASSNAME, NG_ANIMATING_SELECTOR, NG_TRIGGER_CLASSNAME, NG_TRIGGER_SELECTOR, setStyles } from '../util';
import { getOrSetDefaultValue, 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 {
get params() {
return this.options.params;
}
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 = {};
}
}
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 = new Map();
this._queue = [];
this._elementListeners = new Map();
this._hostClassName = 'ng-tns-' + id;
addClass(hostElement, this._hostClassName);
}
listen(element, name, phase, callback) {
if (!this._triggers.has(name)) {
throw missingTrigger(phase, name);
}
if (phase == null || phase.length == 0) {
throw missingEvent(name);
}
if (!isTriggerEventValid(phase)) {
throw unsupportedTriggerEvent(phase, name);
}
const listeners = getOrSetDefaultValue(this._elementListeners, element, []);
const data = { name, phase, callback };
listeners.push(data);
const triggersWithStates = getOrSetDefaultValue(this._engine.statesByElement, element, new Map());
if (!triggersWithStates.has(name)) {
addClass(element, NG_TRIGGER_CLASSNAME);
addClass(element, NG_TRIGGER_CLASSNAME + '-' + name);
triggersWithStates.set(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.has(name)) {
triggersWithStates.delete(name);
}
});
};
}
register(name, ast) {
if (this._triggers.has(name)) {
// throw
return false;
}
else {
this._triggers.set(name, ast);
return true;
}
}
_getTrigger(name) {
const trigger = this._triggers.get(name);
if (!trigger) {
throw unregisteredTrigger(name);
}
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 = new Map());
}
let fromState = triggersWithStates.get(triggerName);
const toState = new StateValue(value, this.id);
const isObj = value && value.hasOwnProperty('value');
if (!isObj && fromState) {
toState.absorbOptions(fromState.options);
}
triggersWithStates.set(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 = getOrSetDefaultValue(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 player)
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) {
this._triggers.delete(name);
this._engine.statesByElement.forEach(stateMap => stateMap.delete(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);
const previousTriggersValues = new Map();
if (triggerStates) {
const players = [];
triggerStates.forEach((state, triggerName) => {
previousTriggersValues.set(triggerName, state.value);
// 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.has(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, previousTriggersValues);
if (destroyAfterComplete) {
optimizeGroupPlayer(players).onDone(() => this._engine.processLeaveNode(element));
}
return true;
}
}
return false;
}
prepareLeaveAnimationListeners(element) {
const listeners = this._elementListeners.get(element);
const elementStates = this._engine.statesByElement.get(element);
// if this statement fails then it means that the element was picked up
// by an earlier flush (or there are no listeners at all to track the leave).
if (listeners && elementStates) {
const visitedTriggers = new Set();
listeners.forEach(listener => {
const triggerName = listener.name;
if (visitedTriggers.has(triggerName))
return;
visitedTriggers.add(triggerName);
const trigger = this._triggers.get(triggerName);
const transition = trigger.fallbackTransition;
const fromState = elementStates.get(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);
}
}
export class TransitionAnimationEngine {
/** @internal */
_onRemovalComplete(element, context) {
this.onRemovalComplete(element, context);
}
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) => { };
}
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 (this.bodyNode && this.driver.containsElement(this.bodyNode, hostElement)) {
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 a part of the animation code, it
// may or may not be inserted by a parent node that is 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 namespaceList = this._namespaceList;
const namespacesByHostElement = this.namespacesByHostElement;
const limit = namespaceList.length - 1;
if (limit >= 0) {
let found = false;
// Find the closest ancestor with an existing namespace so we can then insert `ns` after it,
// establishing a top-down ordering of namespaces in `this._namespaceList`.
let ancestor = this.driver.getParentElement(hostElement);
while (ancestor) {
const ancestorNs = namespacesByHostElement.get(ancestor);
if (ancestorNs) {
// An animation namespace has been registered for this ancestor, so we insert `ns`
// right after it to establish top-down ordering of animation namespaces.
const index = namespaceList.indexOf(ancestorNs);
namespaceList.splice(index + 1, 0, ns);
found = true;
break;
}
ancestor = this.driver.getParentElement(ancestor);
}
if (!found) {
// No namespace exists that is an ancestor of `ns`, so `ns` is inserted at the front to
// ensure that any existing descendants are ordered after `ns`, retaining the desired
// top-down ordering.
namespaceList.unshift(ns);
}
}
else {
namespaceList.push(ns);
}
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;
this.afterFlush(() => { });
this.afterFlushAnimationsDone(() => {
const ns = this._fetchNamespace(namespaceId);
this.namespacesByHostElement.delete(ns.hostElement);
const index = this._namespaceList.indexOf(ns);
if (index >= 0) {
this._namespaceList.splice(index, 1);
}
ns.destroy(context);
delete this._namespaceLookup[namespaceId];
});
}
_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 deduplicate
// the namespaces in case (for the reason described above) there are multiple triggers
const namespaces = new Set();
const elementStates = this.statesByElement.get(element);
if (elementStates) {
for (let stateValue of elementStates.values()) {
if (stateValue.namespaceId) {
const ns = this._fetchNamespace(stateValue.namespaceId);
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, context) {
if (isElementNode(element)) {
const ns = namespaceId ? this._fetchNamespace(namespaceId) : null;
if (ns) {
ns.removeNode(element, context);
}
else {
this.markElementAsRemoved(namespaceId, element, false, context);
}
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, previousTriggersValues) {
this.collectedLeaveElements.push(element);
element[REMOVAL_FLAG] = {
namespaceId,
setForRemoval: context,
hasAnimation,
removedBeforeQueried: false,
previousTriggersValues
};
}
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 (element.classList?.contains(DISABLED_CLASSNAME)) {
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 triggerTransitionsFailed(errors);
}
_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];
// animations for move operations (elements being removed and reinserted,
// e.g. when the order of an *ngFor list changes) are currently not supported
if (details && details.setForMove) {
if (details.previousTriggersValues &&
details.previousTriggersValues.has(entry.triggerName)) {
const previousValue = details.previousTriggersValues.get(entry.triggerName);
// we need to restore the previous trigger value since the element has
// only been moved and hasn't actually left the DOM
const triggersWithStates = this.statesByElement.get(entry.element);
if (triggersWithStates && triggersWithStates.has(entry.triggerName)) {
const state = triggersWithStates.get(entry.triggerName);
state.value = previousValue;
triggersWithStates.set(entry.triggerName, state);
}
}
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 in 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 to still style the element.
if (nodeIsOrphaned) {
player.onStart(() => eraseStyles(element, instruction.fromStyles));
player.onDestroy(() => setStyles(element, instruction.toStyles));
skippedPlayers.push(player);
return;
}
// if an unmatched transition is queued and ready 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 not to add a player delay, but
// instead stretch the first keyframe gap until the animation starts. This is
// important in order to prevent extra initialization styles from being
// required by the user for the animation.
const timelines = [];
instruction.timelines.forEach(tl => {
tl.stretchStartingKeyframe = true;
if (!this.disabledNodes.has(tl.element)) {
timelines.push(tl);
}
});
instruction.timelines = timelines;
subTimelines.append(element, instruction.timelines);
const tuple = { instruction, player, element };
queuedInstructions.push(tuple);
instruction.queriedElements.forEach(element => getOrSetDefaultValue(queriedElements, element, []).push(player));
instruction.preStyleProps.forEach((stringMap, element) => {
if (stringMap.size) {
let setVal = allPreStyleElements.get(element);
if (!setVal) {
allPreStyleElements.set(element, setVal = new Set());
}
stringMap.forEach((_, prop) => setVal.add(prop));
}
});
instruction.postStyleProps.forEach((stringMap, element) => {
let setVal = allPostStyleElements.get(element);
if (!setVal) {
allPostStyleElements.set(element, setVal = new Set());
}
stringMap.forEach((_, prop) => setVal.add(prop));
});
});
}
if (erroneousTransitions.length) {
const errors = [];
erroneousTransitions.forEach(instruction => {
errors.push(transitionFailed(instruction.triggerName, instruction.errors));
});
allPlayers.forEach(player => player.destroy());
this.reportError(errors);
}
const allPreviousPlayersMap = new Map();
// this map tells us which element in the DOM tree is contained by
// which animation. Further down this map will get populated once
// the players are built and in doing so we can use it to 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 => {
getOrSetDefaultValue(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
// passed to getComputedStyle 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, new Map([...(post?.entries() ?? []), ...(pre?.entries() ?? [])]));
});
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 players
subPlayers.forEach(player => {
// even if no players are found for a sub animation 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) {
playe