import { warning, invariant } from '../utils/errors.mjs'; import { createBox } from '../projection/geometry/models.mjs'; import { isRefObject } from '../utils/is-ref-object.mjs'; import { initPrefersReducedMotion } from '../utils/reduced-motion/index.mjs'; import { hasReducedMotionListener, prefersReducedMotion } from '../utils/reduced-motion/state.mjs'; import { SubscriptionManager } from '../utils/subscription-manager.mjs'; import { motionValue } from '../value/index.mjs'; import { isWillChangeMotionValue } from '../value/use-will-change/is.mjs'; import { isMotionValue } from '../value/utils/is-motion-value.mjs'; import { transformProps } from './html/utils/transform.mjs'; import { isControllingVariants, isVariantNode } from './utils/is-controlling-variants.mjs'; import { isVariantLabel } from './utils/is-variant-label.mjs'; import { updateMotionValuesFromProps } from './utils/motion-values.mjs'; import { resolveVariantFromProps } from './utils/resolve-variants.mjs'; import { warnOnce } from '../utils/warn-once.mjs'; import { featureDefinitions } from '../motion/features/definitions.mjs'; import { variantProps } from './utils/variant-props.mjs'; import { visualElementStore } from './store.mjs'; import { frame, cancelFrame } from '../frameloop/frame.mjs'; const featureNames = Object.keys(featureDefinitions); const numFeatures = featureNames.length; const propEventHandlers = [ "AnimationStart", "AnimationComplete", "Update", "BeforeLayoutMeasure", "LayoutMeasure", "LayoutAnimationStart", "LayoutAnimationComplete", ]; const numVariantProps = variantProps.length; /** * A VisualElement is an imperative abstraction around UI elements such as * HTMLElement, SVGElement, Three.Object3D etc. */ class VisualElement { constructor({ parent, props, presenceContext, reducedMotionConfig, 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(); /** * 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.scheduleRender = () => 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.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 neccessarily a breaking change, * more a reflection of the test. */ const { willChange, ...initialMotionValues } = this.scrapeMotionValuesFromProps(props, {}); for (const key in initialMotionValues) { const value = initialMotionValues[key]; if (latestValues[key] !== undefined && isMotionValue(value)) { value.set(latestValues[key], false); if (isWillChangeMotionValue(willChange)) { willChange.add(key); } } } } /** * 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) { return {}; } 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") { warnOnce(this.shouldReduceMotion !== true, "You have Reduced Motion enabled on your device. Animations may not appear as expected."); } if (this.parent) this.parent.children.add(this); this.update(this.props, this.presenceContext); } unmount() { visualElementStore.delete(this.current); this.projection && this.projection.unmount(); cancelFrame(this.notifyUpdate); cancelFrame(this.render); this.valueSubscriptions.forEach((remove) => remove()); this.removeFromVariantTree && this.removeFromVariantTree(); this.parent && this.parent.children.delete(this); for (const key in this.events) { this.events[key].clear(); } for (const key in this.features) { this.features[key].unmount(); } this.current = null; } bindToMotionValue(key, value) { const valueIsTransform = transformProps.has(key); const removeOnChange = value.on("change", (latestValue) => { this.latestValues[key] = latestValue; this.props.onUpdate && frame.update(this.notifyUpdate, false, true); if (valueIsTransform && this.projection) { this.projection.isTransformDirty = true; } }); const removeOnRenderRequest = value.on("renderRequest", this.scheduleRender); this.valueSubscriptions.set(key, () => { removeOnChange(); removeOnRenderRequest(); }); } 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); } loadFeatures({ children, ...renderedProps }, isStrict, preloadedFeatures, initialLayoutGroupConfig) { let ProjectionNodeConstructor; let MeasureLayout; /** * 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."; renderedProps.ignoreStrict ? warning(false, strictMessage) : invariant(false, strictMessage); } for (let i = 0; i < numFeatures; i++) { const name = featureNames[i]; const { isEnabled, Feature: FeatureConstructor, ProjectionNode, MeasureLayout: MeasureLayoutComponent, } = featureDefinitions[name]; if (ProjectionNode) ProjectionNodeConstructor = ProjectionNode; if (isEnabled(renderedProps)) { if (!this.features[name] && FeatureConstructor) { this.features[name] = new FeatureConstructor(this); } if (MeasureLayoutComponent) { MeasureLayout = MeasureLayoutComponent; } } } if ((this.type === "html" || this.type === "svg") && !this.projection && ProjectionNodeConstructor) { this.projection = new ProjectionNodeConstructor(this.latestValues, this.parent && this.parent.projection); const { layoutId, layout, drag, dragConstraints, layoutScroll, layoutRoot, } = renderedProps; this.projection.setOptions({ layoutId, layout, alwaysMeasureLayout: Boolean(drag) || (dragConstraints && isRefObject(dragConstraints)), visualElement: this, scheduleRender: () => this.scheduleRender(), /** * 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: initialLayoutGroupConfig, layoutScroll, layoutRoot, }); } return MeasureLayout; } updateFeatures() { for (const key in this.features) { const feature = this.features[key]; if (feature.isMounted) { feature.update(); } else { feature.mount(); feature.isMounted = true; } } } triggerBuild() { this.build(this.renderState, this.latestValues, this.options, 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; } /** * Make a target animatable by Popmotion. For instance, if we're * trying to animate width from 100px to 100vw we need to measure 100vw * in pixels to determine what we really need to animate to. This is also * pluggable to support Framer's custom value types like Color, * and CSS variables. */ makeTargetAnimatable(target, canMutate = true) { return this.makeTargetAnimatableFromInstance(target, this.props, canMutate); } /** * 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 listener = props["on" + key]; if (listener) { this.propEventSubscriptions[key] = this.on(key, listener); } } this.prevMotionValues = updateMotionValuesFromProps(this, this.scrapeMotionValuesFromProps(props, this.prevProps), 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; } getVariantContext(startAtParent = false) { if (startAtParent) { return this.parent ? this.parent.getVariantContext() : undefined; } if (!this.isControllingVariants) { const context = this.parent ? this.parent.getVariantContext() || {} : {}; if (this.props.initial !== undefined) { context.initial = this.props.initial; } return context; } const context = {}; for (let i = 0; i < numVariantProps; i++) { const name = variantProps[i]; const prop = this.props[name]; if (isVariantLabel(prop) || prop === false) { context[name] = prop; } } return context; } /** * 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 if (value !== this.values.get(key)) { 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 = motionValue(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) { var _a; return this.latestValues[key] !== undefined || !this.current ? this.latestValues[key] : (_a = this.getBaseTargetFromProps(this.props, key)) !== null && _a !== void 0 ? _a : this.readValueFromInstance(this.current, key, this.options); } /** * 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) { var _a; const { initial } = this.props; const valueFromInitial = typeof initial === "string" || typeof initial === "object" ? (_a = resolveVariantFromProps(this.props, initial)) === null || _a === void 0 ? void 0 : _a[key] : undefined; /** * 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 && !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 SubscriptionManager(); } return this.events[eventName].add(callback); } notify(eventName, ...args) { if (this.events[eventName]) { this.events[eventName].notify(...args); } } } export { VisualElement };