motion-dom
Version:
541 lines (538 loc) • 21.4 kB
JavaScript
import { warnOnce, secondsToMilliseconds, isNumericalString, isZeroValueString, SubscriptionManager } from 'motion-utils';
import { KeyframeResolver } from '../animation/keyframes/KeyframesResolver.mjs';
import { NativeAnimation } from '../animation/NativeAnimation.mjs';
import { acceleratedValues } from '../animation/waapi/utils/accelerated-values.mjs';
import { transformGroups } from '../animation/waapi/transforms/groups.mjs';
import { microtask } from '../frameloop/microtask.mjs';
import { time } from '../frameloop/sync-time.mjs';
import { createBox } from '../projection/geometry/models.mjs';
import { motionValue } from '../value/index.mjs';
import { complex } from '../value/types/complex/index.mjs';
import { getAnimatableNone } from '../value/types/utils/animatable-none.mjs';
import { isMotionValue } from '../value/utils/is-motion-value.mjs';
import { visualElementStore } from './store.mjs';
import { isControllingVariants, isVariantNode } from './utils/is-controlling-variants.mjs';
import { transformProps } from './utils/keys-transform.mjs';
import { updateMotionValuesFromProps } from './utils/motion-values.mjs';
import { initPrefersReducedMotion } from './utils/reduced-motion/index.mjs';
import { hasReducedMotionListener, prefersReducedMotion } from './utils/reduced-motion/state.mjs';
import { frame, cancelFrame } from '../frameloop/frame.mjs';
const propEventHandlers = [
"AnimationStart",
"AnimationComplete",
"Update",
"BeforeLayoutMeasure",
"LayoutMeasure",
"LayoutAnimationStart",
"LayoutAnimationComplete",
];
/**
* Static feature definitions - can be injected by framework layer
*/
let featureDefinitions = {};
/**
* Set feature definitions for all VisualElements.
* This should be called by the framework layer (e.g., framer-motion) during initialization.
*/
function setFeatureDefinitions(definitions) {
featureDefinitions = definitions;
}
/**
* Get the current feature definitions
*/
function getFeatureDefinitions() {
return featureDefinitions;
}
/**
* 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, skipAnimations, 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;
/**
* Decides whether animations should be skipped for this VisualElement.
* Useful for E2E tests and visual regression testing.
*/
this.shouldSkipAnimations = false;
/**
* 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 = 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 = {};
/**
* Track whether this element has been mounted before, to detect
* remounts after Suspense unmount/remount cycles.
*/
this.hasBeenMounted = false;
/**
* 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 = time.now();
if (this.renderScheduledAt < now) {
this.renderScheduledAt = now;
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.skipAnimationsConfig = skipAnimations;
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 && isMotionValue(value)) {
value.set(latestValues[key]);
}
}
}
mount(instance) {
/**
* If this element has been mounted before (e.g. after a Suspense
* unmount/remount), reset motion values to their initial state
* so animations replay correctly from initial → animate, unless
* the presence asks to keep them or the element is only shown
* again to exit (#4049).
*/
const presence = this.presenceContext;
if (this.hasBeenMounted &&
presence?.isPresent !== false &&
!presence?.keepValuesOnRemount) {
for (const key in this.initialValues) {
this.values.get(key)?.jump(this.initialValues[key]);
this.latestValues[key] = this.initialValues[key];
}
}
this.current = instance;
visualElementStore.set(instance, this);
this.projection?.instance || this.projection?.mount(instance);
/**
* Join the closest variant node above, so its variant changes
* propagate here, unless inherit={false} cuts the chain.
*/
if (this.isVariantNode && !this.isControllingVariants) {
let node = this;
do
node = node.props.inherit !== false && node.parent;
while (node && !node.isVariantNode);
if (node) {
const { variantChildren } = node;
variantChildren.add(this);
this.removeFromVariantTree = () => variantChildren.delete(this);
}
}
this.values.forEach((value, key) => this.bindToMotionValue(key, value));
/**
* Determine reduced motion preference. Only initialize the matchMedia
* listener if we actually need the dynamic value (i.e., when config
* is neither "never" nor "always").
*/
if (this.reducedMotionConfig === "never") {
this.shouldReduceMotion = false;
}
else if (this.reducedMotionConfig === "always") {
this.shouldReduceMotion = true;
}
else {
if (!hasReducedMotionListener.current) {
initPrefersReducedMotion();
}
this.shouldReduceMotion = prefersReducedMotion.current;
}
if (process.env.NODE_ENV !== "production") {
warnOnce(this.shouldReduceMotion !== true, "You have Reduced Motion enabled on your device. Animations may not appear as expected.", "reduced-motion-disabled");
}
/**
* Set whether animations should be skipped based on the config.
*/
this.shouldSkipAnimations = this.skipAnimationsConfig ?? false;
this.parent?.addChild(this);
this.update(this.props, this.presenceContext);
this.hasBeenMounted = true;
}
unmount() {
this.projection?.unmount();
cancelFrame(this.notifyUpdate);
cancelFrame(this.render);
this.valueSubscriptions.forEach((remove) => remove());
this.valueSubscriptions.clear();
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())).add(child);
}
removeChild(child) {
this.children.delete(child);
this.enteringChildren?.delete(child);
}
bindToMotionValue(key, value) {
this.valueSubscriptions.get(key)?.();
if (value.accelerate &&
acceleratedValues.has(key) &&
this.current instanceof HTMLElement) {
const { factory, keyframes, times, ease, duration } = value.accelerate;
const animation = new NativeAnimation({
element: this.current,
name: key,
keyframes,
times,
ease,
duration: secondsToMilliseconds(duration),
});
const cleanup = factory(animation);
this.valueSubscriptions.set(key, () => {
cleanup();
animation.cancel();
});
return;
}
const valueIsTransform = transformProps.has(key);
if (valueIsTransform && this.onBindTransform) {
this.onBindTransform();
}
const removeOnChange = value.on("change", (latestValue) => {
this.latestValues[key] = latestValue;
this.props.onUpdate && frame.preRender(this.notifyUpdate);
if (valueIsTransform) {
if (this.projection) {
this.projection.isTransformDirty = true;
}
transformGroups.get(this.current)?.check();
}
this.scheduleRender();
});
let removeSyncCheck;
if (typeof window !== "undefined" &&
window.MotionCheckAppearSync) {
removeSyncCheck = window.MotionCheckAppearSync(this, key, value);
}
this.valueSubscriptions.set(key, () => {
removeOnChange();
removeSyncCheck?.();
// Defer to MotionValue.on("change") auto-stop so React 19 remounts
// can resubscribe before the animation is cancelled (#3315).
});
}
sortNodePosition(other) {
/**
* If either node isn't mounted, or they aren't even of the same type,
* we can't compare their document order.
*/
if (!this.current ||
!other.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) {
const { transformTemplate, onUpdate } = this.props;
if (props.transformTemplate || transformTemplate) {
this.scheduleRender();
}
/**
* Transforms on the compositor can't use a new transformTemplate
* or onUpdate, so move them to the main thread.
*/
if ((props.transformTemplate && !transformTemplate) ||
(props.onUpdate && !onUpdate)) {
transformGroups.get(this.current)?.demote();
}
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];
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);
this.handleChildMotionValue?.();
}
getProps() {
return this.props;
}
/**
* Returns the variant definition with a given name.
*/
getVariant(name) {
return this.props.variants?.[name];
}
/**
* Returns the defined default transition on this component.
*/
getDefaultTransition() {
return this.props.transition;
}
getTransformPagePoint() {
return this.props.transformPagePoint;
}
/**
* 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);
/**
* An animated value with no base value is undefined until the
* keyframe resolver reads its origin. Rendering it before then
* writes an invalid placeholder (e.g. points="undefined") that
* the resolver can read back from the DOM as the origin.
*/
const latest = value.get();
if (latest !== undefined)
this.latestValues[key] = latest;
}
}
/**
* Remove a motion value and unbind any active subscriptions.
*/
removeValue(key) {
this.values.delete(key);
this.valueSubscriptions.get(key)?.();
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) {
let value = this.props.values?.[key];
if (value)
return value;
value = this.values.get(key);
if (value === undefined && defaultValue !== undefined) {
value = motionValue(defaultValue ?? this.getDefaultValue?.(key), {
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" &&
(isNumericalString(value) || 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 (
/**
* If the value as read isn't animatable (e.g. "none") but
* the target is, derive an animatable none from the target.
* A value is animatable if it's a number or contains a
* number or color, which is cheaper to check than trying
* every value type in turn.
*/
typeof value !== "number" &&
!complex.test(value) &&
complex.test(target)) {
value = getAnimatableNone(key, target);
}
this.setBaseTarget(key, isMotionValue(value) ? value.get() : value);
}
return 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;
}
on(eventName, callback) {
return (this.events[eventName] ||
(this.events[eventName] = new SubscriptionManager())).add(callback);
}
notify(eventName, ...args) {
this.events[eventName]?.notify(...args);
}
scheduleRenderMicrotask() {
microtask.render(this.render);
}
}
export { VisualElement, getFeatureDefinitions, setFeatureDefinitions };
//# sourceMappingURL=VisualElement.mjs.map