UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

1,314 lines (1,280 loc) • 241 kB
'use strict'; var motionDom = require('motion-dom'); var motionUtils = require('motion-utils'); var jsxRuntime = require('react/jsx-runtime'); var React = require('react'); const LayoutGroupContext = React.createContext({}); /** * Creates a constant value over the lifecycle of a component. * * Even if `useMemo` is provided an empty array as its final argument, it doesn't offer * a guarantee that it won't re-run for performance reasons later on. By using `useConstant` * you can ensure that initialisers don't execute twice or more. */ function useConstant(init) { const ref = React.useRef(null); if (ref.current === null) { ref.current = init(); } return ref.current; } const isBrowser = typeof window !== "undefined"; const useIsomorphicLayoutEffect = isBrowser ? React.useLayoutEffect : React.useEffect; /** * @public */ const PresenceContext = /* @__PURE__ */ React.createContext(null); /** * @public */ const MotionConfigContext = React.createContext({ transformPagePoint: (p) => p, isStatic: false, reducedMotion: "never", }); /** * When a component is the child of `AnimatePresence`, it can use `usePresence` * to access information about whether it's still present in the React tree. * * ```jsx * import { usePresence } from "framer-motion" * * export const Component = () => { * const [isPresent, safeToRemove] = usePresence() * * useEffect(() => { * !isPresent && setTimeout(safeToRemove, 1000) * }, [isPresent]) * * return <div /> * } * ``` * * If `isPresent` is `false`, it means that a component has been removed the tree, but * `AnimatePresence` won't really remove it until `safeToRemove` has been called. * * @public */ function usePresence(subscribe = true) { const context = React.useContext(PresenceContext); if (context === null) return [true, null]; const { isPresent, onExitComplete, register } = context; // It's safe to call the following hooks conditionally (after an early return) because the context will always // either be null or non-null for the lifespan of the component. const id = React.useId(); React.useEffect(() => { if (subscribe) { return register(id); } }, [subscribe]); const safeToRemove = React.useCallback(() => subscribe && onExitComplete && onExitComplete(id), [id, onExitComplete, subscribe]); return !isPresent && onExitComplete ? [false, safeToRemove] : [true]; } /** * Similar to `usePresence`, except `useIsPresent` simply returns whether or not the component is present. * There is no `safeToRemove` function. * * ```jsx * import { useIsPresent } from "framer-motion" * * export const Component = () => { * const isPresent = useIsPresent() * * useEffect(() => { * !isPresent && console.log("I've been removed!") * }, [isPresent]) * * return <div /> * } * ``` * * @public */ function useIsPresent() { return isPresent(React.useContext(PresenceContext)); } function isPresent(context) { return context === null ? true : context.isPresent; } const SCALE_PRECISION = 0.0001; const SCALE_MIN = 1 - SCALE_PRECISION; const SCALE_MAX = 1 + SCALE_PRECISION; const TRANSLATE_PRECISION = 0.01; const TRANSLATE_MIN = 0 - TRANSLATE_PRECISION; const TRANSLATE_MAX = 0 + TRANSLATE_PRECISION; function calcLength(axis) { return axis.max - axis.min; } function isNear(value, target, maxDistance) { return Math.abs(value - target) <= maxDistance; } function calcAxisDelta(delta, source, target, origin = 0.5) { delta.origin = origin; delta.originPoint = motionDom.mixNumber(source.min, source.max, delta.origin); delta.scale = calcLength(target) / calcLength(source); delta.translate = motionDom.mixNumber(target.min, target.max, delta.origin) - delta.originPoint; if ((delta.scale >= SCALE_MIN && delta.scale <= SCALE_MAX) || isNaN(delta.scale)) { delta.scale = 1.0; } if ((delta.translate >= TRANSLATE_MIN && delta.translate <= TRANSLATE_MAX) || isNaN(delta.translate)) { delta.translate = 0.0; } } function calcBoxDelta(delta, source, target, origin) { calcAxisDelta(delta.x, source.x, target.x, origin ? origin.originX : undefined); calcAxisDelta(delta.y, source.y, target.y, origin ? origin.originY : undefined); } function calcRelativeAxis(target, relative, parent) { target.min = parent.min + relative.min; target.max = target.min + calcLength(relative); } function calcRelativeBox(target, relative, parent) { calcRelativeAxis(target.x, relative.x, parent.x); calcRelativeAxis(target.y, relative.y, parent.y); } function calcRelativeAxisPosition(target, layout, parent) { target.min = layout.min - parent.min; target.max = target.min + calcLength(layout); } function calcRelativePosition(target, layout, parent) { calcRelativeAxisPosition(target.x, layout.x, parent.x); calcRelativeAxisPosition(target.y, layout.y, parent.y); } 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); }; function animateSingleValue(value, keyframes, options) { const motionValue = motionDom.isMotionValue(value) ? value : motionDom.motionValue(value); motionValue.start(animateMotionValue("", motionValue, keyframes, options)); return motionValue.animation; } /** * 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 compareByDepth = (a, b) => a.depth - b.depth; class FlatTree { constructor() { this.children = []; this.isDirty = false; } add(child) { motionUtils.addUniqueItem(this.children, child); this.isDirty = true; } remove(child) { motionUtils.removeItem(this.children, child); this.isDirty = true; } forEach(callback) { this.isDirty && this.children.sort(compareByDepth); this.isDirty = false; this.children.forEach(callback); } } /** * Timeout defined in ms */ function delay(callback, timeout) { const start = motionDom.time.now(); const checkElapsed = ({ timestamp }) => { const elapsed = timestamp - start; if (elapsed >= timeout) { motionDom.cancelFrame(checkElapsed); callback(elapsed - timeout); } }; motionDom.frame.setup(checkElapsed, true); return () => motionDom.cancelFrame(checkElapsed); } /** * If the provided value is a MotionValue, this returns the actual value, otherwise just the value itself * * TODO: Remove and move to library */ function resolveMotionValue(value) { return motionDom.isMotionValue(value) ? value.get() : value; } const borders = ["TopLeft", "TopRight", "BottomLeft", "BottomRight"]; const numBorders = borders.length; const asNumber = (value) => typeof value === "string" ? parseFloat(value) : value; const isPx = (value) => typeof value === "number" || motionDom.px.test(value); function mixValues(target, follow, lead, progress, shouldCrossfadeOpacity, isOnlyMember) { if (shouldCrossfadeOpacity) { target.opacity = motionDom.mixNumber(0, lead.opacity ?? 1, easeCrossfadeIn(progress)); target.opacityExit = motionDom.mixNumber(follow.opacity ?? 1, 0, easeCrossfadeOut(progress)); } else if (isOnlyMember) { target.opacity = motionDom.mixNumber(follow.opacity ?? 1, lead.opacity ?? 1, progress); } /** * Mix border radius */ for (let i = 0; i < numBorders; i++) { const borderLabel = `border${borders[i]}Radius`; let followRadius = getRadius(follow, borderLabel); let leadRadius = getRadius(lead, borderLabel); if (followRadius === undefined && leadRadius === undefined) continue; followRadius || (followRadius = 0); leadRadius || (leadRadius = 0); const canMix = followRadius === 0 || leadRadius === 0 || isPx(followRadius) === isPx(leadRadius); if (canMix) { target[borderLabel] = Math.max(motionDom.mixNumber(asNumber(followRadius), asNumber(leadRadius), progress), 0); if (motionDom.percent.test(leadRadius) || motionDom.percent.test(followRadius)) { target[borderLabel] += "%"; } } else { target[borderLabel] = leadRadius; } } /** * Mix rotation */ if (follow.rotate || lead.rotate) { target.rotate = motionDom.mixNumber(follow.rotate || 0, lead.rotate || 0, progress); } } function getRadius(values, radiusName) { return values[radiusName] !== undefined ? values[radiusName] : values.borderRadius; } // /** // * We only want to mix the background color if there's a follow element // * that we're not crossfading opacity between. For instance with switch // * AnimateSharedLayout animations, this helps the illusion of a continuous // * element being animated but also cuts down on the number of paints triggered // * for elements where opacity is doing that work for us. // */ // if ( // !hasFollowElement && // latestLeadValues.backgroundColor && // latestFollowValues.backgroundColor // ) { // /** // * This isn't ideal performance-wise as mixColor is creating a new function every frame. // * We could probably create a mixer that runs at the start of the animation but // * the idea behind the crossfader is that it runs dynamically between two potentially // * changing targets (ie opacity or borderRadius may be animating independently via variants) // */ // leadState.backgroundColor = followState.backgroundColor = mixColor( // latestFollowValues.backgroundColor as string, // latestLeadValues.backgroundColor as string // )(p) // } const easeCrossfadeIn = /*@__PURE__*/ compress(0, 0.5, motionUtils.circOut); const easeCrossfadeOut = /*@__PURE__*/ compress(0.5, 0.95, motionUtils.noop); function compress(min, max, easing) { return (p) => { // Could replace ifs with clamp if (p < min) return 0; if (p > max) return 1; return easing(motionUtils.progress(min, max, p)); }; } /** * Reset an axis to the provided origin box. * * This is a mutative operation. */ function copyAxisInto(axis, originAxis) { axis.min = originAxis.min; axis.max = originAxis.max; } /** * Reset a box to the provided origin box. * * This is a mutative operation. */ function copyBoxInto(box, originBox) { copyAxisInto(box.x, originBox.x); copyAxisInto(box.y, originBox.y); } /** * Reset a delta to the provided origin box. * * This is a mutative operation. */ function copyAxisDeltaInto(delta, originDelta) { delta.translate = originDelta.translate; delta.scale = originDelta.scale; delta.originPoint = originDelta.originPoint; delta.origin = originDelta.origin; } function isIdentityScale(scale) { return scale === undefined || scale === 1; } function hasScale({ scale, scaleX, scaleY }) { return (!isIdentityScale(scale) || !isIdentityScale(scaleX) || !isIdentityScale(scaleY)); } function hasTransform(values) { return (hasScale(values) || has2DTranslate(values) || values.z || values.rotate || values.rotateX || values.rotateY || values.skewX || values.skewY); } function has2DTranslate(values) { return is2DTranslate(values.x) || is2DTranslate(values.y); } function is2DTranslate(value) { return value && value !== "0%"; } /** * Scales a point based on a factor and an originPoint */ function scalePoint(point, scale, originPoint) { const distanceFromOrigin = point - originPoint; const scaled = scale * distanceFromOrigin; return originPoint + scaled; } /** * Applies a translate/scale delta to a point */ function applyPointDelta(point, translate, scale, originPoint, boxScale) { if (boxScale !== undefined) { point = scalePoint(point, boxScale, originPoint); } return scalePoint(point, scale, originPoint) + translate; } /** * Applies a translate/scale delta to an axis */ function applyAxisDelta(axis, translate = 0, scale = 1, originPoint, boxScale) { axis.min = applyPointDelta(axis.min, translate, scale, originPoint, boxScale); axis.max = applyPointDelta(axis.max, translate, scale, originPoint, boxScale); } /** * Applies a translate/scale delta to a box */ function applyBoxDelta(box, { x, y }) { applyAxisDelta(box.x, x.translate, x.scale, x.originPoint); applyAxisDelta(box.y, y.translate, y.scale, y.originPoint); } const TREE_SCALE_SNAP_MIN = 0.999999999999; const TREE_SCALE_SNAP_MAX = 1.0000000000001; /** * Apply a tree of deltas to a box. We do this to calculate the effect of all the transforms * in a tree upon our box before then calculating how to project it into our desired viewport-relative box * * This is the final nested loop within updateLayoutDelta for future refactoring */ function applyTreeDeltas(box, treeScale, treePath, isSharedTransition = false) { const treeLength = treePath.length; if (!treeLength) return; // Reset the treeScale treeScale.x = treeScale.y = 1; let node; let delta; for (let i = 0; i < treeLength; i++) { node = treePath[i]; delta = node.projectionDelta; /** * TODO: Prefer to remove this, but currently we have motion components with * display: contents in Framer. */ const { visualElement } = node.options; if (visualElement && visualElement.props.style && visualElement.props.style.display === "contents") { continue; } if (isSharedTransition && node.options.layoutScroll && node.scroll && node !== node.root) { transformBox(box, { x: -node.scroll.offset.x, y: -node.scroll.offset.y, }); } if (delta) { // Incoporate each ancestor's scale into a culmulative treeScale for this component treeScale.x *= delta.x.scale; treeScale.y *= delta.y.scale; // Apply each ancestor's calculated delta into this component's recorded layout box applyBoxDelta(box, delta); } if (isSharedTransition && hasTransform(node.latestValues)) { transformBox(box, node.latestValues); } } /** * Snap tree scale back to 1 if it's within a non-perceivable threshold. * This will help reduce useless scales getting rendered. */ if (treeScale.x < TREE_SCALE_SNAP_MAX && treeScale.x > TREE_SCALE_SNAP_MIN) { treeScale.x = 1.0; } if (treeScale.y < TREE_SCALE_SNAP_MAX && treeScale.y > TREE_SCALE_SNAP_MIN) { treeScale.y = 1.0; } } function translateAxis(axis, distance) { axis.min = axis.min + distance; axis.max = axis.max + distance; } /** * Apply a transform to an axis from the latest resolved motion values. * This function basically acts as a bridge between a flat motion value map * and applyAxisDelta */ function transformAxis(axis, axisTranslate, axisScale, boxScale, axisOrigin = 0.5) { const originPoint = motionDom.mixNumber(axis.min, axis.max, axisOrigin); // Apply the axis delta to the final axis applyAxisDelta(axis, axisTranslate, axisScale, originPoint, boxScale); } /** * Apply a transform to a box from the latest resolved motion values. */ function transformBox(box, transform) { transformAxis(box.x, transform.x, transform.scaleX, transform.scale, transform.originX); transformAxis(box.y, transform.y, transform.scaleY, transform.scale, transform.originY); } /** * Remove a delta from a point. This is essentially the steps of applyPointDelta in reverse */ function removePointDelta(point, translate, scale, originPoint, boxScale) { point -= translate; point = scalePoint(point, 1 / scale, originPoint); if (boxScale !== undefined) { point = scalePoint(point, 1 / boxScale, originPoint); } return point; } /** * Remove a delta from an axis. This is essentially the steps of applyAxisDelta in reverse */ function removeAxisDelta(axis, translate = 0, scale = 1, origin = 0.5, boxScale, originAxis = axis, sourceAxis = axis) { if (motionDom.percent.test(translate)) { translate = parseFloat(translate); const relativeProgress = motionDom.mixNumber(sourceAxis.min, sourceAxis.max, translate / 100); translate = relativeProgress - sourceAxis.min; } if (typeof translate !== "number") return; let originPoint = motionDom.mixNumber(originAxis.min, originAxis.max, origin); if (axis === originAxis) originPoint -= translate; axis.min = removePointDelta(axis.min, translate, scale, originPoint, boxScale); axis.max = removePointDelta(axis.max, translate, scale, originPoint, boxScale); } /** * Remove a transforms from an axis. This is essentially the steps of applyAxisTransforms in reverse * and acts as a bridge between motion values and removeAxisDelta */ function removeAxisTransforms(axis, transforms, [key, scaleKey, originKey], origin, sourceAxis) { removeAxisDelta(axis, transforms[key], transforms[scaleKey], transforms[originKey], transforms.scale, origin, sourceAxis); } /** * The names of the motion values we want to apply as translation, scale and origin. */ const xKeys = ["x", "scaleX", "originX"]; const yKeys = ["y", "scaleY", "originY"]; /** * Remove a transforms from an box. This is essentially the steps of applyAxisBox in reverse * and acts as a bridge between motion values and removeAxisDelta */ function removeBoxTransforms(box, transforms, originBox, sourceBox) { removeAxisTransforms(box.x, transforms, xKeys, originBox ? originBox.x : undefined, sourceBox ? sourceBox.x : undefined); removeAxisTransforms(box.y, transforms, yKeys, originBox ? originBox.y : undefined, sourceBox ? sourceBox.y : undefined); } const createAxisDelta = () => ({ translate: 0, scale: 1, origin: 0, originPoint: 0, }); const createDelta = () => ({ x: createAxisDelta(), y: createAxisDelta(), }); const createAxis = () => ({ min: 0, max: 0 }); const createBox = () => ({ x: createAxis(), y: createAxis(), }); function isAxisDeltaZero(delta) { return delta.translate === 0 && delta.scale === 1; } function isDeltaZero(delta) { return isAxisDeltaZero(delta.x) && isAxisDeltaZero(delta.y); } function axisEquals(a, b) { return a.min === b.min && a.max === b.max; } function boxEquals(a, b) { return axisEquals(a.x, b.x) && axisEquals(a.y, b.y); } function axisEqualsRounded(a, b) { return (Math.round(a.min) === Math.round(b.min) && Math.round(a.max) === Math.round(b.max)); } function boxEqualsRounded(a, b) { return axisEqualsRounded(a.x, b.x) && axisEqualsRounded(a.y, b.y); } function aspectRatio(box) { return calcLength(box.x) / calcLength(box.y); } function axisDeltaEquals(a, b) { return (a.translate === b.translate && a.scale === b.scale && a.originPoint === b.originPoint); } class NodeStack { constructor() { this.members = []; } add(node) { motionUtils.addUniqueItem(this.members, node); node.scheduleRender(); } remove(node) { motionUtils.removeItem(this.members, node); if (node === this.prevLead) { this.prevLead = undefined; } if (node === this.lead) { const prevLead = this.members[this.members.length - 1]; if (prevLead) { this.promote(prevLead); } } } relegate(node) { const indexOfNode = this.members.findIndex((member) => node === member); if (indexOfNode === 0) return false; /** * Find the next projection node that is present */ let prevLead; for (let i = indexOfNode; i >= 0; i--) { const member = this.members[i]; if (member.isPresent !== false) { prevLead = member; break; } } if (prevLead) { this.promote(prevLead); return true; } else { return false; } } promote(node, preserveFollowOpacity) { const prevLead = this.lead; if (node === prevLead) return; this.prevLead = prevLead; this.lead = node; node.show(); if (prevLead) { prevLead.instance && prevLead.scheduleRender(); node.scheduleRender(); node.resumeFrom = prevLead; if (preserveFollowOpacity) { node.resumeFrom.preserveOpacity = true; } if (prevLead.snapshot) { node.snapshot = prevLead.snapshot; node.snapshot.latestValues = prevLead.animationValues || prevLead.latestValues; } if (node.root && node.root.isUpdating) { node.isLayoutDirty = true; } const { crossfade } = node.options; if (crossfade === false) { prevLead.hide(); } /** * TODO: * - Test border radius when previous node was deleted * - boxShadow mixing * - Shared between element A in scrolled container and element B (scroll stays the same or changes) * - Shared between element A in transformed container and element B (transform stays the same or changes) * - Shared between element A in scrolled page and element B (scroll stays the same or changes) * --- * - Crossfade opacity of root nodes * - layoutId changes after animation * - layoutId changes mid animation */ } } exitAnimationComplete() { this.members.forEach((node) => { const { options, resumingFrom } = node; options.onExitComplete && options.onExitComplete(); if (resumingFrom) { resumingFrom.options.onExitComplete && resumingFrom.options.onExitComplete(); } }); } scheduleRender() { this.members.forEach((node) => { node.instance && node.scheduleRender(false); }); } /** * Clear any leads that have been removed this render to prevent them from being * used in future animations and to prevent memory leaks */ removeLeadSnapshot() { if (this.lead && this.lead.snapshot) { this.lead.snapshot = undefined; } } } const scaleCorrectors = {}; function addScaleCorrector(correctors) { for (const key in correctors) { scaleCorrectors[key] = correctors[key]; if (motionDom.isCSSVariableName(key)) { scaleCorrectors[key].isCSSVariable = true; } } } function buildProjectionTransform(delta, treeScale, latestTransform) { let transform = ""; /** * The translations we use to calculate are always relative to the viewport coordinate space. * But when we apply scales, we also scale the coordinate space of an element and its children. * For instance if we have a treeScale (the culmination of all parent scales) of 0.5 and we need * to move an element 100 pixels, we actually need to move it 200 in within that scaled space. */ const xTranslate = delta.x.translate / treeScale.x; const yTranslate = delta.y.translate / treeScale.y; const zTranslate = latestTransform?.z || 0; if (xTranslate || yTranslate || zTranslate) { transform = `translate3d(${xTranslate}px, ${yTranslate}px, ${zTranslate}px) `; } /** * Apply scale correction for the tree transform. * This will apply scale to the screen-orientated axes. */ if (treeScale.x !== 1 || treeScale.y !== 1) { transform += `scale(${1 / treeScale.x}, ${1 / treeScale.y}) `; } if (latestTransform) { const { transformPerspective, rotate, rotateX, rotateY, skewX, skewY } = latestTransform; if (transformPerspective) transform = `perspective(${transformPerspective}px) ${transform}`; if (rotate) transform += `rotate(${rotate}deg) `; if (rotateX) transform += `rotateX(${rotateX}deg) `; if (rotateY) transform += `rotateY(${rotateY}deg) `; if (skewX) transform += `skewX(${skewX}deg) `; if (skewY) transform += `skewY(${skewY}deg) `; } /** * Apply scale to match the size of the element to the size we want it. * This will apply scale to the element-orientated axes. */ const elementScaleX = delta.x.scale * treeScale.x; const elementScaleY = delta.y.scale * treeScale.y; if (elementScaleX !== 1 || elementScaleY !== 1) { transform += `scale(${elementScaleX}, ${elementScaleY})`; } return transform || "none"; } function eachAxis(callback) { return [callback("x"), callback("y")]; } /** * This should only ever be modified on the client otherwise it'll * persist through server requests. If we need instanced states we * could lazy-init via root. */ const globalProjectionState = { /** * Global flag as to whether the tree has animated since the last time * we resized the window */ hasAnimatedSinceResize: true, /** * We set this to true once, on the first update. Any nodes added to the tree beyond that * update will be given a `data-projection-id` attribute. */ hasEverUpdated: false, }; const metrics = { nodes: 0, calculatedTargetDeltas: 0, calculatedProjections: 0, }; const transformAxes = ["", "X", "Y", "Z"]; const hiddenVisibility = { visibility: "hidden" }; /** * We use 1000 as the animation target as 0-1000 maps better to pixels than 0-1 * which has a noticeable difference in spring animations */ const animationTarget = 1000; let id$1 = 0; function resetDistortingTransform(key, visualElement, values, sharedAnimationValues) { const { latestValues } = visualElement; // Record the distorting transform and then temporarily set it to 0 if (latestValues[key]) { values[key] = latestValues[key]; visualElement.setStaticValue(key, 0); if (sharedAnimationValues) { sharedAnimationValues[key] = 0; } } } function cancelTreeOptimisedTransformAnimations(projectionNode) { projectionNode.hasCheckedOptimisedAppear = true; if (projectionNode.root === projectionNode) return; const { visualElement } = projectionNode.options; if (!visualElement) return; const appearId = getOptimisedAppearId(visualElement); if (window.MotionHasOptimisedAnimation(appearId, "transform")) { const { layout, layoutId } = projectionNode.options; window.MotionCancelOptimisedAnimation(appearId, "transform", motionDom.frame, !(layout || layoutId)); } const { parent } = projectionNode; if (parent && !parent.hasCheckedOptimisedAppear) { cancelTreeOptimisedTransformAnimations(parent); } } function createProjectionNode$1({ attachResizeListener, defaultParent, measureScroll, checkIsScrollRoot, resetTransform, }) { return class ProjectionNode { constructor(latestValues = {}, parent = defaultParent?.()) { /** * A unique ID generated for every projection node. */ this.id = id$1++; /** * An id that represents a unique session instigated by startUpdate. */ this.animationId = 0; /** * A Set containing all this component's children. This is used to iterate * through the children. * * TODO: This could be faster to iterate as a flat array stored on the root node. */ this.children = new Set(); /** * Options for the node. We use this to configure what kind of layout animations * we should perform (if any). */ this.options = {}; /** * We use this to detect when its safe to shut down part of a projection tree. * We have to keep projecting children for scale correction and relative projection * until all their parents stop performing layout animations. */ this.isTreeAnimating = false; this.isAnimationBlocked = false; /** * Flag to true if we think this layout has been changed. We can't always know this, * currently we set it to true every time a component renders, or if it has a layoutDependency * if that has changed between renders. Additionally, components can be grouped by LayoutGroup * and if one node is dirtied, they all are. */ this.isLayoutDirty = false; /** * Flag to true if we think the projection calculations for this node needs * recalculating as a result of an updated transform or layout animation. */ this.isProjectionDirty = false; /** * Flag to true if the layout *or* transform has changed. This then gets propagated * throughout the projection tree, forcing any element below to recalculate on the next frame. */ this.isSharedProjectionDirty = false; /** * Flag transform dirty. This gets propagated throughout the whole tree but is only * respected by shared nodes. */ this.isTransformDirty = false; /** * Block layout updates for instant layout transitions throughout the tree. */ this.updateManuallyBlocked = false; this.updateBlockedByResize = false; /** * Set to true between the start of the first `willUpdate` call and the end of the `didUpdate` * call. */ this.isUpdating = false; /** * If this is an SVG element we currently disable projection transforms */ this.isSVG = false; /** * Flag to true (during promotion) if a node doing an instant layout transition needs to reset * its projection styles. */ this.needsReset = false; /** * Flags whether this node should have its transform reset prior to measuring. */ this.shouldResetTransform = false; /** * Store whether this node has been checked for optimised appear animations. As * effects fire bottom-up, and we want to look up the tree for appear animations, * this makes sure we only check each path once, stopping at nodes that * have already been checked. */ this.hasCheckedOptimisedAppear = false; /** * An object representing the calculated contextual/accumulated/tree scale. * This will be used to scale calculcated projection transforms, as these are * calculated in screen-space but need to be scaled for elements to layoutly * make it to their calculated destinations. * * TODO: Lazy-init */ this.treeScale = { x: 1, y: 1 }; /** * */ this.eventHandlers = new Map(); this.hasTreeAnimated = false; // Note: Currently only running on root node this.updateScheduled = false; this.scheduleUpdate = () => this.update(); this.projectionUpdateScheduled = false; this.checkUpdateFailed = () => { if (this.isUpdating) { this.isUpdating = false; this.clearAllSnapshots(); } }; /** * This is a multi-step process as shared nodes might be of different depths. Nodes * are sorted by depth order, so we need to resolve the entire tree before moving to * the next step. */ this.updateProjection = () => { this.projectionUpdateScheduled = false; /** * Reset debug counts. Manually resetting rather than creating a new * object each frame. */ if (motionDom.statsBuffer.value) { metrics.nodes = metrics.calculatedTargetDeltas = metrics.calculatedProjections = 0; } this.nodes.forEach(propagateDirtyNodes); this.nodes.forEach(resolveTargetDelta); this.nodes.forEach(calcProjection); this.nodes.forEach(cleanDirtyNodes); if (motionDom.statsBuffer.addProjectionMetrics) { motionDom.statsBuffer.addProjectionMetrics(metrics); } }; /** * Frame calculations */ this.resolvedRelativeTargetAt = 0.0; this.hasProjected = false; this.isVisible = true; this.animationProgress = 0; /** * Shared layout */ // TODO Only running on root node this.sharedNodes = new Map(); this.latestValues = latestValues; this.root = parent ? parent.root || parent : this; this.path = parent ? [...parent.path, parent] : []; this.parent = parent; this.depth = parent ? parent.depth + 1 : 0; for (let i = 0; i < this.path.length; i++) { this.path[i].shouldResetTransform = true; } if (this.root === this) this.nodes = new FlatTree(); } addEventListener(name, handler) { if (!this.eventHandlers.has(name)) { this.eventHandlers.set(name, new motionUtils.SubscriptionManager()); } return this.eventHandlers.get(name).add(handler); } notifyListeners(name, ...args) { const subscriptionManager = this.eventHandlers.get(name); subscriptionManager && subscriptionManager.notify(...args); } hasListeners(name) { return this.eventHandlers.has(name); } /** * Lifecycles */ mount(instance) { if (this.instance) return; this.isSVG = motionDom.isSVGElement(instance) && !motionDom.isSVGSVGElement(instance); this.instance = instance; const { layoutId, layout, visualElement } = this.options; if (visualElement && !visualElement.current) { visualElement.mount(instance); } this.root.nodes.add(this); this.parent && this.parent.children.add(this); if (this.root.hasTreeAnimated && (layout || layoutId)) { this.isLayoutDirty = true; } if (attachResizeListener) { let cancelDelay; const resizeUnblockUpdate = () => (this.root.updateBlockedByResize = false); attachResizeListener(instance, () => { this.root.updateBlockedByResize = true; cancelDelay && cancelDelay(); cancelDelay = delay(resizeUnblockUpdate, 250); if (globalProjectionState.hasAnimatedSinceResize) { globalProjectionState.hasAnimatedSinceResize = false; this.nodes.forEach(finishAnimation); } }); } if (layoutId) { this.root.registerSharedNode(layoutId, this); } // Only register the handler if it requires layout animation if (this.options.animate !== false && visualElement && (layoutId || layout)) { this.addEventListener("didUpdate", ({ delta, hasLayoutChanged, hasRelativeLayoutChanged, layout: newLayout, }) => { if (this.isTreeAnimationBlocked()) { this.target = undefined; this.relativeTarget = undefined; return; } // TODO: Check here if an animation exists const layoutTransition = this.options.transition || visualElement.getDefaultTransition() || defaultLayoutTransition; const { onLayoutAnimationStart, onLayoutAnimationComplete, } = visualElement.getProps(); /** * The target layout of the element might stay the same, * but its position relative to its parent has changed. */ const hasTargetChanged = !this.targetLayout || !boxEqualsRounded(this.targetLayout, newLayout); /* * Note: Disabled to fix relative animations always triggering new * layout animations. If this causes further issues, we can try * a different approach to detecting relative target changes. */ // || hasRelativeLayoutChanged /** * If the layout hasn't seemed to have changed, it might be that the * element is visually in the same place in the document but its position * relative to its parent has indeed changed. So here we check for that. */ const hasOnlyRelativeTargetChanged = !hasLayoutChanged && hasRelativeLayoutChanged; if (this.options.layoutRoot || this.resumeFrom || hasOnlyRelativeTargetChanged || (hasLayoutChanged && (hasTargetChanged || !this.currentAnimation))) { if (this.resumeFrom) { this.resumingFrom = this.resumeFrom; this.resumingFrom.resumingFrom = undefined; } const animationOptions = { ...motionDom.getValueTransition(layoutTransition, "layout"), onPlay: onLayoutAnimationStart, onComplete: onLayoutAnimationComplete, }; if (visualElement.shouldReduceMotion || this.options.layoutRoot) { animationOptions.delay = 0; animationOptions.type = false; } this.startAnimation(animationOptions); /** * Set animation origin after starting animation to avoid layout jump * caused by stopping previous layout animation */ this.setAnimationOrigin(delta, hasOnlyRelativeTargetChanged); } else { /** * If the layout hasn't changed and we have an animation that hasn't started yet, * finish it immediately. Otherwise it will be animating from a location * that was probably never commited to screen and look like a jumpy box. */ if (!hasLayoutChanged) { finishAnimation(this); } if (this.isLead() && this.options.onExitComplete) { this.options.onExitComplete(); } } this.targetLayout = newLayout; }); } } unmount() { this.options.layoutId && this.willUpdate(); this.root.nodes.remove(this); const stack = this.getStack(); stack && stack.remove(this); this.parent && this.parent.children.delete(this); this.instance = undefined; this.eventHandlers.clear(); motionDom.cancelFrame(this.updateProjection); } // only on the root blockUpdate() { this.updateManuallyBlocked = true; } unblockUpdate() { this.updateManuallyBlocked = false; } isUpdateBlocked() { return this.updateManuallyBlocked || this.updateBlockedByResize; } isTreeAnimationBlocked() { return (this.isAnimationBlocked || (this.parent && this.parent.isTreeAnimationBlocked()) || false); } // Note: currently only running on root node startUpdate() { if (this.isUpdateBlocked()) return; this.isUpdating = true; this.nodes && this.nodes.forEach(resetSkewAndRotation); this.animationId++; } getTransformTemplate() { const { visualElement } = this.options; return visualElement && visualElement.getProps().transformTemplate; } willUpdate(shouldNotifyListeners = true) { this.root.hasTreeAnimated = true; if (this.root.isUpdateBlocked()) { this.options.onExitComplete && this.options.onExitComplete(); return; } /** * If we're running optimised appear animations then these must be * cancelled before measuring the DOM. This is so we can measure * the true layout of the element rather than the WAAPI animation * which will be unaffected by the resetSkewAndRotate step. * * Note: This is a DOM write. Worst case scenario is this is sandwiched * between other snapshot reads which will cause unnecessary style recalculations. * This has to happen here though, as we don't yet know which nodes will need * snapshots in startUpdate(), but we only want to cancel optimised animations * if a layout animation measurement is actually going to be affected by them. */ if (window.MotionCancelOptimisedAnimation && !this.hasCheckedOptimisedAppear) { cancelTreeOptimisedTransformAnimations(this); } !this.root.isUpdating && this.root.startUpdate(); if (this.isLayoutDirty) return; this.isLayoutDirty = true; for (let i = 0; i < this.path.length; i++) { const node = this.path[i]; node.shouldResetTransform = true; node.updateScroll("snapshot"); if (node.options.layoutRoot) { node.willUpdate(false); } } const { layoutId, layout } = this.options; if (layoutId === undefined && !layout) return; const transformTemplate = this.getTransformTemplate(); this.prevTransformTemplateValue = transformTemplate ? transformTemplate(this.latestValues, "") : undefined; this.updateSnapshot(); shouldNotifyListeners && this.notifyListeners("willUpdate"); } update() { this.updateScheduled = false; const updateWasBlocked = this.isUpdateBlocked(); // When doing an instant transition, we skip the layout update, // but should still clean up the measurements so that the next // snapshot could be taken correctly. if (updateWasBlocked) { this.unblockUpdate(); this.clear