UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

645 lines (607 loc) • 24 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var motionDom = require('motion-dom'); var motionUtils = require('motion-utils'); const wrap = (min, max, v) => { const rangeSize = max - min; return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min; }; const isEasingArray = (ease) => { return Array.isArray(ease) && typeof ease[0] !== "number"; }; function getEasingForSegment(easing, i) { return isEasingArray(easing) ? easing[wrap(0, easing.length, i)] : easing; } /* Value in range from progress Given a lower limit and an upper limit, we return the value within that range as expressed by progress (usually a number from 0 to 1) So progress = 0.5 would change from -------- to to from ---- to E.g. from = 10, to = 20, progress = 0.5 => 15 @param [number]: Lower limit of range @param [number]: Upper limit of range @param [number]: The progress between lower and upper limits expressed 0-1 @return [number]: Value as calculated from progress within range (not limited within range) */ const mixNumber = (from, to, progress) => { return from + (to - from) * progress; }; function fillOffset(offset, remaining) { const min = offset[offset.length - 1]; for (let i = 1; i <= remaining; i++) { const offsetProgress = motionUtils.progress(0, remaining, i); offset.push(mixNumber(min, 1, offsetProgress)); } } function defaultOffset(arr) { const offset = [0]; fillOffset(offset, arr.length - 1); return offset; } const isMotionValue = (value) => Boolean(value && value.getVelocity); function isDOMKeyframes(keyframes) { return typeof keyframes === "object" && !Array.isArray(keyframes); } function resolveSubjects(subject, keyframes, scope, selectorCache) { if (typeof subject === "string" && isDOMKeyframes(keyframes)) { return motionDom.resolveElements(subject, scope, selectorCache); } else if (subject instanceof NodeList) { return Array.from(subject); } else if (Array.isArray(subject)) { return subject; } else { return [subject]; } } function calculateRepeatDuration(duration, repeat, _repeatDelay) { return duration * (repeat + 1); } /** * Given a absolute or relative time definition and current/prev time state of the sequence, * calculate an absolute time for the next keyframes. */ function calcNextTime(current, next, prev, labels) { var _a; if (typeof next === "number") { return next; } else if (next.startsWith("-") || next.startsWith("+")) { return Math.max(0, current + parseFloat(next)); } else if (next === "<") { return prev; } else { return (_a = labels.get(next)) !== null && _a !== void 0 ? _a : current; } } function removeItem(arr, item) { const index = arr.indexOf(item); if (index > -1) arr.splice(index, 1); } function eraseKeyframes(sequence, startTime, endTime) { for (let i = 0; i < sequence.length; i++) { const keyframe = sequence[i]; if (keyframe.at > startTime && keyframe.at < endTime) { removeItem(sequence, keyframe); // If we remove this item we have to push the pointer back one i--; } } } function addKeyframes(sequence, keyframes, easing, offset, startTime, endTime) { /** * Erase every existing value between currentTime and targetTime, * this will essentially splice this timeline into any currently * defined ones. */ eraseKeyframes(sequence, startTime, endTime); for (let i = 0; i < keyframes.length; i++) { sequence.push({ value: keyframes[i], at: mixNumber(startTime, endTime, offset[i]), easing: getEasingForSegment(easing, i), }); } } /** * Take an array of times that represent repeated keyframes. For instance * if we have original times of [0, 0.5, 1] then our repeated times will * be [0, 0.5, 1, 1, 1.5, 2]. Loop over the times and scale them back * down to a 0-1 scale. */ function normalizeTimes(times, repeat) { for (let i = 0; i < times.length; i++) { times[i] = times[i] / (repeat + 1); } } function compareByTime(a, b) { if (a.at === b.at) { if (a.value === null) return 1; if (b.value === null) return -1; return 0; } else { return a.at - b.at; } } const defaultSegmentEasing = "easeInOut"; const MAX_REPEAT = 20; function createAnimationsFromSequence(sequence, { defaultTransition = {}, ...sequenceTransition } = {}, scope, generators) { const defaultDuration = defaultTransition.duration || 0.3; const animationDefinitions = new Map(); const sequences = new Map(); const elementCache = {}; const timeLabels = new Map(); let prevTime = 0; let currentTime = 0; let totalDuration = 0; /** * Build the timeline by mapping over the sequence array and converting * the definitions into keyframes and offsets with absolute time values. * These will later get converted into relative offsets in a second pass. */ for (let i = 0; i < sequence.length; i++) { const segment = sequence[i]; /** * If this is a timeline label, mark it and skip the rest of this iteration. */ if (typeof segment === "string") { timeLabels.set(segment, currentTime); continue; } else if (!Array.isArray(segment)) { timeLabels.set(segment.name, calcNextTime(currentTime, segment.at, prevTime, timeLabels)); continue; } let [subject, keyframes, transition = {}] = segment; /** * If a relative or absolute time value has been specified we need to resolve * it in relation to the currentTime. */ if (transition.at !== undefined) { currentTime = calcNextTime(currentTime, transition.at, prevTime, timeLabels); } /** * Keep track of the maximum duration in this definition. This will be * applied to currentTime once the definition has been parsed. */ let maxDuration = 0; const resolveValueSequence = (valueKeyframes, valueTransition, valueSequence, elementIndex = 0, numSubjects = 0) => { const valueKeyframesAsList = keyframesAsList(valueKeyframes); const { delay = 0, times = defaultOffset(valueKeyframesAsList), type = "keyframes", repeat, repeatType, repeatDelay = 0, ...remainingTransition } = valueTransition; let { ease = defaultTransition.ease || "easeOut", duration } = valueTransition; /** * Resolve stagger() if defined. */ const calculatedDelay = typeof delay === "function" ? delay(elementIndex, numSubjects) : delay; /** * If this animation should and can use a spring, generate a spring easing function. */ const numKeyframes = valueKeyframesAsList.length; const createGenerator = motionDom.isGenerator(type) ? type : generators === null || generators === void 0 ? void 0 : generators[type]; if (numKeyframes <= 2 && createGenerator) { /** * As we're creating an easing function from a spring, * ideally we want to generate it using the real distance * between the two keyframes. However this isn't always * possible - in these situations we use 0-100. */ let absoluteDelta = 100; if (numKeyframes === 2 && isNumberKeyframesArray(valueKeyframesAsList)) { const delta = valueKeyframesAsList[1] - valueKeyframesAsList[0]; absoluteDelta = Math.abs(delta); } const springTransition = { ...remainingTransition }; if (duration !== undefined) { springTransition.duration = motionUtils.secondsToMilliseconds(duration); } const springEasing = motionDom.createGeneratorEasing(springTransition, absoluteDelta, createGenerator); ease = springEasing.ease; duration = springEasing.duration; } duration !== null && duration !== void 0 ? duration : (duration = defaultDuration); const startTime = currentTime + calculatedDelay; /** * If there's only one time offset of 0, fill in a second with length 1 */ if (times.length === 1 && times[0] === 0) { times[1] = 1; } /** * Fill out if offset if fewer offsets than keyframes */ const remainder = times.length - valueKeyframesAsList.length; remainder > 0 && fillOffset(times, remainder); /** * If only one value has been set, ie [1], push a null to the start of * the keyframe array. This will let us mark a keyframe at this point * that will later be hydrated with the previous value. */ valueKeyframesAsList.length === 1 && valueKeyframesAsList.unshift(null); /** * Handle repeat options */ if (repeat) { motionUtils.invariant(repeat < MAX_REPEAT, "Repeat count too high, must be less than 20"); duration = calculateRepeatDuration(duration, repeat); const originalKeyframes = [...valueKeyframesAsList]; const originalTimes = [...times]; ease = Array.isArray(ease) ? [...ease] : [ease]; const originalEase = [...ease]; for (let repeatIndex = 0; repeatIndex < repeat; repeatIndex++) { valueKeyframesAsList.push(...originalKeyframes); for (let keyframeIndex = 0; keyframeIndex < originalKeyframes.length; keyframeIndex++) { times.push(originalTimes[keyframeIndex] + (repeatIndex + 1)); ease.push(keyframeIndex === 0 ? "linear" : getEasingForSegment(originalEase, keyframeIndex - 1)); } } normalizeTimes(times, repeat); } const targetTime = startTime + duration; /** * Add keyframes, mapping offsets to absolute time. */ addKeyframes(valueSequence, valueKeyframesAsList, ease, times, startTime, targetTime); maxDuration = Math.max(calculatedDelay + duration, maxDuration); totalDuration = Math.max(targetTime, totalDuration); }; if (isMotionValue(subject)) { const subjectSequence = getSubjectSequence(subject, sequences); resolveValueSequence(keyframes, transition, getValueSequence("default", subjectSequence)); } else { const subjects = resolveSubjects(subject, keyframes, scope, elementCache); const numSubjects = subjects.length; /** * For every element in this segment, process the defined values. */ for (let subjectIndex = 0; subjectIndex < numSubjects; subjectIndex++) { /** * Cast necessary, but we know these are of this type */ keyframes = keyframes; transition = transition; const thisSubject = subjects[subjectIndex]; const subjectSequence = getSubjectSequence(thisSubject, sequences); for (const key in keyframes) { resolveValueSequence(keyframes[key], getValueTransition(transition, key), getValueSequence(key, subjectSequence), subjectIndex, numSubjects); } } } prevTime = currentTime; currentTime += maxDuration; } /** * For every element and value combination create a new animation. */ sequences.forEach((valueSequences, element) => { for (const key in valueSequences) { const valueSequence = valueSequences[key]; /** * Arrange all the keyframes in ascending time order. */ valueSequence.sort(compareByTime); const keyframes = []; const valueOffset = []; const valueEasing = []; /** * For each keyframe, translate absolute times into * relative offsets based on the total duration of the timeline. */ for (let i = 0; i < valueSequence.length; i++) { const { at, value, easing } = valueSequence[i]; keyframes.push(value); valueOffset.push(motionUtils.progress(0, totalDuration, at)); valueEasing.push(easing || "easeOut"); } /** * If the first keyframe doesn't land on offset: 0 * provide one by duplicating the initial keyframe. This ensures * it snaps to the first keyframe when the animation starts. */ if (valueOffset[0] !== 0) { valueOffset.unshift(0); keyframes.unshift(keyframes[0]); valueEasing.unshift(defaultSegmentEasing); } /** * If the last keyframe doesn't land on offset: 1 * provide one with a null wildcard value. This will ensure it * stays static until the end of the animation. */ if (valueOffset[valueOffset.length - 1] !== 1) { valueOffset.push(1); keyframes.push(null); } if (!animationDefinitions.has(element)) { animationDefinitions.set(element, { keyframes: {}, transition: {}, }); } const definition = animationDefinitions.get(element); definition.keyframes[key] = keyframes; definition.transition[key] = { ...defaultTransition, duration: totalDuration, ease: valueEasing, times: valueOffset, ...sequenceTransition, }; } }); return animationDefinitions; } function getSubjectSequence(subject, sequences) { !sequences.has(subject) && sequences.set(subject, {}); return sequences.get(subject); } function getValueSequence(name, sequences) { if (!sequences[name]) sequences[name] = []; return sequences[name]; } function keyframesAsList(keyframes) { return Array.isArray(keyframes) ? keyframes : [keyframes]; } function getValueTransition(transition, key) { return transition && transition[key] ? { ...transition, ...transition[key], } : { ...transition }; } const isNumber = (keyframe) => typeof keyframe === "number"; const isNumberKeyframesArray = (keyframes) => keyframes.every(isNumber); function startWaapiAnimation(element, valueName, keyframes, { delay = 0, duration = 300, repeat = 0, repeatType = "loop", ease = "easeInOut", times, } = {}) { const keyframeOptions = { [valueName]: keyframes }; if (times) keyframeOptions.offset = times; const easing = motionDom.mapEasingToNativeEasing(ease, duration); /** * If this is an easing array, apply to keyframes, not animation as a whole */ if (Array.isArray(easing)) keyframeOptions.easing = easing; return element.animate(keyframeOptions, { delay, duration, easing: !Array.isArray(easing) ? easing : "linear", fill: "both", iterations: repeat + 1, direction: repeatType === "reverse" ? "alternate" : "normal", }); } const createUnitType = (unit) => ({ test: (v) => typeof v === "string" && v.endsWith(unit) && v.split(" ").length === 1, parse: parseFloat, transform: (v) => `${v}${unit}`, }); const px = /*@__PURE__*/ createUnitType("px"); const browserNumberValueTypes = { // Border props borderWidth: px, borderTopWidth: px, borderRightWidth: px, borderBottomWidth: px, borderLeftWidth: px, borderRadius: px, radius: px, borderTopLeftRadius: px, borderTopRightRadius: px, borderBottomRightRadius: px, borderBottomLeftRadius: px, // Positioning props width: px, maxWidth: px, height: px, maxHeight: px, top: px, right: px, bottom: px, left: px, // Spacing props padding: px, paddingTop: px, paddingRight: px, paddingBottom: px, paddingLeft: px, margin: px, marginTop: px, marginRight: px, marginBottom: px, marginLeft: px, // Misc backgroundPositionX: px, backgroundPositionY: px, }; const isNotNull = (value) => value !== null; function getFinalKeyframe(keyframes, { repeat, repeatType = "loop" }, finalKeyframe) { const resolvedKeyframes = keyframes.filter(isNotNull); const index = repeat && repeatType !== "loop" && repeat % 2 === 1 ? 0 : resolvedKeyframes.length - 1; return !index || finalKeyframe === undefined ? resolvedKeyframes[index] : finalKeyframe; } function setCSSVar(element, name, value) { element.style.setProperty(`--${name}`, value); } function setStyle(element, name, value) { element.style[name] = value; } const supportsPartialKeyframes = /*@__PURE__*/ motionUtils.memo(() => { try { document.createElement("div").animate({ opacity: [1] }); } catch (e) { return false; } return true; }); const supportsWaapi = /*@__PURE__*/ motionUtils.memo(() => Object.hasOwnProperty.call(Element.prototype, "animate")); const state = new WeakMap(); function hydrateKeyframes(valueName, keyframes, read) { for (let i = 0; i < keyframes.length; i++) { if (keyframes[i] === null) { keyframes[i] = i === 0 ? read() : keyframes[i - 1]; } if (typeof keyframes[i] === "number" && browserNumberValueTypes[valueName]) { keyframes[i] = browserNumberValueTypes[valueName].transform(keyframes[i]); } } if (!supportsPartialKeyframes() && keyframes.length < 2) { keyframes.unshift(read()); } } const defaultEasing = "easeOut"; function getElementAnimationState(element) { const animationState = state.get(element) || new Map(); state.set(element, animationState); return state.get(element); } class NativeAnimation extends motionDom.NativeAnimationControls { constructor(element, valueName, valueKeyframes, options) { const isCSSVar = valueName.startsWith("--"); motionUtils.invariant(typeof options.type !== "string", `animateMini doesn't support "type" as a string. Did you mean to import { spring } from "framer-motion"?`); const existingAnimation = getElementAnimationState(element).get(valueName); existingAnimation && existingAnimation.stop(); const readInitialKeyframe = () => { return valueName.startsWith("--") ? element.style.getPropertyValue(valueName) : window.getComputedStyle(element)[valueName]; }; if (!Array.isArray(valueKeyframes)) { valueKeyframes = [valueKeyframes]; } hydrateKeyframes(valueName, valueKeyframes, readInitialKeyframe); // TODO: Replace this with toString()? if (motionDom.isGenerator(options.type)) { const generatorOptions = motionDom.createGeneratorEasing(options, 100, options.type); options.ease = motionDom.supportsLinearEasing() ? generatorOptions.ease : defaultEasing; options.duration = motionUtils.secondsToMilliseconds(generatorOptions.duration); options.type = "keyframes"; } else { options.ease = options.ease || defaultEasing; } const onFinish = () => { this.setValue(element, valueName, getFinalKeyframe(valueKeyframes, options)); this.cancel(); this.resolveFinishedPromise(); }; const init = () => { this.setValue = isCSSVar ? setCSSVar : setStyle; this.options = options; this.updateFinishedPromise(); this.removeAnimation = () => { const elementState = state.get(element); elementState && elementState.delete(valueName); }; }; if (!supportsWaapi()) { super(); init(); onFinish(); } else { super(startWaapiAnimation(element, valueName, valueKeyframes, options)); init(); if (options.autoplay === false) { this.animation.pause(); } this.animation.onfinish = onFinish; getElementAnimationState(element).set(valueName, this); } } /** * Allows the returned animation to be awaited or promise-chained. Currently * resolves when the animation finishes at all but in a future update could/should * reject if its cancels. */ then(resolve, reject) { return this.currentFinishedPromise.then(resolve, reject); } updateFinishedPromise() { this.currentFinishedPromise = new Promise((resolve) => { this.resolveFinishedPromise = resolve; }); } play() { if (this.state === "finished") { this.updateFinishedPromise(); } super.play(); } cancel() { this.removeAnimation(); super.cancel(); } } function animateElements(elementOrSelector, keyframes, options, scope) { const elements = motionDom.resolveElements(elementOrSelector, scope); const numElements = elements.length; motionUtils.invariant(Boolean(numElements), "No valid element provided."); const animations = []; for (let i = 0; i < numElements; i++) { const element = elements[i]; const elementTransition = { ...options }; /** * Resolve stagger function if provided. */ if (typeof elementTransition.delay === "function") { elementTransition.delay = elementTransition.delay(i, numElements); } for (const valueName in keyframes) { const valueKeyframes = keyframes[valueName]; const valueOptions = { ...motionDom.getValueTransition(elementTransition, valueName), }; valueOptions.duration = valueOptions.duration ? motionUtils.secondsToMilliseconds(valueOptions.duration) : valueOptions.duration; valueOptions.delay = motionUtils.secondsToMilliseconds(valueOptions.delay || 0); animations.push(new NativeAnimation(element, valueName, valueKeyframes, valueOptions)); } } return animations; } function animateSequence(definition, options) { const animations = []; createAnimationsFromSequence(definition, options).forEach(({ keyframes, transition }, element) => { animations.push(...animateElements(element, keyframes, transition)); }); return new motionDom.GroupPlaybackControls(animations); } const createScopedWaapiAnimate = (scope) => { function scopedAnimate(elementOrSelector, keyframes, options) { return new motionDom.GroupPlaybackControls(animateElements(elementOrSelector, keyframes, options, scope)); } return scopedAnimate; }; const animateMini = /*@__PURE__*/ createScopedWaapiAnimate(); exports.animate = animateMini; exports.animateSequence = animateSequence;