1584 lines
56 KiB
TypeScript
1584 lines
56 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google Inc. 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, AnimationOptions, AnimationPlayer, NoopAnimationPlayer, ɵAnimationGroupPlayer as AnimationGroupPlayer, ɵPRE_STYLE as PRE_STYLE, ɵStyleData} from '@angular/animations';
|
|
|
|
import {AnimationTimelineInstruction} from '../dsl/animation_timeline_instruction';
|
|
import {AnimationTransitionFactory} from '../dsl/animation_transition_factory';
|
|
import {AnimationTransitionInstruction} from '../dsl/animation_transition_instruction';
|
|
import {AnimationTrigger} from '../dsl/animation_trigger';
|
|
import {ElementInstructionMap} from '../dsl/element_instruction_map';
|
|
import {AnimationStyleNormalizer} from '../dsl/style_normalization/animation_style_normalizer';
|
|
import {ENTER_CLASSNAME, LEAVE_CLASSNAME, NG_ANIMATING_CLASSNAME, NG_ANIMATING_SELECTOR, NG_TRIGGER_CLASSNAME, NG_TRIGGER_SELECTOR, copyObj, eraseStyles, setStyles} from '../util';
|
|
|
|
import {AnimationDriver} from './animation_driver';
|
|
import {getBodyNode, 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 EMPTY_PLAYER_ARRAY: TransitionAnimationPlayer[] = [];
|
|
const NULL_REMOVAL_STATE: ElementAnimationState = {
|
|
namespaceId: '',
|
|
setForRemoval: null,
|
|
hasAnimation: false,
|
|
removedBeforeQueried: false
|
|
};
|
|
const NULL_REMOVED_QUERIED_STATE: ElementAnimationState = {
|
|
namespaceId: '',
|
|
setForRemoval: null,
|
|
hasAnimation: false,
|
|
removedBeforeQueried: true
|
|
};
|
|
|
|
interface TriggerListener {
|
|
name: string;
|
|
phase: string;
|
|
callback: (event: any) => any;
|
|
}
|
|
|
|
export interface QueueInstruction {
|
|
element: any;
|
|
triggerName: string;
|
|
fromState: StateValue;
|
|
toState: StateValue;
|
|
transition: AnimationTransitionFactory;
|
|
player: TransitionAnimationPlayer;
|
|
isFallbackTransition: boolean;
|
|
}
|
|
|
|
export const REMOVAL_FLAG = '__ng_removed';
|
|
|
|
export interface ElementAnimationState {
|
|
setForRemoval: any;
|
|
hasAnimation: boolean;
|
|
namespaceId: string;
|
|
removedBeforeQueried: boolean;
|
|
}
|
|
|
|
export class StateValue {
|
|
public value: string;
|
|
public options: AnimationOptions;
|
|
|
|
get params(): {[key: string]: any} { return this.options.params as{[key: string]: any}; }
|
|
|
|
constructor(input: any) {
|
|
const isObj = input && input.hasOwnProperty('value');
|
|
const value = isObj ? input['value'] : input;
|
|
this.value = normalizeTriggerValue(value);
|
|
if (isObj) {
|
|
const options = copyObj(input as any);
|
|
delete options['value'];
|
|
this.options = options as AnimationOptions;
|
|
} else {
|
|
this.options = {};
|
|
}
|
|
if (!this.options.params) {
|
|
this.options.params = {};
|
|
}
|
|
}
|
|
|
|
absorbOptions(options: AnimationOptions) {
|
|
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 const DELETED_STATE_VALUE = new StateValue('DELETED');
|
|
|
|
export class AnimationTransitionNamespace {
|
|
public players: TransitionAnimationPlayer[] = [];
|
|
|
|
private _triggers: {[triggerName: string]: AnimationTrigger} = {};
|
|
private _queue: QueueInstruction[] = [];
|
|
|
|
private _elementListeners = new Map<any, TriggerListener[]>();
|
|
|
|
private _hostClassName: string;
|
|
|
|
constructor(
|
|
public id: string, public hostElement: any, private _engine: TransitionAnimationEngine) {
|
|
this._hostClassName = 'ng-tns-' + id;
|
|
addClass(hostElement, this._hostClassName);
|
|
}
|
|
|
|
listen(element: any, name: string, phase: string, callback: (event: any) => boolean): () => any {
|
|
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] = null;
|
|
}
|
|
|
|
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: string, ast: AnimationTrigger): boolean {
|
|
if (this._triggers[name]) {
|
|
// throw
|
|
return false;
|
|
} else {
|
|
this._triggers[name] = ast;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private _getTrigger(name: string) {
|
|
const trigger = this._triggers[name];
|
|
if (!trigger) {
|
|
throw new Error(`The provided animation trigger "${name}" has not been registered!`);
|
|
}
|
|
return trigger;
|
|
}
|
|
|
|
trigger(element: any, triggerName: string, value: any, defaultToFallback: boolean = true):
|
|
TransitionAnimationPlayer|undefined {
|
|
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);
|
|
|
|
const isObj = value && value.hasOwnProperty('value');
|
|
if (!isObj && fromState) {
|
|
toState.absorbOptions(fromState.options);
|
|
}
|
|
|
|
triggersWithStates[triggerName] = toState;
|
|
|
|
if (!fromState) {
|
|
fromState = DEFAULT_STATE_VALUE;
|
|
} else if (fromState === DELETED_STATE_VALUE) {
|
|
return player;
|
|
}
|
|
|
|
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: any[] = [];
|
|
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: TransitionAnimationPlayer[] =
|
|
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);
|
|
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: string) {
|
|
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: any) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
private _destroyInnerNodes(rootElement: any, context: any, animate: boolean = false) {
|
|
this._engine.driver.query(rootElement, NG_TRIGGER_SELECTOR, true).forEach(elm => {
|
|
if (animate && containsClass(elm, this._hostClassName)) {
|
|
const innerNs = this._engine.namespacesByHostElement.get(elm);
|
|
|
|
// special case for a host element with animations on the same element
|
|
if (innerNs) {
|
|
innerNs.removeNode(elm, context, true);
|
|
}
|
|
|
|
this.removeNode(elm, context, true);
|
|
} else {
|
|
this.clearElementCache(elm);
|
|
}
|
|
});
|
|
}
|
|
|
|
removeNode(element: any, context: any, doNotRecurse?: boolean): void {
|
|
const engine = this._engine;
|
|
|
|
if (!doNotRecurse && element.childElementCount) {
|
|
this._destroyInnerNodes(element, context, true);
|
|
}
|
|
|
|
const triggerStates = engine.statesByElement.get(element);
|
|
if (triggerStates) {
|
|
const players: TransitionAnimationPlayer[] = [];
|
|
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, false);
|
|
if (player) {
|
|
players.push(player);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (players.length) {
|
|
engine.markElementAsRemoved(this.id, element, true, context);
|
|
optimizeGroupPlayer(players).onDone(() => engine.processLeaveNode(element));
|
|
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 fowards 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)
|
|
const listeners = this._elementListeners.get(element);
|
|
if (listeners) {
|
|
const visitedTriggers = new Set<string>();
|
|
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 = 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
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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 {
|
|
// 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: any, parent: any): void { addClass(element, this._hostClassName); }
|
|
|
|
drainQueuedTransitions(microtaskId: number): QueueInstruction[] {
|
|
const instructions: QueueInstruction[] = [];
|
|
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: TriggerListener) => {
|
|
if (listener.name == entry.triggerName) {
|
|
const baseEvent = makeAnimationEvent(
|
|
element, entry.triggerName, entry.fromState.value, entry.toState.value);
|
|
(baseEvent as any)['_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: any) {
|
|
this.players.forEach(p => p.destroy());
|
|
this._destroyInnerNodes(this.hostElement, context);
|
|
}
|
|
|
|
elementContainsData(element: any): boolean {
|
|
let containsData = false;
|
|
if (this._elementListeners.has(element)) containsData = true;
|
|
containsData =
|
|
(this._queue.find(entry => entry.element === element) ? true : false) || containsData;
|
|
return containsData;
|
|
}
|
|
}
|
|
|
|
export interface QueuedTransition {
|
|
element: any;
|
|
instruction: AnimationTransitionInstruction;
|
|
player: TransitionAnimationPlayer;
|
|
}
|
|
|
|
export class TransitionAnimationEngine {
|
|
public players: TransitionAnimationPlayer[] = [];
|
|
public newHostElements = new Map<any, AnimationTransitionNamespace>();
|
|
public playersByElement = new Map<any, TransitionAnimationPlayer[]>();
|
|
public playersByQueriedElement = new Map<any, TransitionAnimationPlayer[]>();
|
|
public statesByElement = new Map<any, {[triggerName: string]: StateValue}>();
|
|
public disabledNodes = new Set<any>();
|
|
|
|
public totalAnimations = 0;
|
|
public totalQueuedPlayers = 0;
|
|
|
|
private _namespaceLookup: {[id: string]: AnimationTransitionNamespace} = {};
|
|
private _namespaceList: AnimationTransitionNamespace[] = [];
|
|
private _flushFns: (() => any)[] = [];
|
|
private _whenQuietFns: (() => any)[] = [];
|
|
|
|
public namespacesByHostElement = new Map<any, AnimationTransitionNamespace>();
|
|
public collectedEnterElements: any[] = [];
|
|
public collectedLeaveElements: any[] = [];
|
|
|
|
// this method is designed to be overridden by the code that uses this engine
|
|
public onRemovalComplete = (element: any, context: any) => {};
|
|
|
|
/** @internal */
|
|
_onRemovalComplete(element: any, context: any) { this.onRemovalComplete(element, context); }
|
|
|
|
constructor(public driver: AnimationDriver, private _normalizer: AnimationStyleNormalizer) {}
|
|
|
|
get queuedPlayers(): TransitionAnimationPlayer[] {
|
|
const players: TransitionAnimationPlayer[] = [];
|
|
this._namespaceList.forEach(ns => {
|
|
ns.players.forEach(player => {
|
|
if (player.queued) {
|
|
players.push(player);
|
|
}
|
|
});
|
|
});
|
|
return players;
|
|
}
|
|
|
|
createNamespace(namespaceId: string, hostElement: any) {
|
|
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;
|
|
}
|
|
|
|
private _balanceNamespaceList(ns: AnimationTransitionNamespace, hostElement: any) {
|
|
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: string, hostElement: any) {
|
|
let ns = this._namespaceLookup[namespaceId];
|
|
if (!ns) {
|
|
ns = this.createNamespace(namespaceId, hostElement);
|
|
}
|
|
return ns;
|
|
}
|
|
|
|
registerTrigger(namespaceId: string, name: string, trigger: AnimationTrigger) {
|
|
let ns = this._namespaceLookup[namespaceId];
|
|
if (ns && ns.register(name, trigger)) {
|
|
this.totalAnimations++;
|
|
}
|
|
}
|
|
|
|
destroy(namespaceId: string, context: any) {
|
|
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));
|
|
}
|
|
|
|
private _fetchNamespace(id: string) { return this._namespaceLookup[id]; }
|
|
|
|
trigger(namespaceId: string, element: any, name: string, value: any): boolean {
|
|
if (isElementNode(element)) {
|
|
this._fetchNamespace(namespaceId).trigger(element, name, value);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
insertNode(namespaceId: string, element: any, parent: any, insertBefore: boolean): void {
|
|
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] as ElementAnimationState;
|
|
if (details && details.setForRemoval) {
|
|
details.setForRemoval = false;
|
|
}
|
|
|
|
// 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) {
|
|
this._fetchNamespace(namespaceId).insertNode(element, parent);
|
|
}
|
|
|
|
// only *directives and host elements are inserted before
|
|
if (insertBefore) {
|
|
this.collectEnterElement(element);
|
|
}
|
|
}
|
|
|
|
collectEnterElement(element: any) { this.collectedEnterElements.push(element); }
|
|
|
|
markElementAsDisabled(element: any, value: boolean) {
|
|
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: string, element: any, context: any, doNotRecurse?: boolean): void {
|
|
if (!isElementNode(element)) {
|
|
this._onRemovalComplete(element, context);
|
|
return;
|
|
}
|
|
|
|
const ns = namespaceId ? this._fetchNamespace(namespaceId) : null;
|
|
if (ns) {
|
|
ns.removeNode(element, context, doNotRecurse);
|
|
} else {
|
|
this.markElementAsRemoved(namespaceId, element, false, context);
|
|
}
|
|
}
|
|
|
|
markElementAsRemoved(namespaceId: string, element: any, hasAnimation?: boolean, context?: any) {
|
|
this.collectedLeaveElements.push(element);
|
|
element[REMOVAL_FLAG] = {
|
|
namespaceId,
|
|
setForRemoval: context, hasAnimation,
|
|
removedBeforeQueried: false
|
|
};
|
|
}
|
|
|
|
listen(
|
|
namespaceId: string, element: any, name: string, phase: string,
|
|
callback: (event: any) => boolean): () => any {
|
|
if (isElementNode(element)) {
|
|
return this._fetchNamespace(namespaceId).listen(element, name, phase, callback);
|
|
}
|
|
return () => {};
|
|
}
|
|
|
|
private _buildInstruction(entry: QueueInstruction, subTimelines: ElementInstructionMap) {
|
|
return entry.transition.build(
|
|
this.driver, entry.element, entry.fromState.value, entry.toState.value,
|
|
entry.fromState.options, entry.toState.options, subTimelines);
|
|
}
|
|
|
|
destroyInnerAnimations(containerElement: any) {
|
|
let elements = this.driver.query(containerElement, NG_TRIGGER_SELECTOR, true);
|
|
elements.forEach(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();
|
|
}
|
|
});
|
|
}
|
|
const stateMap = this.statesByElement.get(element);
|
|
if (stateMap) {
|
|
Object.keys(stateMap).forEach(triggerName => stateMap[triggerName] = DELETED_STATE_VALUE);
|
|
}
|
|
});
|
|
|
|
if (this.playersByQueriedElement.size == 0) return;
|
|
|
|
elements = this.driver.query(containerElement, NG_ANIMATING_SELECTOR, true);
|
|
if (elements.length) {
|
|
elements.forEach(element => {
|
|
const players = this.playersByQueriedElement.get(element);
|
|
if (players) {
|
|
players.forEach(player => player.finish());
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
whenRenderingDone(): Promise<any> {
|
|
return new Promise(resolve => {
|
|
if (this.players.length) {
|
|
return optimizeGroupPlayer(this.players).onDone(() => resolve());
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
}
|
|
|
|
processLeaveNode(element: any) {
|
|
const details = element[REMOVAL_FLAG] as ElementAnimationState;
|
|
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(element, false);
|
|
});
|
|
}
|
|
|
|
flush(microtaskId: number = -1) {
|
|
let players: AnimationPlayer[] = [];
|
|
if (this.newHostElements.size) {
|
|
this.newHostElements.forEach((ns, element) => this._balanceNamespaceList(ns, element));
|
|
this.newHostElements.clear();
|
|
}
|
|
|
|
if (this._namespaceList.length &&
|
|
(this.totalQueuedPlayers || this.collectedLeaveElements.length)) {
|
|
const cleanupFns: Function[] = [];
|
|
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: string[]) {
|
|
throw new Error(
|
|
`Unable to process animations due to the following failed trigger transitions\n ${errors.join("\n")}`);
|
|
}
|
|
|
|
private _flushAnimations(cleanupFns: Function[], microtaskId: number):
|
|
TransitionAnimationPlayer[] {
|
|
const subTimelines = new ElementInstructionMap();
|
|
const skippedPlayers: TransitionAnimationPlayer[] = [];
|
|
const skippedPlayersMap = new Map<any, AnimationPlayer[]>();
|
|
const queuedInstructions: QueuedTransition[] = [];
|
|
const queriedElements = new Map<any, TransitionAnimationPlayer[]>();
|
|
const allPreStyleElements = new Map<any, Set<string>>();
|
|
const allPostStyleElements = new Map<any, Set<string>>();
|
|
|
|
const disabledElementsSet = new Set<any>();
|
|
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 = getBodyNode();
|
|
const allEnterNodes: any[] = this.collectedEnterElements.length ?
|
|
this.collectedEnterElements.filter(createIsRootFilterFn(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).
|
|
for (let i = 0; i < allEnterNodes.length; i++) {
|
|
addClass(allEnterNodes[i], ENTER_CLASSNAME);
|
|
}
|
|
|
|
const allLeaveNodes: any[] = [];
|
|
const leaveNodesWithoutAnimations = new Set<any>();
|
|
for (let i = 0; i < this.collectedLeaveElements.length; i++) {
|
|
const element = this.collectedLeaveElements[i];
|
|
const details = element[REMOVAL_FLAG] as ElementAnimationState;
|
|
if (details && details.setForRemoval) {
|
|
addClass(element, LEAVE_CLASSNAME);
|
|
allLeaveNodes.push(element);
|
|
if (!details.hasAnimation) {
|
|
leaveNodesWithoutAnimations.add(element);
|
|
}
|
|
}
|
|
}
|
|
|
|
cleanupFns.push(() => {
|
|
allEnterNodes.forEach(element => removeClass(element, ENTER_CLASSNAME));
|
|
allLeaveNodes.forEach(element => {
|
|
removeClass(element, LEAVE_CLASSNAME);
|
|
this.processLeaveNode(element);
|
|
});
|
|
});
|
|
|
|
const allPlayers: TransitionAnimationPlayer[] = [];
|
|
const erroneousTransitions: AnimationTransitionInstruction[] = [];
|
|
for (let i = this._namespaceList.length - 1; i >= 0; i--) {
|
|
const ns = this._namespaceList[i];
|
|
ns.drainQueuedTransitions(microtaskId).forEach(entry => {
|
|
const player = entry.player;
|
|
allPlayers.push(player);
|
|
|
|
const element = entry.element;
|
|
if (!bodyNode || !this.driver.containsElement(bodyNode, element)) {
|
|
player.destroy();
|
|
return;
|
|
}
|
|
|
|
const instruction = this._buildInstruction(entry, subTimelines) !;
|
|
if (instruction.errors && instruction.errors.length) {
|
|
erroneousTransitions.push(instruction);
|
|
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: Set<string> = allPreStyleElements.get(element) !;
|
|
if (!setVal) {
|
|
allPreStyleElements.set(element, setVal = new Set<string>());
|
|
}
|
|
props.forEach(prop => setVal.add(prop));
|
|
}
|
|
});
|
|
|
|
instruction.postStyleProps.forEach((stringMap, element) => {
|
|
const props = Object.keys(stringMap);
|
|
let setVal: Set<string> = allPostStyleElements.get(element) !;
|
|
if (!setVal) {
|
|
allPostStyleElements.set(element, setVal = new Set<string>());
|
|
}
|
|
props.forEach(prop => setVal.add(prop));
|
|
});
|
|
});
|
|
}
|
|
|
|
if (erroneousTransitions.length) {
|
|
const errors: string[] = [];
|
|
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);
|
|
}
|
|
|
|
// these can only be detected here since we have a map of all the elements
|
|
// that have animations attached to them... We use a set here in the event
|
|
// multiple enter captures on the same element were caught in different
|
|
// renderer namespaces (e.g. when a @trigger was on a host binding that had *ngIf)
|
|
const enterNodesWithoutAnimations = new Set<any>();
|
|
for (let i = 0; i < allEnterNodes.length; i++) {
|
|
const element = allEnterNodes[i];
|
|
if (!subTimelines.has(element)) {
|
|
enterNodesWithoutAnimations.add(element);
|
|
}
|
|
}
|
|
|
|
const allPreviousPlayersMap = new Map<any, TransitionAnimationPlayer[]>();
|
|
let sortedParentElements: any[] = [];
|
|
queuedInstructions.forEach(entry => {
|
|
const element = entry.element;
|
|
if (subTimelines.has(element)) {
|
|
sortedParentElements.unshift(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, allLeaveQueriedNodes] = cloakAndComputeStyles(
|
|
this.driver, leaveNodesWithoutAnimations, allPostStyleElements, AUTO_STYLE);
|
|
|
|
allLeaveQueriedNodes.forEach(node => {
|
|
if (replacePostStylesAsPre(node, allPreStyleElements, allPostStyleElements)) {
|
|
replaceNodes.push(node);
|
|
}
|
|
});
|
|
|
|
// PRE STAGE: fill the ! styles
|
|
const [preStylesMap] = allPreStyleElements.size ?
|
|
cloakAndComputeStyles(
|
|
this.driver, enterNodesWithoutAnimations, allPreStyleElements, PRE_STYLE) :
|
|
[new Map<any, ɵStyleData>()];
|
|
|
|
replaceNodes.forEach(node => {
|
|
const post = postStylesMap.get(node);
|
|
const pre = preStylesMap.get(node);
|
|
postStylesMap.set(node, { ...post, ...pre } as any);
|
|
});
|
|
|
|
const rootPlayers: TransitionAnimationPlayer[] = [];
|
|
const subPlayers: TransitionAnimationPlayer[] = [];
|
|
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)) {
|
|
skippedPlayers.push(player);
|
|
return;
|
|
}
|
|
|
|
const innerPlayer = this._buildAnimation(
|
|
player.namespaceId, instruction, allPreviousPlayersMap, skippedPlayersMap, preStylesMap,
|
|
postStylesMap);
|
|
player.setRealPlayer(innerPlayer);
|
|
|
|
let parentHasPriority: any = null;
|
|
for (let i = 0; i < sortedParentElements.length; i++) {
|
|
const parent = sortedParentElements[i];
|
|
if (parent === element) break;
|
|
if (this.driver.containsElement(parent, element)) {
|
|
parentHasPriority = parent;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (parentHasPriority) {
|
|
const parentPlayers = this.playersByElement.get(parentHasPriority);
|
|
if (parentPlayers && parentPlayers.length) {
|
|
player.parentPlayer = optimizeGroupPlayer(parentPlayers);
|
|
}
|
|
skippedPlayers.push(player);
|
|
} else {
|
|
rootPlayers.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.parentPlayer.onDestroy(() => player.destroy());
|
|
} 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] as ElementAnimationState;
|
|
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: TransitionAnimationPlayer[] = [];
|
|
|
|
// 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: string, element: any) {
|
|
let containsData = false;
|
|
const details = element[REMOVAL_FLAG] as ElementAnimationState;
|
|
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: () => any) { this._flushFns.push(callback); }
|
|
|
|
afterFlushAnimationsDone(callback: () => any) { this._whenQuietFns.push(callback); }
|
|
|
|
private _getPreviousPlayers(
|
|
element: string, isQueriedElement: boolean, namespaceId?: string, triggerName?: string,
|
|
toStateValue?: any): TransitionAnimationPlayer[] {
|
|
let players: TransitionAnimationPlayer[] = [];
|
|
if (isQueriedElement) {
|
|
const queriedElementPlayers = this.playersByQueriedElement.get(element);
|
|
if (queriedElementPlayers) {
|
|
players = queriedElementPlayers;
|
|
}
|
|
} else {
|
|
const elementPlayers = this.playersByElement.get(element);
|
|
if (elementPlayers) {
|
|
const isRemovalAnimation = !toStateValue || toStateValue == VOID_VALUE;
|
|
elementPlayers.forEach(player => {
|
|
if (player.queued) return;
|
|
if (!isRemovalAnimation && player.triggerName != triggerName) return;
|
|
players.push(player);
|
|
});
|
|
}
|
|
}
|
|
if (namespaceId || triggerName) {
|
|
players = players.filter(player => {
|
|
if (namespaceId && namespaceId != player.namespaceId) return false;
|
|
if (triggerName && triggerName != player.triggerName) return false;
|
|
return true;
|
|
});
|
|
}
|
|
return players;
|
|
}
|
|
|
|
private _beforeAnimationBuild(
|
|
namespaceId: string, instruction: AnimationTransitionInstruction,
|
|
allPreviousPlayersMap: Map<any, TransitionAnimationPlayer[]>) {
|
|
const triggerName = instruction.triggerName;
|
|
const rootElement = instruction.element;
|
|
|
|
// when a removal animation occurs, ALL previous players are collected
|
|
// and destroyed (even if they are outside of the current namespace)
|
|
const targetNameSpaceId: string|undefined =
|
|
instruction.isRemovalTransition ? undefined : namespaceId;
|
|
const targetTriggerName: string|undefined =
|
|
instruction.isRemovalTransition ? undefined : triggerName;
|
|
|
|
instruction.timelines.map(timelineInstruction => {
|
|
const element = timelineInstruction.element;
|
|
const isQueriedElement = element !== rootElement;
|
|
const players = getOrSetAsInMap(allPreviousPlayersMap, element, []);
|
|
const previousPlayers = this._getPreviousPlayers(
|
|
element, isQueriedElement, targetNameSpaceId, targetTriggerName, instruction.toState);
|
|
previousPlayers.forEach(player => {
|
|
const realPlayer = player.getRealPlayer() as any;
|
|
if (realPlayer.beforeDestroy) {
|
|
realPlayer.beforeDestroy();
|
|
}
|
|
player.destroy();
|
|
players.push(player);
|
|
});
|
|
});
|
|
|
|
// this needs to be done so that the PRE/POST styles can be
|
|
// computed properly without interfering with the previous animation
|
|
eraseStyles(rootElement, instruction.fromStyles);
|
|
}
|
|
|
|
private _buildAnimation(
|
|
namespaceId: string, instruction: AnimationTransitionInstruction,
|
|
allPreviousPlayersMap: Map<any, TransitionAnimationPlayer[]>,
|
|
skippedPlayersMap: Map<any, AnimationPlayer[]>, preStylesMap: Map<any, ɵStyleData>,
|
|
postStylesMap: Map<any, ɵStyleData>): AnimationPlayer {
|
|
const triggerName = instruction.triggerName;
|
|
const rootElement = instruction.element;
|
|
|
|
// we first run this so that the previous animation player
|
|
// data can be passed into the successive animation players
|
|
const allQueriedPlayers: TransitionAnimationPlayer[] = [];
|
|
const allConsumedElements = new Set<any>();
|
|
const allSubElements = new Set<any>();
|
|
const allNewPlayers = instruction.timelines.map(timelineInstruction => {
|
|
const element = timelineInstruction.element;
|
|
allConsumedElements.add(element);
|
|
|
|
// FIXME (matsko): make sure to-be-removed animations are removed properly
|
|
const details = element[REMOVAL_FLAG];
|
|
if (details && details.removedBeforeQueried) return new NoopAnimationPlayer();
|
|
|
|
const isQueriedElement = element !== rootElement;
|
|
const previousPlayers =
|
|
flattenGroupPlayers((allPreviousPlayersMap.get(element) || EMPTY_PLAYER_ARRAY)
|
|
.map(p => p.getRealPlayer()))
|
|
.filter(p => {
|
|
// the `element` is not apart of the AnimationPlayer definition, but
|
|
// Mock/WebAnimations
|
|
// use the element within their implementation. This will be added in Angular5 to
|
|
// AnimationPlayer
|
|
const pp = p as any;
|
|
return pp.element ? pp.element === element : false;
|
|
});
|
|
|
|
const preStyles = preStylesMap.get(element);
|
|
const postStyles = postStylesMap.get(element);
|
|
const keyframes = normalizeKeyframes(
|
|
this.driver, this._normalizer, element, timelineInstruction.keyframes, preStyles,
|
|
postStyles);
|
|
const player = this._buildPlayer(timelineInstruction, keyframes, previousPlayers);
|
|
|
|
// this means that this particular player belongs to a sub trigger. It is
|
|
// important that we match this player up with the corresponding (@trigger.listener)
|
|
if (timelineInstruction.subTimeline && skippedPlayersMap) {
|
|
allSubElements.add(element);
|
|
}
|
|
|
|
if (isQueriedElement) {
|
|
const wrappedPlayer = new TransitionAnimationPlayer(namespaceId, triggerName, element);
|
|
wrappedPlayer.setRealPlayer(player);
|
|
allQueriedPlayers.push(wrappedPlayer);
|
|
}
|
|
|
|
return player;
|
|
});
|
|
|
|
allQueriedPlayers.forEach(player => {
|
|
getOrSetAsInMap(this.playersByQueriedElement, player.element, []).push(player);
|
|
player.onDone(() => deleteOrUnsetInMap(this.playersByQueriedElement, player.element, player));
|
|
});
|
|
|
|
allConsumedElements.forEach(element => addClass(element, NG_ANIMATING_CLASSNAME));
|
|
const player = optimizeGroupPlayer(allNewPlayers);
|
|
player.onDestroy(() => {
|
|
allConsumedElements.forEach(element => removeClass(element, NG_ANIMATING_CLASSNAME));
|
|
setStyles(rootElement, instruction.toStyles);
|
|
});
|
|
|
|
// this basically makes all of the callbacks for sub element animations
|
|
// be dependent on the upper players for when they finish
|
|
allSubElements.forEach(
|
|
element => { getOrSetAsInMap(skippedPlayersMap, element, []).push(player); });
|
|
|
|
return player;
|
|
}
|
|
|
|
private _buildPlayer(
|
|
instruction: AnimationTimelineInstruction, keyframes: ɵStyleData[],
|
|
previousPlayers: AnimationPlayer[]): AnimationPlayer {
|
|
if (keyframes.length > 0) {
|
|
return this.driver.animate(
|
|
instruction.element, keyframes, instruction.duration, instruction.delay,
|
|
instruction.easing, previousPlayers);
|
|
}
|
|
|
|
// special case for when an empty transition|definition is provided
|
|
// ... there is no point in rendering an empty animation
|
|
return new NoopAnimationPlayer();
|
|
}
|
|
}
|
|
|
|
export class TransitionAnimationPlayer implements AnimationPlayer {
|
|
private _player: AnimationPlayer = new NoopAnimationPlayer();
|
|
private _containsRealPlayer = false;
|
|
|
|
private _queuedCallbacks: {[name: string]: (() => any)[]} = {};
|
|
private _destroyed = false;
|
|
public parentPlayer: AnimationPlayer;
|
|
|
|
public markedForDestroy: boolean = false;
|
|
|
|
constructor(public namespaceId: string, public triggerName: string, public element: any) {}
|
|
|
|
get queued() { return this._containsRealPlayer == false; }
|
|
|
|
get destroyed() { return this._destroyed; }
|
|
|
|
setRealPlayer(player: AnimationPlayer) {
|
|
if (this._containsRealPlayer) return;
|
|
|
|
this._player = player;
|
|
Object.keys(this._queuedCallbacks).forEach(phase => {
|
|
this._queuedCallbacks[phase].forEach(
|
|
callback => listenOnPlayer(player, phase, undefined, callback));
|
|
});
|
|
this._queuedCallbacks = {};
|
|
this._containsRealPlayer = true;
|
|
}
|
|
|
|
getRealPlayer() { return this._player; }
|
|
|
|
private _queueEvent(name: string, callback: (event: any) => any): void {
|
|
getOrSetAsInMap(this._queuedCallbacks, name, []).push(callback);
|
|
}
|
|
|
|
onDone(fn: () => void): void {
|
|
if (this.queued) {
|
|
this._queueEvent('done', fn);
|
|
}
|
|
this._player.onDone(fn);
|
|
}
|
|
|
|
onStart(fn: () => void): void {
|
|
if (this.queued) {
|
|
this._queueEvent('start', fn);
|
|
}
|
|
this._player.onStart(fn);
|
|
}
|
|
|
|
onDestroy(fn: () => void): void {
|
|
if (this.queued) {
|
|
this._queueEvent('destroy', fn);
|
|
}
|
|
this._player.onDestroy(fn);
|
|
}
|
|
|
|
init(): void { this._player.init(); }
|
|
|
|
hasStarted(): boolean { return this.queued ? false : this._player.hasStarted(); }
|
|
|
|
play(): void { !this.queued && this._player.play(); }
|
|
|
|
pause(): void { !this.queued && this._player.pause(); }
|
|
|
|
restart(): void { !this.queued && this._player.restart(); }
|
|
|
|
finish(): void { this._player.finish(); }
|
|
|
|
destroy(): void {
|
|
this._destroyed = true;
|
|
this._player.destroy();
|
|
}
|
|
|
|
reset(): void { !this.queued && this._player.reset(); }
|
|
|
|
setPosition(p: any): void {
|
|
if (!this.queued) {
|
|
this._player.setPosition(p);
|
|
}
|
|
}
|
|
|
|
getPosition(): number { return this.queued ? 0 : this._player.getPosition(); }
|
|
|
|
get totalTime(): number { return this._player.totalTime; }
|
|
}
|
|
|
|
function deleteOrUnsetInMap(map: Map<any, any[]>| {[key: string]: any}, key: any, value: any) {
|
|
let currentValues: any[]|null|undefined;
|
|
if (map instanceof Map) {
|
|
currentValues = map.get(key);
|
|
if (currentValues) {
|
|
if (currentValues.length) {
|
|
const index = currentValues.indexOf(value);
|
|
currentValues.splice(index, 1);
|
|
}
|
|
if (currentValues.length == 0) {
|
|
map.delete(key);
|
|
}
|
|
}
|
|
} else {
|
|
currentValues = map[key];
|
|
if (currentValues) {
|
|
if (currentValues.length) {
|
|
const index = currentValues.indexOf(value);
|
|
currentValues.splice(index, 1);
|
|
}
|
|
if (currentValues.length == 0) {
|
|
delete map[key];
|
|
}
|
|
}
|
|
}
|
|
return currentValues;
|
|
}
|
|
|
|
function normalizeTriggerValue(value: any): string {
|
|
switch (typeof value) {
|
|
case 'boolean':
|
|
return value ? '1' : '0';
|
|
default:
|
|
return value != null ? value.toString() : null;
|
|
}
|
|
}
|
|
|
|
function isElementNode(node: any) {
|
|
return node && node['nodeType'] === 1;
|
|
}
|
|
|
|
function isTriggerEventValid(eventName: string): boolean {
|
|
return eventName == 'start' || eventName == 'done';
|
|
}
|
|
|
|
function cloakElement(element: any, value?: string) {
|
|
const oldValue = element.style.display;
|
|
element.style.display = value != null ? value : 'none';
|
|
return oldValue;
|
|
}
|
|
|
|
function cloakAndComputeStyles(
|
|
driver: AnimationDriver, elements: Set<any>, elementPropsMap: Map<any, Set<string>>,
|
|
defaultStyle: string): [Map<any, ɵStyleData>, any[]] {
|
|
const cloakVals: string[] = [];
|
|
elements.forEach(element => cloakVals.push(cloakElement(element)));
|
|
|
|
const valuesMap = new Map<any, ɵStyleData>();
|
|
const failedElements: any[] = [];
|
|
|
|
elementPropsMap.forEach((props: Set<string>, element: any) => {
|
|
const styles: ɵStyleData = {};
|
|
props.forEach(prop => {
|
|
const value = styles[prop] = driver.computeStyle(element, prop, defaultStyle);
|
|
|
|
// there is no easy way to detect this because a sub element could be removed
|
|
// by a parent animation element being detached.
|
|
if (!value || value.length == 0) {
|
|
element[REMOVAL_FLAG] = NULL_REMOVED_QUERIED_STATE;
|
|
failedElements.push(element);
|
|
}
|
|
});
|
|
valuesMap.set(element, styles);
|
|
});
|
|
|
|
// we use a index variable here since Set.forEach(a, i) does not return
|
|
// an index value for the closure (but instead just the value)
|
|
let i = 0;
|
|
elements.forEach(element => cloakElement(element, cloakVals[i++]));
|
|
return [valuesMap, failedElements];
|
|
}
|
|
|
|
/*
|
|
Since the Angular renderer code will return a collection of inserted
|
|
nodes in all areas of a DOM tree, it's up to this algorithm to figure
|
|
out which nodes are roots.
|
|
|
|
By placing all nodes into a set and traversing upwards to the edge,
|
|
the recursive code can figure out if a clean path from the DOM node
|
|
to the edge container is clear. If no other node is detected in the
|
|
set then it is a root element.
|
|
|
|
This algorithm also keeps track of all nodes along the path so that
|
|
if other sibling nodes are also tracked then the lookup process can
|
|
skip a lot of steps in between and avoid traversing the entire tree
|
|
multiple times to the edge.
|
|
*/
|
|
function createIsRootFilterFn(nodes: any): (node: any) => boolean {
|
|
const nodeSet = new Set(nodes);
|
|
const knownRootContainer = new Set();
|
|
let isRoot: (node: any) => boolean;
|
|
isRoot = node => {
|
|
if (!node) return true;
|
|
if (nodeSet.has(node.parentNode)) return false;
|
|
if (knownRootContainer.has(node.parentNode)) return true;
|
|
if (isRoot(node.parentNode)) {
|
|
knownRootContainer.add(node);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
return isRoot;
|
|
}
|
|
|
|
const CLASSES_CACHE_KEY = '$$classes';
|
|
function containsClass(element: any, className: string): boolean {
|
|
if (element.classList) {
|
|
return element.classList.contains(className);
|
|
} else {
|
|
const classes = element[CLASSES_CACHE_KEY];
|
|
return classes && classes[className];
|
|
}
|
|
}
|
|
|
|
function addClass(element: any, className: string) {
|
|
if (element.classList) {
|
|
element.classList.add(className);
|
|
} else {
|
|
let classes: {[className: string]: boolean} = element[CLASSES_CACHE_KEY];
|
|
if (!classes) {
|
|
classes = element[CLASSES_CACHE_KEY] = {};
|
|
}
|
|
classes[className] = true;
|
|
}
|
|
}
|
|
|
|
function removeClass(element: any, className: string) {
|
|
if (element.classList) {
|
|
element.classList.remove(className);
|
|
} else {
|
|
let classes: {[className: string]: boolean} = element[CLASSES_CACHE_KEY];
|
|
if (classes) {
|
|
delete classes[className];
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeNodesAfterAnimationDone(
|
|
engine: TransitionAnimationEngine, element: any, players: AnimationPlayer[]) {
|
|
optimizeGroupPlayer(players).onDone(() => engine.processLeaveNode(element));
|
|
}
|
|
|
|
function flattenGroupPlayers(players: AnimationPlayer[]): AnimationPlayer[] {
|
|
const finalPlayers: AnimationPlayer[] = [];
|
|
_flattenGroupPlayersRecur(players, finalPlayers);
|
|
return finalPlayers;
|
|
}
|
|
|
|
function _flattenGroupPlayersRecur(players: AnimationPlayer[], finalPlayers: AnimationPlayer[]) {
|
|
for (let i = 0; i < players.length; i++) {
|
|
const player = players[i];
|
|
if (player instanceof AnimationGroupPlayer) {
|
|
_flattenGroupPlayersRecur(player.players, finalPlayers);
|
|
} else {
|
|
finalPlayers.push(player as AnimationPlayer);
|
|
}
|
|
}
|
|
}
|
|
|
|
function objEquals(a: {[key: string]: any}, b: {[key: string]: any}): boolean {
|
|
const k1 = Object.keys(a);
|
|
const k2 = Object.keys(b);
|
|
if (k1.length != k2.length) return false;
|
|
for (let i = 0; i < k1.length; i++) {
|
|
const prop = k1[i];
|
|
if (!b.hasOwnProperty(prop) || a[prop] !== b[prop]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function replacePostStylesAsPre(
|
|
element: any, allPreStyleElements: Map<any, Set<string>>,
|
|
allPostStyleElements: Map<any, Set<string>>): boolean {
|
|
const postEntry = allPostStyleElements.get(element);
|
|
if (!postEntry) return false;
|
|
|
|
let preEntry = allPreStyleElements.get(element);
|
|
if (preEntry) {
|
|
postEntry.forEach(data => preEntry !.add(data));
|
|
} else {
|
|
allPreStyleElements.set(element, postEntry);
|
|
}
|
|
|
|
allPostStyleElements.delete(element);
|
|
return true;
|
|
}
|