UNPKG

animejs

Version:

JavaScript animation engine

1,251 lines (1,235 loc) 278 kB
/** * anime.js - ESM * @version v4.0.0 * @author Julian Garnier * @license MIT * @copyright (c) 2025 Julian Garnier * @see https://animejs.com */ // Environments // TODO: Do we need to check if we're running inside a worker ? const isBrowser = typeof window !== 'undefined'; /** @type {Object|Null} */ const win = isBrowser ? window : null; /** @type {Document} */ const doc = isBrowser ? document : null; // Enums /** @enum {Number} */ const tweenTypes = { OBJECT: 0, ATTRIBUTE: 1, CSS: 2, TRANSFORM: 3, CSS_VAR: 4, }; /** @enum {Number} */ const valueTypes = { NUMBER: 0, UNIT: 1, COLOR: 2, COMPLEX: 3, }; /** @enum {Number} */ const tickModes = { NONE: 0, AUTO: 1, FORCE: 2, }; /** @enum {Number} */ const compositionTypes = { replace: 0, none: 1, blend: 2, }; // Cache symbols const isRegisteredTargetSymbol = Symbol(); const isDomSymbol = Symbol(); const isSvgSymbol = Symbol(); const transformsSymbol = Symbol(); const morphPointsSymbol = Symbol(); const proxyTargetSymbol = Symbol(); // Numbers const minValue = 1e-11; const maxValue = 1e12; const K = 1e3; const maxFps = 120; // Strings const emptyString = ''; const shortTransforms = new Map(); shortTransforms.set('x', 'translateX'); shortTransforms.set('y', 'translateY'); shortTransforms.set('z', 'translateZ'); const validTransforms = [ 'translateX', 'translateY', 'translateZ', 'rotate', 'rotateX', 'rotateY', 'rotateZ', 'scale', 'scaleX', 'scaleY', 'scaleZ', 'skew', 'skewX', 'skewY', 'perspective', 'matrix', 'matrix3d', ]; const transformsFragmentStrings = validTransforms.reduce((a, v) => ({ ...a, [v]: v + '(' }), {}); // Functions /** @return {void} */ const noop = () => { }; // Regex const hexTestRgx = /(^#([\da-f]{3}){1,2}$)|(^#([\da-f]{4}){1,2}$)/i; const rgbExecRgx = /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i; const rgbaExecRgx = /rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(-?\d+|-?\d*.\d+)\s*\)/i; const hslExecRgx = /hsl\(\s*(-?\d+|-?\d*.\d+)\s*,\s*(-?\d+|-?\d*.\d+)%\s*,\s*(-?\d+|-?\d*.\d+)%\s*\)/i; const hslaExecRgx = /hsla\(\s*(-?\d+|-?\d*.\d+)\s*,\s*(-?\d+|-?\d*.\d+)%\s*,\s*(-?\d+|-?\d*.\d+)%\s*,\s*(-?\d+|-?\d*.\d+)\s*\)/i; // export const digitWithExponentRgx = /[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?/g; const digitWithExponentRgx = /[-+]?\d*\.?\d+(?:e[-+]?\d)?/gi; // export const unitsExecRgx = /^([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)+([a-z]+|%)$/i; const unitsExecRgx = /^([-+]?\d*\.?\d+(?:e[-+]?\d+)?)([a-z]+|%)$/i; const lowerCaseRgx = /([a-z])([A-Z])/g; const transformsExecRgx = /(\w+)(\([^)]+\)+)/g; // Match inline transforms with cacl() values, returns the value wrapped in () const relativeValuesExecRgx = /(\*=|\+=|-=)/; /** @type {DefaultsParams} */ const defaults = { id: null, keyframes: null, playbackEase: null, playbackRate: 1, frameRate: maxFps, loop: 0, reversed: false, alternate: false, autoplay: true, duration: K, delay: 0, loopDelay: 0, ease: 'out(2)', composition: compositionTypes.replace, modifier: v => v, onBegin: noop, onBeforeUpdate: noop, onUpdate: noop, onLoop: noop, onPause: noop, onComplete: noop, onRender: noop, }; const globals = { /** @type {DefaultsParams} */ defaults, /** @type {Document|DOMTarget} */ root: doc, /** @type {Scope} */ scope: null, /** @type {Number} */ precision: 4, /** @type {Number} */ timeScale: 1, /** @type {Number} */ tickThreshold: 200, }; const globalVersions = { version: '4.0.0', engine: null }; if (isBrowser) { if (!win.AnimeJS) win.AnimeJS = []; win.AnimeJS.push(globalVersions); } // Strings /** * @param {String} str * @return {String} */ const toLowerCase = str => str.replace(lowerCaseRgx, '$1-$2').toLowerCase(); /** * Prioritize this method instead of regex when possible * @param {String} str * @param {String} sub * @return {Boolean} */ const stringStartsWith = (str, sub) => str.indexOf(sub) === 0; // Time // Note: Date.now is used instead of performance.now since it is precise enough for timings calculations, performs slightly faster and works in Node.js environement. const now = Date.now; // Types checkers const isArr = Array.isArray; /**@param {any} a @return {a is Record<String, any>} */ const isObj = a => a && a.constructor === Object; /**@param {any} a @return {a is Number} */ const isNum = a => typeof a === 'number' && !isNaN(a); /**@param {any} a @return {a is String} */ const isStr = a => typeof a === 'string'; /**@param {any} a @return {a is Function} */ const isFnc = a => typeof a === 'function'; /**@param {any} a @return {a is undefined} */ const isUnd = a => typeof a === 'undefined'; /**@param {any} a @return {a is null | undefined} */ const isNil = a => isUnd(a) || a === null; /**@param {any} a @return {a is SVGElement} */ const isSvg = a => isBrowser && a instanceof SVGElement; /**@param {any} a @return {Boolean} */ const isHex = a => hexTestRgx.test(a); /**@param {any} a @return {Boolean} */ const isRgb = a => stringStartsWith(a, 'rgb'); /**@param {any} a @return {Boolean} */ const isHsl = a => stringStartsWith(a, 'hsl'); /**@param {any} a @return {Boolean} */ const isCol = a => isHex(a) || isRgb(a) || isHsl(a); /**@param {any} a @return {Boolean} */ const isKey = a => !globals.defaults.hasOwnProperty(a); // Number /** * @param {Number|String} str * @return {Number} */ const parseNumber = str => isStr(str) ? parseFloat(/** @type {String} */ (str)) : /** @type {Number} */ (str); // Math const pow = Math.pow; const sqrt = Math.sqrt; const sin = Math.sin; const cos = Math.cos; const abs = Math.abs; const exp = Math.exp; const ceil = Math.ceil; const floor = Math.floor; const asin = Math.asin; const max = Math.max; const atan2 = Math.atan2; const PI = Math.PI; const _round = Math.round; /** * @param {Number} v * @param {Number} min * @param {Number} max * @return {Number} */ const clamp = (v, min, max) => v < min ? min : v > max ? max : v; const powCache = {}; /** * @param {Number} v * @param {Number} decimalLength * @return {Number} */ const round = (v, decimalLength) => { if (decimalLength < 0) return v; if (!decimalLength) return _round(v); let p = powCache[decimalLength]; if (!p) p = powCache[decimalLength] = 10 ** decimalLength; return _round(v * p) / p; }; /** * @param {Number} v * @param {Number|Array<Number>} increment * @return {Number} */ const snap = (v, increment) => isArr(increment) ? increment.reduce((closest, cv) => (abs(cv - v) < abs(closest - v) ? cv : closest)) : increment ? _round(v / increment) * increment : v; /** * @param {Number} start * @param {Number} end * @param {Number} progress * @return {Number} */ const interpolate = (start, end, progress) => start + (end - start) * progress; /** * @param {Number} v * @return {Number} */ const clampInfinity = v => v === Infinity ? maxValue : v === -Infinity ? -1e12 : v; /** * @param {Number} v * @return {Number} */ const clampZero = v => v < minValue ? minValue : v; // Arrays /** * @template T * @param {T[]} a * @return {T[]} */ const cloneArray = a => isArr(a) ? [...a] : a; // Objects /** * @template T * @template U * @param {T} o1 * @param {U} o2 * @return {T & U} */ const mergeObjects = (o1, o2) => { const merged = /** @type {T & U} */ ({ ...o1 }); for (let p in o2) { const o1p = /** @type {T & U} */ (o1)[p]; merged[p] = isUnd(o1p) ? /** @type {T & U} */ (o2)[p] : o1p; } return merged; }; // Linked lists /** * @param {Object} parent * @param {Function} callback * @param {Boolean} [reverse] * @param {String} [prevProp] * @param {String} [nextProp] * @return {void} */ const forEachChildren = (parent, callback, reverse, prevProp = '_prev', nextProp = '_next') => { let next = parent._head; let adjustedNextProp = nextProp; if (reverse) { next = parent._tail; adjustedNextProp = prevProp; } while (next) { const currentNext = next[adjustedNextProp]; callback(next); next = currentNext; } }; /** * @param {Object} parent * @param {Object} child * @param {String} [prevProp] * @param {String} [nextProp] * @return {void} */ const removeChild = (parent, child, prevProp = '_prev', nextProp = '_next') => { const prev = child[prevProp]; const next = child[nextProp]; prev ? prev[nextProp] = next : parent._head = next; next ? next[prevProp] = prev : parent._tail = prev; child[prevProp] = null; child[nextProp] = null; }; /** * @param {Object} parent * @param {Object} child * @param {Function} [sortMethod] * @param {String} prevProp * @param {String} nextProp * @return {void} */ const addChild = (parent, child, sortMethod, prevProp = '_prev', nextProp = '_next') => { let prev = parent._tail; while (prev && sortMethod && sortMethod(prev, child)) prev = prev[prevProp]; const next = prev ? prev[nextProp] : parent._head; prev ? prev[nextProp] = child : parent._head = child; next ? next[prevProp] = child : parent._tail = child; child[prevProp] = prev; child[nextProp] = next; }; /* * Base class to control framerate and playback rate. * Inherited by Engine, Timer, Animation and Timeline. */ class Clock { /** @param {Number} [initTime] */ constructor(initTime = 0) { /** @type {Number} */ this.deltaTime = 0; /** @type {Number} */ this._currentTime = initTime; /** @type {Number} */ this._elapsedTime = initTime; /** @type {Number} */ this._startTime = initTime; /** @type {Number} */ this._lastTime = initTime; /** @type {Number} */ this._scheduledTime = 0; /** @type {Number} */ this._frameDuration = round(K / maxFps, 0); /** @type {Number} */ this._fps = maxFps; /** @type {Number} */ this._speed = 1; /** @type {Boolean} */ this._hasChildren = false; /** @type {Tickable|Tween} */ this._head = null; /** @type {Tickable|Tween} */ this._tail = null; } get fps() { return this._fps; } set fps(frameRate) { const previousFrameDuration = this._frameDuration; const fr = +frameRate; const fps = fr < minValue ? minValue : fr; const frameDuration = round(K / fps, 0); this._fps = fps; this._frameDuration = frameDuration; this._scheduledTime += frameDuration - previousFrameDuration; } get speed() { return this._speed; } set speed(playbackRate) { const pbr = +playbackRate; this._speed = pbr < minValue ? minValue : pbr; } /** * @param {Number} time * @return {tickModes} */ requestTick(time) { const scheduledTime = this._scheduledTime; const elapsedTime = this._elapsedTime; this._elapsedTime += (time - elapsedTime); // If the elapsed time is lower than the scheduled time // this means not enough time has passed to hit one frameDuration // so skip that frame if (elapsedTime < scheduledTime) return tickModes.NONE; const frameDuration = this._frameDuration; const frameDelta = elapsedTime - scheduledTime; // Ensures that _scheduledTime progresses in steps of at least 1 frameDuration. // Skips ahead if the actual elapsed time is higher. this._scheduledTime += frameDelta < frameDuration ? frameDuration : frameDelta; return tickModes.AUTO; } /** * @param {Number} time * @return {Number} */ computeDeltaTime(time) { const delta = time - this._lastTime; this.deltaTime = delta; this._lastTime = time; return delta; } } /** * @param {Tickable} tickable * @param {Number} time * @param {Number} muteCallbacks * @param {Number} internalRender * @param {tickModes} tickMode * @return {Number} */ const render = (tickable, time, muteCallbacks, internalRender, tickMode) => { const parent = tickable.parent; const duration = tickable.duration; const completed = tickable.completed; const iterationDuration = tickable.iterationDuration; const iterationCount = tickable.iterationCount; const _currentIteration = tickable._currentIteration; const _loopDelay = tickable._loopDelay; const _reversed = tickable._reversed; const _alternate = tickable._alternate; const _hasChildren = tickable._hasChildren; const tickableDelay = tickable._delay; const tickablePrevAbsoluteTime = tickable._currentTime; // TODO: rename ._currentTime to ._absoluteCurrentTime const tickableEndTime = tickableDelay + iterationDuration; const tickableAbsoluteTime = time - tickableDelay; const tickablePrevTime = clamp(tickablePrevAbsoluteTime, -tickableDelay, duration); const tickableCurrentTime = clamp(tickableAbsoluteTime, -tickableDelay, duration); const deltaTime = tickableAbsoluteTime - tickablePrevAbsoluteTime; const isCurrentTimeAboveZero = tickableCurrentTime > 0; const isCurrentTimeEqualOrAboveDuration = tickableCurrentTime >= duration; const isSetter = duration <= minValue; const forcedTick = tickMode === tickModes.FORCE; let isOdd = 0; let iterationElapsedTime = tickableAbsoluteTime; // Render checks // Used to also check if the children have rendered in order to trigger the onRender callback on the parent timer let hasRendered = 0; // Execute the "expensive" iterations calculations only when necessary if (iterationCount > 1) { // bitwise NOT operator seems to be generally faster than Math.floor() across browsers const currentIteration = ~~(tickableCurrentTime / (iterationDuration + (isCurrentTimeEqualOrAboveDuration ? 0 : _loopDelay))); tickable._currentIteration = clamp(currentIteration, 0, iterationCount); // Prevent the iteration count to go above the max iterations when reaching the end of the animation if (isCurrentTimeEqualOrAboveDuration) tickable._currentIteration--; isOdd = tickable._currentIteration % 2; iterationElapsedTime = tickableCurrentTime % (iterationDuration + _loopDelay) || 0; } // Checks if exactly one of _reversed and (_alternate && isOdd) is true const isReversed = _reversed ^ (_alternate && isOdd); const _ease = /** @type {Renderable} */ (tickable)._ease; let iterationTime = isCurrentTimeEqualOrAboveDuration ? isReversed ? 0 : duration : isReversed ? iterationDuration - iterationElapsedTime : iterationElapsedTime; if (_ease) iterationTime = iterationDuration * _ease(iterationTime / iterationDuration) || 0; const isRunningBackwards = (parent ? parent.backwards : tickableAbsoluteTime < tickablePrevAbsoluteTime) ? !isReversed : !!isReversed; tickable._currentTime = tickableAbsoluteTime; tickable._iterationTime = iterationTime; tickable.backwards = isRunningBackwards; if (isCurrentTimeAboveZero && !tickable.began) { tickable.began = true; if (!muteCallbacks && !(parent && (isRunningBackwards || !parent.began))) { tickable.onBegin(/** @type {CallbackArgument} */ (tickable)); } } else if (tickableAbsoluteTime <= 0) { tickable.began = false; } // Only triggers onLoop for tickable without children, otherwise call the the onLoop callback in the tick function // Make sure to trigger the onLoop before rendering to allow .refresh() to pickup the current values if (!muteCallbacks && !_hasChildren && isCurrentTimeAboveZero && tickable._currentIteration !== _currentIteration) { tickable.onLoop(/** @type {CallbackArgument} */ (tickable)); } if (forcedTick || tickMode === tickModes.AUTO && (time >= tickableDelay && time <= tickableEndTime || // Normal render time <= tickableDelay && tickablePrevTime > tickableDelay || // Playhead is before the animation start time so make sure the animation is at its initial state time >= tickableEndTime && tickablePrevTime !== duration // Playhead is after the animation end time so make sure the animation is at its end state ) || iterationTime >= tickableEndTime && tickablePrevTime !== duration || iterationTime <= tickableDelay && tickablePrevTime > 0 || time <= tickablePrevTime && tickablePrevTime === duration && completed || // Force a render if a seek occurs on an completed animation isCurrentTimeEqualOrAboveDuration && !completed && isSetter // This prevents 0 duration tickables to be skipped ) { if (isCurrentTimeAboveZero) { // Trigger onUpdate callback before rendering tickable.computeDeltaTime(tickablePrevTime); if (!muteCallbacks) tickable.onBeforeUpdate(/** @type {CallbackArgument} */ (tickable)); } // Start tweens rendering if (!_hasChildren) { // Time has jumped more than globals.tickThreshold so consider this tick manual const forcedRender = forcedTick || (isRunningBackwards ? deltaTime * -1 : deltaTime) >= globals.tickThreshold; const absoluteTime = tickable._offset + (parent ? parent._offset : 0) + tickableDelay + iterationTime; // Only Animation can have tweens, Timer returns undefined let tween = /** @type {Tween} */ ( /** @type {JSAnimation} */(tickable)._head); let tweenTarget; let tweenStyle; let tweenTargetTransforms; let tweenTargetTransformsProperties; let tweenTransformsNeedUpdate = 0; while (tween) { const tweenComposition = tween._composition; const tweenCurrentTime = tween._currentTime; const tweenChangeDuration = tween._changeDuration; const tweenAbsEndTime = tween._absoluteStartTime + tween._changeDuration; const tweenNextRep = tween._nextRep; const tweenPrevRep = tween._prevRep; const tweenHasComposition = tweenComposition !== compositionTypes.none; if ((forcedRender || ((tweenCurrentTime !== tweenChangeDuration || absoluteTime <= tweenAbsEndTime + (tweenNextRep ? tweenNextRep._delay : 0)) && (tweenCurrentTime !== 0 || absoluteTime >= tween._absoluteStartTime))) && (!tweenHasComposition || (!tween._isOverridden && (!tween._isOverlapped || absoluteTime <= tweenAbsEndTime) && (!tweenNextRep || (tweenNextRep._isOverridden || absoluteTime <= tweenNextRep._absoluteStartTime)) && (!tweenPrevRep || (tweenPrevRep._isOverridden || (absoluteTime >= (tweenPrevRep._absoluteStartTime + tweenPrevRep._changeDuration) + tween._delay)))))) { const tweenNewTime = tween._currentTime = clamp(iterationTime - tween._startTime, 0, tweenChangeDuration); const tweenProgress = tween._ease(tweenNewTime / tween._updateDuration); const tweenModifier = tween._modifier; const tweenValueType = tween._valueType; const tweenType = tween._tweenType; const tweenIsObject = tweenType === tweenTypes.OBJECT; const tweenIsNumber = tweenValueType === valueTypes.NUMBER; // Only round the in-between frames values if the final value is a string const tweenPrecision = (tweenIsNumber && tweenIsObject) || tweenProgress === 0 || tweenProgress === 1 ? -1 : globals.precision; // Recompose tween value /** @type {String|Number} */ let value; /** @type {Number} */ let number; if (tweenIsNumber) { value = number = /** @type {Number} */ (tweenModifier(round(interpolate(tween._fromNumber, tween._toNumber, tweenProgress), tweenPrecision))); } else if (tweenValueType === valueTypes.UNIT) { // Rounding the values speed up string composition number = /** @type {Number} */ (tweenModifier(round(interpolate(tween._fromNumber, tween._toNumber, tweenProgress), tweenPrecision))); value = `${number}${tween._unit}`; } else if (tweenValueType === valueTypes.COLOR) { const fn = tween._fromNumbers; const tn = tween._toNumbers; const r = round(clamp(/** @type {Number} */ (tweenModifier(interpolate(fn[0], tn[0], tweenProgress))), 0, 255), 0); const g = round(clamp(/** @type {Number} */ (tweenModifier(interpolate(fn[1], tn[1], tweenProgress))), 0, 255), 0); const b = round(clamp(/** @type {Number} */ (tweenModifier(interpolate(fn[2], tn[2], tweenProgress))), 0, 255), 0); const a = clamp(/** @type {Number} */ (tweenModifier(round(interpolate(fn[3], tn[3], tweenProgress), tweenPrecision))), 0, 1); value = `rgba(${r},${g},${b},${a})`; if (tweenHasComposition) { const ns = tween._numbers; ns[0] = r; ns[1] = g; ns[2] = b; ns[3] = a; } } else if (tweenValueType === valueTypes.COMPLEX) { value = tween._strings[0]; for (let j = 0, l = tween._toNumbers.length; j < l; j++) { const n = /** @type {Number} */ (tweenModifier(round(interpolate(tween._fromNumbers[j], tween._toNumbers[j], tweenProgress), tweenPrecision))); const s = tween._strings[j + 1]; value += `${s ? n + s : n}`; if (tweenHasComposition) { tween._numbers[j] = n; } } } // For additive tweens and Animatables if (tweenHasComposition) { tween._number = number; } if (!internalRender && tweenComposition !== compositionTypes.blend) { const tweenProperty = tween.property; tweenTarget = tween.target; if (tweenIsObject) { tweenTarget[tweenProperty] = value; } else if (tweenType === tweenTypes.ATTRIBUTE) { /** @type {DOMTarget} */ (tweenTarget).setAttribute(tweenProperty, /** @type {String} */ (value)); } else { tweenStyle = /** @type {DOMTarget} */ (tweenTarget).style; if (tweenType === tweenTypes.TRANSFORM) { if (tweenTarget !== tweenTargetTransforms) { tweenTargetTransforms = tweenTarget; // NOTE: Referencing the cachedTransforms in the tween property directly can be a little bit faster but appears to increase memory usage. tweenTargetTransformsProperties = tweenTarget[transformsSymbol]; } tweenTargetTransformsProperties[tweenProperty] = value; tweenTransformsNeedUpdate = 1; } else if (tweenType === tweenTypes.CSS) { tweenStyle[tweenProperty] = value; } else if (tweenType === tweenTypes.CSS_VAR) { tweenStyle.setProperty(tweenProperty, /** @type {String} */ (value)); } } if (isCurrentTimeAboveZero) hasRendered = 1; } else { // Used for composing timeline tweens without having to do a real render tween._value = value; } } // NOTE: Possible improvement: Use translate(x,y) / translate3d(x,y,z) syntax // to reduce memory usage on string composition if (tweenTransformsNeedUpdate && tween._renderTransforms) { let str = emptyString; for (let key in tweenTargetTransformsProperties) { str += `${transformsFragmentStrings[key]}${tweenTargetTransformsProperties[key]}) `; } tweenStyle.transform = str; tweenTransformsNeedUpdate = 0; } tween = tween._next; } if (!muteCallbacks && hasRendered) { /** @type {JSAnimation} */ (tickable).onRender(/** @type {JSAnimation} */ (tickable)); } } if (!muteCallbacks && isCurrentTimeAboveZero) { tickable.onUpdate(/** @type {CallbackArgument} */ (tickable)); } } // End tweens rendering // Handle setters on timeline differently and allow re-trigering the onComplete callback when seeking backwards if (parent && isSetter) { if (!muteCallbacks && ((parent.began && !isRunningBackwards && tickableAbsoluteTime >= duration && !completed) || (isRunningBackwards && tickableAbsoluteTime <= minValue && completed))) { tickable.onComplete(/** @type {CallbackArgument} */ (tickable)); tickable.completed = !isRunningBackwards; } // If currentTime is both above 0 and at least equals to duration, handles normal onComplete or infinite loops } else if (isCurrentTimeAboveZero && isCurrentTimeEqualOrAboveDuration) { if (iterationCount === Infinity) { // Offset the tickable _startTime with its duration to reset _currentTime to 0 and continue the infinite timer tickable._startTime += tickable.duration; } else if (tickable._currentIteration >= iterationCount - 1) { // By setting paused to true, we tell the engine loop to not render this tickable and removes it from the list on the next tick tickable.paused = true; if (!completed && !_hasChildren) { // If the tickable has children, triggers onComplete() only when all children have completed in the tick function tickable.completed = true; if (!muteCallbacks && !(parent && (isRunningBackwards || !parent.began))) { tickable.onComplete(/** @type {CallbackArgument} */ (tickable)); tickable._resolve(/** @type {CallbackArgument} */ (tickable)); } } } // Otherwise set the completed flag to false } else { tickable.completed = false; } // NOTE: hasRendered * direction (negative for backwards) this way we can remove the tickable.backwards property completly ? return hasRendered; }; /** * @param {Tickable} tickable * @param {Number} time * @param {Number} muteCallbacks * @param {Number} internalRender * @param {Number} tickMode * @return {void} */ const tick = (tickable, time, muteCallbacks, internalRender, tickMode) => { const _currentIteration = tickable._currentIteration; render(tickable, time, muteCallbacks, internalRender, tickMode); if (tickable._hasChildren) { const tl = /** @type {Timeline} */ (tickable); const tlIsRunningBackwards = tl.backwards; const tlChildrenTime = internalRender ? time : tl._iterationTime; const tlCildrenTickTime = now(); let tlChildrenHasRendered = 0; let tlChildrenHaveCompleted = true; // If the timeline has looped forward, we need to manually triggers children skipped callbacks if (!internalRender && tl._currentIteration !== _currentIteration) { const tlIterationDuration = tl.iterationDuration; forEachChildren(tl, (/** @type {JSAnimation} */ child) => { if (!tlIsRunningBackwards) { // Force an internal render to trigger the callbacks if the child has not completed on loop if (!child.completed && !child.backwards && child._currentTime < child.iterationDuration) { render(child, tlIterationDuration, muteCallbacks, 1, tickModes.FORCE); } // Reset their began and completed flags to allow retrigering callbacks on the next iteration child.began = false; child.completed = false; } else { const childDuration = child.duration; const childStartTime = child._offset + child._delay; const childEndTime = childStartTime + childDuration; // Triggers the onComplete callback on reverse for children on the edges of the timeline if (!muteCallbacks && childDuration <= minValue && (!childStartTime || childEndTime === tlIterationDuration)) { child.onComplete(child); } } }); if (!muteCallbacks) tl.onLoop(/** @type {CallbackArgument} */ (tl)); } forEachChildren(tl, (/** @type {JSAnimation} */ child) => { const childTime = round((tlChildrenTime - child._offset) * child._speed, 12); // Rounding is needed when using seconds const childTickMode = child._fps < tl._fps ? child.requestTick(tlCildrenTickTime) : tickMode; tlChildrenHasRendered += render(child, childTime, muteCallbacks, internalRender, childTickMode); if (!child.completed && tlChildrenHaveCompleted) tlChildrenHaveCompleted = false; }, tlIsRunningBackwards); // Renders on timeline are triggered by its children so it needs to be set after rendering the children if (!muteCallbacks && tlChildrenHasRendered) tl.onRender(/** @type {CallbackArgument} */ (tl)); // Triggers the timeline onComplete() once all chindren all completed and the current time has reached the end if (tlChildrenHaveCompleted && tl._currentTime >= tl.duration) { // Make sure the paused flag is false in case it has been skipped in the render function tl.paused = true; if (!tl.completed) { tl.completed = true; if (!muteCallbacks) { tl.onComplete(/** @type {CallbackArgument} */ (tl)); tl._resolve(/** @type {CallbackArgument} */ (tl)); } } } } }; const additive = { animation: null, update: noop, }; /** * @typedef AdditiveAnimation * @property {Number} duration * @property {Number} _offset * @property {Number} _delay * @property {Tween} _head * @property {Tween} _tail */ /** * @param {TweenAdditiveLookups} lookups * @return {AdditiveAnimation} */ const addAdditiveAnimation = lookups => { let animation = additive.animation; if (!animation) { animation = { duration: minValue, computeDeltaTime: noop, _offset: 0, _delay: 0, _head: null, _tail: null, }; additive.animation = animation; additive.update = () => { lookups.forEach(propertyAnimation => { for (let propertyName in propertyAnimation) { const tweens = propertyAnimation[propertyName]; const lookupTween = tweens._head; if (lookupTween) { const valueType = lookupTween._valueType; const additiveValues = valueType === valueTypes.COMPLEX || valueType === valueTypes.COLOR ? cloneArray(lookupTween._fromNumbers) : null; let additiveValue = lookupTween._fromNumber; let tween = tweens._tail; while (tween && tween !== lookupTween) { if (additiveValues) { for (let i = 0, l = tween._numbers.length; i < l; i++) additiveValues[i] += tween._numbers[i]; } else { additiveValue += tween._number; } tween = tween._prevAdd; } lookupTween._toNumber = additiveValue; lookupTween._toNumbers = additiveValues; } } }); // TODO: Avoid polymorphism here, idealy the additive animation should be a regular animation with a higher priority in the render loop render(animation, 1, 1, 0, tickModes.FORCE); }; } return animation; }; const engineTickMethod = isBrowser ? requestAnimationFrame : setImmediate; const engineCancelMethod = isBrowser ? cancelAnimationFrame : clearImmediate; class Engine extends Clock { /** @param {Number} [initTime] */ constructor(initTime) { super(initTime); this.useDefaultMainLoop = true; this.pauseOnDocumentHidden = true; /** @type {DefaultsParams} */ this.defaults = defaults; this.paused = isBrowser && doc.hidden ? true : false; /** @type {Number|NodeJS.Immediate} */ this.reqId = null; } update() { const time = this._currentTime = now(); if (this.requestTick(time)) { this.computeDeltaTime(time); const engineSpeed = this._speed; const engineFps = this._fps; let activeTickable = /** @type {Tickable} */ (this._head); while (activeTickable) { const nextTickable = activeTickable._next; if (!activeTickable.paused) { tick(activeTickable, (time - activeTickable._startTime) * activeTickable._speed * engineSpeed, 0, // !muteCallbacks 0, // !internalRender activeTickable._fps < engineFps ? activeTickable.requestTick(time) : tickModes.AUTO); } else { removeChild(this, activeTickable); this._hasChildren = !!this._tail; activeTickable._running = false; if (activeTickable.completed && !activeTickable._cancelled) { activeTickable.cancel(); } } activeTickable = nextTickable; } additive.update(); } } wake() { if (this.useDefaultMainLoop && !this.reqId && !this.paused) { this.reqId = engineTickMethod(tickEngine); } return this; } pause() { this.paused = true; return killEngine(); } resume() { if (!this.paused) return; this.paused = false; forEachChildren(this, (/** @type {Tickable} */ child) => child.resetTime()); return this.wake(); } // Getter and setter for speed get speed() { return this._speed * (globals.timeScale === 1 ? 1 : K); } set speed(playbackRate) { this._speed = playbackRate * globals.timeScale; forEachChildren(this, (/** @type {Tickable} */ child) => child.speed = child._speed); } // Getter and setter for timeUnit get timeUnit() { return globals.timeScale === 1 ? 'ms' : 's'; } ; set timeUnit(unit) { const secondsScale = 0.001; const isSecond = unit === 's'; const newScale = isSecond ? secondsScale : 1; if (globals.timeScale !== newScale) { globals.timeScale = newScale; globals.tickThreshold = 200 * newScale; const scaleFactor = isSecond ? secondsScale : K; /** @type {Number} */ (this.defaults.duration) *= scaleFactor; this._speed *= scaleFactor; } } // Getter and setter for precision get precision() { return globals.precision; } set precision(precision) { globals.precision = precision; } } const engine = /*#__PURE__*/ (() => { const engine = new Engine(now()); if (isBrowser) { globalVersions.engine = engine; doc.addEventListener('visibilitychange', () => { if (!engine.pauseOnDocumentHidden) return; doc.hidden ? engine.pause() : engine.resume(); }); } return engine; })(); const tickEngine = () => { if (engine._head) { engine.reqId = engineTickMethod(tickEngine); engine.update(); } else { engine.reqId = 0; } }; const killEngine = () => { engineCancelMethod(/** @type {NodeJS.Immediate & Number} */ (engine.reqId)); engine.reqId = 0; return engine; }; /** * @param {DOMTarget} target * @param {String} propName * @param {Object} animationInlineStyles * @return {String} */ const parseInlineTransforms = (target, propName, animationInlineStyles) => { const inlineTransforms = target.style.transform; let inlinedStylesPropertyValue; if (inlineTransforms) { const cachedTransforms = target[transformsSymbol]; let t; while (t = transformsExecRgx.exec(inlineTransforms)) { const inlinePropertyName = t[1]; // const inlinePropertyValue = t[2]; const inlinePropertyValue = t[2].slice(1, -1); cachedTransforms[inlinePropertyName] = inlinePropertyValue; if (inlinePropertyName === propName) { inlinedStylesPropertyValue = inlinePropertyValue; // Store the new parsed inline styles if animationInlineStyles is provided if (animationInlineStyles) { animationInlineStyles[propName] = inlinePropertyValue; } } } } return inlineTransforms && !isUnd(inlinedStylesPropertyValue) ? inlinedStylesPropertyValue : stringStartsWith(propName, 'scale') ? '1' : stringStartsWith(propName, 'rotate') || stringStartsWith(propName, 'skew') ? '0deg' : '0px'; }; /** * @param {DOMTargetsParam|TargetsParam} v * @return {NodeList|HTMLCollection} */ function getNodeList(v) { const n = isStr(v) ? globals.root.querySelectorAll(v) : v; if (n instanceof NodeList || n instanceof HTMLCollection) return n; } /** * @overload * @param {DOMTargetsParam} targets * @return {DOMTargetsArray} * * @overload * @param {JSTargetsParam} targets * @return {JSTargetsArray} * * @overload * @param {TargetsParam} targets * @return {TargetsArray} * * @param {DOMTargetsParam|JSTargetsParam|TargetsParam} targets */ function parseTargets(targets) { if (isNil(targets)) return /** @type {TargetsArray} */ ([]); if (isArr(targets)) { const flattened = targets.flat(Infinity); /** @type {TargetsArray} */ const parsed = []; for (let i = 0, l = flattened.length; i < l; i++) { const item = flattened[i]; if (!isNil(item)) { const nodeList = getNodeList(item); if (nodeList) { for (let j = 0, jl = nodeList.length; j < jl; j++) { const subItem = nodeList[j]; if (!isNil(subItem)) { let isDuplicate = false; for (let k = 0, kl = parsed.length; k < kl; k++) { if (parsed[k] === subItem) { isDuplicate = true; break; } } if (!isDuplicate) { parsed.push(subItem); } } } } else { let isDuplicate = false; for (let j = 0, jl = parsed.length; j < jl; j++) { if (parsed[j] === item) { isDuplicate = true; break; } } if (!isDuplicate) { parsed.push(item); } } } } return parsed; } if (!isBrowser) return /** @type {JSTargetsArray} */ ([targets]); const nodeList = getNodeList(targets); if (nodeList) return /** @type {DOMTargetsArray} */ (Array.from(nodeList)); return /** @type {TargetsArray} */ ([targets]); } /** * @overload * @param {DOMTargetsParam} targets * @return {DOMTargetsArray} * * @overload * @param {JSTargetsParam} targets * @return {JSTargetsArray} * * @overload * @param {TargetsParam} targets * @return {TargetsArray} * * @param {DOMTargetsParam|JSTargetsParam|TargetsParam} targets */ function registerTargets(targets) { const parsedTargetsArray = parseTargets(targets); const parsedTargetsLength = parsedTargetsArray.length; if (parsedTargetsLength) { for (let i = 0; i < parsedTargetsLength; i++) { const target = parsedTargetsArray[i]; if (!target[isRegisteredTargetSymbol]) { target[isRegisteredTargetSymbol] = true; const isSvgType = isSvg(target); const isDom = /** @type {DOMTarget} */ (target).nodeType || isSvgType; if (isDom) { target[isDomSymbol] = true; target[isSvgSymbol] = isSvgType; target[transformsSymbol] = {}; } } } } return parsedTargetsArray; } /** * @param {TargetsParam} path * @return {SVGGeometryElement|undefined} */ const getPath = path => { const parsedTargets = parseTargets(path); const $parsedSvg = /** @type {SVGGeometryElement} */ (parsedTargets[0]); if (!$parsedSvg || !isSvg($parsedSvg)) return; return $parsedSvg; }; /** * @param {TargetsParam} path2 * @param {Number} [precision] * @return {FunctionValue} */ const morphTo = (path2, precision = .33) => ($path1) => { const $path2 = /** @type {SVGGeometryElement} */ (getPath(path2)); if (!$path2) return; const isPath = $path1.tagName === 'path'; const separator = isPath ? ' ' : ','; const previousPoints = $path1[morphPointsSymbol]; if (previousPoints) $path1.setAttribute(isPath ? 'd' : 'points', previousPoints); let v1 = '', v2 = ''; if (!precision) { v1 = $path1.getAttribute(isPath ? 'd' : 'points'); v2 = $path2.getAttribute(isPath ? 'd' : 'points'); } else { const length1 = /** @type {SVGGeometryElement} */ ($path1).getTotalLength(); const length2 = $path2.getTotalLength(); const maxPoints = Math.max(Math.ceil(length1 * precision), Math.ceil(length2 * precision)); for (let i = 0; i < maxPoints; i++) { const t = i / (maxPoints - 1); const pointOnPath1 = /** @type {SVGGeometryElement} */ ($path1).getPointAtLength(length1 * t); const pointOnPath2 = $path2.getPointAtLength(length2 * t); const prefix = isPath ? (i === 0 ? 'M' : 'L') : ''; v1 += prefix + round(pointOnPath1.x, 3) + separator + pointOnPath1.y + ' '; v2 += prefix + round(pointOnPath2.x, 3) + separator + pointOnPath2.y + ' '; } } $path1[morphPointsSymbol] = v2; return [v1, v2]; }; /** * @param {SVGGeometryElement} $el * @param {Number} start * @param {Number} end * @return {Proxy} */ function createDrawableProxy($el, start, end) { const strokeLineCap = getComputedStyle($el).strokeLinecap; const pathLength = K; let currentCap = strokeLineCap; const proxy = new Proxy($el, { get(target, property) { const value = target[property]; if (property === proxyTargetSymbol) return target; if (property === 'setAttribute') { /** @param {any[]} args */ return (...args) => { if (args[0] === 'draw') { const value = args[1]; const values = value.split(' '); const v1 = +values[0]; const v2 = +values[1]; // TOTO: Benchmark if performing two slices is more performant than one split // const spaceIndex = value.indexOf(' '); // const v1 = round(+value.slice(0, spaceIndex), precision); // const v2 = round(+value.slice(spaceIndex + 1), precision); const os = v1 * -1e3; const d1 = (v2 * pathLength) + os; // Prevents linecap to smear by offsetting the dasharray length by 0.01% when v2 is not at max const d2 = (pathLength + ((v1 === 0 && v2 === 1) || (v1 === 1 && v2 === 0) ? 0 : 10) - d1); // Handle cases where the cap is still visible when the line is completly hidden if (strokeLineCap !== 'butt') { const newCap = v1 === v2 ? 'butt' : strokeLineCap; if (currentCap !== newCap) { target.setAttribute('stroke-linecap', `${newCap}`); currentCap = newCap; } } target.setAttribute('stroke-dashoffset', `${os}`); target.setAttribute('stroke-dasharray', `${d1} ${d2}`); } return Reflect.apply(value, target, args); }; } if (isFnc(value)) { /** @param {any[]} args */ return (...args) => Reflect.apply(value, target, args); } else { return value; } } }); if ($el.getAttribute('pathLength') !== `${pathLength}`) { $el.setAttribute('pathLength', `${pathLength}`); proxy.setAttribute('draw', `${start} ${end}`); } return /** @type {typeof Proxy} */ ( /** @type {unknown} */(proxy)); } /** * @param {TargetsParam} selector * @param {Number} [start=0] * @param {Number} [end=0] * @return {Array.<Proxy>} */ const createDrawable = (selector, start = 0, end = 0) => { const els = /** @type {Array.<Proxy>} */ (( /** @type {unknown} */(parseTargets(selector)))); els.forEach(($el, i) => els[i] = createDrawableProxy(/** @type {SVGGeometryElement} */ ( /** @type {unknown} */($el)), start, end)); return els; }; // Motion path animation /** * @param {SVGGeometryElement} $path * @param {Number} progress * @param {Number}lookup * @return {DOMPoint} */ const getPathPoint = ($path, progress, lookup = 0) => { return $path.getPointAtLength(progress + lookup >= 1 ? progress + lookup : 0); }; /** * @param {SVGGeometryElement} $path * @param {String} pathProperty * @return {FunctionValue} */ const getPathProgess = ($path, pathProperty) => { return $el => { const totalLength = +($path.getTotalLength()); const inSvg = $el[isSvgSymbol]; const ctm = $path.getCTM(); /** @type {TweenObjectValue} */ return { from: 0, to: totalLength, /** @type {TweenModifier} */ modifier: progress => { if (pathProperty === 'a') { const p0 = getPathPoint($path, progress, -1); const p1 = getPathPoint($path, progress, 1); return atan2(p1.y - p0.y, p1.x - p0.x) * 180 / PI; } else { const p = getPathPoint($path, progress, 0); return pathProperty === 'x' ? inSvg || !ctm ? p.x : p.x * ctm.a + p.y * ctm.c + ctm.e : inSvg || !ctm ? p.y : p.x * ctm.b + p.y * ctm.d + ctm.f; } } }; }; }; /** * @param {TargetsParam} path */ const createMotionPath = path => { const $path = getPath(path); if (!$path) return; return { translateX: getPathProgess($path, 'x'), translateY: getPathProgess($path, 'y'), rotate: getPathProgess($path, 'a'), }; }; // Check for valid SVG attribute const cssReservedProperties = ['opacity', 'rotate', 'overflow', 'color']; /** * @param {Target} el * @param {String} propertyName * @return {Boolean} */ const isValidSVGAttribute = (el, propertyName) => { // Return early and use CSS opacity animation instead (already better default values (opacity: 1 instead of 0)) and rotate should be considered a transform if (cssReservedProperties.includes(propertyName)) return false; if (el.getAttribute(propertyName) || propertyName in el) { if (propertyName === '