UNPKG

aurelia-animator-css

Version:

An implementation of the abstract Animator interface from templating which enables css-based animations.

575 lines (473 loc) 19.5 kB
import { animationEvent } from 'aurelia-templating'; import { DOM } from 'aurelia-pal'; interface CssAnimation { className: string; element: Element; } /** * An implementation of the Animator using CSS3-Animations. */ export class CssAnimator { /** * Creates an instance of CssAnimator. */ constructor() { this.useAnimationDoneClasses = false; this.animationEnteredClass = 'au-entered'; this.animationLeftClass = 'au-left'; this.isAnimating = false; // toggle this on to save performance at the cost of animations referring // to missing keyframes breaking detection of termination this.verifyKeyframesExist = true; } /** * Add multiple listeners at once to the given element * * @param el the element to attach listeners to * @param s collection of events to bind listeners to * @param fn callback that gets executed */ _addMultipleEventListener(el: Element, s: string, fn: Function): void { let evts = s.split(' '); for (let i = 0, ii = evts.length; i < ii; ++i) { el.addEventListener(evts[i], fn, false); } } /** * Remove multiple listeners at once from the given element * * @param el the element * @param s collection of events to remove * @param fn callback to remove */ _removeMultipleEventListener(el: Element, s: string, fn: Function): void { let evts = s.split(' '); for (let i = 0, ii = evts.length; i < ii; ++i) { el.removeEventListener(evts[i], fn, false); } } /** * Vendor-prefix save method to get the animation-delay * * @param element the element to inspect * @returns animation-delay in seconds */ _getElementAnimationDelay(element: Element): number { let styl = DOM.getComputedStyle(element); let prop; let delay; if (styl.getPropertyValue('animation-delay')) { prop = 'animation-delay'; } else if (styl.getPropertyValue('-webkit-animation-delay')) { prop = '-webkit-animation-delay'; } else if (styl.getPropertyValue('-moz-animation-delay')) { prop = '-moz-animation-delay'; } else { return 0; } delay = styl.getPropertyValue(prop); delay = Number(delay.replace(/[^\d\.]/g, '')); return (delay * 1000); } /** * Vendor-prefix safe method to get the animation names * * @param element the element to inspect * @returns array of animation names */ _getElementAnimationNames(element: Element): Array<String> { let styl = DOM.getComputedStyle(element); let prefix; if (styl.getPropertyValue('animation-name')) { prefix = ''; } else if (styl.getPropertyValue('-webkit-animation-name')) { prefix = '-webkit-'; } else if (styl.getPropertyValue('-moz-animation-name')) { prefix = '-moz-'; } else { return []; } let animationNames = styl.getPropertyValue(prefix + 'animation-name'); return animationNames ? animationNames.split(' ') : []; } /** * Run an animation for the given element with the specified className * * @param element the element to be animated * @param className the class to be added and removed * @returns {Promise<Boolean>} */ _performSingleAnimate(element: Element, className: string): Promise<boolean> { this._triggerDOMEvent(animationEvent.animateBegin, element); return this.addClass(element, className, true) .then((result) => { this._triggerDOMEvent(animationEvent.animateActive, element); if (result !== false) { return this.removeClass(element, className, true) .then(() => { this._triggerDOMEvent(animationEvent.animateDone, element); }); } return false; }) .catch(() => { this._triggerDOMEvent(animationEvent.animateTimeout, element); }); } /** * Triggers a DOM-Event with the given type as name and adds the provided element as detail * @param eventType the event type * @param element the element to be dispatched as event detail */ _triggerDOMEvent(eventType: string, element: Element): void { let evt = DOM.createCustomEvent(eventType, { bubbles: true, cancelable: true, detail: element }); DOM.dispatchEvent(evt); } /** * Returns true if there is a new animation with valid keyframes * @param animationNames the current animation style. * @param prevAnimationNames the previous animation style * @private */ _animationChangeWithValidKeyframe(animationNames: Array<string>, prevAnimationNames: Array<string>): bool { let newAnimationNames = animationNames.filter(name => prevAnimationNames.indexOf(name) === -1); if (newAnimationNames.length === 0) { return false; } if (!this.verifyKeyframesExist) { return true; } const keyframesRuleType = window.CSSRule.KEYFRAMES_RULE || window.CSSRule.MOZ_KEYFRAMES_RULE || window.CSSRule.WEBKIT_KEYFRAMES_RULE; // loop through the stylesheets searching for the keyframes. no cache is // used in case of dynamic changes to the stylesheets. let styleSheets = document.styleSheets; try { for (let i = 0; i < styleSheets.length; ++i) { let cssRules = null; try { cssRules = styleSheets[i].cssRules; } catch (e) { // do nothing } if (!cssRules) { continue; } for (let j = 0; j < cssRules.length; ++j) { let cssRule = cssRules[j]; if (cssRule.type === keyframesRuleType) { if (newAnimationNames.indexOf(cssRule.name) !== -1) { return true; } } } } } catch (e) { //do nothing } return false; } /* Public API Begin */ /** * Execute a single animation. * @param element Element to animate * @param className Properties to animate or name of the effect to use. For css animators this represents the className to be added and removed right after the animation is done. * @param options options for the animation (duration, easing, ...) * @returns Resolved when the animation is done */ animate(element: Element | Array<Element>, className: string): Promise<boolean> { if (Array.isArray(element)) { return Promise.all(element.map((el) => { return this._performSingleAnimate(el, className); })); } return this._performSingleAnimate(element, className); } /** * Run a sequence of animations one after the other. * @param sequence An array of effectNames or classNames * @returns Resolved when all animations are done */ runSequence(animations: Array<CssAnimation>): Promise<boolean> { this._triggerDOMEvent(animationEvent.sequenceBegin, null); return animations.reduce((p, anim) => { return p.then(() => { return this.animate(anim.element, anim.className); }); }, Promise.resolve(true)).then(() => { this._triggerDOMEvent(animationEvent.sequenceDone, null); }); } /** * Animates element on enter or leave * @param element element to animate * @param direction 'enter' or 'leave' * @param doneClass class to apply when done * @private */ _stateAnim(element: Element, direction: string, doneClass: string) { const auClass = 'au-' + direction; const auClassActive = auClass + '-active'; return new Promise((resolve, reject) => { const classList = element.classList; this._triggerDOMEvent(animationEvent[direction + 'Begin'], element); // Step 1.2: remove done classes if (this.useAnimationDoneClasses) { classList.remove(this.animationEnteredClass); classList.remove(this.animationLeftClass); } // Step 2: Add animation preparation class classList.add(auClass); const prevAnimationNames = this._getElementAnimationNames(element); // Step 3: setup event to check whether animations started let animStart; let animHasStarted = false; this._addMultipleEventListener(element, 'webkitAnimationStart animationstart', animStart = (evAnimStart) => { if (evAnimStart.target !== element) { return; } animHasStarted = true; this.isAnimating = true; this._triggerDOMEvent(animationEvent[direction + 'Active'], element); // Stop event propagation, bubbling will otherwise prevent parent animation evAnimStart.stopPropagation(); evAnimStart.target.removeEventListener(evAnimStart.type, animStart); }, false); // Step 3.1: Wait for animation to finish let animEnd; this._addMultipleEventListener(element, 'webkitAnimationEnd animationend', animEnd = (evAnimEnd) => { if (!animHasStarted) { return; } if (evAnimEnd.target !== element) { return; } // Step 3.1.0: Stop event propagation, bubbling will otherwise prevent parent animation evAnimEnd.stopPropagation(); // Step 3.1.1: remove animation classes classList.remove(auClassActive); classList.remove(auClass); // Step 3.1.2 remove animationend listener evAnimEnd.target.removeEventListener(evAnimEnd.type, animEnd); // Step 3.1.3 in case animation done animations are active, add the defined done class to the element if (this.useAnimationDoneClasses && doneClass !== undefined && doneClass !== null) { classList.add(doneClass); } this.isAnimating = false; this._triggerDOMEvent(animationEvent[direction + 'Done'], element); resolve(true); }, false); // Step 4: check if parent element is defined to stagger animations otherwise trigger active immediately const parent = element.parentElement; const attrib = 'data-animator-pending' + direction; const cleanupAnimation = () => { // Step 5: if no animations scheduled cleanup animation classes const animationNames = this._getElementAnimationNames(element); if (!this._animationChangeWithValidKeyframe(animationNames, prevAnimationNames)) { classList.remove(auClassActive); classList.remove(auClass); this._removeMultipleEventListener(element, 'webkitAnimationEnd animationend', animEnd); this._removeMultipleEventListener(element, 'webkitAnimationStart animationstart', animStart); this._triggerDOMEvent(animationEvent[direction + 'Timeout'], element); resolve(false); } parent && parent.setAttribute(attrib, +(parent.getAttribute(attrib) || 1) - 1); }; if (parent !== null && parent !== undefined && (parent.classList.contains('au-stagger') || parent.classList.contains('au-stagger-' + direction))) { const offset = +(parent.getAttribute(attrib) || 0); parent.setAttribute(attrib, offset + 1); const delay = this._getElementAnimationDelay(parent) * offset; this._triggerDOMEvent(animationEvent.staggerNext, element); setTimeout(() => { classList.add(auClassActive); cleanupAnimation(); }, delay); } else { classList.add(auClassActive); cleanupAnimation(); } }); } /** * Execute an 'enter' animation on an element * @param element Element to animate * @returns Resolved when the animation is done */ enter(element: Element): Promise<boolean> { return this._stateAnim(element, 'enter', this.animationEnteredClass); } /** * Execute a 'leave' animation on an element * @param element Element to animate * @returns Resolved when the animation is done */ leave(element: Element): Promise<boolean> { return this._stateAnim(element, 'leave', this.animationLeftClass); } /** * Add a class to an element to trigger an animation. * @param element Element to animate * @param className Properties to animate or name of the effect to use * @param suppressEvents Indicates whether or not to suppress animation events. * @returns Resolved when the animation is done */ removeClass(element: Element, className: string, suppressEvents: boolean = false): Promise<boolean> { return new Promise((resolve, reject) => { let classList = element.classList; // if neither the class exists on the element, nor is not currently being added, resolve immediately. if (!classList.contains(className) && !classList.contains(className + '-add')) { resolve(false); return; } if (suppressEvents !== true) { this._triggerDOMEvent(animationEvent.removeClassBegin, element); } // Step 1: If the 'addClass' animation is in progress, finish it prematurely. if (classList.contains(className + '-add')) { classList.remove(className + '-add'); classList.add(className); } // Step 2: Remove final className, so animation can start classList.remove(className); let prevAnimationNames = this._getElementAnimationNames(element); // Step 3: setup event to check whether animations started let animStart; let animHasStarted = false; this._addMultipleEventListener(element, 'webkitAnimationStart animationstart', animStart = (evAnimStart) => { if (evAnimStart.target !== element) { return; } animHasStarted = true; this.isAnimating = true; if (suppressEvents !== true) { this._triggerDOMEvent(animationEvent.removeClassActive, element); } // Stop event propagation, bubbling will otherwise prevent parent animation evAnimStart.stopPropagation(); evAnimStart.target.removeEventListener(evAnimStart.type, animStart); }, false); // Step 3.1: Wait for animation to finish let animEnd; this._addMultipleEventListener(element, 'webkitAnimationEnd animationend', animEnd = (evAnimEnd) => { if (!animHasStarted) { return; } if (evAnimEnd.target !== element) { return; } // Step 3.1.0: Do nothing if a new addClass animation has started and ended the removeClass animation prematurely if (!element.classList.contains(className + '-remove')) { resolve(true); } // Step 3.1.1: Stop event propagation, bubbling will otherwise prevent parent animation evAnimEnd.stopPropagation(); // Step 3.1.2: Remove the class classList.remove(className); // Step 3.1.3: Remove -remove suffixed class classList.remove(className + '-remove'); // Step 3.1.4: remove animationend listener evAnimEnd.target.removeEventListener(evAnimEnd.type, animEnd); this.isAnimating = false; if (suppressEvents !== true) { this._triggerDOMEvent(animationEvent.removeClassDone, element); } resolve(true); }, false); // Step 4: Add given className + -remove suffix to kick off animation classList.add(className + '-remove'); // Step 5: if no animations happened cleanup animation classes and remove final class let animationNames = this._getElementAnimationNames(element); if (!this._animationChangeWithValidKeyframe(animationNames, prevAnimationNames)) { classList.remove(className + '-remove'); classList.remove(className); this._removeMultipleEventListener(element, 'webkitAnimationEnd animationend', animEnd); this._removeMultipleEventListener(element, 'webkitAnimationStart animationstart', animStart); if (suppressEvents !== true) { this._triggerDOMEvent(animationEvent.removeClassTimeout, element); } resolve(false); } }); } /** * Add a class to an element to trigger an animation. * @param element Element to animate * @param className Properties to animate or name of the effect to use * @param suppressEvents Indicates whether or not to suppress animation events. * @returns Resolved when the animation is done */ addClass(element: Element, className: string, suppressEvents: boolean = false): Promise<boolean> { return new Promise((resolve, reject) => { let classList = element.classList; if (suppressEvents !== true) { this._triggerDOMEvent(animationEvent.addClassBegin, element); } // Step 1: If the 'removeClass' animation is in progress, finish it prematurely. if (classList.contains(className + '-remove')) { classList.remove(className + '-remove'); classList.remove(className); } // Step 2: setup event to check whether animations started let animStart; let animHasStarted = false; this._addMultipleEventListener(element, 'webkitAnimationStart animationstart', animStart = (evAnimStart) => { if (evAnimStart.target !== element) { return; } animHasStarted = true; this.isAnimating = true; if (suppressEvents !== true) { this._triggerDOMEvent(animationEvent.addClassActive, element); } // Stop event propagation, bubbling will otherwise prevent parent animation evAnimStart.stopPropagation(); evAnimStart.target.removeEventListener(evAnimStart.type, animStart); }, false); // Step 2.1: Wait for animation to finish let animEnd; this._addMultipleEventListener(element, 'webkitAnimationEnd animationend', animEnd = (evAnimEnd) => { if (!animHasStarted) { return; } if (evAnimEnd.target !== element) { return; } // Step 2.1.0: Do nothing if a new removeClass animation has started and ended the addClass animation prematurely if (!element.classList.contains(className + '-add')) { resolve(true); } // Step 2.1.1: Stop event propagation, bubbling will otherwise prevent parent animation evAnimEnd.stopPropagation(); // Step 2.1.2: Add final className classList.add(className); // Step 2.1.3: Remove -add suffixed class classList.remove(className + '-add'); // Step 2.1.4: remove animationend listener evAnimEnd.target.removeEventListener(evAnimEnd.type, animEnd); this.isAnimating = false; if (suppressEvents !== true) { this._triggerDOMEvent(animationEvent.addClassDone, element); } resolve(true); }, false); let prevAnimationNames = this._getElementAnimationNames(element); // Step 3: Add given className + -add suffix to kick off animation classList.add(className + '-add'); // Step 4: if no animations happened cleanup animation classes and add final class let animationNames = this._getElementAnimationNames(element); if (!this._animationChangeWithValidKeyframe(animationNames, prevAnimationNames)) { classList.remove(className + '-add'); classList.add(className); this._removeMultipleEventListener(element, 'webkitAnimationEnd animationend', animEnd); this._removeMultipleEventListener(element, 'webkitAnimationStart animationstart', animStart); if (suppressEvents !== true) { this._triggerDOMEvent(animationEvent.addClassTimeout, element); } resolve(false); } }); } /* Public API End */ }