UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

1,333 lines (1,293 loc) • 90.7 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var motionDom = require('motion-dom'); var motionUtils = require('motion-utils'); 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) { 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 labels.get(next) ?? current; } } function eraseKeyframes(sequence, startTime, endTime) { for (let i = 0; i < sequence.length; i++) { const keyframe = sequence[i]; if (keyframe.at > startTime && keyframe.at < endTime) { motionUtils.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: motionDom.mixNumber(startTime, endTime, offset[i]), easing: motionUtils.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 = motionDom.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?.[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 ?? (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 && motionDom.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" : motionUtils.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 (motionDom.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); const visualElementStore = new WeakMap(); const isKeyframesTarget = (v) => { return Array.isArray(v); }; function getValueState(visualElement) { const state = [{}, {}]; visualElement?.values.forEach((value, key) => { state[0][key] = value.get(); state[1][key] = value.getVelocity(); }); return state; } function resolveVariantFromProps(props, definition, custom, visualElement) { /** * If the variant definition is a function, resolve. */ if (typeof definition === "function") { const [current, velocity] = getValueState(visualElement); definition = definition(custom !== undefined ? custom : props.custom, current, velocity); } /** * If the variant definition is a variant label, or * the function returned a variant label, resolve. */ if (typeof definition === "string") { definition = props.variants && props.variants[definition]; } /** * At this point we've resolved both functions and variant labels, * but the resolved variant label might itself have been a function. * If so, resolve. This can only have returned a valid target object. */ if (typeof definition === "function") { const [current, velocity] = getValueState(visualElement); definition = definition(custom !== undefined ? custom : props.custom, current, velocity); } return definition; } function resolveVariant(visualElement, definition, custom) { const props = visualElement.getProps(); return resolveVariantFromProps(props, definition, custom !== undefined ? custom : props.custom, visualElement); } /** * Set VisualElement's MotionValue, creating a new MotionValue for it if * it doesn't exist. */ function setMotionValue(visualElement, key, value) { if (visualElement.hasValue(key)) { visualElement.getValue(key).set(value); } else { visualElement.addValue(key, motionDom.motionValue(value)); } } function resolveFinalValueInKeyframes(v) { // TODO maybe throw if v.length - 1 is placeholder token? return isKeyframesTarget(v) ? v[v.length - 1] || 0 : v; } function setTarget(visualElement, definition) { const resolved = resolveVariant(visualElement, definition); let { transitionEnd = {}, transition = {}, ...target } = resolved || {}; target = { ...target, ...transitionEnd }; for (const key in target) { const value = resolveFinalValueInKeyframes(target[key]); setMotionValue(visualElement, key, value); } } function isWillChangeMotionValue(value) { return Boolean(motionDom.isMotionValue(value) && value.add); } function addValueToWillChange(visualElement, key) { const willChange = visualElement.getValue("willChange"); /** * It could be that a user has set willChange to a regular MotionValue, * in which case we can't add the value to it. */ if (isWillChangeMotionValue(willChange)) { return willChange.add(key); } else if (!willChange && motionUtils.MotionGlobalConfig.WillChange) { const newWillChange = new motionUtils.MotionGlobalConfig.WillChange("auto"); visualElement.addValue("willChange", newWillChange); newWillChange.add(key); } } /** * Convert camelCase to dash-case properties. */ const camelToDash = (str) => str.replace(/([a-z])([A-Z])/gu, "$1-$2").toLowerCase(); const optimizedAppearDataId = "framerAppearId"; const optimizedAppearDataAttribute = "data-" + camelToDash(optimizedAppearDataId); function getOptimisedAppearId(visualElement) { return visualElement.props[optimizedAppearDataAttribute]; } 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; } const underDampedSpring = { type: "spring", stiffness: 500, damping: 25, restSpeed: 10, }; const criticallyDampedSpring = (target) => ({ type: "spring", stiffness: 550, damping: target === 0 ? 2 * Math.sqrt(550) : 30, restSpeed: 10, }); const keyframesTransition = { type: "keyframes", duration: 0.8, }; /** * Default easing curve is a slightly shallower version of * the default browser easing curve. */ const ease = { type: "keyframes", ease: [0.25, 0.1, 0.35, 1], duration: 0.3, }; const getDefaultTransition = (valueKey, { keyframes }) => { if (keyframes.length > 2) { return keyframesTransition; } else if (motionDom.transformProps.has(valueKey)) { return valueKey.startsWith("scale") ? criticallyDampedSpring(keyframes[1]) : underDampedSpring; } return ease; }; /** * Decide whether a transition is defined on a given Transition. * This filters out orchestration options and returns true * if any options are left. */ function isTransitionDefined({ when, delay: _delay, delayChildren, staggerChildren, staggerDirection, repeat, repeatType, repeatDelay, from, elapsed, ...transition }) { return !!Object.keys(transition).length; } const animateMotionValue = (name, value, target, transition = {}, element, isHandoff) => (onComplete) => { const valueTransition = motionDom.getValueTransition(transition, name) || {}; /** * Most transition values are currently completely overwritten by value-specific * transitions. In the future it'd be nicer to blend these transitions. But for now * delay actually does inherit from the root transition if not value-specific. */ const delay = valueTransition.delay || transition.delay || 0; /** * Elapsed isn't a public transition option but can be passed through from * optimized appear effects in milliseconds. */ let { elapsed = 0 } = transition; elapsed = elapsed - motionUtils.secondsToMilliseconds(delay); const options = { keyframes: Array.isArray(target) ? target : [null, target], ease: "easeOut", velocity: value.getVelocity(), ...valueTransition, delay: -elapsed, onUpdate: (v) => { value.set(v); valueTransition.onUpdate && valueTransition.onUpdate(v); }, onComplete: () => { onComplete(); valueTransition.onComplete && valueTransition.onComplete(); }, name, motionValue: value, element: isHandoff ? undefined : element, }; /** * If there's no transition defined for this value, we can generate * unique transition settings for this value. */ if (!isTransitionDefined(valueTransition)) { Object.assign(options, getDefaultTransition(name, options)); } /** * Both WAAPI and our internal animation functions use durations * as defined by milliseconds, while our external API defines them * as seconds. */ options.duration && (options.duration = motionUtils.secondsToMilliseconds(options.duration)); options.repeatDelay && (options.repeatDelay = motionUtils.secondsToMilliseconds(options.repeatDelay)); /** * Support deprecated way to set initial value. Prefer keyframe syntax. */ if (options.from !== undefined) { options.keyframes[0] = options.from; } let shouldSkip = false; if (options.type === false || (options.duration === 0 && !options.repeatDelay)) { options.duration = 0; if (options.delay === 0) { shouldSkip = true; } } if (motionUtils.MotionGlobalConfig.instantAnimations || motionUtils.MotionGlobalConfig.skipAnimations) { shouldSkip = true; options.duration = 0; options.delay = 0; } /** * If the transition type or easing has been explicitly set by the user * then we don't want to allow flattening the animation. */ options.allowFlatten = !valueTransition.type && !valueTransition.ease; /** * If we can or must skip creating the animation, and apply only * the final keyframe, do so. We also check once keyframes are resolved but * this early check prevents the need to create an animation at all. */ if (shouldSkip && !isHandoff && value.get() !== undefined) { const finalKeyframe = getFinalKeyframe(options.keyframes, valueTransition); if (finalKeyframe !== undefined) { motionDom.frame.update(() => { options.onUpdate(finalKeyframe); options.onComplete(); }); return; } } return valueTransition.isSync ? new motionDom.JSAnimation(options) : new motionDom.AsyncMotionValueAnimation(options); }; /** * Decide whether we should block this animation. Previously, we achieved this * just by checking whether the key was listed in protectedKeys, but this * posed problems if an animation was triggered by afterChildren and protectedKeys * had been set to true in the meantime. */ function shouldBlockAnimation({ protectedKeys, needsAnimating }, key) { const shouldBlock = protectedKeys.hasOwnProperty(key) && needsAnimating[key] !== true; needsAnimating[key] = false; return shouldBlock; } function animateTarget(visualElement, targetAndTransition, { delay = 0, transitionOverride, type } = {}) { let { transition = visualElement.getDefaultTransition(), transitionEnd, ...target } = targetAndTransition; if (transitionOverride) transition = transitionOverride; const animations = []; const animationTypeState = type && visualElement.animationState && visualElement.animationState.getState()[type]; for (const key in target) { const value = visualElement.getValue(key, visualElement.latestValues[key] ?? null); const valueTarget = target[key]; if (valueTarget === undefined || (animationTypeState && shouldBlockAnimation(animationTypeState, key))) { continue; } const valueTransition = { delay, ...motionDom.getValueTransition(transition || {}, key), }; /** * If the value is already at the defined target, skip the animation. */ const currentValue = value.get(); if (currentValue !== undefined && !value.isAnimating && !Array.isArray(valueTarget) && valueTarget === currentValue && !valueTransition.velocity) { continue; } /** * If this is the first time a value is being animated, check * to see if we're handling off from an existing animation. */ let isHandoff = false; if (window.MotionHandoffAnimation) { const appearId = getOptimisedAppearId(visualElement); if (appearId) { const startTime = window.MotionHandoffAnimation(appearId, key, motionDom.frame); if (startTime !== null) { valueTransition.startTime = startTime; isHandoff = true; } } } addValueToWillChange(visualElement, key); value.start(animateMotionValue(key, value, valueTarget, visualElement.shouldReduceMotion && motionDom.positionalKeys.has(key) ? { type: false } : valueTransition, visualElement, isHandoff)); const animation = value.animation; if (animation) { animations.push(animation); } } if (transitionEnd) { Promise.all(animations).then(() => { motionDom.frame.update(() => { transitionEnd && setTarget(visualElement, transitionEnd); }); }); } return animations; } /** * Bounding boxes tend to be defined as top, left, right, bottom. For various operations * it's easier to consider each axis individually. This function returns a bounding box * as a map of single-axis min/max values. */ function convertBoundingBoxToBox({ top, left, right, bottom, }) { return { x: { min: left, max: right }, y: { min: top, max: bottom }, }; } /** * Applies a TransformPoint function to a bounding box. TransformPoint is usually a function * provided by Framer to allow measured points to be corrected for device scaling. This is used * when measuring DOM elements and DOM event points. */ function transformBoxPoints(point, transformPoint) { if (!transformPoint) return point; const topLeft = transformPoint({ x: point.left, y: point.top }); const bottomRight = transformPoint({ x: point.right, y: point.bottom }); return { top: topLeft.y, left: topLeft.x, bottom: bottomRight.y, right: bottomRight.x, }; } function measureViewportBox(instance, transformPoint) { return convertBoundingBoxToBox(transformBoxPoints(instance.getBoundingClientRect(), transformPoint)); } const featureProps = { animation: [ "animate", "variants", "whileHover", "whileTap", "exit", "whileInView", "whileFocus", "whileDrag", ], exit: ["exit"], drag: ["drag", "dragControls"], focus: ["whileFocus"], hover: ["whileHover", "onHoverStart", "onHoverEnd"], tap: ["whileTap", "onTap", "onTapStart", "onTapCancel"], pan: ["onPan", "onPanStart", "onPanSessionStart", "onPanEnd"], inView: ["whileInView", "onViewportEnter", "onViewportLeave"], layout: ["layout", "layoutId"], }; const featureDefinitions = {}; for (const key in featureProps) { featureDefinitions[key] = { isEnabled: (props) => featureProps[key].some((name) => !!props[name]), }; } const createAxis = () => ({ min: 0, max: 0 }); const createBox = () => ({ x: createAxis(), y: createAxis(), }); const isBrowser = typeof window !== "undefined"; // Does this device prefer reduced motion? Returns `null` server-side. const prefersReducedMotion = { current: null }; const hasReducedMotionListener = { current: false }; function initPrefersReducedMotion() { hasReducedMotionListener.current = true; if (!isBrowser) return; if (window.matchMedia) { const motionMediaQuery = window.matchMedia("(prefers-reduced-motion)"); const setReducedMotionPreferences = () => (prefersReducedMotion.current = motionMediaQuery.matches); motionMediaQuery.addListener(setReducedMotionPreferences); setReducedMotionPreferences(); } else { prefersReducedMotion.current = false; } } function isAnimationControls(v) { return (v !== null && typeof v === "object" && typeof v.start === "function"); } /** * Decides if the supplied variable is variant label */ function isVariantLabel(v) { return typeof v === "string" || Array.isArray(v); } const variantPriorityOrder = [ "animate", "whileInView", "whileFocus", "whileHover", "whileTap", "whileDrag", "exit", ]; const variantProps = ["initial", ...variantPriorityOrder]; function isControllingVariants(props) { return (isAnimationControls(props.animate) || variantProps.some((name) => isVariantLabel(props[name]))); } function isVariantNode(props) { return Boolean(isControllingVariants(props) || props.variants); } function updateMotionValuesFromProps(element, next, prev) { for (const key in next) { const nextValue = next[key]; const prevValue = prev[key]; if (motionDom.isMotionValue(nextValue)) { /** * If this is a motion value found in props or style, we want to add it * to our visual element's motion value map. */ element.addValue(key, nextValue); } else if (motionDom.isMotionValue(prevValue)) { /** * If we're swapping from a motion value to a static value, * create a new motion value from that */ element.addValue(key, motionDom.motionValue(nextValue, { owner: element })); } else if (prevValue !== nextValue) { /** * If this is a flat value that has changed, update the motion value * or create one if it doesn't exist. We only want to do this if we're * not handling the value with our animation state. */ if (element.hasValue(key)) { const existingValue = element.getValue(key); if (existingValue.liveStyle === true) { existingValue.jump(nextValue); } else if (!existingValue.hasAnimated) { existingValue.set(nextValue); } } else { const latestValue = element.getStaticValue(key); element.addValue(key, motionDom.motionValue(latestValue !== undefined ? latestValue : nextValue, { owner: element })); } } } // Handle removed values for (const key in prev) { if (next[key] === undefined) element.removeValue(key); } return next; } const propEventHandlers = [ "AnimationStart", "AnimationComplete", "Update", "BeforeLayoutMeasure", "LayoutMeasure", "LayoutAnimationStart", "LayoutAnimationComplete", ]; /** * A VisualElement is an imperative abstraction around UI elements such as * HTMLElement, SVGElement, Three.Object3D etc. */ class VisualElement { /** * This method takes React props and returns found MotionValues. For example, HTML * MotionValues will be found within the style prop, whereas for Three.js within attribute arrays. * * This isn't an abstract method as it needs calling in the constructor, but it is * intended to be one. */ scrapeMotionValuesFromProps(_props, _prevProps, _visualElement) { return {}; } constructor({ parent, props, presenceContext, reducedMotionConfig, blockInitialAnimation, visualState, }, options = {}) { /** * A reference to the current underlying Instance, e.g. a HTMLElement * or Three.Mesh etc. */ this.current = null; /** * A set containing references to this VisualElement's children. */ this.children = new Set(); /** * Determine what role this visual element should take in the variant tree. */ this.isVariantNode = false; this.isControllingVariants = false; /** * Decides whether this VisualElement should animate in reduced motion * mode. * * TODO: This is currently set on every individual VisualElement but feels * like it could be set globally. */ this.shouldReduceMotion = null; /** * A map of all motion values attached to this visual element. Motion * values are source of truth for any given animated value. A motion * value might be provided externally by the component via props. */ this.values = new Map(); this.KeyframeResolver = motionDom.KeyframeResolver; /** * Cleanup functions for active features (hover/tap/exit etc) */ this.features = {}; /** * A map of every subscription that binds the provided or generated * motion values onChange listeners to this visual element. */ this.valueSubscriptions = new Map(); /** * A reference to the previously-provided motion values as returned * from scrapeMotionValuesFromProps. We use the keys in here to determine * if any motion values need to be removed after props are updated. */ this.prevMotionValues = {}; /** * An object containing a SubscriptionManager for each active event. */ this.events = {}; /** * An object containing an unsubscribe function for each prop event subscription. * For example, every "Update" event can have multiple subscribers via * VisualElement.on(), but only one of those can be defined via the onUpdate prop. */ this.propEventSubscriptions = {}; this.notifyUpdate = () => this.notify("Update", this.latestValues); this.render = () => { if (!this.current) return; this.triggerBuild(); this.renderInstance(this.current, this.renderState, this.props.style, this.projection); }; this.renderScheduledAt = 0.0; this.scheduleRender = () => { const now = motionDom.time.now(); if (this.renderScheduledAt < now) { this.renderScheduledAt = now; motionDom.frame.render(this.render, false, true); } }; const { latestValues, renderState } = visualState; this.latestValues = latestValues; this.baseTarget = { ...latestValues }; this.initialValues = props.initial ? { ...latestValues } : {}; this.renderState = renderState; this.parent = parent; this.props = props; this.presenceContext = presenceContext; this.depth = parent ? parent.depth + 1 : 0; this.reducedMotionConfig = reducedMotionConfig; this.options = options; this.blockInitialAnimation = Boolean(blockInitialAnimation); this.isControllingVariants = isControllingVariants(props); this.isVariantNode = isVariantNode(props); if (this.isVariantNode) { this.variantChildren = new Set(); } this.manuallyAnimateOnMount = Boolean(parent && parent.current); /** * Any motion values that are provided to the element when created * aren't yet bound to the element, as this would technically be impure. * However, we iterate through the motion values and set them to the * initial values for this component. * * TODO: This is impure and we should look at changing this to run on mount. * Doing so will break some tests but this isn't necessarily a breaking change, * more a reflection of the test. */ const { willChange, ...initialMotionValues } = this.scrapeMotionValuesFromProps(props, {}, this); for (const key in initialMotionValues) { const value = initialMotionValues[key]; if (latestValues[key] !== undefined && motionDom.isMotionValue(value)) { value.set(latestValues[key], false); } } } mount(instance) { this.current = instance; visualElementStore.set(instance, this); if (this.projection && !this.projection.instance) { this.projection.mount(instance); } if (this.parent && this.isVariantNode && !this.isControllingVariants) { this.removeFromVariantTree = this.parent.addVariantChild(this); } this.values.forEach((value, key) => this.bindToMotionValue(key, value)); if (!hasReducedMotionListener.current) { initPrefersReducedMotion(); } this.shouldReduceMotion = this.reducedMotionConfig === "never" ? false : this.reducedMotionConfig === "always" ? true : prefersReducedMotion.current; if (process.env.NODE_ENV !== "production") { motionUtils.warnOnce(this.shouldReduceMotion !== true, "You have Reduced Motion enabled on your device. Animations may not appear as expected."); } if (this.parent) this.parent.children.add(this); this.update(this.props, this.presenceContext); } unmount() { this.projection && this.projection.unmount(); motionDom.cancelFrame(this.notifyUpdate); motionDom.cancelFrame(this.render); this.valueSubscriptions.forEach((remove) => remove()); this.valueSubscriptions.clear(); this.removeFromVariantTree && this.removeFromVariantTree(); this.parent && this.parent.children.delete(this); for (const key in this.events) { this.events[key].clear(); } for (const key in this.features) { const feature = this.features[key]; if (feature) { feature.unmount(); feature.isMounted = false; } } this.current = null; } bindToMotionValue(key, value) { if (this.valueSubscriptions.has(key)) { this.valueSubscriptions.get(key)(); } const valueIsTransform = motionDom.transformProps.has(key); if (valueIsTransform && this.onBindTransform) { this.onBindTransform(); } const removeOnChange = value.on("change", (latestValue) => { this.latestValues[key] = latestValue; this.props.onUpdate && motionDom.frame.preRender(this.notifyUpdate); if (valueIsTransform && this.projection) { this.projection.isTransformDirty = true; } }); const removeOnRenderRequest = value.on("renderRequest", this.scheduleRender); let removeSyncCheck; if (window.MotionCheckAppearSync) { removeSyncCheck = window.MotionCheckAppearSync(this, key, value); } this.valueSubscriptions.set(key, () => { removeOnChange(); removeOnRenderRequest(); if (removeSyncCheck) removeSyncCheck(); if (value.owner) value.stop(); }); } sortNodePosition(other) { /** * If these nodes aren't even of the same type we can't compare their depth. */ if (!this.current || !this.sortInstanceNodePosition || this.type !== other.type) { return 0; } return this.sortInstanceNodePosition(this.current, other.current); } updateFeatures() { let key = "animation"; for (key in featureDefinitions) { const featureDefinition = featureDefinitions[key]; if (!featureDefinition) continue; const { isEnabled, Feature: FeatureConstructor } = featureDefinition; /** * If this feature is enabled but not active, make a new instance. */ if (!this.features[key] && FeatureConstructor && isEnabled(this.props)) { this.features[key] = new FeatureConstructor(this); } /** * If we have a feature, mount or update it. */ if (this.features[key]) { const feature = this.features[key]; if (feature.isMounted) { feature.update(); } else { feature.mount(); feature.isMounted = true; } } } } triggerBuild() { this.build(this.renderState, this.latestValues, this.props); } /** * Measure the current viewport box with or without transforms. * Only measures axis-aligned boxes, rotate and skew must be manually * removed with a re-render to work. */ measureViewportBox() { return this.current ? this.measureInstanceViewportBox(this.current, this.props) : createBox(); } getStaticValue(key) { return this.latestValues[key]; } setStaticValue(key, value) { this.latestValues[key] = value; } /** * Update the provided props. Ensure any newly-added motion values are * added to our map, old ones removed, and listeners updated. */ update(props, presenceContext) { if (props.transformTemplate || this.props.transformTemplate) { this.scheduleRender(); } this.prevProps = this.props; this.props = props; this.prevPresenceContext = this.presenceContext; this.presenceContext = presenceContext; /** * Update prop event handlers ie onAnimationStart, onAnimationComplete */ for (let i = 0; i < propEventHandlers.length; i++) { const key = propEventHandlers[i]; if (this.propEventSubscriptions[key]) { this.propEventSubscriptions[key](); delete this.propEventSubscriptions[key]; } const listenerName = ("on" + key); const listener = props[listenerName]; if (listener) { this.propEventSubscriptions[key] = this.on(key, listener); } } this.prevMotionValues = updateMotionValuesFromProps(this, this.scrapeMotionValuesFromProps(props, this.prevProps, this), this.prevMotionValues); if (this.handleChildMotionValue) { this.handleChildMotionValue(); } } getProps() { return this.props; } /** * Returns the variant definition with a given name. */ getVariant(name) { return this.props.variants ? this.props.variants[name] : undefined; } /** * Returns the defined default transition on this component. */ getDefaultTransition() { return this.props.transition; } getTransformPagePoint() { return this.props.transformPagePoint; } getClosestVariantNode() { return this.isVariantNode ? this : this.parent ? this.parent.getClosestVariantNode() : undefined; } /** * Add a child visual element to our set of children. */ addVariantChild(child) { const closestVariantNode = this.getClosestVariantNode(); if (closestVariantNode) { closestVariantNode.variantChildren && closestVariantNode.variantChildren.add(child); return () => closestVariantNode.variantChildren.delete(child); } } /** * Add a motion value and bind it to this visual element. */ addValue(key, value) { // Remove existing value if it exists const existingValue = this.values.get(key); if (value !== existingValue) { if (existingValue) this.removeValue(key); this.bindToMotionValue(key, value); this.values.set(key, value); this.latestValues[key] = value.get(); } } /** * Remove a motion value and unbind any active subscriptions. */ removeValue(key) { this.values.delete(key); const unsubscribe = this.valueSubscriptions.get(key); if (unsubscribe) { unsubscribe(); this.valueSubscriptions.delete(key); } delete this.latestValues[key]; this.removeValueFromRenderState(key, this.renderState); } /** * Check whether we have a motion value for this key */ hasValue(key) { return this.values.has(key); } getValue(key, defaultValue) { if (this.props.values && this.props.values[key]) { return this.props.values[key]; } let value = this.values.get(key); if (value === undefined && defaultValue !== undefined) { value = motionDom.motionValue(defaultValue === null ? undefined : defaultValue, { owner: this }); this.addValue(key, value); } return value; } /** * If we're trying to animate to a previously unencountered value, * we need to check for it in our state and as a last resort read it * directly from the instance (which might have performance implications). */ readValue(key, target) { let value = this.latestValues[key] !== undefined || !this.current ? this.latestValues[key] : this.getBaseTargetFromProps(this.props, key) ?? this.readValueFromInstance(this.current, key, this.options); if (value !== undefined && value !== null) { if (typeof value === "string" && (motionUtils.isNumericalString(value) || motionUtils.isZeroValueString(value))) { // If this is a number read as a string, ie "0" or "200", convert it to a number value = parseFloat(value); } else if (!motionDom.findValueType(value) && motionDom.complex.test(target)) { value = motionDom.getAnimatableNone(key, target); } this.setBaseTarget(key, motionDom.isMotionValue(value) ? value.get() : value); } return motionDom.isMotionValue(value) ? value.get() : value; } /** * Set the base target to later animate back to. This is currently * only hydrated on creation and when we first read a value. */ setBaseTarget(key, value) { this.baseTarget[key] = value; } /** * Find the base target for a value thats been removed from all animation * props. */ getBaseTarget(key) { const { initial } = this.props; let valueFromInitial; if (typeof initial === "string" || typeof initial === "object") { const variant = resolveVariantFromProps(this.props, initial, this.presenceContext?.custom); if (variant) { valueFromInitial = variant[key]; } } /** * If this value still exists in the current initial variant, read that. */ if (initial && valueFromInitial !== undefined) { return valueFromInitial; } /** * Alternatively, if this VisualElement config has defined a getBaseTarget * so we can read the value from an alternative source, try that. */ const target = this.getBaseTargetFromProps(this.props, key); if (target !== undefined && !motionDom.isMotionValue(target)) return target; /** * If the value was initially defined on initial, but it doesn't any more, * return undefined. Otherwise return the value as initially read from the DOM. */ return this.initialValues[key] !== undefined && valueFromInitial === undefined ? undefined : this.baseTarget[key]; } on(eventName, callback) { if (!this.events[eventName]) { this.events[eventName] = new motionUtils.SubscriptionManager(); } return this.events[eventName].add(callback); } notify(eventName, ...args) { if (this.events[eventName]) { this.events[eventName].notify(...args); } } } class DOMVisualElement extends VisualElement { constructor() { super(...arguments); this.KeyframeResolver = motionDom.DOMKeyframesResolver; } sortInstanceNodePosition(a, b) { /** * compareDocumentPosition returns a bitmask, by using the bitwise & * we're returning true if 2 in that bitmask is set to true. 2 is set * to true if b preceeds a. */ return a.compareDocumentPosition(b) & 2 ? 1 : -1; } getBaseTargetFromProps(props, key) { return props.style ? props.style[key] : undefined; } removeValueFromRenderState(key, { vars, style }) { delete vars[key]; delete style[key]; } handleChildMotionValue() { if (this.childSubscription) { this.childSubscription(); delete this.childSubscription; } const { children } = this.props; if (motionDom.isMotionValue(children)) { this.childSubscription = children.on("change", (latest) => { if (this.current) { this.current.textContent = `${latest}`; } }); } } } const translateAlias = { x: "translateX", y: "translateY", z: "translateZ", transformPerspective: "perspective", }; const numTransforms