UNPKG

@angular/animations

Version:

Angular - animations integration with web-animations

1,084 lines 222 kB
/** * @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