6284 lines
240 KiB
JavaScript
6284 lines
240 KiB
JavaScript
'use strict';
|
|
|
|
var React = require('react');
|
|
var motionDom = require('motion-dom');
|
|
var motionUtils = require('motion-utils');
|
|
var jsxRuntime = require('react/jsx-runtime');
|
|
|
|
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)) {
|
|
motionDom.makeAnimationInstant(options);
|
|
if (options.delay === 0) {
|
|
shouldSkip = true;
|
|
}
|
|
}
|
|
if (motionUtils.MotionGlobalConfig.instantAnimations ||
|
|
motionUtils.MotionGlobalConfig.skipAnimations) {
|
|
shouldSkip = true;
|
|
motionDom.makeAnimationInstant(options);
|
|
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"];
|
|
/**
|
|
* 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;
|
|
this.animationCommitId = 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;
|
|
let innerWidth = 0;
|
|
const resizeUnblockUpdate = () => (this.root.updateBlockedByResize = false);
|
|
// Set initial innerWidth in a frame.read callback to batch the read
|
|
motionDom.frame.read(() => {
|
|
innerWidth = window.innerWidth;
|
|
});
|
|
attachResizeListener(instance, () => {
|
|
const newInnerWidth = window.innerWidth;
|
|
if (newInnerWidth === innerWidth)
|
|
return;
|
|
innerWidth = newInnerWidth;
|
|
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.clearAllSnapshots();
|
|
this.nodes.forEach(clearMeasurements);
|
|
return;
|
|
}
|
|
/**
|
|
* If this is a repeat of didUpdate then ignore the animation.
|
|
*/
|
|
if (this.animationId <= this.animationCommitId) {
|
|
this.nodes.forEach(clearIsLayoutDirty);
|
|
return;
|
|
}
|
|
this.animationCommitId = this.animationId;
|
|
if (!this.isUpdating) {
|
|
this.nodes.forEach(clearIsLayoutDirty);
|
|
}
|
|
else {
|
|
this.isUpdating = false;
|
|
/**
|
|
* Write
|
|
*/
|
|
this.nodes.forEach(resetTransformStyle);
|
|
/**
|
|
* Read ==================
|
|
*/
|
|
// Update layout measurements of updated children
|
|
this.nodes.forEach(updateLayout);
|
|
/**
|
|
* Write
|
|
*/
|
|
// Notify listeners that the layout is updated
|
|
this.nodes.forEach(notifyLayoutUpdate);
|
|
}
|
|
this.clearAllSnapshots();
|
|
/**
|
|
* Manually flush any pending updates. Ideally
|
|
* we could leave this to the following requestAnimationFrame but this seems
|
|
* to leave a flash of incorrectly styled content.
|
|
*/
|
|
const now = motionDom.time.now();
|
|
motionDom.frameData.delta = motionUtils.clamp(0, 1000 / 60, now - motionDom.frameData.timestamp);
|
|
motionDom.frameData.timestamp = now;
|
|
motionDom.frameData.isProcessing = true;
|
|
motionDom.frameSteps.update.process(motionDom.frameData);
|
|
motionDom.frameSteps.preRender.process(motionDom.frameData);
|
|
motionDom.frameSteps.render.process(motionDom.frameData);
|
|
motionDom.frameData.isProcessing = false;
|
|
}
|
|
didUpdate() {
|
|
if (!this.updateScheduled) {
|
|
this.updateScheduled = true;
|
|
motionDom.microtask.read(this.scheduleUpdate);
|
|
}
|
|
}
|
|
clearAllSnapshots() {
|
|
this.nodes.forEach(clearSnapshot);
|
|
this.sharedNodes.forEach(removeLeadSnapshots);
|
|
}
|
|
scheduleUpdateProjection() {
|
|
if (!this.projectionUpdateScheduled) {
|
|
this.projectionUpdateScheduled = true;
|
|
motionDom.frame.preRender(this.updateProjection, false, true);
|
|
}
|
|
}
|
|
scheduleCheckAfterUnmount() {
|
|
/**
|
|
* If the unmounting node is in a layoutGroup and did trigger a willUpdate,
|
|
* we manually call didUpdate to give a chance to the siblings to animate.
|
|
* Otherwise, cleanup all snapshots to prevents future nodes from reusing them.
|
|
*/
|
|
motionDom.frame.postRender(() => {
|
|
if (this.isLayoutDirty) {
|
|
this.root.didUpdate();
|
|
}
|
|
else {
|
|
this.root.checkUpdateFailed();
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Update measurements
|
|
*/
|
|
updateSnapshot() {
|
|
if (this.snapshot || !this.instance)
|
|
return;
|
|
this.snapshot = this.measure();
|
|
if (this.snapshot &&
|
|
!calcLength(this.snapshot.measuredBox.x) &&
|
|
!calcLength(this.snapshot.measuredBox.y)) {
|
|
this.snapshot = undefined;
|
|
}
|
|
}
|
|
updateLayout() {
|
|
if (!this.instance)
|
|
return;
|
|
this.updateScroll();
|
|
if (!(this.options.alwaysMeasureLayout && this.isLead()) &&
|
|
!this.isLayoutDirty) {
|
|
return;
|
|
}
|
|
/**
|
|
* When a node is mounted, it simply resumes from the prevLead's
|
|
* snapshot instead of taking a new one, but the ancestors scroll
|
|
* might have updated while the prevLead is unmounted. We need to
|
|
* update the scroll again to make sure the layout we measure is
|
|
* up to date.
|
|
*/
|
|
if (this.resumeFrom && !this.resumeFrom.instance) {
|
|
for (let i = 0; i < this.path.length; i++) {
|
|
const node = this.path[i];
|
|
node.updateScroll();
|
|
}
|
|
}
|
|
const prevLayout = this.layout;
|
|
this.layout = this.measure(false);
|
|
this.layoutCorrected = createBox();
|
|
this.isLayoutDirty = false;
|
|
this.projectionDelta = undefined;
|
|
this.notifyListeners("measure", this.layout.layoutBox);
|
|
const { visualElement } = this.options;
|
|
visualElement &&
|
|
visualElement.notify("LayoutMeasure", this.layout.layoutBox, prevLayout ? prevLayout.layoutBox : undefined);
|
|
}
|
|
updateScroll(phase = "measure") {
|
|
let needsMeasurement = Boolean(this.options.layoutScroll && this.instance);
|
|
if (this.scroll &&
|
|
this.scroll.animationId === this.root.animationId &&
|
|
this.scroll.phase === phase) {
|
|
needsMeasurement = false;
|
|
}
|
|
if (needsMeasurement && this.instance) {
|
|
const isRoot = checkIsScrollRoot(this.instance);
|
|
this.scroll = {
|
|
animationId: this.root.animationId,
|
|
phase,
|
|
isRoot,
|
|
offset: measureScroll(this.instance),
|
|
wasRoot: this.scroll ? this.scroll.isRoot : isRoot,
|
|
};
|
|
}
|
|
}
|
|
resetTransform() {
|
|
if (!resetTransform)
|
|
return;
|
|
const isResetRequested = this.isLayoutDirty ||
|
|
this.shouldResetTransform ||
|
|
this.options.alwaysMeasureLayout;
|
|
const hasProjection = this.projectionDelta && !isDeltaZero(this.projectionDelta);
|
|
const transformTemplate = this.getTransformTemplate();
|
|
const transformTemplateValue = transformTemplate
|
|
? transformTemplate(this.latestValues, "")
|
|
: undefined;
|
|
const transformTemplateHasChanged = transformTemplateValue !== this.prevTransformTemplateValue;
|
|
if (isResetRequested &&
|
|
this.instance &&
|
|
(hasProjection ||
|
|
hasTransform(this.latestValues) ||
|
|
transformTemplateHasChanged)) {
|
|
resetTransform(this.instance, transformTemplateValue);
|
|
this.shouldResetTransform = false;
|
|
this.scheduleRender();
|
|
}
|
|
}
|
|
measure(removeTransform = true) {
|
|
const pageBox = this.measurePageBox();
|
|
let layoutBox = this.removeElementScroll(pageBox);
|
|
/**
|
|
* Measurements taken during the pre-render stage
|
|
* still have transforms applied so we remove them
|
|
* via calculation.
|
|
*/
|
|
if (removeTransform) {
|
|
layoutBox = this.removeTransform(layoutBox);
|
|
}
|
|
roundBox(layoutBox);
|
|
return {
|
|
animationId: this.root.animationId,
|
|
measuredBox: pageBox,
|
|
layoutBox,
|
|
latestValues: {},
|
|
source: this.id,
|
|
};
|
|
}
|
|
measurePageBox() {
|
|
const { visualElement } = this.options;
|
|
if (!visualElement)
|
|
return createBox();
|
|
const box = visualElement.measureViewportBox();
|
|
const wasInScrollRoot = this.scroll?.wasRoot || this.path.some(checkNodeWasScrollRoot);
|
|
if (!wasInScrollRoot) {
|
|
// Remove viewport scroll to give page-relative coordinates
|
|
const { scroll } = this.root;
|
|
if (scroll) {
|
|
translateAxis(box.x, scroll.offset.x);
|
|
translateAxis(box.y, scroll.offset.y);
|
|
}
|
|
}
|
|
return box;
|
|
}
|
|
removeElementScroll(box) {
|
|
const boxWithoutScroll = createBox();
|
|
copyBoxInto(boxWithoutScroll, box);
|
|
if (this.scroll?.wasRoot) {
|
|
return boxWithoutScroll;
|
|
}
|
|
/**
|
|
* Performance TODO: Keep a cumulative scroll offset down the tree
|
|
* rather than loop back up the path.
|
|
*/
|
|
for (let i = 0; i < this.path.length; i++) {
|
|
const node = this.path[i];
|
|
const { scroll, options } = node;
|
|
if (node !== this.root && scroll && options.layoutScroll) {
|
|
/**
|
|
* If this is a new scroll root, we want to remove all previous scrolls
|
|
* from the viewport box.
|
|
*/
|
|
if (scroll.wasRoot) {
|
|
copyBoxInto(boxWithoutScroll, box);
|
|
}
|
|
translateAxis(boxWithoutScroll.x, scroll.offset.x);
|
|
translateAxis(boxWithoutScroll.y, scroll.offset.y);
|
|
}
|
|
}
|
|
return boxWithoutScroll;
|
|
}
|
|
applyTransform(box, transformOnly = false) {
|
|
const withTransforms = createBox();
|
|
copyBoxInto(withTransforms, box);
|
|
for (let i = 0; i < this.path.length; i++) {
|
|
const node = this.path[i];
|
|
if (!transformOnly &&
|
|
node.options.layoutScroll &&
|
|
node.scroll &&
|
|
node !== node.root) {
|
|
transformBox(withTransforms, {
|
|
x: -node.scroll.offset.x,
|
|
y: -node.scroll.offset.y,
|
|
});
|
|
}
|
|
if (!hasTransform(node.latestValues))
|
|
continue;
|
|
transformBox(withTransforms, node.latestValues);
|
|
}
|
|
if (hasTransform(this.latestValues)) {
|
|
transformBox(withTransforms, this.latestValues);
|
|
}
|
|
return withTransforms;
|
|
}
|
|
removeTransform(box) {
|
|
const boxWithoutTransform = createBox();
|
|
copyBoxInto(boxWithoutTransform, box);
|
|
for (let i = 0; i < this.path.length; i++) {
|
|
const node = this.path[i];
|
|
if (!node.instance)
|
|
continue;
|
|
if (!hasTransform(node.latestValues))
|
|
continue;
|
|
hasScale(node.latestValues) && node.updateSnapshot();
|
|
const sourceBox = createBox();
|
|
const nodeBox = node.measurePageBox();
|
|
copyBoxInto(sourceBox, nodeBox);
|
|
removeBoxTransforms(boxWithoutTransform, node.latestValues, node.snapshot ? node.snapshot.layoutBox : undefined, sourceBox);
|
|
}
|
|
if (hasTransform(this.latestValues)) {
|
|
removeBoxTransforms(boxWithoutTransform, this.latestValues);
|
|
}
|
|
return boxWithoutTransform;
|
|
}
|
|
setTargetDelta(delta) {
|
|
this.targetDelta = delta;
|
|
this.root.scheduleUpdateProjection();
|
|
this.isProjectionDirty = true;
|
|
}
|
|
setOptions(options) {
|
|
this.options = {
|
|
...this.options,
|
|
...options,
|
|
crossfade: options.crossfade !== undefined ? options.crossfade : true,
|
|
};
|
|
}
|
|
clearMeasurements() {
|
|
this.scroll = undefined;
|
|
this.layout = undefined;
|
|
this.snapshot = undefined;
|
|
this.prevTransformTemplateValue = undefined;
|
|
this.targetDelta = undefined;
|
|
this.target = undefined;
|
|
this.isLayoutDirty = false;
|
|
}
|
|
forceRelativeParentToResolveTarget() {
|
|
if (!this.relativeParent)
|
|
return;
|
|
/**
|
|
* If the parent target isn't up-to-date, force it to update.
|
|
* This is an unfortunate de-optimisation as it means any updating relative
|
|
* projection will cause all the relative parents to recalculate back
|
|
* up the tree.
|
|
*/
|
|
if (this.relativeParent.resolvedRelativeTargetAt !==
|
|
motionDom.frameData.timestamp) {
|
|
this.relativeParent.resolveTargetDelta(true);
|
|
}
|
|
}
|
|
resolveTargetDelta(forceRecalculation = false) {
|
|
/**
|
|
* Once the dirty status of nodes has been spread through the tree, we also
|
|
* need to check if we have a shared node of a different depth that has itself
|
|
* been dirtied.
|
|
*/
|
|
const lead = this.getLead();
|
|
this.isProjectionDirty || (this.isProjectionDirty = lead.isProjectionDirty);
|
|
this.isTransformDirty || (this.isTransformDirty = lead.isTransformDirty);
|
|
this.isSharedProjectionDirty || (this.isSharedProjectionDirty = lead.isSharedProjectionDirty);
|
|
const isShared = Boolean(this.resumingFrom) || this !== lead;
|
|
/**
|
|
* We don't use transform for this step of processing so we don't
|
|
* need to check whether any nodes have changed transform.
|
|
*/
|
|
const canSkip = !(forceRecalculation ||
|
|
(isShared && this.isSharedProjectionDirty) ||
|
|
this.isProjectionDirty ||
|
|
this.parent?.isProjectionDirty ||
|
|
this.attemptToResolveRelativeTarget ||
|
|
this.root.updateBlockedByResize);
|
|
if (canSkip)
|
|
return;
|
|
const { layout, layoutId } = this.options;
|
|
/**
|
|
* If we have no layout, we can't perform projection, so early return
|
|
*/
|
|
if (!this.layout || !(layout || layoutId))
|
|
return;
|
|
this.resolvedRelativeTargetAt = motionDom.frameData.timestamp;
|
|
/**
|
|
* If we don't have a targetDelta but do have a layout, we can attempt to resolve
|
|
* a relativeParent. This will allow a component to perform scale correction
|
|
* even if no animation has started.
|
|
*/
|
|
if (!this.targetDelta && !this.relativeTarget) {
|
|
const relativeParent = this.getClosestProjectingParent();
|
|
if (relativeParent &&
|
|
relativeParent.layout &&
|
|
this.animationProgress !== 1) {
|
|
this.relativeParent = relativeParent;
|
|
this.forceRelativeParentToResolveTarget();
|
|
this.relativeTarget = createBox();
|
|
this.relativeTargetOrigin = createBox();
|
|
calcRelativePosition(this.relativeTargetOrigin, this.layout.layoutBox, relativeParent.layout.layoutBox);
|
|
copyBoxInto(this.relativeTarget, this.relativeTargetOrigin);
|
|
}
|
|
else {
|
|
this.relativeParent = this.relativeTarget = undefined;
|
|
}
|
|
}
|
|
/**
|
|
* If we have no relative target or no target delta our target isn't valid
|
|
* for this frame.
|
|
*/
|
|
if (!this.relativeTarget && !this.targetDelta)
|
|
return;
|
|
/**
|
|
* Lazy-init target data structure
|
|
*/
|
|
if (!this.target) {
|
|
this.target = createBox();
|
|
this.targetWithTransforms = createBox();
|
|
}
|
|
/**
|
|
* If we've got a relative box for this component, resolve it into a target relative to the parent.
|
|
*/
|
|
if (this.relativeTarget &&
|
|
this.relativeTargetOrigin &&
|
|
this.relativeParent &&
|
|
this.relativeParent.target) {
|
|
this.forceRelativeParentToResolveTarget();
|
|
calcRelativeBox(this.target, this.relativeTarget, this.relativeParent.target);
|
|
/**
|
|
* If we've only got a targetDelta, resolve it into a target
|
|
*/
|
|
}
|
|
else if (this.targetDelta) {
|
|
if (Boolean(this.resumingFrom)) {
|
|
// TODO: This is creating a new object every frame
|
|
this.target = this.applyTransform(this.layout.layoutBox);
|
|
}
|
|
else {
|
|
copyBoxInto(this.target, this.layout.layoutBox);
|
|
}
|
|
applyBoxDelta(this.target, this.targetDelta);
|
|
}
|
|
else {
|
|
/**
|
|
* If no target, use own layout as target
|
|
*/
|
|
copyBoxInto(this.target, this.layout.layoutBox);
|
|
}
|
|
/**
|
|
* If we've been told to attempt to resolve a relative target, do so.
|
|
*/
|
|
if (this.attemptToResolveRelativeTarget) {
|
|
this.attemptToResolveRelativeTarget = false;
|
|
const relativeParent = this.getClosestProjectingParent();
|
|
if (relativeParent &&
|
|
Boolean(relativeParent.resumingFrom) ===
|
|
Boolean(this.resumingFrom) &&
|
|
!relativeParent.options.layoutScroll &&
|
|
relativeParent.target &&
|
|
this.animationProgress !== 1) {
|
|
this.relativeParent = relativeParent;
|
|
this.forceRelativeParentToResolveTarget();
|
|
this.relativeTarget = createBox();
|
|
this.relativeTargetOrigin = createBox();
|
|
calcRelativePosition(this.relativeTargetOrigin, this.target, relativeParent.target);
|
|
copyBoxInto(this.relativeTarget, this.relativeTargetOrigin);
|
|
}
|
|
else {
|
|
this.relativeParent = this.relativeTarget = undefined;
|
|
}
|
|
}
|
|
/**
|
|
* Increase debug counter for resolved target deltas
|
|
*/
|
|
if (motionDom.statsBuffer.value) {
|
|
metrics.calculatedTargetDeltas++;
|
|
}
|
|
}
|
|
getClosestProjectingParent() {
|
|
if (!this.parent ||
|
|
hasScale(this.parent.latestValues) ||
|
|
has2DTranslate(this.parent.latestValues)) {
|
|
return undefined;
|
|
}
|
|
if (this.parent.isProjecting()) {
|
|
return this.parent;
|
|
}
|
|
else {
|
|
return this.parent.getClosestProjectingParent();
|
|
}
|
|
}
|
|
isProjecting() {
|
|
return Boolean((this.relativeTarget ||
|
|
this.targetDelta ||
|
|
this.options.layoutRoot) &&
|
|
this.layout);
|
|
}
|
|
calcProjection() {
|
|
const lead = this.getLead();
|
|
const isShared = Boolean(this.resumingFrom) || this !== lead;
|
|
let canSkip = true;
|
|
/**
|
|
* If this is a normal layout animation and neither this node nor its nearest projecting
|
|
* is dirty then we can't skip.
|
|
*/
|
|
if (this.isProjectionDirty || this.parent?.isProjectionDirty) {
|
|
canSkip = false;
|
|
}
|
|
/**
|
|
* If this is a shared layout animation and this node's shared projection is dirty then
|
|
* we can't skip.
|
|
*/
|
|
if (isShared &&
|
|
(this.isSharedProjectionDirty || this.isTransformDirty)) {
|
|
canSkip = false;
|
|
}
|
|
/**
|
|
* If we have resolved the target this frame we must recalculate the
|
|
* projection to ensure it visually represents the internal calculations.
|
|
*/
|
|
if (this.resolvedRelativeTargetAt === motionDom.frameData.timestamp) {
|
|
canSkip = false;
|
|
}
|
|
if (canSkip)
|
|
return;
|
|
const { layout, layoutId } = this.options;
|
|
/**
|
|
* If this section of the tree isn't animating we can
|
|
* delete our target sources for the following frame.
|
|
*/
|
|
this.isTreeAnimating = Boolean((this.parent && this.parent.isTreeAnimating) ||
|
|
this.currentAnimation ||
|
|
this.pendingAnimation);
|
|
if (!this.isTreeAnimating) {
|
|
this.targetDelta = this.relativeTarget = undefined;
|
|
}
|
|
if (!this.layout || !(layout || layoutId))
|
|
return;
|
|
/**
|
|
* Reset the corrected box with the latest values from box, as we're then going
|
|
* to perform mutative operations on it.
|
|
*/
|
|
copyBoxInto(this.layoutCorrected, this.layout.layoutBox);
|
|
/**
|
|
* Record previous tree scales before updating.
|
|
*/
|
|
const prevTreeScaleX = this.treeScale.x;
|
|
const prevTreeScaleY = this.treeScale.y;
|
|
/**
|
|
* Apply all the parent deltas to this box to produce the corrected box. This
|
|
* is the layout box, as it will appear on screen as a result of the transforms of its parents.
|
|
*/
|
|
applyTreeDeltas(this.layoutCorrected, this.treeScale, this.path, isShared);
|
|
/**
|
|
* If this layer needs to perform scale correction but doesn't have a target,
|
|
* use the layout as the target.
|
|
*/
|
|
if (lead.layout &&
|
|
!lead.target &&
|
|
(this.treeScale.x !== 1 || this.treeScale.y !== 1)) {
|
|
lead.target = lead.layout.layoutBox;
|
|
lead.targetWithTransforms = createBox();
|
|
}
|
|
const { target } = lead;
|
|
if (!target) {
|
|
/**
|
|
* If we don't have a target to project into, but we were previously
|
|
* projecting, we want to remove the stored transform and schedule
|
|
* a render to ensure the elements reflect the removed transform.
|
|
*/
|
|
if (this.prevProjectionDelta) {
|
|
this.createProjectionDeltas();
|
|
this.scheduleRender();
|
|
}
|
|
return;
|
|
}
|
|
if (!this.projectionDelta || !this.prevProjectionDelta) {
|
|
this.createProjectionDeltas();
|
|
}
|
|
else {
|
|
copyAxisDeltaInto(this.prevProjectionDelta.x, this.projectionDelta.x);
|
|
copyAxisDeltaInto(this.prevProjectionDelta.y, this.projectionDelta.y);
|
|
}
|
|
/**
|
|
* Update the delta between the corrected box and the target box before user-set transforms were applied.
|
|
* This will allow us to calculate the corrected borderRadius and boxShadow to compensate
|
|
* for our layout reprojection, but still allow them to be scaled correctly by the user.
|
|
* It might be that to simplify this we may want to accept that user-set scale is also corrected
|
|
* and we wouldn't have to keep and calc both deltas, OR we could support a user setting
|
|
* to allow people to choose whether these styles are corrected based on just the
|
|
* layout reprojection or the final bounding box.
|
|
*/
|
|
calcBoxDelta(this.projectionDelta, this.layoutCorrected, target, this.latestValues);
|
|
if (this.treeScale.x !== prevTreeScaleX ||
|
|
this.treeScale.y !== prevTreeScaleY ||
|
|
!axisDeltaEquals(this.projectionDelta.x, this.prevProjectionDelta.x) ||
|
|
!axisDeltaEquals(this.projectionDelta.y, this.prevProjectionDelta.y)) {
|
|
this.hasProjected = true;
|
|
this.scheduleRender();
|
|
this.notifyListeners("projectionUpdate", target);
|
|
}
|
|
/**
|
|
* Increase debug counter for recalculated projections
|
|
*/
|
|
if (motionDom.statsBuffer.value) {
|
|
metrics.calculatedProjections++;
|
|
}
|
|
}
|
|
hide() {
|
|
this.isVisible = false;
|
|
// TODO: Schedule render
|
|
}
|
|
show() {
|
|
this.isVisible = true;
|
|
// TODO: Schedule render
|
|
}
|
|
scheduleRender(notifyAll = true) {
|
|
this.options.visualElement?.scheduleRender();
|
|
if (notifyAll) {
|
|
const stack = this.getStack();
|
|
stack && stack.scheduleRender();
|
|
}
|
|
if (this.resumingFrom && !this.resumingFrom.instance) {
|
|
this.resumingFrom = undefined;
|
|
}
|
|
}
|
|
createProjectionDeltas() {
|
|
this.prevProjectionDelta = createDelta();
|
|
this.projectionDelta = createDelta();
|
|
this.projectionDeltaWithTransform = createDelta();
|
|
}
|
|
setAnimationOrigin(delta, hasOnlyRelativeTargetChanged = false) {
|
|
const snapshot = this.snapshot;
|
|
const snapshotLatestValues = snapshot ? snapshot.latestValues : {};
|
|
const mixedValues = { ...this.latestValues };
|
|
const targetDelta = createDelta();
|
|
if (!this.relativeParent ||
|
|
!this.relativeParent.options.layoutRoot) {
|
|
this.relativeTarget = this.relativeTargetOrigin = undefined;
|
|
}
|
|
this.attemptToResolveRelativeTarget = !hasOnlyRelativeTargetChanged;
|
|
const relativeLayout = createBox();
|
|
const snapshotSource = snapshot ? snapshot.source : undefined;
|
|
const layoutSource = this.layout ? this.layout.source : undefined;
|
|
const isSharedLayoutAnimation = snapshotSource !== layoutSource;
|
|
const stack = this.getStack();
|
|
const isOnlyMember = !stack || stack.members.length <= 1;
|
|
const shouldCrossfadeOpacity = Boolean(isSharedLayoutAnimation &&
|
|
!isOnlyMember &&
|
|
this.options.crossfade === true &&
|
|
!this.path.some(hasOpacityCrossfade));
|
|
this.animationProgress = 0;
|
|
let prevRelativeTarget;
|
|
this.mixTargetDelta = (latest) => {
|
|
const progress = latest / 1000;
|
|
mixAxisDelta(targetDelta.x, delta.x, progress);
|
|
mixAxisDelta(targetDelta.y, delta.y, progress);
|
|
this.setTargetDelta(targetDelta);
|
|
if (this.relativeTarget &&
|
|
this.relativeTargetOrigin &&
|
|
this.layout &&
|
|
this.relativeParent &&
|
|
this.relativeParent.layout) {
|
|
calcRelativePosition(relativeLayout, this.layout.layoutBox, this.relativeParent.layout.layoutBox);
|
|
mixBox(this.relativeTarget, this.relativeTargetOrigin, relativeLayout, progress);
|
|
/**
|
|
* If this is an unchanged relative target we can consider the
|
|
* projection not dirty.
|
|
*/
|
|
if (prevRelativeTarget &&
|
|
boxEquals(this.relativeTarget, prevRelativeTarget)) {
|
|
this.isProjectionDirty = false;
|
|
}
|
|
if (!prevRelativeTarget)
|
|
prevRelativeTarget = createBox();
|
|
copyBoxInto(prevRelativeTarget, this.relativeTarget);
|
|
}
|
|
if (isSharedLayoutAnimation) {
|
|
this.animationValues = mixedValues;
|
|
mixValues(mixedValues, snapshotLatestValues, this.latestValues, progress, shouldCrossfadeOpacity, isOnlyMember);
|
|
}
|
|
this.root.scheduleUpdateProjection();
|
|
this.scheduleRender();
|
|
this.animationProgress = progress;
|
|
};
|
|
this.mixTargetDelta(this.options.layoutRoot ? 1000 : 0);
|
|
}
|
|
startAnimation(options) {
|
|
this.notifyListeners("animationStart");
|
|
this.currentAnimation?.stop();
|
|
this.resumingFrom?.currentAnimation?.stop();
|
|
if (this.pendingAnimation) {
|
|
motionDom.cancelFrame(this.pendingAnimation);
|
|
this.pendingAnimation = undefined;
|
|
}
|
|
/**
|
|
* Start the animation in the next frame to have a frame with progress 0,
|
|
* where the target is the same as when the animation started, so we can
|
|
* calculate the relative positions correctly for instant transitions.
|
|
*/
|
|
this.pendingAnimation = motionDom.frame.update(() => {
|
|
globalProjectionState.hasAnimatedSinceResize = true;
|
|
motionDom.activeAnimations.layout++;
|
|
this.motionValue || (this.motionValue = motionDom.motionValue(0));
|
|
this.currentAnimation = animateSingleValue(this.motionValue, [0, 1000], {
|
|
...options,
|
|
velocity: 0,
|
|
isSync: true,
|
|
onUpdate: (latest) => {
|
|
this.mixTargetDelta(latest);
|
|
options.onUpdate && options.onUpdate(latest);
|
|
},
|
|
onStop: () => {
|
|
motionDom.activeAnimations.layout--;
|
|
},
|
|
onComplete: () => {
|
|
motionDom.activeAnimations.layout--;
|
|
options.onComplete && options.onComplete();
|
|
this.completeAnimation();
|
|
},
|
|
});
|
|
if (this.resumingFrom) {
|
|
this.resumingFrom.currentAnimation = this.currentAnimation;
|
|
}
|
|
this.pendingAnimation = undefined;
|
|
});
|
|
}
|
|
completeAnimation() {
|
|
if (this.resumingFrom) {
|
|
this.resumingFrom.currentAnimation = undefined;
|
|
this.resumingFrom.preserveOpacity = undefined;
|
|
}
|
|
const stack = this.getStack();
|
|
stack && stack.exitAnimationComplete();
|
|
this.resumingFrom =
|
|
this.currentAnimation =
|
|
this.animationValues =
|
|
undefined;
|
|
this.notifyListeners("animationComplete");
|
|
}
|
|
finishAnimation() {
|
|
if (this.currentAnimation) {
|
|
this.mixTargetDelta && this.mixTargetDelta(animationTarget);
|
|
this.currentAnimation.stop();
|
|
}
|
|
this.completeAnimation();
|
|
}
|
|
applyTransformsToTarget() {
|
|
const lead = this.getLead();
|
|
let { targetWithTransforms, target, layout, latestValues } = lead;
|
|
if (!targetWithTransforms || !target || !layout)
|
|
return;
|
|
/**
|
|
* If we're only animating position, and this element isn't the lead element,
|
|
* then instead of projecting into the lead box we instead want to calculate
|
|
* a new target that aligns the two boxes but maintains the layout shape.
|
|
*/
|
|
if (this !== lead &&
|
|
this.layout &&
|
|
layout &&
|
|
shouldAnimatePositionOnly(this.options.animationType, this.layout.layoutBox, layout.layoutBox)) {
|
|
target = this.target || createBox();
|
|
const xLength = calcLength(this.layout.layoutBox.x);
|
|
target.x.min = lead.target.x.min;
|
|
target.x.max = target.x.min + xLength;
|
|
const yLength = calcLength(this.layout.layoutBox.y);
|
|
target.y.min = lead.target.y.min;
|
|
target.y.max = target.y.min + yLength;
|
|
}
|
|
copyBoxInto(targetWithTransforms, target);
|
|
/**
|
|
* Apply the latest user-set transforms to the targetBox to produce the targetBoxFinal.
|
|
* This is the final box that we will then project into by calculating a transform delta and
|
|
* applying it to the corrected box.
|
|
*/
|
|
transformBox(targetWithTransforms, latestValues);
|
|
/**
|
|
* Update the delta between the corrected box and the final target box, after
|
|
* user-set transforms are applied to it. This will be used by the renderer to
|
|
* create a transform style that will reproject the element from its layout layout
|
|
* into the desired bounding box.
|
|
*/
|
|
calcBoxDelta(this.projectionDeltaWithTransform, this.layoutCorrected, targetWithTransforms, latestValues);
|
|
}
|
|
registerSharedNode(layoutId, node) {
|
|
if (!this.sharedNodes.has(layoutId)) {
|
|
this.sharedNodes.set(layoutId, new NodeStack());
|
|
}
|
|
const stack = this.sharedNodes.get(layoutId);
|
|
stack.add(node);
|
|
const config = node.options.initialPromotionConfig;
|
|
node.promote({
|
|
transition: config ? config.transition : undefined,
|
|
preserveFollowOpacity: config && config.shouldPreserveFollowOpacity
|
|
? config.shouldPreserveFollowOpacity(node)
|
|
: undefined,
|
|
});
|
|
}
|
|
isLead() {
|
|
const stack = this.getStack();
|
|
return stack ? stack.lead === this : true;
|
|
}
|
|
getLead() {
|
|
const { layoutId } = this.options;
|
|
return layoutId ? this.getStack()?.lead || this : this;
|
|
}
|
|
getPrevLead() {
|
|
const { layoutId } = this.options;
|
|
return layoutId ? this.getStack()?.prevLead : undefined;
|
|
}
|
|
getStack() {
|
|
const { layoutId } = this.options;
|
|
if (layoutId)
|
|
return this.root.sharedNodes.get(layoutId);
|
|
}
|
|
promote({ needsReset, transition, preserveFollowOpacity, } = {}) {
|
|
const stack = this.getStack();
|
|
if (stack)
|
|
stack.promote(this, preserveFollowOpacity);
|
|
if (needsReset) {
|
|
this.projectionDelta = undefined;
|
|
this.needsReset = true;
|
|
}
|
|
if (transition)
|
|
this.setOptions({ transition });
|
|
}
|
|
relegate() {
|
|
const stack = this.getStack();
|
|
if (stack) {
|
|
return stack.relegate(this);
|
|
}
|
|
else {
|
|
return false;
|
|
}
|
|
}
|
|
resetSkewAndRotation() {
|
|
const { visualElement } = this.options;
|
|
if (!visualElement)
|
|
return;
|
|
// If there's no detected skew or rotation values, we can early return without a forced render.
|
|
let hasDistortingTransform = false;
|
|
/**
|
|
* An unrolled check for rotation values. Most elements don't have any rotation and
|
|
* skipping the nested loop and new object creation is 50% faster.
|
|
*/
|
|
const { latestValues } = visualElement;
|
|
if (latestValues.z ||
|
|
latestValues.rotate ||
|
|
latestValues.rotateX ||
|
|
latestValues.rotateY ||
|
|
latestValues.rotateZ ||
|
|
latestValues.skewX ||
|
|
latestValues.skewY) {
|
|
hasDistortingTransform = true;
|
|
}
|
|
// If there's no distorting values, we don't need to do any more.
|
|
if (!hasDistortingTransform)
|
|
return;
|
|
const resetValues = {};
|
|
if (latestValues.z) {
|
|
resetDistortingTransform("z", visualElement, resetValues, this.animationValues);
|
|
}
|
|
// Check the skew and rotate value of all axes and reset to 0
|
|
for (let i = 0; i < transformAxes.length; i++) {
|
|
resetDistortingTransform(`rotate${transformAxes[i]}`, visualElement, resetValues, this.animationValues);
|
|
resetDistortingTransform(`skew${transformAxes[i]}`, visualElement, resetValues, this.animationValues);
|
|
}
|
|
// Force a render of this element to apply the transform with all skews and rotations
|
|
// set to 0.
|
|
visualElement.render();
|
|
// Put back all the values we reset
|
|
for (const key in resetValues) {
|
|
visualElement.setStaticValue(key, resetValues[key]);
|
|
if (this.animationValues) {
|
|
this.animationValues[key] = resetValues[key];
|
|
}
|
|
}
|
|
// Schedule a render for the next frame. This ensures we won't visually
|
|
// see the element with the reset rotate value applied.
|
|
visualElement.scheduleRender();
|
|
}
|
|
applyProjectionStyles(targetStyle, // CSSStyleDeclaration - doesn't allow numbers to be assigned to properties
|
|
styleProp) {
|
|
if (!this.instance || this.isSVG)
|
|
return;
|
|
if (!this.isVisible) {
|
|
targetStyle.visibility = "hidden";
|
|
return;
|
|
}
|
|
const transformTemplate = this.getTransformTemplate();
|
|
if (this.needsReset) {
|
|
this.needsReset = false;
|
|
targetStyle.visibility = "";
|
|
targetStyle.opacity = "";
|
|
targetStyle.pointerEvents =
|
|
resolveMotionValue(styleProp?.pointerEvents) || "";
|
|
targetStyle.transform = transformTemplate
|
|
? transformTemplate(this.latestValues, "")
|
|
: "none";
|
|
return;
|
|
}
|
|
const lead = this.getLead();
|
|
if (!this.projectionDelta || !this.layout || !lead.target) {
|
|
if (this.options.layoutId) {
|
|
targetStyle.opacity =
|
|
this.latestValues.opacity !== undefined
|
|
? this.latestValues.opacity
|
|
: 1;
|
|
targetStyle.pointerEvents =
|
|
resolveMotionValue(styleProp?.pointerEvents) || "";
|
|
}
|
|
if (this.hasProjected && !hasTransform(this.latestValues)) {
|
|
targetStyle.transform = transformTemplate
|
|
? transformTemplate({}, "")
|
|
: "none";
|
|
this.hasProjected = false;
|
|
}
|
|
return;
|
|
}
|
|
targetStyle.visibility = "";
|
|
const valuesToRender = lead.animationValues || lead.latestValues;
|
|
this.applyTransformsToTarget();
|
|
let transform = buildProjectionTransform(this.projectionDeltaWithTransform, this.treeScale, valuesToRender);
|
|
if (transformTemplate) {
|
|
transform = transformTemplate(valuesToRender, transform);
|
|
}
|
|
targetStyle.transform = transform;
|
|
const { x, y } = this.projectionDelta;
|
|
targetStyle.transformOrigin = `${x.origin * 100}% ${y.origin * 100}% 0`;
|
|
if (lead.animationValues) {
|
|
/**
|
|
* If the lead component is animating, assign this either the entering/leaving
|
|
* opacity
|
|
*/
|
|
targetStyle.opacity =
|
|
lead === this
|
|
? valuesToRender.opacity ??
|
|
this.latestValues.opacity ??
|
|
1
|
|
: this.preserveOpacity
|
|
? this.latestValues.opacity
|
|
: valuesToRender.opacityExit;
|
|
}
|
|
else {
|
|
/**
|
|
* Or we're not animating at all, set the lead component to its layout
|
|
* opacity and other components to hidden.
|
|
*/
|
|
targetStyle.opacity =
|
|
lead === this
|
|
? valuesToRender.opacity !== undefined
|
|
? valuesToRender.opacity
|
|
: ""
|
|
: valuesToRender.opacityExit !== undefined
|
|
? valuesToRender.opacityExit
|
|
: 0;
|
|
}
|
|
/**
|
|
* Apply scale correction
|
|
*/
|
|
for (const key in scaleCorrectors) {
|
|
if (valuesToRender[key] === undefined)
|
|
continue;
|
|
const { correct, applyTo, isCSSVariable } = scaleCorrectors[key];
|
|
/**
|
|
* Only apply scale correction to the value if we have an
|
|
* active projection transform. Otherwise these values become
|
|
* vulnerable to distortion if the element changes size without
|
|
* a corresponding layout animation.
|
|
*/
|
|
const corrected = transform === "none"
|
|
? valuesToRender[key]
|
|
: correct(valuesToRender[key], lead);
|
|
if (applyTo) {
|
|
const num = applyTo.length;
|
|
for (let i = 0; i < num; i++) {
|
|
targetStyle[applyTo[i]] = corrected;
|
|
}
|
|
}
|
|
else {
|
|
// If this is a CSS variable, set it directly on the instance.
|
|
// Replacing this function from creating styles to setting them
|
|
// would be a good place to remove per frame object creation
|
|
if (isCSSVariable) {
|
|
this.options.visualElement.renderState.vars[key] = corrected;
|
|
}
|
|
else {
|
|
targetStyle[key] = corrected;
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Disable pointer events on follow components. This is to ensure
|
|
* that if a follow component covers a lead component it doesn't block
|
|
* pointer events on the lead.
|
|
*/
|
|
if (this.options.layoutId) {
|
|
targetStyle.pointerEvents =
|
|
lead === this
|
|
? resolveMotionValue(styleProp?.pointerEvents) || ""
|
|
: "none";
|
|
}
|
|
}
|
|
clearSnapshot() {
|
|
this.resumeFrom = this.snapshot = undefined;
|
|
}
|
|
// Only run on root
|
|
resetTree() {
|
|
this.root.nodes.forEach((node) => node.currentAnimation?.stop());
|
|
this.root.nodes.forEach(clearMeasurements);
|
|
this.root.sharedNodes.clear();
|
|
}
|
|
};
|
|
}
|
|
function updateLayout(node) {
|
|
node.updateLayout();
|
|
}
|
|
function notifyLayoutUpdate(node) {
|
|
const snapshot = node.resumeFrom?.snapshot || node.snapshot;
|
|
if (node.isLead() &&
|
|
node.layout &&
|
|
snapshot &&
|
|
node.hasListeners("didUpdate")) {
|
|
const { layoutBox: layout, measuredBox: measuredLayout } = node.layout;
|
|
const { animationType } = node.options;
|
|
const isShared = snapshot.source !== node.layout.source;
|
|
// TODO Maybe we want to also resize the layout snapshot so we don't trigger
|
|
// animations for instance if layout="size" and an element has only changed position
|
|
if (animationType === "size") {
|
|
eachAxis((axis) => {
|
|
const axisSnapshot = isShared
|
|
? snapshot.measuredBox[axis]
|
|
: snapshot.layoutBox[axis];
|
|
const length = calcLength(axisSnapshot);
|
|
axisSnapshot.min = layout[axis].min;
|
|
axisSnapshot.max = axisSnapshot.min + length;
|
|
});
|
|
}
|
|
else if (shouldAnimatePositionOnly(animationType, snapshot.layoutBox, layout)) {
|
|
eachAxis((axis) => {
|
|
const axisSnapshot = isShared
|
|
? snapshot.measuredBox[axis]
|
|
: snapshot.layoutBox[axis];
|
|
const length = calcLength(layout[axis]);
|
|
axisSnapshot.max = axisSnapshot.min + length;
|
|
/**
|
|
* Ensure relative target gets resized and rerendererd
|
|
*/
|
|
if (node.relativeTarget && !node.currentAnimation) {
|
|
node.isProjectionDirty = true;
|
|
node.relativeTarget[axis].max =
|
|
node.relativeTarget[axis].min + length;
|
|
}
|
|
});
|
|
}
|
|
const layoutDelta = createDelta();
|
|
calcBoxDelta(layoutDelta, layout, snapshot.layoutBox);
|
|
const visualDelta = createDelta();
|
|
if (isShared) {
|
|
calcBoxDelta(visualDelta, node.applyTransform(measuredLayout, true), snapshot.measuredBox);
|
|
}
|
|
else {
|
|
calcBoxDelta(visualDelta, layout, snapshot.layoutBox);
|
|
}
|
|
const hasLayoutChanged = !isDeltaZero(layoutDelta);
|
|
let hasRelativeLayoutChanged = false;
|
|
if (!node.resumeFrom) {
|
|
const relativeParent = node.getClosestProjectingParent();
|
|
/**
|
|
* If the relativeParent is itself resuming from a different element then
|
|
* the relative snapshot is not relavent
|
|
*/
|
|
if (relativeParent && !relativeParent.resumeFrom) {
|
|
const { snapshot: parentSnapshot, layout: parentLayout } = relativeParent;
|
|
if (parentSnapshot && parentLayout) {
|
|
const relativeSnapshot = createBox();
|
|
calcRelativePosition(relativeSnapshot, snapshot.layoutBox, parentSnapshot.layoutBox);
|
|
const relativeLayout = createBox();
|
|
calcRelativePosition(relativeLayout, layout, parentLayout.layoutBox);
|
|
if (!boxEqualsRounded(relativeSnapshot, relativeLayout)) {
|
|
hasRelativeLayoutChanged = true;
|
|
}
|
|
if (relativeParent.options.layoutRoot) {
|
|
node.relativeTarget = relativeLayout;
|
|
node.relativeTargetOrigin = relativeSnapshot;
|
|
node.relativeParent = relativeParent;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
node.notifyListeners("didUpdate", {
|
|
layout,
|
|
snapshot,
|
|
delta: visualDelta,
|
|
layoutDelta,
|
|
hasLayoutChanged,
|
|
hasRelativeLayoutChanged,
|
|
});
|
|
}
|
|
else if (node.isLead()) {
|
|
const { onExitComplete } = node.options;
|
|
onExitComplete && onExitComplete();
|
|
}
|
|
/**
|
|
* Clearing transition
|
|
* TODO: Investigate why this transition is being passed in as {type: false } from Framer
|
|
* and why we need it at all
|
|
*/
|
|
node.options.transition = undefined;
|
|
}
|
|
function propagateDirtyNodes(node) {
|
|
/**
|
|
* Increase debug counter for nodes encountered this frame
|
|
*/
|
|
if (motionDom.statsBuffer.value) {
|
|
metrics.nodes++;
|
|
}
|
|
if (!node.parent)
|
|
return;
|
|
/**
|
|
* If this node isn't projecting, propagate isProjectionDirty. It will have
|
|
* no performance impact but it will allow the next child that *is* projecting
|
|
* but *isn't* dirty to just check its parent to see if *any* ancestor needs
|
|
* correcting.
|
|
*/
|
|
if (!node.isProjecting()) {
|
|
node.isProjectionDirty = node.parent.isProjectionDirty;
|
|
}
|
|
/**
|
|
* Propagate isSharedProjectionDirty and isTransformDirty
|
|
* throughout the whole tree. A future revision can take another look at
|
|
* this but for safety we still recalcualte shared nodes.
|
|
*/
|
|
node.isSharedProjectionDirty || (node.isSharedProjectionDirty = Boolean(node.isProjectionDirty ||
|
|
node.parent.isProjectionDirty ||
|
|
node.parent.isSharedProjectionDirty));
|
|
node.isTransformDirty || (node.isTransformDirty = node.parent.isTransformDirty);
|
|
}
|
|
function cleanDirtyNodes(node) {
|
|
node.isProjectionDirty =
|
|
node.isSharedProjectionDirty =
|
|
node.isTransformDirty =
|
|
false;
|
|
}
|
|
function clearSnapshot(node) {
|
|
node.clearSnapshot();
|
|
}
|
|
function clearMeasurements(node) {
|
|
node.clearMeasurements();
|
|
}
|
|
function clearIsLayoutDirty(node) {
|
|
node.isLayoutDirty = false;
|
|
}
|
|
function resetTransformStyle(node) {
|
|
const { visualElement } = node.options;
|
|
if (visualElement && visualElement.getProps().onBeforeLayoutMeasure) {
|
|
visualElement.notify("BeforeLayoutMeasure");
|
|
}
|
|
node.resetTransform();
|
|
}
|
|
function finishAnimation(node) {
|
|
node.finishAnimation();
|
|
node.targetDelta = node.relativeTarget = node.target = undefined;
|
|
node.isProjectionDirty = true;
|
|
}
|
|
function resolveTargetDelta(node) {
|
|
node.resolveTargetDelta();
|
|
}
|
|
function calcProjection(node) {
|
|
node.calcProjection();
|
|
}
|
|
function resetSkewAndRotation(node) {
|
|
node.resetSkewAndRotation();
|
|
}
|
|
function removeLeadSnapshots(stack) {
|
|
stack.removeLeadSnapshot();
|
|
}
|
|
function mixAxisDelta(output, delta, p) {
|
|
output.translate = motionDom.mixNumber(delta.translate, 0, p);
|
|
output.scale = motionDom.mixNumber(delta.scale, 1, p);
|
|
output.origin = delta.origin;
|
|
output.originPoint = delta.originPoint;
|
|
}
|
|
function mixAxis(output, from, to, p) {
|
|
output.min = motionDom.mixNumber(from.min, to.min, p);
|
|
output.max = motionDom.mixNumber(from.max, to.max, p);
|
|
}
|
|
function mixBox(output, from, to, p) {
|
|
mixAxis(output.x, from.x, to.x, p);
|
|
mixAxis(output.y, from.y, to.y, p);
|
|
}
|
|
function hasOpacityCrossfade(node) {
|
|
return (node.animationValues && node.animationValues.opacityExit !== undefined);
|
|
}
|
|
const defaultLayoutTransition = {
|
|
duration: 0.45,
|
|
ease: [0.4, 0, 0.1, 1],
|
|
};
|
|
const userAgentContains = (string) => typeof navigator !== "undefined" &&
|
|
navigator.userAgent &&
|
|
navigator.userAgent.toLowerCase().includes(string);
|
|
/**
|
|
* Measured bounding boxes must be rounded in Safari and
|
|
* left untouched in Chrome, otherwise non-integer layouts within scaled-up elements
|
|
* can appear to jump.
|
|
*/
|
|
const roundPoint = userAgentContains("applewebkit/") && !userAgentContains("chrome/")
|
|
? Math.round
|
|
: motionUtils.noop;
|
|
function roundAxis(axis) {
|
|
// Round to the nearest .5 pixels to support subpixel layouts
|
|
axis.min = roundPoint(axis.min);
|
|
axis.max = roundPoint(axis.max);
|
|
}
|
|
function roundBox(box) {
|
|
roundAxis(box.x);
|
|
roundAxis(box.y);
|
|
}
|
|
function shouldAnimatePositionOnly(animationType, snapshot, layout) {
|
|
return (animationType === "position" ||
|
|
(animationType === "preserve-aspect" &&
|
|
!isNear(aspectRatio(snapshot), aspectRatio(layout), 0.2)));
|
|
}
|
|
function checkNodeWasScrollRoot(node) {
|
|
return node !== node.root && node.scroll?.wasRoot;
|
|
}
|
|
|
|
function addDomEvent(target, eventName, handler, options = { passive: true }) {
|
|
target.addEventListener(eventName, handler, options);
|
|
return () => target.removeEventListener(eventName, handler);
|
|
}
|
|
|
|
const DocumentProjectionNode = createProjectionNode$1({
|
|
attachResizeListener: (ref, notify) => addDomEvent(ref, "resize", notify),
|
|
measureScroll: () => ({
|
|
x: document.documentElement.scrollLeft || document.body.scrollLeft,
|
|
y: document.documentElement.scrollTop || document.body.scrollTop,
|
|
}),
|
|
checkIsScrollRoot: () => true,
|
|
});
|
|
|
|
const rootProjectionNode = {
|
|
current: undefined,
|
|
};
|
|
const HTMLProjectionNode = createProjectionNode$1({
|
|
measureScroll: (instance) => ({
|
|
x: instance.scrollLeft,
|
|
y: instance.scrollTop,
|
|
}),
|
|
defaultParent: () => {
|
|
if (!rootProjectionNode.current) {
|
|
const documentNode = new DocumentProjectionNode({});
|
|
documentNode.mount(window);
|
|
documentNode.setOptions({ layoutScroll: true });
|
|
rootProjectionNode.current = documentNode;
|
|
}
|
|
return rootProjectionNode.current;
|
|
},
|
|
resetTransform: (instance, value) => {
|
|
instance.style.transform = value !== undefined ? value : "none";
|
|
},
|
|
checkIsScrollRoot: (instance) => Boolean(window.getComputedStyle(instance).position === "fixed"),
|
|
});
|
|
|
|
function pixelsToPercent(pixels, axis) {
|
|
if (axis.max === axis.min)
|
|
return 0;
|
|
return (pixels / (axis.max - axis.min)) * 100;
|
|
}
|
|
/**
|
|
* We always correct borderRadius as a percentage rather than pixels to reduce paints.
|
|
* For example, if you are projecting a box that is 100px wide with a 10px borderRadius
|
|
* into a box that is 200px wide with a 20px borderRadius, that is actually a 10%
|
|
* borderRadius in both states. If we animate between the two in pixels that will trigger
|
|
* a paint each time. If we animate between the two in percentage we'll avoid a paint.
|
|
*/
|
|
const correctBorderRadius = {
|
|
correct: (latest, node) => {
|
|
if (!node.target)
|
|
return latest;
|
|
/**
|
|
* If latest is a string, if it's a percentage we can return immediately as it's
|
|
* going to be stretched appropriately. Otherwise, if it's a pixel, convert it to a number.
|
|
*/
|
|
if (typeof latest === "string") {
|
|
if (motionDom.px.test(latest)) {
|
|
latest = parseFloat(latest);
|
|
}
|
|
else {
|
|
return latest;
|
|
}
|
|
}
|
|
/**
|
|
* If latest is a number, it's a pixel value. We use the current viewportBox to calculate that
|
|
* pixel value as a percentage of each axis
|
|
*/
|
|
const x = pixelsToPercent(latest, node.target.x);
|
|
const y = pixelsToPercent(latest, node.target.y);
|
|
return `${x}% ${y}%`;
|
|
},
|
|
};
|
|
|
|
const correctBoxShadow = {
|
|
correct: (latest, { treeScale, projectionDelta }) => {
|
|
const original = latest;
|
|
const shadow = motionDom.complex.parse(latest);
|
|
// TODO: Doesn't support multiple shadows
|
|
if (shadow.length > 5)
|
|
return original;
|
|
const template = motionDom.complex.createTransformer(latest);
|
|
const offset = typeof shadow[0] !== "number" ? 1 : 0;
|
|
// Calculate the overall context scale
|
|
const xScale = projectionDelta.x.scale * treeScale.x;
|
|
const yScale = projectionDelta.y.scale * treeScale.y;
|
|
shadow[0 + offset] /= xScale;
|
|
shadow[1 + offset] /= yScale;
|
|
/**
|
|
* Ideally we'd correct x and y scales individually, but because blur and
|
|
* spread apply to both we have to take a scale average and apply that instead.
|
|
* We could potentially improve the outcome of this by incorporating the ratio between
|
|
* the two scales.
|
|
*/
|
|
const averageScale = motionDom.mixNumber(xScale, yScale, 0.5);
|
|
// Blur
|
|
if (typeof shadow[2 + offset] === "number")
|
|
shadow[2 + offset] /= averageScale;
|
|
// Spread
|
|
if (typeof shadow[3 + offset] === "number")
|
|
shadow[3 + offset] /= averageScale;
|
|
return template(shadow);
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 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 },
|
|
};
|
|
}
|
|
function convertBoxToBoundingBox({ x, y }) {
|
|
return { top: y.min, right: x.max, bottom: y.max, left: x.min };
|
|
}
|
|
/**
|
|
* 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));
|
|
}
|
|
function measurePageBox(element, rootProjectionNode, transformPagePoint) {
|
|
const viewportBox = measureViewportBox(element, transformPagePoint);
|
|
const { scroll } = rootProjectionNode;
|
|
if (scroll) {
|
|
translateAxis(viewportBox.x, scroll.offset.x);
|
|
translateAxis(viewportBox.y, scroll.offset.y);
|
|
}
|
|
return viewportBox;
|
|
}
|
|
|
|
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]),
|
|
};
|
|
}
|
|
|
|
// 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.addEventListener("change", setReducedMotionPreferences);
|
|
setReducedMotionPreferences();
|
|
}
|
|
else {
|
|
prefersReducedMotion.current = false;
|
|
}
|
|
}
|
|
|
|
const visualElementStore = new WeakMap();
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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]);
|
|
}
|
|
}
|
|
}
|
|
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.", "reduced-motion-disabled");
|
|
}
|
|
this.parent?.addChild(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?.removeChild(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;
|
|
}
|
|
addChild(child) {
|
|
this.children.add(child);
|
|
this.enteringChildren ?? (this.enteringChildren = new Set());
|
|
this.enteringChildren.add(child);
|
|
}
|
|
removeChild(child) {
|
|
this.children.delete(child);
|
|
this.enteringChildren && this.enteringChildren.delete(child);
|
|
}
|
|
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;
|
|
}
|
|
this.scheduleRender();
|
|
});
|
|
let removeSyncCheck;
|
|
if (window.MotionCheckAppearSync) {
|
|
removeSyncCheck = window.MotionCheckAppearSync(this, key, value);
|
|
}
|
|
this.valueSubscriptions.set(key, () => {
|
|
removeOnChange();
|
|
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);
|
|
}
|
|
}
|
|
scheduleRenderMicrotask() {
|
|
motionDom.microtask.render(this.render);
|
|
}
|
|
}
|
|
|
|
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 = motionDom.transformPropOrder.length;
|
|
/**
|
|
* Build a CSS transform style from individual x/y/scale etc properties.
|
|
*
|
|
* This outputs with a default order of transforms/scales/rotations, this can be customised by
|
|
* providing a transformTemplate function.
|
|
*/
|
|
function buildTransform(latestValues, transform, transformTemplate) {
|
|
// The transform string we're going to build into.
|
|
let transformString = "";
|
|
let transformIsDefault = true;
|
|
/**
|
|
* Loop over all possible transforms in order, adding the ones that
|
|
* are present to the transform string.
|
|
*/
|
|
for (let i = 0; i < numTransforms; i++) {
|
|
const key = motionDom.transformPropOrder[i];
|
|
const value = latestValues[key];
|
|
if (value === undefined)
|
|
continue;
|
|
let valueIsDefault = true;
|
|
if (typeof value === "number") {
|
|
valueIsDefault = value === (key.startsWith("scale") ? 1 : 0);
|
|
}
|
|
else {
|
|
valueIsDefault = parseFloat(value) === 0;
|
|
}
|
|
if (!valueIsDefault || transformTemplate) {
|
|
const valueAsType = motionDom.getValueAsType(value, motionDom.numberValueTypes[key]);
|
|
if (!valueIsDefault) {
|
|
transformIsDefault = false;
|
|
const transformName = translateAlias[key] || key;
|
|
transformString += `${transformName}(${valueAsType}) `;
|
|
}
|
|
if (transformTemplate) {
|
|
transform[key] = valueAsType;
|
|
}
|
|
}
|
|
}
|
|
transformString = transformString.trim();
|
|
// If we have a custom `transform` template, pass our transform values and
|
|
// generated transformString to that before returning
|
|
if (transformTemplate) {
|
|
transformString = transformTemplate(transform, transformIsDefault ? "" : transformString);
|
|
}
|
|
else if (transformIsDefault) {
|
|
transformString = "none";
|
|
}
|
|
return transformString;
|
|
}
|
|
|
|
function buildHTMLStyles(state, latestValues, transformTemplate) {
|
|
const { style, vars, transformOrigin } = state;
|
|
// Track whether we encounter any transform or transformOrigin values.
|
|
let hasTransform = false;
|
|
let hasTransformOrigin = false;
|
|
/**
|
|
* Loop over all our latest animated values and decide whether to handle them
|
|
* as a style or CSS variable.
|
|
*
|
|
* Transforms and transform origins are kept separately for further processing.
|
|
*/
|
|
for (const key in latestValues) {
|
|
const value = latestValues[key];
|
|
if (motionDom.transformProps.has(key)) {
|
|
// If this is a transform, flag to enable further transform processing
|
|
hasTransform = true;
|
|
continue;
|
|
}
|
|
else if (motionDom.isCSSVariableName(key)) {
|
|
vars[key] = value;
|
|
continue;
|
|
}
|
|
else {
|
|
// Convert the value to its default value type, ie 0 -> "0px"
|
|
const valueAsType = motionDom.getValueAsType(value, motionDom.numberValueTypes[key]);
|
|
if (key.startsWith("origin")) {
|
|
// If this is a transform origin, flag and enable further transform-origin processing
|
|
hasTransformOrigin = true;
|
|
transformOrigin[key] =
|
|
valueAsType;
|
|
}
|
|
else {
|
|
style[key] = valueAsType;
|
|
}
|
|
}
|
|
}
|
|
if (!latestValues.transform) {
|
|
if (hasTransform || transformTemplate) {
|
|
style.transform = buildTransform(latestValues, state.transform, transformTemplate);
|
|
}
|
|
else if (style.transform) {
|
|
/**
|
|
* If we have previously created a transform but currently don't have any,
|
|
* reset transform style to none.
|
|
*/
|
|
style.transform = "none";
|
|
}
|
|
}
|
|
/**
|
|
* Build a transformOrigin style. Uses the same defaults as the browser for
|
|
* undefined origins.
|
|
*/
|
|
if (hasTransformOrigin) {
|
|
const { originX = "50%", originY = "50%", originZ = 0, } = transformOrigin;
|
|
style.transformOrigin = `${originX} ${originY} ${originZ}`;
|
|
}
|
|
}
|
|
|
|
function renderHTML(element, { style, vars }, styleProp, projection) {
|
|
const elementStyle = element.style;
|
|
let key;
|
|
for (key in style) {
|
|
// CSSStyleDeclaration has [index: number]: string; in the types, so we use that as key type.
|
|
elementStyle[key] = style[key];
|
|
}
|
|
// Write projection styles directly to element style
|
|
projection?.applyProjectionStyles(elementStyle, styleProp);
|
|
for (key in vars) {
|
|
// Loop over any CSS variables and assign those.
|
|
// They can only be assigned using `setProperty`.
|
|
elementStyle.setProperty(key, vars[key]);
|
|
}
|
|
}
|
|
|
|
function isForcedMotionValue(key, { layout, layoutId }) {
|
|
return (motionDom.transformProps.has(key) ||
|
|
key.startsWith("origin") ||
|
|
((layout || layoutId !== undefined) &&
|
|
(!!scaleCorrectors[key] || key === "opacity")));
|
|
}
|
|
|
|
function scrapeMotionValuesFromProps$1(props, prevProps, visualElement) {
|
|
const { style } = props;
|
|
const newValues = {};
|
|
for (const key in style) {
|
|
if (motionDom.isMotionValue(style[key]) ||
|
|
(prevProps.style &&
|
|
motionDom.isMotionValue(prevProps.style[key])) ||
|
|
isForcedMotionValue(key, props) ||
|
|
visualElement?.getValue(key)?.liveStyle !== undefined) {
|
|
newValues[key] = style[key];
|
|
}
|
|
}
|
|
return newValues;
|
|
}
|
|
|
|
function getComputedStyle(element) {
|
|
return window.getComputedStyle(element);
|
|
}
|
|
class HTMLVisualElement extends DOMVisualElement {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.type = "html";
|
|
this.renderInstance = renderHTML;
|
|
}
|
|
readValueFromInstance(instance, key) {
|
|
if (motionDom.transformProps.has(key)) {
|
|
return this.projection?.isProjecting
|
|
? motionDom.defaultTransformValue(key)
|
|
: motionDom.readTransformValue(instance, key);
|
|
}
|
|
else {
|
|
const computedStyle = getComputedStyle(instance);
|
|
const value = (motionDom.isCSSVariableName(key)
|
|
? computedStyle.getPropertyValue(key)
|
|
: computedStyle[key]) || 0;
|
|
return typeof value === "string" ? value.trim() : value;
|
|
}
|
|
}
|
|
measureInstanceViewportBox(instance, { transformPagePoint }) {
|
|
return measureViewportBox(instance, transformPagePoint);
|
|
}
|
|
build(renderState, latestValues, props) {
|
|
buildHTMLStyles(renderState, latestValues, props.transformTemplate);
|
|
}
|
|
scrapeMotionValuesFromProps(props, prevProps, visualElement) {
|
|
return scrapeMotionValuesFromProps$1(props, prevProps, visualElement);
|
|
}
|
|
}
|
|
|
|
const LazyContext = React.createContext({ strict: false });
|
|
|
|
function loadFeatures(features) {
|
|
for (const key in features) {
|
|
featureDefinitions[key] = {
|
|
...featureDefinitions[key],
|
|
...features[key],
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A list of all valid MotionProps.
|
|
*
|
|
* @privateRemarks
|
|
* This doesn't throw if a `MotionProp` name is missing - it should.
|
|
*/
|
|
const validMotionProps = new Set([
|
|
"animate",
|
|
"exit",
|
|
"variants",
|
|
"initial",
|
|
"style",
|
|
"values",
|
|
"variants",
|
|
"transition",
|
|
"transformTemplate",
|
|
"custom",
|
|
"inherit",
|
|
"onBeforeLayoutMeasure",
|
|
"onAnimationStart",
|
|
"onAnimationComplete",
|
|
"onUpdate",
|
|
"onDragStart",
|
|
"onDrag",
|
|
"onDragEnd",
|
|
"onMeasureDragConstraints",
|
|
"onDirectionLock",
|
|
"onDragTransitionEnd",
|
|
"_dragX",
|
|
"_dragY",
|
|
"onHoverStart",
|
|
"onHoverEnd",
|
|
"onViewportEnter",
|
|
"onViewportLeave",
|
|
"globalTapTarget",
|
|
"ignoreStrict",
|
|
"viewport",
|
|
]);
|
|
/**
|
|
* Check whether a prop name is a valid `MotionProp` key.
|
|
*
|
|
* @param key - Name of the property to check
|
|
* @returns `true` is key is a valid `MotionProp`.
|
|
*
|
|
* @public
|
|
*/
|
|
function isValidMotionProp(key) {
|
|
return (key.startsWith("while") ||
|
|
(key.startsWith("drag") && key !== "draggable") ||
|
|
key.startsWith("layout") ||
|
|
key.startsWith("onTap") ||
|
|
key.startsWith("onPan") ||
|
|
key.startsWith("onLayout") ||
|
|
validMotionProps.has(key));
|
|
}
|
|
|
|
let shouldForward = (key) => !isValidMotionProp(key);
|
|
function loadExternalIsValidProp(isValidProp) {
|
|
if (typeof isValidProp !== "function")
|
|
return;
|
|
// Explicitly filter our events
|
|
shouldForward = (key) => key.startsWith("on") ? !isValidMotionProp(key) : isValidProp(key);
|
|
}
|
|
/**
|
|
* Emotion and Styled Components both allow users to pass through arbitrary props to their components
|
|
* to dynamically generate CSS. They both use the `@emotion/is-prop-valid` package to determine which
|
|
* of these should be passed to the underlying DOM node.
|
|
*
|
|
* However, when styling a Motion component `styled(motion.div)`, both packages pass through *all* props
|
|
* as it's seen as an arbitrary component rather than a DOM node. Motion only allows arbitrary props
|
|
* passed through the `custom` prop so it doesn't *need* the payload or computational overhead of
|
|
* `@emotion/is-prop-valid`, however to fix this problem we need to use it.
|
|
*
|
|
* By making it an optionalDependency we can offer this functionality only in the situations where it's
|
|
* actually required.
|
|
*/
|
|
try {
|
|
/**
|
|
* We attempt to import this package but require won't be defined in esm environments, in that case
|
|
* isPropValid will have to be provided via `MotionContext`. In a 6.0.0 this should probably be removed
|
|
* in favour of explicit injection.
|
|
*/
|
|
loadExternalIsValidProp(require("@emotion/is-prop-valid").default);
|
|
}
|
|
catch {
|
|
// We don't need to actually do anything here - the fallback is the existing `isPropValid`.
|
|
}
|
|
function filterProps(props, isDom, forwardMotionProps) {
|
|
const filteredProps = {};
|
|
for (const key in props) {
|
|
/**
|
|
* values is considered a valid prop by Emotion, so if it's present
|
|
* this will be rendered out to the DOM unless explicitly filtered.
|
|
*
|
|
* We check the type as it could be used with the `feColorMatrix`
|
|
* element, which we support.
|
|
*/
|
|
if (key === "values" && typeof props.values === "object")
|
|
continue;
|
|
if (shouldForward(key) ||
|
|
(forwardMotionProps === true && isValidMotionProp(key)) ||
|
|
(!isDom && !isValidMotionProp(key)) ||
|
|
// If trying to use native HTML drag events, forward drag listeners
|
|
(props["draggable"] &&
|
|
key.startsWith("onDrag"))) {
|
|
filteredProps[key] =
|
|
props[key];
|
|
}
|
|
}
|
|
return filteredProps;
|
|
}
|
|
|
|
const dashKeys = {
|
|
offset: "stroke-dashoffset",
|
|
array: "stroke-dasharray",
|
|
};
|
|
const camelKeys = {
|
|
offset: "strokeDashoffset",
|
|
array: "strokeDasharray",
|
|
};
|
|
/**
|
|
* Build SVG path properties. Uses the path's measured length to convert
|
|
* our custom pathLength, pathSpacing and pathOffset into stroke-dashoffset
|
|
* and stroke-dasharray attributes.
|
|
*
|
|
* This function is mutative to reduce per-frame GC.
|
|
*/
|
|
function buildSVGPath(attrs, length, spacing = 1, offset = 0, useDashCase = true) {
|
|
// Normalise path length by setting SVG attribute pathLength to 1
|
|
attrs.pathLength = 1;
|
|
// We use dash case when setting attributes directly to the DOM node and camel case
|
|
// when defining props on a React component.
|
|
const keys = useDashCase ? dashKeys : camelKeys;
|
|
// Build the dash offset
|
|
attrs[keys.offset] = motionDom.px.transform(-offset);
|
|
// Build the dash array
|
|
const pathLength = motionDom.px.transform(length);
|
|
const pathSpacing = motionDom.px.transform(spacing);
|
|
attrs[keys.array] = `${pathLength} ${pathSpacing}`;
|
|
}
|
|
|
|
/**
|
|
* Build SVG visual attributes, like cx and style.transform
|
|
*/
|
|
function buildSVGAttrs(state, { attrX, attrY, attrScale, pathLength, pathSpacing = 1, pathOffset = 0,
|
|
// This is object creation, which we try to avoid per-frame.
|
|
...latest }, isSVGTag, transformTemplate, styleProp) {
|
|
buildHTMLStyles(state, latest, transformTemplate);
|
|
/**
|
|
* For svg tags we just want to make sure viewBox is animatable and treat all the styles
|
|
* as normal HTML tags.
|
|
*/
|
|
if (isSVGTag) {
|
|
if (state.style.viewBox) {
|
|
state.attrs.viewBox = state.style.viewBox;
|
|
}
|
|
return;
|
|
}
|
|
state.attrs = state.style;
|
|
state.style = {};
|
|
const { attrs, style } = state;
|
|
/**
|
|
* However, we apply transforms as CSS transforms.
|
|
* So if we detect a transform, transformOrigin we take it from attrs and copy it into style.
|
|
*/
|
|
if (attrs.transform) {
|
|
style.transform = attrs.transform;
|
|
delete attrs.transform;
|
|
}
|
|
if (style.transform || attrs.transformOrigin) {
|
|
style.transformOrigin = attrs.transformOrigin ?? "50% 50%";
|
|
delete attrs.transformOrigin;
|
|
}
|
|
if (style.transform) {
|
|
/**
|
|
* SVG's element transform-origin uses its own median as a reference.
|
|
* Therefore, transformBox becomes a fill-box
|
|
*/
|
|
style.transformBox = styleProp?.transformBox ?? "fill-box";
|
|
delete attrs.transformBox;
|
|
}
|
|
// Render attrX/attrY/attrScale as attributes
|
|
if (attrX !== undefined)
|
|
attrs.x = attrX;
|
|
if (attrY !== undefined)
|
|
attrs.y = attrY;
|
|
if (attrScale !== undefined)
|
|
attrs.scale = attrScale;
|
|
// Build SVG path if one has been defined
|
|
if (pathLength !== undefined) {
|
|
buildSVGPath(attrs, pathLength, pathSpacing, pathOffset, false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A set of attribute names that are always read/written as camel case.
|
|
*/
|
|
const camelCaseAttributes = new Set([
|
|
"baseFrequency",
|
|
"diffuseConstant",
|
|
"kernelMatrix",
|
|
"kernelUnitLength",
|
|
"keySplines",
|
|
"keyTimes",
|
|
"limitingConeAngle",
|
|
"markerHeight",
|
|
"markerWidth",
|
|
"numOctaves",
|
|
"targetX",
|
|
"targetY",
|
|
"surfaceScale",
|
|
"specularConstant",
|
|
"specularExponent",
|
|
"stdDeviation",
|
|
"tableValues",
|
|
"viewBox",
|
|
"gradientTransform",
|
|
"pathLength",
|
|
"startOffset",
|
|
"textLength",
|
|
"lengthAdjust",
|
|
]);
|
|
|
|
const isSVGTag = (tag) => typeof tag === "string" && tag.toLowerCase() === "svg";
|
|
|
|
function renderSVG(element, renderState, _styleProp, projection) {
|
|
renderHTML(element, renderState, undefined, projection);
|
|
for (const key in renderState.attrs) {
|
|
element.setAttribute(!camelCaseAttributes.has(key) ? camelToDash(key) : key, renderState.attrs[key]);
|
|
}
|
|
}
|
|
|
|
function scrapeMotionValuesFromProps(props, prevProps, visualElement) {
|
|
const newValues = scrapeMotionValuesFromProps$1(props, prevProps, visualElement);
|
|
for (const key in props) {
|
|
if (motionDom.isMotionValue(props[key]) ||
|
|
motionDom.isMotionValue(prevProps[key])) {
|
|
const targetKey = motionDom.transformPropOrder.indexOf(key) !== -1
|
|
? "attr" + key.charAt(0).toUpperCase() + key.substring(1)
|
|
: key;
|
|
newValues[targetKey] = props[key];
|
|
}
|
|
}
|
|
return newValues;
|
|
}
|
|
|
|
class SVGVisualElement extends DOMVisualElement {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.type = "svg";
|
|
this.isSVGTag = false;
|
|
this.measureInstanceViewportBox = createBox;
|
|
}
|
|
getBaseTargetFromProps(props, key) {
|
|
return props[key];
|
|
}
|
|
readValueFromInstance(instance, key) {
|
|
if (motionDom.transformProps.has(key)) {
|
|
const defaultType = motionDom.getDefaultValueType(key);
|
|
return defaultType ? defaultType.default || 0 : 0;
|
|
}
|
|
key = !camelCaseAttributes.has(key) ? camelToDash(key) : key;
|
|
return instance.getAttribute(key);
|
|
}
|
|
scrapeMotionValuesFromProps(props, prevProps, visualElement) {
|
|
return scrapeMotionValuesFromProps(props, prevProps, visualElement);
|
|
}
|
|
build(renderState, latestValues, props) {
|
|
buildSVGAttrs(renderState, latestValues, this.isSVGTag, props.transformTemplate, props.style);
|
|
}
|
|
renderInstance(instance, renderState, styleProp, projection) {
|
|
renderSVG(instance, renderState, styleProp, projection);
|
|
}
|
|
mount(instance) {
|
|
this.isSVGTag = isSVGTag(instance.tagName);
|
|
super.mount(instance);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* We keep these listed separately as we use the lowercase tag names as part
|
|
* of the runtime bundle to detect SVG components
|
|
*/
|
|
const lowercaseSVGElements = [
|
|
"animate",
|
|
"circle",
|
|
"defs",
|
|
"desc",
|
|
"ellipse",
|
|
"g",
|
|
"image",
|
|
"line",
|
|
"filter",
|
|
"marker",
|
|
"mask",
|
|
"metadata",
|
|
"path",
|
|
"pattern",
|
|
"polygon",
|
|
"polyline",
|
|
"rect",
|
|
"stop",
|
|
"switch",
|
|
"symbol",
|
|
"svg",
|
|
"text",
|
|
"tspan",
|
|
"use",
|
|
"view",
|
|
];
|
|
|
|
function isSVGComponent(Component) {
|
|
if (
|
|
/**
|
|
* If it's not a string, it's a custom React component. Currently we only support
|
|
* HTML custom React components.
|
|
*/
|
|
typeof Component !== "string" ||
|
|
/**
|
|
* If it contains a dash, the element is a custom HTML webcomponent.
|
|
*/
|
|
Component.includes("-")) {
|
|
return false;
|
|
}
|
|
else if (
|
|
/**
|
|
* If it's in our list of lowercase SVG tags, it's an SVG component
|
|
*/
|
|
lowercaseSVGElements.indexOf(Component) > -1 ||
|
|
/**
|
|
* If it contains a capital letter, it's an SVG component
|
|
*/
|
|
/[A-Z]/u.test(Component)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const createDomVisualElement = (Component, options) => {
|
|
return isSVGComponent(Component)
|
|
? new SVGVisualElement(options)
|
|
: new HTMLVisualElement(options, {
|
|
allowProjection: Component !== React.Fragment,
|
|
});
|
|
};
|
|
|
|
const MotionContext = /* @__PURE__ */ React.createContext({});
|
|
|
|
function getCurrentTreeVariants(props, context) {
|
|
if (isControllingVariants(props)) {
|
|
const { initial, animate } = props;
|
|
return {
|
|
initial: initial === false || isVariantLabel(initial)
|
|
? initial
|
|
: undefined,
|
|
animate: isVariantLabel(animate) ? animate : undefined,
|
|
};
|
|
}
|
|
return props.inherit !== false ? context : {};
|
|
}
|
|
|
|
function useCreateMotionContext(props) {
|
|
const { initial, animate } = getCurrentTreeVariants(props, React.useContext(MotionContext));
|
|
return React.useMemo(() => ({ initial, animate }), [variantLabelsAsDependency(initial), variantLabelsAsDependency(animate)]);
|
|
}
|
|
function variantLabelsAsDependency(prop) {
|
|
return Array.isArray(prop) ? prop.join(" ") : prop;
|
|
}
|
|
|
|
const createHtmlRenderState = () => ({
|
|
style: {},
|
|
transform: {},
|
|
transformOrigin: {},
|
|
vars: {},
|
|
});
|
|
|
|
function copyRawValuesOnly(target, source, props) {
|
|
for (const key in source) {
|
|
if (!motionDom.isMotionValue(source[key]) && !isForcedMotionValue(key, props)) {
|
|
target[key] = source[key];
|
|
}
|
|
}
|
|
}
|
|
function useInitialMotionValues({ transformTemplate }, visualState) {
|
|
return React.useMemo(() => {
|
|
const state = createHtmlRenderState();
|
|
buildHTMLStyles(state, visualState, transformTemplate);
|
|
return Object.assign({}, state.vars, state.style);
|
|
}, [visualState]);
|
|
}
|
|
function useStyle(props, visualState) {
|
|
const styleProp = props.style || {};
|
|
const style = {};
|
|
/**
|
|
* Copy non-Motion Values straight into style
|
|
*/
|
|
copyRawValuesOnly(style, styleProp, props);
|
|
Object.assign(style, useInitialMotionValues(props, visualState));
|
|
return style;
|
|
}
|
|
function useHTMLProps(props, visualState) {
|
|
// The `any` isn't ideal but it is the type of createElement props argument
|
|
const htmlProps = {};
|
|
const style = useStyle(props, visualState);
|
|
if (props.drag && props.dragListener !== false) {
|
|
// Disable the ghost element when a user drags
|
|
htmlProps.draggable = false;
|
|
// Disable text selection
|
|
style.userSelect =
|
|
style.WebkitUserSelect =
|
|
style.WebkitTouchCallout =
|
|
"none";
|
|
// Disable scrolling on the draggable direction
|
|
style.touchAction =
|
|
props.drag === true
|
|
? "none"
|
|
: `pan-${props.drag === "x" ? "y" : "x"}`;
|
|
}
|
|
if (props.tabIndex === undefined &&
|
|
(props.onTap || props.onTapStart || props.whileTap)) {
|
|
htmlProps.tabIndex = 0;
|
|
}
|
|
htmlProps.style = style;
|
|
return htmlProps;
|
|
}
|
|
|
|
const createSvgRenderState = () => ({
|
|
...createHtmlRenderState(),
|
|
attrs: {},
|
|
});
|
|
|
|
function useSVGProps(props, visualState, _isStatic, Component) {
|
|
const visualProps = React.useMemo(() => {
|
|
const state = createSvgRenderState();
|
|
buildSVGAttrs(state, visualState, isSVGTag(Component), props.transformTemplate, props.style);
|
|
return {
|
|
...state.attrs,
|
|
style: { ...state.style },
|
|
};
|
|
}, [visualState]);
|
|
if (props.style) {
|
|
const rawStyles = {};
|
|
copyRawValuesOnly(rawStyles, props.style, props);
|
|
visualProps.style = { ...rawStyles, ...visualProps.style };
|
|
}
|
|
return visualProps;
|
|
}
|
|
|
|
function useRender(Component, props, ref, { latestValues, }, isStatic, forwardMotionProps = false) {
|
|
const useVisualProps = isSVGComponent(Component)
|
|
? useSVGProps
|
|
: useHTMLProps;
|
|
const visualProps = useVisualProps(props, latestValues, isStatic, Component);
|
|
const filteredProps = filterProps(props, typeof Component === "string", forwardMotionProps);
|
|
const elementProps = Component !== React.Fragment ? { ...filteredProps, ...visualProps, ref } : {};
|
|
/**
|
|
* If component has been handed a motion value as its child,
|
|
* memoise its initial value and render that. Subsequent updates
|
|
* will be handled by the onChange handler
|
|
*/
|
|
const { children } = props;
|
|
const renderedChildren = React.useMemo(() => (motionDom.isMotionValue(children) ? children.get() : children), [children]);
|
|
return React.createElement(Component, {
|
|
...elementProps,
|
|
children: renderedChildren,
|
|
});
|
|
}
|
|
|
|
function makeState({ scrapeMotionValuesFromProps, createRenderState, }, props, context, presenceContext) {
|
|
const state = {
|
|
latestValues: makeLatestValues(props, context, presenceContext, scrapeMotionValuesFromProps),
|
|
renderState: createRenderState(),
|
|
};
|
|
return state;
|
|
}
|
|
function makeLatestValues(props, context, presenceContext, scrapeMotionValues) {
|
|
const values = {};
|
|
const motionValues = scrapeMotionValues(props, {});
|
|
for (const key in motionValues) {
|
|
values[key] = resolveMotionValue(motionValues[key]);
|
|
}
|
|
let { initial, animate } = props;
|
|
const isControllingVariants$1 = isControllingVariants(props);
|
|
const isVariantNode$1 = isVariantNode(props);
|
|
if (context &&
|
|
isVariantNode$1 &&
|
|
!isControllingVariants$1 &&
|
|
props.inherit !== false) {
|
|
if (initial === undefined)
|
|
initial = context.initial;
|
|
if (animate === undefined)
|
|
animate = context.animate;
|
|
}
|
|
let isInitialAnimationBlocked = presenceContext
|
|
? presenceContext.initial === false
|
|
: false;
|
|
isInitialAnimationBlocked = isInitialAnimationBlocked || initial === false;
|
|
const variantToSet = isInitialAnimationBlocked ? animate : initial;
|
|
if (variantToSet &&
|
|
typeof variantToSet !== "boolean" &&
|
|
!isAnimationControls(variantToSet)) {
|
|
const list = Array.isArray(variantToSet) ? variantToSet : [variantToSet];
|
|
for (let i = 0; i < list.length; i++) {
|
|
const resolved = resolveVariantFromProps(props, list[i]);
|
|
if (resolved) {
|
|
const { transitionEnd, transition, ...target } = resolved;
|
|
for (const key in target) {
|
|
let valueTarget = target[key];
|
|
if (Array.isArray(valueTarget)) {
|
|
/**
|
|
* Take final keyframe if the initial animation is blocked because
|
|
* we want to initialise at the end of that blocked animation.
|
|
*/
|
|
const index = isInitialAnimationBlocked
|
|
? valueTarget.length - 1
|
|
: 0;
|
|
valueTarget = valueTarget[index];
|
|
}
|
|
if (valueTarget !== null) {
|
|
values[key] = valueTarget;
|
|
}
|
|
}
|
|
for (const key in transitionEnd) {
|
|
values[key] = transitionEnd[key];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
const makeUseVisualState = (config) => (props, isStatic) => {
|
|
const context = React.useContext(MotionContext);
|
|
const presenceContext = React.useContext(PresenceContext);
|
|
const make = () => makeState(config, props, context, presenceContext);
|
|
return isStatic ? make() : useConstant(make);
|
|
};
|
|
|
|
const useHTMLVisualState = /*@__PURE__*/ makeUseVisualState({
|
|
scrapeMotionValuesFromProps: scrapeMotionValuesFromProps$1,
|
|
createRenderState: createHtmlRenderState,
|
|
});
|
|
|
|
const useSVGVisualState = /*@__PURE__*/ makeUseVisualState({
|
|
scrapeMotionValuesFromProps: scrapeMotionValuesFromProps,
|
|
createRenderState: createSvgRenderState,
|
|
});
|
|
|
|
const motionComponentSymbol = Symbol.for("motionComponentSymbol");
|
|
|
|
function isRefObject(ref) {
|
|
return (ref &&
|
|
typeof ref === "object" &&
|
|
Object.prototype.hasOwnProperty.call(ref, "current"));
|
|
}
|
|
|
|
/**
|
|
* Creates a ref function that, when called, hydrates the provided
|
|
* external ref and VisualElement.
|
|
*/
|
|
function useMotionRef(visualState, visualElement, externalRef) {
|
|
return React.useCallback((instance) => {
|
|
if (instance) {
|
|
visualState.onMount && visualState.onMount(instance);
|
|
}
|
|
if (visualElement) {
|
|
if (instance) {
|
|
visualElement.mount(instance);
|
|
}
|
|
else {
|
|
visualElement.unmount();
|
|
}
|
|
}
|
|
if (externalRef) {
|
|
if (typeof externalRef === "function") {
|
|
externalRef(instance);
|
|
}
|
|
else if (isRefObject(externalRef)) {
|
|
externalRef.current = instance;
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Include externalRef in dependencies to ensure the callback updates
|
|
* when the ref changes, allowing proper ref forwarding.
|
|
*/
|
|
[visualElement]);
|
|
}
|
|
|
|
/**
|
|
* Internal, exported only for usage in Framer
|
|
*/
|
|
const SwitchLayoutGroupContext = React.createContext({});
|
|
|
|
function useVisualElement(Component, visualState, props, createVisualElement, ProjectionNodeConstructor) {
|
|
const { visualElement: parent } = React.useContext(MotionContext);
|
|
const lazyContext = React.useContext(LazyContext);
|
|
const presenceContext = React.useContext(PresenceContext);
|
|
const reducedMotionConfig = React.useContext(MotionConfigContext).reducedMotion;
|
|
const visualElementRef = React.useRef(null);
|
|
/**
|
|
* If we haven't preloaded a renderer, check to see if we have one lazy-loaded
|
|
*/
|
|
createVisualElement =
|
|
createVisualElement ||
|
|
lazyContext.renderer;
|
|
if (!visualElementRef.current && createVisualElement) {
|
|
visualElementRef.current = createVisualElement(Component, {
|
|
visualState,
|
|
parent,
|
|
props,
|
|
presenceContext,
|
|
blockInitialAnimation: presenceContext
|
|
? presenceContext.initial === false
|
|
: false,
|
|
reducedMotionConfig,
|
|
});
|
|
}
|
|
const visualElement = visualElementRef.current;
|
|
/**
|
|
* Load Motion gesture and animation features. These are rendered as renderless
|
|
* components so each feature can optionally make use of React lifecycle methods.
|
|
*/
|
|
const initialLayoutGroupConfig = React.useContext(SwitchLayoutGroupContext);
|
|
if (visualElement &&
|
|
!visualElement.projection &&
|
|
ProjectionNodeConstructor &&
|
|
(visualElement.type === "html" || visualElement.type === "svg")) {
|
|
createProjectionNode(visualElementRef.current, props, ProjectionNodeConstructor, initialLayoutGroupConfig);
|
|
}
|
|
const isMounted = React.useRef(false);
|
|
React.useInsertionEffect(() => {
|
|
/**
|
|
* Check the component has already mounted before calling
|
|
* `update` unnecessarily. This ensures we skip the initial update.
|
|
*/
|
|
if (visualElement && isMounted.current) {
|
|
visualElement.update(props, presenceContext);
|
|
}
|
|
});
|
|
/**
|
|
* Cache this value as we want to know whether HandoffAppearAnimations
|
|
* was present on initial render - it will be deleted after this.
|
|
*/
|
|
const optimisedAppearId = props[optimizedAppearDataAttribute];
|
|
const wantsHandoff = React.useRef(Boolean(optimisedAppearId) &&
|
|
!window.MotionHandoffIsComplete?.(optimisedAppearId) &&
|
|
window.MotionHasOptimisedAnimation?.(optimisedAppearId));
|
|
useIsomorphicLayoutEffect(() => {
|
|
if (!visualElement)
|
|
return;
|
|
isMounted.current = true;
|
|
window.MotionIsMounted = true;
|
|
visualElement.updateFeatures();
|
|
visualElement.scheduleRenderMicrotask();
|
|
/**
|
|
* Ideally this function would always run in a useEffect.
|
|
*
|
|
* However, if we have optimised appear animations to handoff from,
|
|
* it needs to happen synchronously to ensure there's no flash of
|
|
* incorrect styles in the event of a hydration error.
|
|
*
|
|
* So if we detect a situtation where optimised appear animations
|
|
* are running, we use useLayoutEffect to trigger animations.
|
|
*/
|
|
if (wantsHandoff.current && visualElement.animationState) {
|
|
visualElement.animationState.animateChanges();
|
|
}
|
|
});
|
|
React.useEffect(() => {
|
|
if (!visualElement)
|
|
return;
|
|
if (!wantsHandoff.current && visualElement.animationState) {
|
|
visualElement.animationState.animateChanges();
|
|
}
|
|
if (wantsHandoff.current) {
|
|
// This ensures all future calls to animateChanges() in this component will run in useEffect
|
|
queueMicrotask(() => {
|
|
window.MotionHandoffMarkAsComplete?.(optimisedAppearId);
|
|
});
|
|
wantsHandoff.current = false;
|
|
}
|
|
/**
|
|
* Now we've finished triggering animations for this element we
|
|
* can wipe the enteringChildren set for the next render.
|
|
*/
|
|
visualElement.enteringChildren = undefined;
|
|
});
|
|
return visualElement;
|
|
}
|
|
function createProjectionNode(visualElement, props, ProjectionNodeConstructor, initialPromotionConfig) {
|
|
const { layoutId, layout, drag, dragConstraints, layoutScroll, layoutRoot, layoutCrossfade, } = props;
|
|
visualElement.projection = new ProjectionNodeConstructor(visualElement.latestValues, props["data-framer-portal-id"]
|
|
? undefined
|
|
: getClosestProjectingNode(visualElement.parent));
|
|
visualElement.projection.setOptions({
|
|
layoutId,
|
|
layout,
|
|
alwaysMeasureLayout: Boolean(drag) || (dragConstraints && isRefObject(dragConstraints)),
|
|
visualElement,
|
|
/**
|
|
* TODO: Update options in an effect. This could be tricky as it'll be too late
|
|
* to update by the time layout animations run.
|
|
* We also need to fix this safeToRemove by linking it up to the one returned by usePresence,
|
|
* ensuring it gets called if there's no potential layout animations.
|
|
*
|
|
*/
|
|
animationType: typeof layout === "string" ? layout : "both",
|
|
initialPromotionConfig,
|
|
crossfade: layoutCrossfade,
|
|
layoutScroll,
|
|
layoutRoot,
|
|
});
|
|
}
|
|
function getClosestProjectingNode(visualElement) {
|
|
if (!visualElement)
|
|
return undefined;
|
|
return visualElement.options.allowProjection !== false
|
|
? visualElement.projection
|
|
: getClosestProjectingNode(visualElement.parent);
|
|
}
|
|
|
|
/**
|
|
* Create a `motion` component.
|
|
*
|
|
* This function accepts a Component argument, which can be either a string (ie "div"
|
|
* for `motion.div`), or an actual React component.
|
|
*
|
|
* Alongside this is a config option which provides a way of rendering the provided
|
|
* component "offline", or outside the React render cycle.
|
|
*/
|
|
function createMotionComponent(Component, { forwardMotionProps = false } = {}, preloadedFeatures, createVisualElement) {
|
|
preloadedFeatures && loadFeatures(preloadedFeatures);
|
|
const useVisualState = isSVGComponent(Component)
|
|
? useSVGVisualState
|
|
: useHTMLVisualState;
|
|
function MotionDOMComponent(props, externalRef) {
|
|
/**
|
|
* If we need to measure the element we load this functionality in a
|
|
* separate class component in order to gain access to getSnapshotBeforeUpdate.
|
|
*/
|
|
let MeasureLayout;
|
|
const configAndProps = {
|
|
...React.useContext(MotionConfigContext),
|
|
...props,
|
|
layoutId: useLayoutId(props),
|
|
};
|
|
const { isStatic } = configAndProps;
|
|
const context = useCreateMotionContext(props);
|
|
const visualState = useVisualState(props, isStatic);
|
|
if (!isStatic && isBrowser) {
|
|
useStrictMode(configAndProps, preloadedFeatures);
|
|
const layoutProjection = getProjectionFunctionality(configAndProps);
|
|
MeasureLayout = layoutProjection.MeasureLayout;
|
|
/**
|
|
* Create a VisualElement for this component. A VisualElement provides a common
|
|
* interface to renderer-specific APIs (ie DOM/Three.js etc) as well as
|
|
* providing a way of rendering to these APIs outside of the React render loop
|
|
* for more performant animations and interactions
|
|
*/
|
|
context.visualElement = useVisualElement(Component, visualState, configAndProps, createVisualElement, layoutProjection.ProjectionNode);
|
|
}
|
|
/**
|
|
* The mount order and hierarchy is specific to ensure our element ref
|
|
* is hydrated by the time features fire their effects.
|
|
*/
|
|
return (jsxRuntime.jsxs(MotionContext.Provider, { value: context, children: [MeasureLayout && context.visualElement ? (jsxRuntime.jsx(MeasureLayout, { visualElement: context.visualElement, ...configAndProps })) : null, useRender(Component, props, useMotionRef(visualState, context.visualElement, externalRef), visualState, isStatic, forwardMotionProps)] }));
|
|
}
|
|
MotionDOMComponent.displayName = `motion.${typeof Component === "string"
|
|
? Component
|
|
: `create(${Component.displayName ?? Component.name ?? ""})`}`;
|
|
const ForwardRefMotionComponent = React.forwardRef(MotionDOMComponent);
|
|
ForwardRefMotionComponent[motionComponentSymbol] = Component;
|
|
return ForwardRefMotionComponent;
|
|
}
|
|
function useLayoutId({ layoutId }) {
|
|
const layoutGroupId = React.useContext(LayoutGroupContext).id;
|
|
return layoutGroupId && layoutId !== undefined
|
|
? layoutGroupId + "-" + layoutId
|
|
: layoutId;
|
|
}
|
|
function useStrictMode(configAndProps, preloadedFeatures) {
|
|
const isStrict = React.useContext(LazyContext).strict;
|
|
/**
|
|
* If we're in development mode, check to make sure we're not rendering a motion component
|
|
* as a child of LazyMotion, as this will break the file-size benefits of using it.
|
|
*/
|
|
if (process.env.NODE_ENV !== "production" &&
|
|
preloadedFeatures &&
|
|
isStrict) {
|
|
const strictMessage = "You have rendered a `motion` component within a `LazyMotion` component. This will break tree shaking. Import and render a `m` component instead.";
|
|
configAndProps.ignoreStrict
|
|
? motionUtils.warning(false, strictMessage, "lazy-strict-mode")
|
|
: motionUtils.invariant(false, strictMessage, "lazy-strict-mode");
|
|
}
|
|
}
|
|
function getProjectionFunctionality(props) {
|
|
const { drag, layout } = featureDefinitions;
|
|
if (!drag && !layout)
|
|
return {};
|
|
const combined = { ...drag, ...layout };
|
|
return {
|
|
MeasureLayout: drag?.isEnabled(props) || layout?.isEnabled(props)
|
|
? combined.MeasureLayout
|
|
: undefined,
|
|
ProjectionNode: combined.ProjectionNode,
|
|
};
|
|
}
|
|
|
|
function resolveVariant(visualElement, definition, custom) {
|
|
const props = visualElement.getProps();
|
|
return resolveVariantFromProps(props, definition, custom !== undefined ? custom : props.custom, visualElement);
|
|
}
|
|
|
|
const isKeyframesTarget = (v) => {
|
|
return Array.isArray(v);
|
|
};
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
function calcChildStagger(children, child, delayChildren, staggerChildren = 0, staggerDirection = 1) {
|
|
const index = Array.from(children)
|
|
.sort((a, b) => a.sortNodePosition(b))
|
|
.indexOf(child);
|
|
const numChildren = children.size;
|
|
const maxStaggerDuration = (numChildren - 1) * staggerChildren;
|
|
const delayIsFunction = typeof delayChildren === "function";
|
|
return delayIsFunction
|
|
? delayChildren(index, numChildren)
|
|
: staggerDirection === 1
|
|
? index * staggerChildren
|
|
: maxStaggerDuration - index * staggerChildren;
|
|
}
|
|
|
|
function animateVariant(visualElement, variant, options = {}) {
|
|
const resolved = resolveVariant(visualElement, variant, options.type === "exit"
|
|
? visualElement.presenceContext?.custom
|
|
: undefined);
|
|
let { transition = visualElement.getDefaultTransition() || {} } = resolved || {};
|
|
if (options.transitionOverride) {
|
|
transition = options.transitionOverride;
|
|
}
|
|
/**
|
|
* If we have a variant, create a callback that runs it as an animation.
|
|
* Otherwise, we resolve a Promise immediately for a composable no-op.
|
|
*/
|
|
const getAnimation = resolved
|
|
? () => Promise.all(animateTarget(visualElement, resolved, options))
|
|
: () => Promise.resolve();
|
|
/**
|
|
* If we have children, create a callback that runs all their animations.
|
|
* Otherwise, we resolve a Promise immediately for a composable no-op.
|
|
*/
|
|
const getChildAnimations = visualElement.variantChildren && visualElement.variantChildren.size
|
|
? (forwardDelay = 0) => {
|
|
const { delayChildren = 0, staggerChildren, staggerDirection, } = transition;
|
|
return animateChildren(visualElement, variant, forwardDelay, delayChildren, staggerChildren, staggerDirection, options);
|
|
}
|
|
: () => Promise.resolve();
|
|
/**
|
|
* If the transition explicitly defines a "when" option, we need to resolve either
|
|
* this animation or all children animations before playing the other.
|
|
*/
|
|
const { when } = transition;
|
|
if (when) {
|
|
const [first, last] = when === "beforeChildren"
|
|
? [getAnimation, getChildAnimations]
|
|
: [getChildAnimations, getAnimation];
|
|
return first().then(() => last());
|
|
}
|
|
else {
|
|
return Promise.all([getAnimation(), getChildAnimations(options.delay)]);
|
|
}
|
|
}
|
|
function animateChildren(visualElement, variant, delay = 0, delayChildren = 0, staggerChildren = 0, staggerDirection = 1, options) {
|
|
const animations = [];
|
|
for (const child of visualElement.variantChildren) {
|
|
child.notify("AnimationStart", variant);
|
|
animations.push(animateVariant(child, variant, {
|
|
...options,
|
|
delay: delay +
|
|
(typeof delayChildren === "function" ? 0 : delayChildren) +
|
|
calcChildStagger(visualElement.variantChildren, child, delayChildren, staggerChildren, staggerDirection),
|
|
}).then(() => child.notify("AnimationComplete", variant)));
|
|
}
|
|
return Promise.all(animations);
|
|
}
|
|
|
|
function animateVisualElement(visualElement, definition, options = {}) {
|
|
visualElement.notify("AnimationStart", definition);
|
|
let animation;
|
|
if (Array.isArray(definition)) {
|
|
const animations = definition.map((variant) => animateVariant(visualElement, variant, options));
|
|
animation = Promise.all(animations);
|
|
}
|
|
else if (typeof definition === "string") {
|
|
animation = animateVariant(visualElement, definition, options);
|
|
}
|
|
else {
|
|
const resolvedDefinition = typeof definition === "function"
|
|
? resolveVariant(visualElement, definition, options.custom)
|
|
: definition;
|
|
animation = Promise.all(animateTarget(visualElement, resolvedDefinition, options));
|
|
}
|
|
return animation.then(() => {
|
|
visualElement.notify("AnimationComplete", definition);
|
|
});
|
|
}
|
|
|
|
function shallowCompare(next, prev) {
|
|
if (!Array.isArray(prev))
|
|
return false;
|
|
const prevLength = prev.length;
|
|
if (prevLength !== next.length)
|
|
return false;
|
|
for (let i = 0; i < prevLength; i++) {
|
|
if (prev[i] !== next[i])
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const numVariantProps = variantProps.length;
|
|
function getVariantContext(visualElement) {
|
|
if (!visualElement)
|
|
return undefined;
|
|
if (!visualElement.isControllingVariants) {
|
|
const context = visualElement.parent
|
|
? getVariantContext(visualElement.parent) || {}
|
|
: {};
|
|
if (visualElement.props.initial !== undefined) {
|
|
context.initial = visualElement.props.initial;
|
|
}
|
|
return context;
|
|
}
|
|
const context = {};
|
|
for (let i = 0; i < numVariantProps; i++) {
|
|
const name = variantProps[i];
|
|
const prop = visualElement.props[name];
|
|
if (isVariantLabel(prop) || prop === false) {
|
|
context[name] = prop;
|
|
}
|
|
}
|
|
return context;
|
|
}
|
|
|
|
const reversePriorityOrder = [...variantPriorityOrder].reverse();
|
|
const numAnimationTypes = variantPriorityOrder.length;
|
|
function animateList(visualElement) {
|
|
return (animations) => Promise.all(animations.map(({ animation, options }) => animateVisualElement(visualElement, animation, options)));
|
|
}
|
|
function createAnimationState(visualElement) {
|
|
let animate = animateList(visualElement);
|
|
let state = createState();
|
|
let isInitialRender = true;
|
|
/**
|
|
* This function will be used to reduce the animation definitions for
|
|
* each active animation type into an object of resolved values for it.
|
|
*/
|
|
const buildResolvedTypeValues = (type) => (acc, definition) => {
|
|
const resolved = resolveVariant(visualElement, definition, type === "exit"
|
|
? visualElement.presenceContext?.custom
|
|
: undefined);
|
|
if (resolved) {
|
|
const { transition, transitionEnd, ...target } = resolved;
|
|
acc = { ...acc, ...target, ...transitionEnd };
|
|
}
|
|
return acc;
|
|
};
|
|
/**
|
|
* This just allows us to inject mocked animation functions
|
|
* @internal
|
|
*/
|
|
function setAnimateFunction(makeAnimator) {
|
|
animate = makeAnimator(visualElement);
|
|
}
|
|
/**
|
|
* When we receive new props, we need to:
|
|
* 1. Create a list of protected keys for each type. This is a directory of
|
|
* value keys that are currently being "handled" by types of a higher priority
|
|
* so that whenever an animation is played of a given type, these values are
|
|
* protected from being animated.
|
|
* 2. Determine if an animation type needs animating.
|
|
* 3. Determine if any values have been removed from a type and figure out
|
|
* what to animate those to.
|
|
*/
|
|
function animateChanges(changedActiveType) {
|
|
const { props } = visualElement;
|
|
const context = getVariantContext(visualElement.parent) || {};
|
|
/**
|
|
* A list of animations that we'll build into as we iterate through the animation
|
|
* types. This will get executed at the end of the function.
|
|
*/
|
|
const animations = [];
|
|
/**
|
|
* Keep track of which values have been removed. Then, as we hit lower priority
|
|
* animation types, we can check if they contain removed values and animate to that.
|
|
*/
|
|
const removedKeys = new Set();
|
|
/**
|
|
* A dictionary of all encountered keys. This is an object to let us build into and
|
|
* copy it without iteration. Each time we hit an animation type we set its protected
|
|
* keys - the keys its not allowed to animate - to the latest version of this object.
|
|
*/
|
|
let encounteredKeys = {};
|
|
/**
|
|
* If a variant has been removed at a given index, and this component is controlling
|
|
* variant animations, we want to ensure lower-priority variants are forced to animate.
|
|
*/
|
|
let removedVariantIndex = Infinity;
|
|
/**
|
|
* Iterate through all animation types in reverse priority order. For each, we want to
|
|
* detect which values it's handling and whether or not they've changed (and therefore
|
|
* need to be animated). If any values have been removed, we want to detect those in
|
|
* lower priority props and flag for animation.
|
|
*/
|
|
for (let i = 0; i < numAnimationTypes; i++) {
|
|
const type = reversePriorityOrder[i];
|
|
const typeState = state[type];
|
|
const prop = props[type] !== undefined
|
|
? props[type]
|
|
: context[type];
|
|
const propIsVariant = isVariantLabel(prop);
|
|
/**
|
|
* If this type has *just* changed isActive status, set activeDelta
|
|
* to that status. Otherwise set to null.
|
|
*/
|
|
const activeDelta = type === changedActiveType ? typeState.isActive : null;
|
|
if (activeDelta === false)
|
|
removedVariantIndex = i;
|
|
/**
|
|
* If this prop is an inherited variant, rather than been set directly on the
|
|
* component itself, we want to make sure we allow the parent to trigger animations.
|
|
*
|
|
* TODO: Can probably change this to a !isControllingVariants check
|
|
*/
|
|
let isInherited = prop === context[type] &&
|
|
prop !== props[type] &&
|
|
propIsVariant;
|
|
if (isInherited &&
|
|
isInitialRender &&
|
|
visualElement.manuallyAnimateOnMount) {
|
|
isInherited = false;
|
|
}
|
|
/**
|
|
* Set all encountered keys so far as the protected keys for this type. This will
|
|
* be any key that has been animated or otherwise handled by active, higher-priortiy types.
|
|
*/
|
|
typeState.protectedKeys = { ...encounteredKeys };
|
|
// Check if we can skip analysing this prop early
|
|
if (
|
|
// If it isn't active and hasn't *just* been set as inactive
|
|
(!typeState.isActive && activeDelta === null) ||
|
|
// If we didn't and don't have any defined prop for this animation type
|
|
(!prop && !typeState.prevProp) ||
|
|
// Or if the prop doesn't define an animation
|
|
isAnimationControls(prop) ||
|
|
typeof prop === "boolean") {
|
|
continue;
|
|
}
|
|
/**
|
|
* As we go look through the values defined on this type, if we detect
|
|
* a changed value or a value that was removed in a higher priority, we set
|
|
* this to true and add this prop to the animation list.
|
|
*/
|
|
const variantDidChange = checkVariantsDidChange(typeState.prevProp, prop);
|
|
let shouldAnimateType = variantDidChange ||
|
|
// If we're making this variant active, we want to always make it active
|
|
(type === changedActiveType &&
|
|
typeState.isActive &&
|
|
!isInherited &&
|
|
propIsVariant) ||
|
|
// If we removed a higher-priority variant (i is in reverse order)
|
|
(i > removedVariantIndex && propIsVariant);
|
|
let handledRemovedValues = false;
|
|
/**
|
|
* As animations can be set as variant lists, variants or target objects, we
|
|
* coerce everything to an array if it isn't one already
|
|
*/
|
|
const definitionList = Array.isArray(prop) ? prop : [prop];
|
|
/**
|
|
* Build an object of all the resolved values. We'll use this in the subsequent
|
|
* animateChanges calls to determine whether a value has changed.
|
|
*/
|
|
let resolvedValues = definitionList.reduce(buildResolvedTypeValues(type), {});
|
|
if (activeDelta === false)
|
|
resolvedValues = {};
|
|
/**
|
|
* Now we need to loop through all the keys in the prev prop and this prop,
|
|
* and decide:
|
|
* 1. If the value has changed, and needs animating
|
|
* 2. If it has been removed, and needs adding to the removedKeys set
|
|
* 3. If it has been removed in a higher priority type and needs animating
|
|
* 4. If it hasn't been removed in a higher priority but hasn't changed, and
|
|
* needs adding to the type's protectedKeys list.
|
|
*/
|
|
const { prevResolvedValues = {} } = typeState;
|
|
const allKeys = {
|
|
...prevResolvedValues,
|
|
...resolvedValues,
|
|
};
|
|
const markToAnimate = (key) => {
|
|
shouldAnimateType = true;
|
|
if (removedKeys.has(key)) {
|
|
handledRemovedValues = true;
|
|
removedKeys.delete(key);
|
|
}
|
|
typeState.needsAnimating[key] = true;
|
|
const motionValue = visualElement.getValue(key);
|
|
if (motionValue)
|
|
motionValue.liveStyle = false;
|
|
};
|
|
for (const key in allKeys) {
|
|
const next = resolvedValues[key];
|
|
const prev = prevResolvedValues[key];
|
|
// If we've already handled this we can just skip ahead
|
|
if (encounteredKeys.hasOwnProperty(key))
|
|
continue;
|
|
/**
|
|
* If the value has changed, we probably want to animate it.
|
|
*/
|
|
let valueHasChanged = false;
|
|
if (isKeyframesTarget(next) && isKeyframesTarget(prev)) {
|
|
valueHasChanged = !shallowCompare(next, prev);
|
|
}
|
|
else {
|
|
valueHasChanged = next !== prev;
|
|
}
|
|
if (valueHasChanged) {
|
|
if (next !== undefined && next !== null) {
|
|
// If next is defined and doesn't equal prev, it needs animating
|
|
markToAnimate(key);
|
|
}
|
|
else {
|
|
// If it's undefined, it's been removed.
|
|
removedKeys.add(key);
|
|
}
|
|
}
|
|
else if (next !== undefined && removedKeys.has(key)) {
|
|
/**
|
|
* If next hasn't changed and it isn't undefined, we want to check if it's
|
|
* been removed by a higher priority
|
|
*/
|
|
markToAnimate(key);
|
|
}
|
|
else {
|
|
/**
|
|
* If it hasn't changed, we add it to the list of protected values
|
|
* to ensure it doesn't get animated.
|
|
*/
|
|
typeState.protectedKeys[key] = true;
|
|
}
|
|
}
|
|
/**
|
|
* Update the typeState so next time animateChanges is called we can compare the
|
|
* latest prop and resolvedValues to these.
|
|
*/
|
|
typeState.prevProp = prop;
|
|
typeState.prevResolvedValues = resolvedValues;
|
|
if (typeState.isActive) {
|
|
encounteredKeys = { ...encounteredKeys, ...resolvedValues };
|
|
}
|
|
if (isInitialRender && visualElement.blockInitialAnimation) {
|
|
shouldAnimateType = false;
|
|
}
|
|
/**
|
|
* If this is an inherited prop we want to skip this animation
|
|
* unless the inherited variants haven't changed on this render.
|
|
*/
|
|
const willAnimateViaParent = isInherited && variantDidChange;
|
|
const needsAnimating = !willAnimateViaParent || handledRemovedValues;
|
|
if (shouldAnimateType && needsAnimating) {
|
|
animations.push(...definitionList.map((animation) => {
|
|
const options = { type };
|
|
/**
|
|
* If we're performing the initial animation, but we're not
|
|
* rendering at the same time as the variant-controlling parent,
|
|
* we want to use the parent's transition to calculate the stagger.
|
|
*/
|
|
if (typeof animation === "string" &&
|
|
isInitialRender &&
|
|
!willAnimateViaParent &&
|
|
visualElement.manuallyAnimateOnMount &&
|
|
visualElement.parent) {
|
|
const { parent } = visualElement;
|
|
const parentVariant = resolveVariant(parent, animation);
|
|
if (parent.enteringChildren && parentVariant) {
|
|
const { delayChildren } = parentVariant.transition || {};
|
|
options.delay = calcChildStagger(parent.enteringChildren, visualElement, delayChildren);
|
|
}
|
|
}
|
|
return {
|
|
animation: animation,
|
|
options,
|
|
};
|
|
}));
|
|
}
|
|
}
|
|
/**
|
|
* If there are some removed value that haven't been dealt with,
|
|
* we need to create a new animation that falls back either to the value
|
|
* defined in the style prop, or the last read value.
|
|
*/
|
|
if (removedKeys.size) {
|
|
const fallbackAnimation = {};
|
|
/**
|
|
* If the initial prop contains a transition we can use that, otherwise
|
|
* allow the animation function to use the visual element's default.
|
|
*/
|
|
if (typeof props.initial !== "boolean") {
|
|
const initialTransition = resolveVariant(visualElement, Array.isArray(props.initial)
|
|
? props.initial[0]
|
|
: props.initial);
|
|
if (initialTransition && initialTransition.transition) {
|
|
fallbackAnimation.transition = initialTransition.transition;
|
|
}
|
|
}
|
|
removedKeys.forEach((key) => {
|
|
const fallbackTarget = visualElement.getBaseTarget(key);
|
|
const motionValue = visualElement.getValue(key);
|
|
if (motionValue)
|
|
motionValue.liveStyle = true;
|
|
// @ts-expect-error - @mattgperry to figure if we should do something here
|
|
fallbackAnimation[key] = fallbackTarget ?? null;
|
|
});
|
|
animations.push({ animation: fallbackAnimation });
|
|
}
|
|
let shouldAnimate = Boolean(animations.length);
|
|
if (isInitialRender &&
|
|
(props.initial === false || props.initial === props.animate) &&
|
|
!visualElement.manuallyAnimateOnMount) {
|
|
shouldAnimate = false;
|
|
}
|
|
isInitialRender = false;
|
|
return shouldAnimate ? animate(animations) : Promise.resolve();
|
|
}
|
|
/**
|
|
* Change whether a certain animation type is active.
|
|
*/
|
|
function setActive(type, isActive) {
|
|
// If the active state hasn't changed, we can safely do nothing here
|
|
if (state[type].isActive === isActive)
|
|
return Promise.resolve();
|
|
// Propagate active change to children
|
|
visualElement.variantChildren?.forEach((child) => child.animationState?.setActive(type, isActive));
|
|
state[type].isActive = isActive;
|
|
const animations = animateChanges(type);
|
|
for (const key in state) {
|
|
state[key].protectedKeys = {};
|
|
}
|
|
return animations;
|
|
}
|
|
return {
|
|
animateChanges,
|
|
setActive,
|
|
setAnimateFunction,
|
|
getState: () => state,
|
|
reset: () => {
|
|
state = createState();
|
|
/**
|
|
* Temporarily disabling resetting this flag as it prevents components
|
|
* with initial={false} from animating after being remounted, for instance
|
|
* as the child of an Activity component.
|
|
*/
|
|
// isInitialRender = true
|
|
},
|
|
};
|
|
}
|
|
function checkVariantsDidChange(prev, next) {
|
|
if (typeof next === "string") {
|
|
return next !== prev;
|
|
}
|
|
else if (Array.isArray(next)) {
|
|
return !shallowCompare(next, prev);
|
|
}
|
|
return false;
|
|
}
|
|
function createTypeState(isActive = false) {
|
|
return {
|
|
isActive,
|
|
protectedKeys: {},
|
|
needsAnimating: {},
|
|
prevResolvedValues: {},
|
|
};
|
|
}
|
|
function createState() {
|
|
return {
|
|
animate: createTypeState(true),
|
|
whileInView: createTypeState(),
|
|
whileHover: createTypeState(),
|
|
whileTap: createTypeState(),
|
|
whileDrag: createTypeState(),
|
|
whileFocus: createTypeState(),
|
|
exit: createTypeState(),
|
|
};
|
|
}
|
|
|
|
class Feature {
|
|
constructor(node) {
|
|
this.isMounted = false;
|
|
this.node = node;
|
|
}
|
|
update() { }
|
|
}
|
|
|
|
class AnimationFeature extends Feature {
|
|
/**
|
|
* We dynamically generate the AnimationState manager as it contains a reference
|
|
* to the underlying animation library. We only want to load that if we load this,
|
|
* so people can optionally code split it out using the `m` component.
|
|
*/
|
|
constructor(node) {
|
|
super(node);
|
|
node.animationState || (node.animationState = createAnimationState(node));
|
|
}
|
|
updateAnimationControlsSubscription() {
|
|
const { animate } = this.node.getProps();
|
|
if (isAnimationControls(animate)) {
|
|
this.unmountControls = animate.subscribe(this.node);
|
|
}
|
|
}
|
|
/**
|
|
* Subscribe any provided AnimationControls to the component's VisualElement
|
|
*/
|
|
mount() {
|
|
this.updateAnimationControlsSubscription();
|
|
}
|
|
update() {
|
|
const { animate } = this.node.getProps();
|
|
const { animate: prevAnimate } = this.node.prevProps || {};
|
|
if (animate !== prevAnimate) {
|
|
this.updateAnimationControlsSubscription();
|
|
}
|
|
}
|
|
unmount() {
|
|
this.node.animationState.reset();
|
|
this.unmountControls?.();
|
|
}
|
|
}
|
|
|
|
let id = 0;
|
|
class ExitAnimationFeature extends Feature {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.id = id++;
|
|
}
|
|
update() {
|
|
if (!this.node.presenceContext)
|
|
return;
|
|
const { isPresent, onExitComplete } = this.node.presenceContext;
|
|
const { isPresent: prevIsPresent } = this.node.prevPresenceContext || {};
|
|
if (!this.node.animationState || isPresent === prevIsPresent) {
|
|
return;
|
|
}
|
|
const exitAnimation = this.node.animationState.setActive("exit", !isPresent);
|
|
if (onExitComplete && !isPresent) {
|
|
exitAnimation.then(() => {
|
|
onExitComplete(this.id);
|
|
});
|
|
}
|
|
}
|
|
mount() {
|
|
const { register, onExitComplete } = this.node.presenceContext || {};
|
|
if (onExitComplete) {
|
|
onExitComplete(this.id);
|
|
}
|
|
if (register) {
|
|
this.unmount = register(this.id);
|
|
}
|
|
}
|
|
unmount() { }
|
|
}
|
|
|
|
const animations = {
|
|
animation: {
|
|
Feature: AnimationFeature,
|
|
},
|
|
exit: {
|
|
Feature: ExitAnimationFeature,
|
|
},
|
|
};
|
|
|
|
function extractEventInfo(event) {
|
|
return {
|
|
point: {
|
|
x: event.pageX,
|
|
y: event.pageY,
|
|
},
|
|
};
|
|
}
|
|
const addPointerInfo = (handler) => {
|
|
return (event) => motionDom.isPrimaryPointer(event) && handler(event, extractEventInfo(event));
|
|
};
|
|
|
|
function addPointerEvent(target, eventName, handler, options) {
|
|
return addDomEvent(target, eventName, addPointerInfo(handler), options);
|
|
}
|
|
|
|
// Fixes https://github.com/motiondivision/motion/issues/2270
|
|
const getContextWindow = ({ current }) => {
|
|
return current ? current.ownerDocument.defaultView : null;
|
|
};
|
|
|
|
const distance = (a, b) => Math.abs(a - b);
|
|
function distance2D(a, b) {
|
|
// Multi-dimensional
|
|
const xDelta = distance(a.x, b.x);
|
|
const yDelta = distance(a.y, b.y);
|
|
return Math.sqrt(xDelta ** 2 + yDelta ** 2);
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
class PanSession {
|
|
constructor(event, handlers, { transformPagePoint, contextWindow = window, dragSnapToOrigin = false, distanceThreshold = 3, } = {}) {
|
|
/**
|
|
* @internal
|
|
*/
|
|
this.startEvent = null;
|
|
/**
|
|
* @internal
|
|
*/
|
|
this.lastMoveEvent = null;
|
|
/**
|
|
* @internal
|
|
*/
|
|
this.lastMoveEventInfo = null;
|
|
/**
|
|
* @internal
|
|
*/
|
|
this.handlers = {};
|
|
/**
|
|
* @internal
|
|
*/
|
|
this.contextWindow = window;
|
|
this.updatePoint = () => {
|
|
if (!(this.lastMoveEvent && this.lastMoveEventInfo))
|
|
return;
|
|
const info = getPanInfo(this.lastMoveEventInfo, this.history);
|
|
const isPanStarted = this.startEvent !== null;
|
|
// Only start panning if the offset is larger than 3 pixels. If we make it
|
|
// any larger than this we'll want to reset the pointer history
|
|
// on the first update to avoid visual snapping to the cursor.
|
|
const isDistancePastThreshold = distance2D(info.offset, { x: 0, y: 0 }) >= this.distanceThreshold;
|
|
if (!isPanStarted && !isDistancePastThreshold)
|
|
return;
|
|
const { point } = info;
|
|
const { timestamp } = motionDom.frameData;
|
|
this.history.push({ ...point, timestamp });
|
|
const { onStart, onMove } = this.handlers;
|
|
if (!isPanStarted) {
|
|
onStart && onStart(this.lastMoveEvent, info);
|
|
this.startEvent = this.lastMoveEvent;
|
|
}
|
|
onMove && onMove(this.lastMoveEvent, info);
|
|
};
|
|
this.handlePointerMove = (event, info) => {
|
|
this.lastMoveEvent = event;
|
|
this.lastMoveEventInfo = transformPoint(info, this.transformPagePoint);
|
|
// Throttle mouse move event to once per frame
|
|
motionDom.frame.update(this.updatePoint, true);
|
|
};
|
|
this.handlePointerUp = (event, info) => {
|
|
this.end();
|
|
const { onEnd, onSessionEnd, resumeAnimation } = this.handlers;
|
|
if (this.dragSnapToOrigin)
|
|
resumeAnimation && resumeAnimation();
|
|
if (!(this.lastMoveEvent && this.lastMoveEventInfo))
|
|
return;
|
|
const panInfo = getPanInfo(event.type === "pointercancel"
|
|
? this.lastMoveEventInfo
|
|
: transformPoint(info, this.transformPagePoint), this.history);
|
|
if (this.startEvent && onEnd) {
|
|
onEnd(event, panInfo);
|
|
}
|
|
onSessionEnd && onSessionEnd(event, panInfo);
|
|
};
|
|
// If we have more than one touch, don't start detecting this gesture
|
|
if (!motionDom.isPrimaryPointer(event))
|
|
return;
|
|
this.dragSnapToOrigin = dragSnapToOrigin;
|
|
this.handlers = handlers;
|
|
this.transformPagePoint = transformPagePoint;
|
|
this.distanceThreshold = distanceThreshold;
|
|
this.contextWindow = contextWindow || window;
|
|
const info = extractEventInfo(event);
|
|
const initialInfo = transformPoint(info, this.transformPagePoint);
|
|
const { point } = initialInfo;
|
|
const { timestamp } = motionDom.frameData;
|
|
this.history = [{ ...point, timestamp }];
|
|
const { onSessionStart } = handlers;
|
|
onSessionStart &&
|
|
onSessionStart(event, getPanInfo(initialInfo, this.history));
|
|
this.removeListeners = motionUtils.pipe(addPointerEvent(this.contextWindow, "pointermove", this.handlePointerMove), addPointerEvent(this.contextWindow, "pointerup", this.handlePointerUp), addPointerEvent(this.contextWindow, "pointercancel", this.handlePointerUp));
|
|
}
|
|
updateHandlers(handlers) {
|
|
this.handlers = handlers;
|
|
}
|
|
end() {
|
|
this.removeListeners && this.removeListeners();
|
|
motionDom.cancelFrame(this.updatePoint);
|
|
}
|
|
}
|
|
function transformPoint(info, transformPagePoint) {
|
|
return transformPagePoint ? { point: transformPagePoint(info.point) } : info;
|
|
}
|
|
function subtractPoint(a, b) {
|
|
return { x: a.x - b.x, y: a.y - b.y };
|
|
}
|
|
function getPanInfo({ point }, history) {
|
|
return {
|
|
point,
|
|
delta: subtractPoint(point, lastDevicePoint(history)),
|
|
offset: subtractPoint(point, startDevicePoint(history)),
|
|
velocity: getVelocity(history, 0.1),
|
|
};
|
|
}
|
|
function startDevicePoint(history) {
|
|
return history[0];
|
|
}
|
|
function lastDevicePoint(history) {
|
|
return history[history.length - 1];
|
|
}
|
|
function getVelocity(history, timeDelta) {
|
|
if (history.length < 2) {
|
|
return { x: 0, y: 0 };
|
|
}
|
|
let i = history.length - 1;
|
|
let timestampedPoint = null;
|
|
const lastPoint = lastDevicePoint(history);
|
|
while (i >= 0) {
|
|
timestampedPoint = history[i];
|
|
if (lastPoint.timestamp - timestampedPoint.timestamp >
|
|
motionUtils.secondsToMilliseconds(timeDelta)) {
|
|
break;
|
|
}
|
|
i--;
|
|
}
|
|
if (!timestampedPoint) {
|
|
return { x: 0, y: 0 };
|
|
}
|
|
const time = motionUtils.millisecondsToSeconds(lastPoint.timestamp - timestampedPoint.timestamp);
|
|
if (time === 0) {
|
|
return { x: 0, y: 0 };
|
|
}
|
|
const currentVelocity = {
|
|
x: (lastPoint.x - timestampedPoint.x) / time,
|
|
y: (lastPoint.y - timestampedPoint.y) / time,
|
|
};
|
|
if (currentVelocity.x === Infinity) {
|
|
currentVelocity.x = 0;
|
|
}
|
|
if (currentVelocity.y === Infinity) {
|
|
currentVelocity.y = 0;
|
|
}
|
|
return currentVelocity;
|
|
}
|
|
|
|
/**
|
|
* Apply constraints to a point. These constraints are both physical along an
|
|
* axis, and an elastic factor that determines how much to constrain the point
|
|
* by if it does lie outside the defined parameters.
|
|
*/
|
|
function applyConstraints(point, { min, max }, elastic) {
|
|
if (min !== undefined && point < min) {
|
|
// If we have a min point defined, and this is outside of that, constrain
|
|
point = elastic
|
|
? motionDom.mixNumber(min, point, elastic.min)
|
|
: Math.max(point, min);
|
|
}
|
|
else if (max !== undefined && point > max) {
|
|
// If we have a max point defined, and this is outside of that, constrain
|
|
point = elastic
|
|
? motionDom.mixNumber(max, point, elastic.max)
|
|
: Math.min(point, max);
|
|
}
|
|
return point;
|
|
}
|
|
/**
|
|
* Calculate constraints in terms of the viewport when defined relatively to the
|
|
* measured axis. This is measured from the nearest edge, so a max constraint of 200
|
|
* on an axis with a max value of 300 would return a constraint of 500 - axis length
|
|
*/
|
|
function calcRelativeAxisConstraints(axis, min, max) {
|
|
return {
|
|
min: min !== undefined ? axis.min + min : undefined,
|
|
max: max !== undefined
|
|
? axis.max + max - (axis.max - axis.min)
|
|
: undefined,
|
|
};
|
|
}
|
|
/**
|
|
* Calculate constraints in terms of the viewport when
|
|
* defined relatively to the measured bounding box.
|
|
*/
|
|
function calcRelativeConstraints(layoutBox, { top, left, bottom, right }) {
|
|
return {
|
|
x: calcRelativeAxisConstraints(layoutBox.x, left, right),
|
|
y: calcRelativeAxisConstraints(layoutBox.y, top, bottom),
|
|
};
|
|
}
|
|
/**
|
|
* Calculate viewport constraints when defined as another viewport-relative axis
|
|
*/
|
|
function calcViewportAxisConstraints(layoutAxis, constraintsAxis) {
|
|
let min = constraintsAxis.min - layoutAxis.min;
|
|
let max = constraintsAxis.max - layoutAxis.max;
|
|
// If the constraints axis is actually smaller than the layout axis then we can
|
|
// flip the constraints
|
|
if (constraintsAxis.max - constraintsAxis.min <
|
|
layoutAxis.max - layoutAxis.min) {
|
|
[min, max] = [max, min];
|
|
}
|
|
return { min, max };
|
|
}
|
|
/**
|
|
* Calculate viewport constraints when defined as another viewport-relative box
|
|
*/
|
|
function calcViewportConstraints(layoutBox, constraintsBox) {
|
|
return {
|
|
x: calcViewportAxisConstraints(layoutBox.x, constraintsBox.x),
|
|
y: calcViewportAxisConstraints(layoutBox.y, constraintsBox.y),
|
|
};
|
|
}
|
|
/**
|
|
* Calculate a transform origin relative to the source axis, between 0-1, that results
|
|
* in an asthetically pleasing scale/transform needed to project from source to target.
|
|
*/
|
|
function calcOrigin(source, target) {
|
|
let origin = 0.5;
|
|
const sourceLength = calcLength(source);
|
|
const targetLength = calcLength(target);
|
|
if (targetLength > sourceLength) {
|
|
origin = motionUtils.progress(target.min, target.max - sourceLength, source.min);
|
|
}
|
|
else if (sourceLength > targetLength) {
|
|
origin = motionUtils.progress(source.min, source.max - targetLength, target.min);
|
|
}
|
|
return motionUtils.clamp(0, 1, origin);
|
|
}
|
|
/**
|
|
* Rebase the calculated viewport constraints relative to the layout.min point.
|
|
*/
|
|
function rebaseAxisConstraints(layout, constraints) {
|
|
const relativeConstraints = {};
|
|
if (constraints.min !== undefined) {
|
|
relativeConstraints.min = constraints.min - layout.min;
|
|
}
|
|
if (constraints.max !== undefined) {
|
|
relativeConstraints.max = constraints.max - layout.min;
|
|
}
|
|
return relativeConstraints;
|
|
}
|
|
const defaultElastic = 0.35;
|
|
/**
|
|
* Accepts a dragElastic prop and returns resolved elastic values for each axis.
|
|
*/
|
|
function resolveDragElastic(dragElastic = defaultElastic) {
|
|
if (dragElastic === false) {
|
|
dragElastic = 0;
|
|
}
|
|
else if (dragElastic === true) {
|
|
dragElastic = defaultElastic;
|
|
}
|
|
return {
|
|
x: resolveAxisElastic(dragElastic, "left", "right"),
|
|
y: resolveAxisElastic(dragElastic, "top", "bottom"),
|
|
};
|
|
}
|
|
function resolveAxisElastic(dragElastic, minLabel, maxLabel) {
|
|
return {
|
|
min: resolvePointElastic(dragElastic, minLabel),
|
|
max: resolvePointElastic(dragElastic, maxLabel),
|
|
};
|
|
}
|
|
function resolvePointElastic(dragElastic, label) {
|
|
return typeof dragElastic === "number"
|
|
? dragElastic
|
|
: dragElastic[label] || 0;
|
|
}
|
|
|
|
const elementDragControls = new WeakMap();
|
|
class VisualElementDragControls {
|
|
constructor(visualElement) {
|
|
this.openDragLock = null;
|
|
this.isDragging = false;
|
|
this.currentDirection = null;
|
|
this.originPoint = { x: 0, y: 0 };
|
|
/**
|
|
* The permitted boundaries of travel, in pixels.
|
|
*/
|
|
this.constraints = false;
|
|
this.hasMutatedConstraints = false;
|
|
/**
|
|
* The per-axis resolved elastic values.
|
|
*/
|
|
this.elastic = createBox();
|
|
/**
|
|
* The latest pointer event. Used as fallback when the `cancel` and `stop` functions are called without arguments.
|
|
*/
|
|
this.latestPointerEvent = null;
|
|
/**
|
|
* The latest pan info. Used as fallback when the `cancel` and `stop` functions are called without arguments.
|
|
*/
|
|
this.latestPanInfo = null;
|
|
this.visualElement = visualElement;
|
|
}
|
|
start(originEvent, { snapToCursor = false, distanceThreshold } = {}) {
|
|
/**
|
|
* Don't start dragging if this component is exiting
|
|
*/
|
|
const { presenceContext } = this.visualElement;
|
|
if (presenceContext && presenceContext.isPresent === false)
|
|
return;
|
|
const onSessionStart = (event) => {
|
|
const { dragSnapToOrigin } = this.getProps();
|
|
// Stop or pause any animations on both axis values immediately. This allows the user to throw and catch
|
|
// the component.
|
|
dragSnapToOrigin ? this.pauseAnimation() : this.stopAnimation();
|
|
if (snapToCursor) {
|
|
this.snapToCursor(extractEventInfo(event).point);
|
|
}
|
|
};
|
|
const onStart = (event, info) => {
|
|
// Attempt to grab the global drag gesture lock - maybe make this part of PanSession
|
|
const { drag, dragPropagation, onDragStart } = this.getProps();
|
|
if (drag && !dragPropagation) {
|
|
if (this.openDragLock)
|
|
this.openDragLock();
|
|
this.openDragLock = motionDom.setDragLock(drag);
|
|
// If we don 't have the lock, don't start dragging
|
|
if (!this.openDragLock)
|
|
return;
|
|
}
|
|
this.latestPointerEvent = event;
|
|
this.latestPanInfo = info;
|
|
this.isDragging = true;
|
|
this.currentDirection = null;
|
|
this.resolveConstraints();
|
|
if (this.visualElement.projection) {
|
|
this.visualElement.projection.isAnimationBlocked = true;
|
|
this.visualElement.projection.target = undefined;
|
|
}
|
|
/**
|
|
* Record gesture origin
|
|
*/
|
|
eachAxis((axis) => {
|
|
let current = this.getAxisMotionValue(axis).get() || 0;
|
|
/**
|
|
* If the MotionValue is a percentage value convert to px
|
|
*/
|
|
if (motionDom.percent.test(current)) {
|
|
const { projection } = this.visualElement;
|
|
if (projection && projection.layout) {
|
|
const measuredAxis = projection.layout.layoutBox[axis];
|
|
if (measuredAxis) {
|
|
const length = calcLength(measuredAxis);
|
|
current = length * (parseFloat(current) / 100);
|
|
}
|
|
}
|
|
}
|
|
this.originPoint[axis] = current;
|
|
});
|
|
// Fire onDragStart event
|
|
if (onDragStart) {
|
|
motionDom.frame.postRender(() => onDragStart(event, info));
|
|
}
|
|
addValueToWillChange(this.visualElement, "transform");
|
|
const { animationState } = this.visualElement;
|
|
animationState && animationState.setActive("whileDrag", true);
|
|
};
|
|
const onMove = (event, info) => {
|
|
this.latestPointerEvent = event;
|
|
this.latestPanInfo = info;
|
|
const { dragPropagation, dragDirectionLock, onDirectionLock, onDrag, } = this.getProps();
|
|
// If we didn't successfully receive the gesture lock, early return.
|
|
if (!dragPropagation && !this.openDragLock)
|
|
return;
|
|
const { offset } = info;
|
|
// Attempt to detect drag direction if directionLock is true
|
|
if (dragDirectionLock && this.currentDirection === null) {
|
|
this.currentDirection = getCurrentDirection(offset);
|
|
// If we've successfully set a direction, notify listener
|
|
if (this.currentDirection !== null) {
|
|
onDirectionLock && onDirectionLock(this.currentDirection);
|
|
}
|
|
return;
|
|
}
|
|
// Update each point with the latest position
|
|
this.updateAxis("x", info.point, offset);
|
|
this.updateAxis("y", info.point, offset);
|
|
/**
|
|
* Ideally we would leave the renderer to fire naturally at the end of
|
|
* this frame but if the element is about to change layout as the result
|
|
* of a re-render we want to ensure the browser can read the latest
|
|
* bounding box to ensure the pointer and element don't fall out of sync.
|
|
*/
|
|
this.visualElement.render();
|
|
/**
|
|
* This must fire after the render call as it might trigger a state
|
|
* change which itself might trigger a layout update.
|
|
*/
|
|
onDrag && onDrag(event, info);
|
|
};
|
|
const onSessionEnd = (event, info) => {
|
|
this.latestPointerEvent = event;
|
|
this.latestPanInfo = info;
|
|
this.stop(event, info);
|
|
this.latestPointerEvent = null;
|
|
this.latestPanInfo = null;
|
|
};
|
|
const resumeAnimation = () => eachAxis((axis) => this.getAnimationState(axis) === "paused" &&
|
|
this.getAxisMotionValue(axis).animation?.play());
|
|
const { dragSnapToOrigin } = this.getProps();
|
|
this.panSession = new PanSession(originEvent, {
|
|
onSessionStart,
|
|
onStart,
|
|
onMove,
|
|
onSessionEnd,
|
|
resumeAnimation,
|
|
}, {
|
|
transformPagePoint: this.visualElement.getTransformPagePoint(),
|
|
dragSnapToOrigin,
|
|
distanceThreshold,
|
|
contextWindow: getContextWindow(this.visualElement),
|
|
});
|
|
}
|
|
/**
|
|
* @internal
|
|
*/
|
|
stop(event, panInfo) {
|
|
const finalEvent = event || this.latestPointerEvent;
|
|
const finalPanInfo = panInfo || this.latestPanInfo;
|
|
const isDragging = this.isDragging;
|
|
this.cancel();
|
|
if (!isDragging || !finalPanInfo || !finalEvent)
|
|
return;
|
|
const { velocity } = finalPanInfo;
|
|
this.startAnimation(velocity);
|
|
const { onDragEnd } = this.getProps();
|
|
if (onDragEnd) {
|
|
motionDom.frame.postRender(() => onDragEnd(finalEvent, finalPanInfo));
|
|
}
|
|
}
|
|
/**
|
|
* @internal
|
|
*/
|
|
cancel() {
|
|
this.isDragging = false;
|
|
const { projection, animationState } = this.visualElement;
|
|
if (projection) {
|
|
projection.isAnimationBlocked = false;
|
|
}
|
|
this.panSession && this.panSession.end();
|
|
this.panSession = undefined;
|
|
const { dragPropagation } = this.getProps();
|
|
if (!dragPropagation && this.openDragLock) {
|
|
this.openDragLock();
|
|
this.openDragLock = null;
|
|
}
|
|
animationState && animationState.setActive("whileDrag", false);
|
|
}
|
|
updateAxis(axis, _point, offset) {
|
|
const { drag } = this.getProps();
|
|
// If we're not dragging this axis, do an early return.
|
|
if (!offset || !shouldDrag(axis, drag, this.currentDirection))
|
|
return;
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
let next = this.originPoint[axis] + offset[axis];
|
|
// Apply constraints
|
|
if (this.constraints && this.constraints[axis]) {
|
|
next = applyConstraints(next, this.constraints[axis], this.elastic[axis]);
|
|
}
|
|
axisValue.set(next);
|
|
}
|
|
resolveConstraints() {
|
|
const { dragConstraints, dragElastic } = this.getProps();
|
|
const layout = this.visualElement.projection &&
|
|
!this.visualElement.projection.layout
|
|
? this.visualElement.projection.measure(false)
|
|
: this.visualElement.projection?.layout;
|
|
const prevConstraints = this.constraints;
|
|
if (dragConstraints && isRefObject(dragConstraints)) {
|
|
if (!this.constraints) {
|
|
this.constraints = this.resolveRefConstraints();
|
|
}
|
|
}
|
|
else {
|
|
if (dragConstraints && layout) {
|
|
this.constraints = calcRelativeConstraints(layout.layoutBox, dragConstraints);
|
|
}
|
|
else {
|
|
this.constraints = false;
|
|
}
|
|
}
|
|
this.elastic = resolveDragElastic(dragElastic);
|
|
/**
|
|
* If we're outputting to external MotionValues, we want to rebase the measured constraints
|
|
* from viewport-relative to component-relative.
|
|
*/
|
|
if (prevConstraints !== this.constraints &&
|
|
layout &&
|
|
this.constraints &&
|
|
!this.hasMutatedConstraints) {
|
|
eachAxis((axis) => {
|
|
if (this.constraints !== false &&
|
|
this.getAxisMotionValue(axis)) {
|
|
this.constraints[axis] = rebaseAxisConstraints(layout.layoutBox[axis], this.constraints[axis]);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
resolveRefConstraints() {
|
|
const { dragConstraints: constraints, onMeasureDragConstraints } = this.getProps();
|
|
if (!constraints || !isRefObject(constraints))
|
|
return false;
|
|
const constraintsElement = constraints.current;
|
|
motionUtils.invariant(constraintsElement !== null, "If `dragConstraints` is set as a React ref, that ref must be passed to another component's `ref` prop.", "drag-constraints-ref");
|
|
const { projection } = this.visualElement;
|
|
// TODO
|
|
if (!projection || !projection.layout)
|
|
return false;
|
|
const constraintsBox = measurePageBox(constraintsElement, projection.root, this.visualElement.getTransformPagePoint());
|
|
let measuredConstraints = calcViewportConstraints(projection.layout.layoutBox, constraintsBox);
|
|
/**
|
|
* If there's an onMeasureDragConstraints listener we call it and
|
|
* if different constraints are returned, set constraints to that
|
|
*/
|
|
if (onMeasureDragConstraints) {
|
|
const userConstraints = onMeasureDragConstraints(convertBoxToBoundingBox(measuredConstraints));
|
|
this.hasMutatedConstraints = !!userConstraints;
|
|
if (userConstraints) {
|
|
measuredConstraints = convertBoundingBoxToBox(userConstraints);
|
|
}
|
|
}
|
|
return measuredConstraints;
|
|
}
|
|
startAnimation(velocity) {
|
|
const { drag, dragMomentum, dragElastic, dragTransition, dragSnapToOrigin, onDragTransitionEnd, } = this.getProps();
|
|
const constraints = this.constraints || {};
|
|
const momentumAnimations = eachAxis((axis) => {
|
|
if (!shouldDrag(axis, drag, this.currentDirection)) {
|
|
return;
|
|
}
|
|
let transition = (constraints && constraints[axis]) || {};
|
|
if (dragSnapToOrigin)
|
|
transition = { min: 0, max: 0 };
|
|
/**
|
|
* Overdamp the boundary spring if `dragElastic` is disabled. There's still a frame
|
|
* of spring animations so we should look into adding a disable spring option to `inertia`.
|
|
* We could do something here where we affect the `bounceStiffness` and `bounceDamping`
|
|
* using the value of `dragElastic`.
|
|
*/
|
|
const bounceStiffness = dragElastic ? 200 : 1000000;
|
|
const bounceDamping = dragElastic ? 40 : 10000000;
|
|
const inertia = {
|
|
type: "inertia",
|
|
velocity: dragMomentum ? velocity[axis] : 0,
|
|
bounceStiffness,
|
|
bounceDamping,
|
|
timeConstant: 750,
|
|
restDelta: 1,
|
|
restSpeed: 10,
|
|
...dragTransition,
|
|
...transition,
|
|
};
|
|
// If we're not animating on an externally-provided `MotionValue` we can use the
|
|
// component's animation controls which will handle interactions with whileHover (etc),
|
|
// otherwise we just have to animate the `MotionValue` itself.
|
|
return this.startAxisValueAnimation(axis, inertia);
|
|
});
|
|
// Run all animations and then resolve the new drag constraints.
|
|
return Promise.all(momentumAnimations).then(onDragTransitionEnd);
|
|
}
|
|
startAxisValueAnimation(axis, transition) {
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
addValueToWillChange(this.visualElement, axis);
|
|
return axisValue.start(animateMotionValue(axis, axisValue, 0, transition, this.visualElement, false));
|
|
}
|
|
stopAnimation() {
|
|
eachAxis((axis) => this.getAxisMotionValue(axis).stop());
|
|
}
|
|
pauseAnimation() {
|
|
eachAxis((axis) => this.getAxisMotionValue(axis).animation?.pause());
|
|
}
|
|
getAnimationState(axis) {
|
|
return this.getAxisMotionValue(axis).animation?.state;
|
|
}
|
|
/**
|
|
* Drag works differently depending on which props are provided.
|
|
*
|
|
* - If _dragX and _dragY are provided, we output the gesture delta directly to those motion values.
|
|
* - Otherwise, we apply the delta to the x/y motion values.
|
|
*/
|
|
getAxisMotionValue(axis) {
|
|
const dragKey = `_drag${axis.toUpperCase()}`;
|
|
const props = this.visualElement.getProps();
|
|
const externalMotionValue = props[dragKey];
|
|
return externalMotionValue
|
|
? externalMotionValue
|
|
: this.visualElement.getValue(axis, (props.initial
|
|
? props.initial[axis]
|
|
: undefined) || 0);
|
|
}
|
|
snapToCursor(point) {
|
|
eachAxis((axis) => {
|
|
const { drag } = this.getProps();
|
|
// If we're not dragging this axis, do an early return.
|
|
if (!shouldDrag(axis, drag, this.currentDirection))
|
|
return;
|
|
const { projection } = this.visualElement;
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
if (projection && projection.layout) {
|
|
const { min, max } = projection.layout.layoutBox[axis];
|
|
axisValue.set(point[axis] - motionDom.mixNumber(min, max, 0.5));
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* When the viewport resizes we want to check if the measured constraints
|
|
* have changed and, if so, reposition the element within those new constraints
|
|
* relative to where it was before the resize.
|
|
*/
|
|
scalePositionWithinConstraints() {
|
|
if (!this.visualElement.current)
|
|
return;
|
|
const { drag, dragConstraints } = this.getProps();
|
|
const { projection } = this.visualElement;
|
|
if (!isRefObject(dragConstraints) || !projection || !this.constraints)
|
|
return;
|
|
/**
|
|
* Stop current animations as there can be visual glitching if we try to do
|
|
* this mid-animation
|
|
*/
|
|
this.stopAnimation();
|
|
/**
|
|
* Record the relative position of the dragged element relative to the
|
|
* constraints box and save as a progress value.
|
|
*/
|
|
const boxProgress = { x: 0, y: 0 };
|
|
eachAxis((axis) => {
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
if (axisValue && this.constraints !== false) {
|
|
const latest = axisValue.get();
|
|
boxProgress[axis] = calcOrigin({ min: latest, max: latest }, this.constraints[axis]);
|
|
}
|
|
});
|
|
/**
|
|
* Update the layout of this element and resolve the latest drag constraints
|
|
*/
|
|
const { transformTemplate } = this.visualElement.getProps();
|
|
this.visualElement.current.style.transform = transformTemplate
|
|
? transformTemplate({}, "")
|
|
: "none";
|
|
projection.root && projection.root.updateScroll();
|
|
projection.updateLayout();
|
|
this.resolveConstraints();
|
|
/**
|
|
* For each axis, calculate the current progress of the layout axis
|
|
* within the new constraints.
|
|
*/
|
|
eachAxis((axis) => {
|
|
if (!shouldDrag(axis, drag, null))
|
|
return;
|
|
/**
|
|
* Calculate a new transform based on the previous box progress
|
|
*/
|
|
const axisValue = this.getAxisMotionValue(axis);
|
|
const { min, max } = this.constraints[axis];
|
|
axisValue.set(motionDom.mixNumber(min, max, boxProgress[axis]));
|
|
});
|
|
}
|
|
addListeners() {
|
|
if (!this.visualElement.current)
|
|
return;
|
|
elementDragControls.set(this.visualElement, this);
|
|
const element = this.visualElement.current;
|
|
/**
|
|
* Attach a pointerdown event listener on this DOM element to initiate drag tracking.
|
|
*/
|
|
const stopPointerListener = addPointerEvent(element, "pointerdown", (event) => {
|
|
const { drag, dragListener = true } = this.getProps();
|
|
drag && dragListener && this.start(event);
|
|
});
|
|
const measureDragConstraints = () => {
|
|
const { dragConstraints } = this.getProps();
|
|
if (isRefObject(dragConstraints) && dragConstraints.current) {
|
|
this.constraints = this.resolveRefConstraints();
|
|
}
|
|
};
|
|
const { projection } = this.visualElement;
|
|
const stopMeasureLayoutListener = projection.addEventListener("measure", measureDragConstraints);
|
|
if (projection && !projection.layout) {
|
|
projection.root && projection.root.updateScroll();
|
|
projection.updateLayout();
|
|
}
|
|
motionDom.frame.read(measureDragConstraints);
|
|
/**
|
|
* Attach a window resize listener to scale the draggable target within its defined
|
|
* constraints as the window resizes.
|
|
*/
|
|
const stopResizeListener = addDomEvent(window, "resize", () => this.scalePositionWithinConstraints());
|
|
/**
|
|
* If the element's layout changes, calculate the delta and apply that to
|
|
* the drag gesture's origin point.
|
|
*/
|
|
const stopLayoutUpdateListener = projection.addEventListener("didUpdate", (({ delta, hasLayoutChanged }) => {
|
|
if (this.isDragging && hasLayoutChanged) {
|
|
eachAxis((axis) => {
|
|
const motionValue = this.getAxisMotionValue(axis);
|
|
if (!motionValue)
|
|
return;
|
|
this.originPoint[axis] += delta[axis].translate;
|
|
motionValue.set(motionValue.get() + delta[axis].translate);
|
|
});
|
|
this.visualElement.render();
|
|
}
|
|
}));
|
|
return () => {
|
|
stopResizeListener();
|
|
stopPointerListener();
|
|
stopMeasureLayoutListener();
|
|
stopLayoutUpdateListener && stopLayoutUpdateListener();
|
|
};
|
|
}
|
|
getProps() {
|
|
const props = this.visualElement.getProps();
|
|
const { drag = false, dragDirectionLock = false, dragPropagation = false, dragConstraints = false, dragElastic = defaultElastic, dragMomentum = true, } = props;
|
|
return {
|
|
...props,
|
|
drag,
|
|
dragDirectionLock,
|
|
dragPropagation,
|
|
dragConstraints,
|
|
dragElastic,
|
|
dragMomentum,
|
|
};
|
|
}
|
|
}
|
|
function shouldDrag(direction, drag, currentDirection) {
|
|
return ((drag === true || drag === direction) &&
|
|
(currentDirection === null || currentDirection === direction));
|
|
}
|
|
/**
|
|
* Based on an x/y offset determine the current drag direction. If both axis' offsets are lower
|
|
* than the provided threshold, return `null`.
|
|
*
|
|
* @param offset - The x/y offset from origin.
|
|
* @param lockThreshold - (Optional) - the minimum absolute offset before we can determine a drag direction.
|
|
*/
|
|
function getCurrentDirection(offset, lockThreshold = 10) {
|
|
let direction = null;
|
|
if (Math.abs(offset.y) > lockThreshold) {
|
|
direction = "y";
|
|
}
|
|
else if (Math.abs(offset.x) > lockThreshold) {
|
|
direction = "x";
|
|
}
|
|
return direction;
|
|
}
|
|
|
|
class DragGesture extends Feature {
|
|
constructor(node) {
|
|
super(node);
|
|
this.removeGroupControls = motionUtils.noop;
|
|
this.removeListeners = motionUtils.noop;
|
|
this.controls = new VisualElementDragControls(node);
|
|
}
|
|
mount() {
|
|
// If we've been provided a DragControls for manual control over the drag gesture,
|
|
// subscribe this component to it on mount.
|
|
const { dragControls } = this.node.getProps();
|
|
if (dragControls) {
|
|
this.removeGroupControls = dragControls.subscribe(this.controls);
|
|
}
|
|
this.removeListeners = this.controls.addListeners() || motionUtils.noop;
|
|
}
|
|
unmount() {
|
|
this.removeGroupControls();
|
|
this.removeListeners();
|
|
}
|
|
}
|
|
|
|
const asyncHandler = (handler) => (event, info) => {
|
|
if (handler) {
|
|
motionDom.frame.postRender(() => handler(event, info));
|
|
}
|
|
};
|
|
class PanGesture extends Feature {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.removePointerDownListener = motionUtils.noop;
|
|
}
|
|
onPointerDown(pointerDownEvent) {
|
|
this.session = new PanSession(pointerDownEvent, this.createPanHandlers(), {
|
|
transformPagePoint: this.node.getTransformPagePoint(),
|
|
contextWindow: getContextWindow(this.node),
|
|
});
|
|
}
|
|
createPanHandlers() {
|
|
const { onPanSessionStart, onPanStart, onPan, onPanEnd } = this.node.getProps();
|
|
return {
|
|
onSessionStart: asyncHandler(onPanSessionStart),
|
|
onStart: asyncHandler(onPanStart),
|
|
onMove: onPan,
|
|
onEnd: (event, info) => {
|
|
delete this.session;
|
|
if (onPanEnd) {
|
|
motionDom.frame.postRender(() => onPanEnd(event, info));
|
|
}
|
|
},
|
|
};
|
|
}
|
|
mount() {
|
|
this.removePointerDownListener = addPointerEvent(this.node.current, "pointerdown", (event) => this.onPointerDown(event));
|
|
}
|
|
update() {
|
|
this.session && this.session.updateHandlers(this.createPanHandlers());
|
|
}
|
|
unmount() {
|
|
this.removePointerDownListener();
|
|
this.session && this.session.end();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Track whether we've taken any snapshots yet. If not,
|
|
* we can safely skip notification of didUpdate.
|
|
*
|
|
* Difficult to capture in a test but to prevent flickering
|
|
* we must set this to true either on update or unmount.
|
|
* Running `next-env/layout-id` in Safari will show this behaviour if broken.
|
|
*/
|
|
let hasTakenAnySnapshot = false;
|
|
class MeasureLayoutWithContext extends React.Component {
|
|
/**
|
|
* This only mounts projection nodes for components that
|
|
* need measuring, we might want to do it for all components
|
|
* in order to incorporate transforms
|
|
*/
|
|
componentDidMount() {
|
|
const { visualElement, layoutGroup, switchLayoutGroup, layoutId } = this.props;
|
|
const { projection } = visualElement;
|
|
addScaleCorrector(defaultScaleCorrectors);
|
|
if (projection) {
|
|
if (layoutGroup.group)
|
|
layoutGroup.group.add(projection);
|
|
if (switchLayoutGroup && switchLayoutGroup.register && layoutId) {
|
|
switchLayoutGroup.register(projection);
|
|
}
|
|
if (hasTakenAnySnapshot) {
|
|
projection.root.didUpdate();
|
|
}
|
|
projection.addEventListener("animationComplete", () => {
|
|
this.safeToRemove();
|
|
});
|
|
projection.setOptions({
|
|
...projection.options,
|
|
onExitComplete: () => this.safeToRemove(),
|
|
});
|
|
}
|
|
globalProjectionState.hasEverUpdated = true;
|
|
}
|
|
getSnapshotBeforeUpdate(prevProps) {
|
|
const { layoutDependency, visualElement, drag, isPresent } = this.props;
|
|
const { projection } = visualElement;
|
|
if (!projection)
|
|
return null;
|
|
/**
|
|
* TODO: We use this data in relegate to determine whether to
|
|
* promote a previous element. There's no guarantee its presence data
|
|
* will have updated by this point - if a bug like this arises it will
|
|
* have to be that we markForRelegation and then find a new lead some other way,
|
|
* perhaps in didUpdate
|
|
*/
|
|
projection.isPresent = isPresent;
|
|
hasTakenAnySnapshot = true;
|
|
if (drag ||
|
|
prevProps.layoutDependency !== layoutDependency ||
|
|
layoutDependency === undefined ||
|
|
prevProps.isPresent !== isPresent) {
|
|
projection.willUpdate();
|
|
}
|
|
else {
|
|
this.safeToRemove();
|
|
}
|
|
if (prevProps.isPresent !== isPresent) {
|
|
if (isPresent) {
|
|
projection.promote();
|
|
}
|
|
else if (!projection.relegate()) {
|
|
/**
|
|
* If there's another stack member taking over from this one,
|
|
* it's in charge of the exit animation and therefore should
|
|
* be in charge of the safe to remove. Otherwise we call it here.
|
|
*/
|
|
motionDom.frame.postRender(() => {
|
|
const stack = projection.getStack();
|
|
if (!stack || !stack.members.length) {
|
|
this.safeToRemove();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
componentDidUpdate() {
|
|
const { projection } = this.props.visualElement;
|
|
if (projection) {
|
|
projection.root.didUpdate();
|
|
motionDom.microtask.postRender(() => {
|
|
if (!projection.currentAnimation && projection.isLead()) {
|
|
this.safeToRemove();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
componentWillUnmount() {
|
|
const { visualElement, layoutGroup, switchLayoutGroup: promoteContext, } = this.props;
|
|
const { projection } = visualElement;
|
|
hasTakenAnySnapshot = true;
|
|
if (projection) {
|
|
projection.scheduleCheckAfterUnmount();
|
|
if (layoutGroup && layoutGroup.group)
|
|
layoutGroup.group.remove(projection);
|
|
if (promoteContext && promoteContext.deregister)
|
|
promoteContext.deregister(projection);
|
|
}
|
|
}
|
|
safeToRemove() {
|
|
const { safeToRemove } = this.props;
|
|
safeToRemove && safeToRemove();
|
|
}
|
|
render() {
|
|
return null;
|
|
}
|
|
}
|
|
function MeasureLayout(props) {
|
|
const [isPresent, safeToRemove] = usePresence();
|
|
const layoutGroup = React.useContext(LayoutGroupContext);
|
|
return (jsxRuntime.jsx(MeasureLayoutWithContext, { ...props, layoutGroup: layoutGroup, switchLayoutGroup: React.useContext(SwitchLayoutGroupContext), isPresent: isPresent, safeToRemove: safeToRemove }));
|
|
}
|
|
const defaultScaleCorrectors = {
|
|
borderRadius: {
|
|
...correctBorderRadius,
|
|
applyTo: [
|
|
"borderTopLeftRadius",
|
|
"borderTopRightRadius",
|
|
"borderBottomLeftRadius",
|
|
"borderBottomRightRadius",
|
|
],
|
|
},
|
|
borderTopLeftRadius: correctBorderRadius,
|
|
borderTopRightRadius: correctBorderRadius,
|
|
borderBottomLeftRadius: correctBorderRadius,
|
|
borderBottomRightRadius: correctBorderRadius,
|
|
boxShadow: correctBoxShadow,
|
|
};
|
|
|
|
const drag = {
|
|
pan: {
|
|
Feature: PanGesture,
|
|
},
|
|
drag: {
|
|
Feature: DragGesture,
|
|
ProjectionNode: HTMLProjectionNode,
|
|
MeasureLayout,
|
|
},
|
|
};
|
|
|
|
function handleHoverEvent(node, event, lifecycle) {
|
|
const { props } = node;
|
|
if (node.animationState && props.whileHover) {
|
|
node.animationState.setActive("whileHover", lifecycle === "Start");
|
|
}
|
|
const eventName = ("onHover" + lifecycle);
|
|
const callback = props[eventName];
|
|
if (callback) {
|
|
motionDom.frame.postRender(() => callback(event, extractEventInfo(event)));
|
|
}
|
|
}
|
|
class HoverGesture extends Feature {
|
|
mount() {
|
|
const { current } = this.node;
|
|
if (!current)
|
|
return;
|
|
this.unmount = motionDom.hover(current, (_element, startEvent) => {
|
|
handleHoverEvent(this.node, startEvent, "Start");
|
|
return (endEvent) => handleHoverEvent(this.node, endEvent, "End");
|
|
});
|
|
}
|
|
unmount() { }
|
|
}
|
|
|
|
class FocusGesture extends Feature {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.isActive = false;
|
|
}
|
|
onFocus() {
|
|
let isFocusVisible = false;
|
|
/**
|
|
* If this element doesn't match focus-visible then don't
|
|
* apply whileHover. But, if matches throws that focus-visible
|
|
* is not a valid selector then in that browser outline styles will be applied
|
|
* to the element by default and we want to match that behaviour with whileFocus.
|
|
*/
|
|
try {
|
|
isFocusVisible = this.node.current.matches(":focus-visible");
|
|
}
|
|
catch (e) {
|
|
isFocusVisible = true;
|
|
}
|
|
if (!isFocusVisible || !this.node.animationState)
|
|
return;
|
|
this.node.animationState.setActive("whileFocus", true);
|
|
this.isActive = true;
|
|
}
|
|
onBlur() {
|
|
if (!this.isActive || !this.node.animationState)
|
|
return;
|
|
this.node.animationState.setActive("whileFocus", false);
|
|
this.isActive = false;
|
|
}
|
|
mount() {
|
|
this.unmount = motionUtils.pipe(addDomEvent(this.node.current, "focus", () => this.onFocus()), addDomEvent(this.node.current, "blur", () => this.onBlur()));
|
|
}
|
|
unmount() { }
|
|
}
|
|
|
|
function handlePressEvent(node, event, lifecycle) {
|
|
const { props } = node;
|
|
if (node.current instanceof HTMLButtonElement && node.current.disabled) {
|
|
return;
|
|
}
|
|
if (node.animationState && props.whileTap) {
|
|
node.animationState.setActive("whileTap", lifecycle === "Start");
|
|
}
|
|
const eventName = ("onTap" + (lifecycle === "End" ? "" : lifecycle));
|
|
const callback = props[eventName];
|
|
if (callback) {
|
|
motionDom.frame.postRender(() => callback(event, extractEventInfo(event)));
|
|
}
|
|
}
|
|
class PressGesture extends Feature {
|
|
mount() {
|
|
const { current } = this.node;
|
|
if (!current)
|
|
return;
|
|
this.unmount = motionDom.press(current, (_element, startEvent) => {
|
|
handlePressEvent(this.node, startEvent, "Start");
|
|
return (endEvent, { success }) => handlePressEvent(this.node, endEvent, success ? "End" : "Cancel");
|
|
}, { useGlobalTarget: this.node.props.globalTapTarget });
|
|
}
|
|
unmount() { }
|
|
}
|
|
|
|
/**
|
|
* Map an IntersectionHandler callback to an element. We only ever make one handler for one
|
|
* element, so even though these handlers might all be triggered by different
|
|
* observers, we can keep them in the same map.
|
|
*/
|
|
const observerCallbacks = new WeakMap();
|
|
/**
|
|
* Multiple observers can be created for multiple element/document roots. Each with
|
|
* different settings. So here we store dictionaries of observers to each root,
|
|
* using serialised settings (threshold/margin) as lookup keys.
|
|
*/
|
|
const observers = new WeakMap();
|
|
const fireObserverCallback = (entry) => {
|
|
const callback = observerCallbacks.get(entry.target);
|
|
callback && callback(entry);
|
|
};
|
|
const fireAllObserverCallbacks = (entries) => {
|
|
entries.forEach(fireObserverCallback);
|
|
};
|
|
function initIntersectionObserver({ root, ...options }) {
|
|
const lookupRoot = root || document;
|
|
/**
|
|
* If we don't have an observer lookup map for this root, create one.
|
|
*/
|
|
if (!observers.has(lookupRoot)) {
|
|
observers.set(lookupRoot, {});
|
|
}
|
|
const rootObservers = observers.get(lookupRoot);
|
|
const key = JSON.stringify(options);
|
|
/**
|
|
* If we don't have an observer for this combination of root and settings,
|
|
* create one.
|
|
*/
|
|
if (!rootObservers[key]) {
|
|
rootObservers[key] = new IntersectionObserver(fireAllObserverCallbacks, { root, ...options });
|
|
}
|
|
return rootObservers[key];
|
|
}
|
|
function observeIntersection(element, options, callback) {
|
|
const rootInteresectionObserver = initIntersectionObserver(options);
|
|
observerCallbacks.set(element, callback);
|
|
rootInteresectionObserver.observe(element);
|
|
return () => {
|
|
observerCallbacks.delete(element);
|
|
rootInteresectionObserver.unobserve(element);
|
|
};
|
|
}
|
|
|
|
const thresholdNames = {
|
|
some: 0,
|
|
all: 1,
|
|
};
|
|
class InViewFeature extends Feature {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.hasEnteredView = false;
|
|
this.isInView = false;
|
|
}
|
|
startObserver() {
|
|
this.unmount();
|
|
const { viewport = {} } = this.node.getProps();
|
|
const { root, margin: rootMargin, amount = "some", once } = viewport;
|
|
const options = {
|
|
root: root ? root.current : undefined,
|
|
rootMargin,
|
|
threshold: typeof amount === "number" ? amount : thresholdNames[amount],
|
|
};
|
|
const onIntersectionUpdate = (entry) => {
|
|
const { isIntersecting } = entry;
|
|
/**
|
|
* If there's been no change in the viewport state, early return.
|
|
*/
|
|
if (this.isInView === isIntersecting)
|
|
return;
|
|
this.isInView = isIntersecting;
|
|
/**
|
|
* Handle hasEnteredView. If this is only meant to run once, and
|
|
* element isn't visible, early return. Otherwise set hasEnteredView to true.
|
|
*/
|
|
if (once && !isIntersecting && this.hasEnteredView) {
|
|
return;
|
|
}
|
|
else if (isIntersecting) {
|
|
this.hasEnteredView = true;
|
|
}
|
|
if (this.node.animationState) {
|
|
this.node.animationState.setActive("whileInView", isIntersecting);
|
|
}
|
|
/**
|
|
* Use the latest committed props rather than the ones in scope
|
|
* when this observer is created
|
|
*/
|
|
const { onViewportEnter, onViewportLeave } = this.node.getProps();
|
|
const callback = isIntersecting ? onViewportEnter : onViewportLeave;
|
|
callback && callback(entry);
|
|
};
|
|
return observeIntersection(this.node.current, options, onIntersectionUpdate);
|
|
}
|
|
mount() {
|
|
this.startObserver();
|
|
}
|
|
update() {
|
|
if (typeof IntersectionObserver === "undefined")
|
|
return;
|
|
const { props, prevProps } = this.node;
|
|
const hasOptionsChanged = ["amount", "margin", "root"].some(hasViewportOptionChanged(props, prevProps));
|
|
if (hasOptionsChanged) {
|
|
this.startObserver();
|
|
}
|
|
}
|
|
unmount() { }
|
|
}
|
|
function hasViewportOptionChanged({ viewport = {} }, { viewport: prevViewport = {} } = {}) {
|
|
return (name) => viewport[name] !== prevViewport[name];
|
|
}
|
|
|
|
const gestureAnimations = {
|
|
inView: {
|
|
Feature: InViewFeature,
|
|
},
|
|
tap: {
|
|
Feature: PressGesture,
|
|
},
|
|
focus: {
|
|
Feature: FocusGesture,
|
|
},
|
|
hover: {
|
|
Feature: HoverGesture,
|
|
},
|
|
};
|
|
|
|
const layout = {
|
|
layout: {
|
|
ProjectionNode: HTMLProjectionNode,
|
|
MeasureLayout,
|
|
},
|
|
};
|
|
|
|
const featureBundle = {
|
|
...animations,
|
|
...gestureAnimations,
|
|
...drag,
|
|
...layout,
|
|
};
|
|
|
|
exports.HTMLVisualElement = HTMLVisualElement;
|
|
exports.LayoutGroupContext = LayoutGroupContext;
|
|
exports.LazyContext = LazyContext;
|
|
exports.MotionConfigContext = MotionConfigContext;
|
|
exports.MotionContext = MotionContext;
|
|
exports.PresenceContext = PresenceContext;
|
|
exports.SVGVisualElement = SVGVisualElement;
|
|
exports.SwitchLayoutGroupContext = SwitchLayoutGroupContext;
|
|
exports.VisualElement = VisualElement;
|
|
exports.addDomEvent = addDomEvent;
|
|
exports.addPointerEvent = addPointerEvent;
|
|
exports.addPointerInfo = addPointerInfo;
|
|
exports.addScaleCorrector = addScaleCorrector;
|
|
exports.animateSingleValue = animateSingleValue;
|
|
exports.animateTarget = animateTarget;
|
|
exports.animateVisualElement = animateVisualElement;
|
|
exports.animations = animations;
|
|
exports.buildTransform = buildTransform;
|
|
exports.calcLength = calcLength;
|
|
exports.createBox = createBox;
|
|
exports.createDomVisualElement = createDomVisualElement;
|
|
exports.createMotionComponent = createMotionComponent;
|
|
exports.delay = delay;
|
|
exports.distance = distance;
|
|
exports.distance2D = distance2D;
|
|
exports.drag = drag;
|
|
exports.featureBundle = featureBundle;
|
|
exports.filterProps = filterProps;
|
|
exports.gestureAnimations = gestureAnimations;
|
|
exports.getOptimisedAppearId = getOptimisedAppearId;
|
|
exports.hasReducedMotionListener = hasReducedMotionListener;
|
|
exports.initPrefersReducedMotion = initPrefersReducedMotion;
|
|
exports.isBrowser = isBrowser;
|
|
exports.isValidMotionProp = isValidMotionProp;
|
|
exports.layout = layout;
|
|
exports.loadExternalIsValidProp = loadExternalIsValidProp;
|
|
exports.loadFeatures = loadFeatures;
|
|
exports.makeUseVisualState = makeUseVisualState;
|
|
exports.motionComponentSymbol = motionComponentSymbol;
|
|
exports.optimizedAppearDataAttribute = optimizedAppearDataAttribute;
|
|
exports.optimizedAppearDataId = optimizedAppearDataId;
|
|
exports.prefersReducedMotion = prefersReducedMotion;
|
|
exports.resolveMotionValue = resolveMotionValue;
|
|
exports.rootProjectionNode = rootProjectionNode;
|
|
exports.setTarget = setTarget;
|
|
exports.useConstant = useConstant;
|
|
exports.useIsPresent = useIsPresent;
|
|
exports.useIsomorphicLayoutEffect = useIsomorphicLayoutEffect;
|
|
exports.usePresence = usePresence;
|
|
exports.visualElementStore = visualElementStore;
|