From 18c41af065006a804a3d38eecca7ae184103ece9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matias=20Niemel=C3=A4?= Date: Wed, 26 Feb 2014 22:37:03 -0500 Subject: [PATCH] fix($animate): delegate down to addClass/removeClass if setClass is not found Closes #6463 --- src/ngAnimate/animate.js | 349 ++++++++++++++++++---------------- test/ngAnimate/animateSpec.js | 99 ++++++++++ 2 files changed, 279 insertions(+), 169 deletions(-) diff --git a/src/ngAnimate/animate.js b/src/ngAnimate/animate.js index 5f2d440139ae..76280b3b560e 100644 --- a/src/ngAnimate/animate.js +++ b/src/ngAnimate/animate.js @@ -349,6 +349,148 @@ angular.module('ngAnimate', ['ng']) } } + function animationRunner(element, animationEvent, className) { + //transcluded directives may sometimes fire an animation using only comment nodes + //best to catch this early on to prevent any animation operations from occurring + var node = element[0]; + if(!node) { + return; + } + + var isSetClassOperation = animationEvent == 'setClass'; + var isClassBased = isSetClassOperation || + animationEvent == 'addClass' || + animationEvent == 'removeClass'; + + var classNameAdd, classNameRemove; + if(angular.isArray(className)) { + classNameAdd = className[0]; + classNameRemove = className[1]; + className = classNameAdd + ' ' + classNameRemove; + } + + var currentClassName = element.attr('class'); + var classes = currentClassName + ' ' + className; + if(!isAnimatableClassName(classes)) { + return; + } + + var beforeComplete = noop, + beforeCancel = [], + before = [], + afterComplete = noop, + afterCancel = [], + after = []; + + var animationLookup = (' ' + classes).replace(/\s+/g,'.'); + forEach(lookup(animationLookup), function(animationFactory) { + var created = registerAnimation(animationFactory, animationEvent); + if(!created && isSetClassOperation) { + registerAnimation(animationFactory, 'addClass'); + registerAnimation(animationFactory, 'removeClass'); + } + }); + + function registerAnimation(animationFactory, event) { + var afterFn = animationFactory[event]; + var beforeFn = animationFactory['before' + event.charAt(0).toUpperCase() + event.substr(1)]; + if(afterFn || beforeFn) { + if(event == 'leave') { + beforeFn = afterFn; + //when set as null then animation knows to skip this phase + afterFn = null; + } + after.push({ + event : event, fn : afterFn + }); + before.push({ + event : event, fn : beforeFn + }); + return true; + } + } + + function run(fns, cancellations, allCompleteFn) { + var animations = []; + forEach(fns, function(animation) { + animation.fn && animations.push(animation); + }); + + var count = 0; + function afterAnimationComplete(index) { + if(cancellations) { + (cancellations[index] || noop)(); + if(++count < animations.length) return; + cancellations = null; + } + allCompleteFn(); + } + + //The code below adds directly to the array in order to work with + //both sync and async animations. Sync animations are when the done() + //operation is called right away. DO NOT REFACTOR! + forEach(animations, function(animation, index) { + var progress = function() { + afterAnimationComplete(index); + }; + switch(animation.event) { + case 'setClass': + cancellations.push(animation.fn(element, classNameAdd, classNameRemove, progress)); + break; + case 'addClass': + cancellations.push(animation.fn(element, classNameAdd || className, progress)); + break; + case 'removeClass': + cancellations.push(animation.fn(element, classNameRemove || className, progress)); + break; + default: + cancellations.push(animation.fn(element, progress)); + break; + } + }); + + if(cancellations && cancellations.length === 0) { + allCompleteFn(); + } + } + + return { + node : node, + event : animationEvent, + className : className, + isClassBased : isClassBased, + isSetClassOperation : isSetClassOperation, + before : function(allCompleteFn) { + beforeComplete = allCompleteFn; + run(before, beforeCancel, function() { + beforeComplete = noop; + allCompleteFn(); + }); + }, + after : function(allCompleteFn) { + afterComplete = allCompleteFn; + run(after, afterCancel, function() { + afterComplete = noop; + allCompleteFn(); + }); + }, + cancel : function() { + if(beforeCancel) { + forEach(beforeCancel, function(cancelFn) { + (cancelFn || noop)(true); + }); + beforeComplete(true); + } + if(afterCancel) { + forEach(afterCancel, function(cancelFn) { + (cancelFn || noop)(true); + }); + afterComplete(true); + } + } + }; + } + /** * @ngdoc service * @name $animate @@ -624,22 +766,8 @@ angular.module('ngAnimate', ['ng']) */ function performAnimation(animationEvent, className, element, parentElement, afterElement, domOperation, doneCallback) { - var classNameAdd, classNameRemove, setClassOperation = animationEvent == 'setClass'; - if(setClassOperation) { - classNameAdd = className[0]; - classNameRemove = className[1]; - className = classNameAdd + ' ' + classNameRemove; - } - - var currentClassName, classes, node = element[0]; - if(node) { - currentClassName = node.className; - classes = currentClassName + ' ' + className; - } - - //transcluded directives may sometimes fire an animation using only comment nodes - //best to catch this early on to prevent any animation operations from occurring - if(!node || !isAnimatableClassName(classes)) { + var runner = animationRunner(element, animationEvent, className); + if(!runner) { fireDOMOperation(); fireBeforeCallbackAsync(); fireAfterCallbackAsync(); @@ -647,29 +775,30 @@ angular.module('ngAnimate', ['ng']) return; } - var elementEvents = angular.element._data(node); + className = runner.className; + var elementEvents = angular.element._data(runner.node); elementEvents = elementEvents && elementEvents.events; - var animationLookup = (' ' + classes).replace(/\s+/g,'.'); if (!parentElement) { parentElement = afterElement ? afterElement.parent() : element.parent(); } - var matches = lookup(animationLookup); - var isClassBased = animationEvent == 'addClass' || - animationEvent == 'removeClass' || - setClassOperation; var ngAnimateState = element.data(NG_ANIMATE_STATE) || {}; - var runningAnimations = ngAnimateState.active || {}; var totalActiveAnimations = ngAnimateState.totalActive || 0; var lastAnimation = ngAnimateState.last; + //only allow animations if the currently running animation is not structural + //or if there is no animation running at all + var skipAnimations = runner.isClassBased ? + ngAnimateState.disabled || (lastAnimation && !lastAnimation.isClassBased) : + false; + //skip the animation if animations are disabled, a parent is already being animated, //the element is not currently attached to the document body or then completely close //the animation if any matching animations are not found at all. - //NOTE: IE8 + IE9 should close properly (run closeAnimation()) in case a NO animation is not found. - if (animationsDisabled(element, parentElement) || matches.length === 0) { + //NOTE: IE8 + IE9 should close properly (run closeAnimation()) in case an animation was found. + if (skipAnimations || animationsDisabled(element, parentElement)) { fireDOMOperation(); fireBeforeCallbackAsync(); fireAfterCallbackAsync(); @@ -677,50 +806,10 @@ angular.module('ngAnimate', ['ng']) return; } - var animations = []; - - //only add animations if the currently running animation is not structural - //or if there is no animation running at all - var allowAnimations = isClassBased ? - !ngAnimateState.disabled && (!lastAnimation || lastAnimation.classBased) : - true; - - if(allowAnimations) { - forEach(matches, function(animation) { - //add the animation to the queue to if it is allowed to be cancelled - if(!animation.allowCancel || animation.allowCancel(element, animationEvent, className)) { - var beforeFn, afterFn = animation[animationEvent]; - - //Special case for a leave animation since there is no point in performing an - //animation on a element node that has already been removed from the DOM - if(animationEvent == 'leave') { - beforeFn = afterFn; - afterFn = null; //this must be falsy so that the animation is skipped for leave - } else { - beforeFn = animation['before' + animationEvent.charAt(0).toUpperCase() + animationEvent.substr(1)]; - } - animations.push({ - before : beforeFn, - after : afterFn - }); - } - }); - } - - //this would mean that an animation was not allowed so let the existing - //animation do it's thing and close this one early - if(animations.length === 0) { - fireDOMOperation(); - fireBeforeCallbackAsync(); - fireAfterCallbackAsync(); - fireDoneCallbackAsync(); - return; - } - var skipAnimation = false; if(totalActiveAnimations > 0) { var animationsToCancel = []; - if(!isClassBased) { + if(!runner.isClassBased) { if(animationEvent == 'leave' && runningAnimations['ng-leave']) { skipAnimation = true; } else { @@ -747,14 +836,13 @@ angular.module('ngAnimate', ['ng']) } if(animationsToCancel.length > 0) { - angular.forEach(animationsToCancel, function(operation) { - (operation.done || noop)(true); - cancelAnimations(operation.animations); + forEach(animationsToCancel, function(operation) { + operation.cancel(); }); } } - if(isClassBased && !setClassOperation && !skipAnimation) { + if(runner.isClassBased && !runner.isSetClassOperation && !skipAnimation) { skipAnimation = (animationEvent == 'addClass') == element.hasClass(className); //opposite of XOR } @@ -771,15 +859,11 @@ angular.module('ngAnimate', ['ng']) //is cancelled midway element.one('$destroy', function(e) { var element = angular.element(this); - var state = element.data(NG_ANIMATE_STATE) || {}; - var activeLeaveAnimation = state.active['ng-leave']; - if(activeLeaveAnimation) { - var animations = activeLeaveAnimation.animations; - - //if the before animation is completed then the element will be - //removed shortly after so there is no need to cancel the animation - if(!animations[0].beforeComplete) { - cancelAnimations(animations); + var state = element.data(NG_ANIMATE_STATE); + if(state) { + var activeLeaveAnimation = state.active['ng-leave']; + if(activeLeaveAnimation) { + activeLeaveAnimation.cancel(); cleanup(element, 'ng-leave'); } } @@ -791,18 +875,11 @@ angular.module('ngAnimate', ['ng']) element.addClass(NG_ANIMATE_CLASS_NAME); var localAnimationCount = globalAnimationCounter++; - lastAnimation = { - classBased : isClassBased, - event : animationEvent, - animations : animations, - done:onBeforeAnimationsComplete - }; - totalActiveAnimations++; - runningAnimations[className] = lastAnimation; + runningAnimations[className] = runner; element.data(NG_ANIMATE_STATE, { - last : lastAnimation, + last : runner, active : runningAnimations, index : localAnimationCount, totalActive : totalActiveAnimations @@ -810,72 +887,21 @@ angular.module('ngAnimate', ['ng']) //first we run the before animations and when all of those are complete //then we perform the DOM operation and run the next set of animations - invokeRegisteredAnimationFns(animations, 'before', onBeforeAnimationsComplete); - - function onBeforeAnimationsComplete(cancelled) { + fireBeforeCallbackAsync(); + runner.before(function(cancelled) { var data = element.data(NG_ANIMATE_STATE); cancelled = cancelled || - !data || !data.active[className] || - (isClassBased && data.active[className].event != animationEvent); + !data || !data.active[className] || + (runner.isClassBased && data.active[className].event != animationEvent); fireDOMOperation(); if(cancelled === true) { closeAnimation(); - return; + } else { + fireAfterCallbackAsync(); + runner.after(closeAnimation); } - - //set the done function to the final done function - //so that the DOM event won't be executed twice by accident - //if the after animation is cancelled as well - var currentAnimation = data.active[className]; - currentAnimation.done = closeAnimation; - invokeRegisteredAnimationFns(animations, 'after', closeAnimation); - } - - function invokeRegisteredAnimationFns(animations, phase, allAnimationFnsComplete) { - phase == 'after' ? - fireAfterCallbackAsync() : - fireBeforeCallbackAsync(); - - var endFnName = phase + 'End'; - forEach(animations, function(animation, index) { - var animationPhaseCompleted = function() { - progress(index, phase); - }; - - //there are no before functions for enter + move since the DOM - //operations happen before the performAnimation method fires - if(phase == 'before' && (animationEvent == 'enter' || animationEvent == 'move')) { - animationPhaseCompleted(); - return; - } - - if(animation[phase]) { - if(setClassOperation) { - animation[endFnName] = animation[phase](element, classNameAdd, classNameRemove, animationPhaseCompleted); - } else { - animation[endFnName] = isClassBased ? - animation[phase](element, className, animationPhaseCompleted) : - animation[phase](element, animationPhaseCompleted); - } - } else { - animationPhaseCompleted(); - } - }); - - function progress(index, phase) { - var phaseCompletionFlag = phase + 'Complete'; - var currentAnimation = animations[index]; - currentAnimation[phaseCompletionFlag] = true; - (currentAnimation[endFnName] || noop)(); - - for(var i=0;i'); + var element = parent.find('span'); + $rootElement.append(parent); + body.append($rootElement); + + expect(element.hasClass('on')).toBe(false); + expect(element.hasClass('off')).toBe(true); + + var signature = ''; + $animate.setClass(element, 'on', 'off', function() { + signature += 'Z'; + }); + + $animate.triggerCallbacks(); + + expect(signature).toBe('Z'); + expect(element.hasClass('on')).toBe(true); + expect(element.hasClass('off')).toBe(false); + })); + it('should fire DOM callbacks on the element being animated', inject(function($animate, $rootScope, $compile, $sniffer, $rootElement, $timeout) {