480 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			480 lines
		
	
	
		
			19 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import { time } from '../frameloop/sync-time.mjs';
 | 
						|
import { featureDefinitions } from '../motion/features/definitions.mjs';
 | 
						|
import { createBox } from '../projection/geometry/models.mjs';
 | 
						|
import { isNumericalString } from '../utils/is-numerical-string.mjs';
 | 
						|
import { isZeroValueString } from '../utils/is-zero-value-string.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 { warnOnce } from '../utils/warn-once.mjs';
 | 
						|
import { motionValue } from '../value/index.mjs';
 | 
						|
import { complex } from '../value/types/complex/index.mjs';
 | 
						|
import { isMotionValue } from '../value/utils/is-motion-value.mjs';
 | 
						|
import { getAnimatableNone } from './dom/value-types/animatable-none.mjs';
 | 
						|
import { findValueType } from './dom/value-types/find.mjs';
 | 
						|
import { transformProps } from './html/utils/keys-transform.mjs';
 | 
						|
import { visualElementStore } from './store.mjs';
 | 
						|
import { isControllingVariants, isVariantNode } from './utils/is-controlling-variants.mjs';
 | 
						|
import { KeyframeResolver } from './utils/KeyframesResolver.mjs';
 | 
						|
import { updateMotionValuesFromProps } from './utils/motion-values.mjs';
 | 
						|
import { resolveVariantFromProps } from './utils/resolve-variants.mjs';
 | 
						|
import { frame, cancelFrame } from '../frameloop/frame.mjs';
 | 
						|
 | 
						|
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 = 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 = time.now();
 | 
						|
            if (this.renderScheduledAt < now) {
 | 
						|
                this.renderScheduledAt = now;
 | 
						|
                frame.render(this.render, false, true);
 | 
						|
            }
 | 
						|
        };
 | 
						|
        const { latestValues, renderState, onUpdate } = visualState;
 | 
						|
        this.onUpdate = onUpdate;
 | 
						|
        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 && isMotionValue(value)) {
 | 
						|
                value.set(latestValues[key], false);
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
    mount(instance) {
 | 
						|
        this.current = instance;
 | 
						|
        visualElementStore.set(instance, this);
 | 
						|
        if (this.projection && !this.projection.instance) {
 | 
						|
            this.projection.mount(instance);
 | 
						|
        }
 | 
						|
        if (this.parent && this.isVariantNode && !this.isControllingVariants) {
 | 
						|
            this.removeFromVariantTree = this.parent.addVariantChild(this);
 | 
						|
        }
 | 
						|
        this.values.forEach((value, key) => this.bindToMotionValue(key, value));
 | 
						|
        if (!hasReducedMotionListener.current) {
 | 
						|
            initPrefersReducedMotion();
 | 
						|
        }
 | 
						|
        this.shouldReduceMotion =
 | 
						|
            this.reducedMotionConfig === "never"
 | 
						|
                ? false
 | 
						|
                : this.reducedMotionConfig === "always"
 | 
						|
                    ? true
 | 
						|
                    : prefersReducedMotion.current;
 | 
						|
        if (process.env.NODE_ENV !== "production") {
 | 
						|
            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.valueSubscriptions.clear();
 | 
						|
        this.removeFromVariantTree && this.removeFromVariantTree();
 | 
						|
        this.parent && this.parent.children.delete(this);
 | 
						|
        for (const key in this.events) {
 | 
						|
            this.events[key].clear();
 | 
						|
        }
 | 
						|
        for (const key in this.features) {
 | 
						|
            const feature = this.features[key];
 | 
						|
            if (feature) {
 | 
						|
                feature.unmount();
 | 
						|
                feature.isMounted = false;
 | 
						|
            }
 | 
						|
        }
 | 
						|
        this.current = null;
 | 
						|
    }
 | 
						|
    bindToMotionValue(key, value) {
 | 
						|
        if (this.valueSubscriptions.has(key)) {
 | 
						|
            this.valueSubscriptions.get(key)();
 | 
						|
        }
 | 
						|
        const valueIsTransform = transformProps.has(key);
 | 
						|
        const removeOnChange = value.on("change", (latestValue) => {
 | 
						|
            this.latestValues[key] = latestValue;
 | 
						|
            this.props.onUpdate && frame.preRender(this.notifyUpdate);
 | 
						|
            if (valueIsTransform && this.projection) {
 | 
						|
                this.projection.isTransformDirty = true;
 | 
						|
            }
 | 
						|
        });
 | 
						|
        const removeOnRenderRequest = value.on("renderRequest", this.scheduleRender);
 | 
						|
        let removeSyncCheck;
 | 
						|
        if (window.MotionCheckAppearSync) {
 | 
						|
            removeSyncCheck = window.MotionCheckAppearSync(this, key, value);
 | 
						|
        }
 | 
						|
        this.valueSubscriptions.set(key, () => {
 | 
						|
            removeOnChange();
 | 
						|
            removeOnRenderRequest();
 | 
						|
            if (removeSyncCheck)
 | 
						|
                removeSyncCheck();
 | 
						|
            if (value.owner)
 | 
						|
                value.stop();
 | 
						|
        });
 | 
						|
    }
 | 
						|
    sortNodePosition(other) {
 | 
						|
        /**
 | 
						|
         * If these nodes aren't even of the same type we can't compare their depth.
 | 
						|
         */
 | 
						|
        if (!this.current ||
 | 
						|
            !this.sortInstanceNodePosition ||
 | 
						|
            this.type !== other.type) {
 | 
						|
            return 0;
 | 
						|
        }
 | 
						|
        return this.sortInstanceNodePosition(this.current, other.current);
 | 
						|
    }
 | 
						|
    updateFeatures() {
 | 
						|
        let key = "animation";
 | 
						|
        for (key in featureDefinitions) {
 | 
						|
            const featureDefinition = featureDefinitions[key];
 | 
						|
            if (!featureDefinition)
 | 
						|
                continue;
 | 
						|
            const { isEnabled, Feature: FeatureConstructor } = featureDefinition;
 | 
						|
            /**
 | 
						|
             * If this feature is enabled but not active, make a new instance.
 | 
						|
             */
 | 
						|
            if (!this.features[key] &&
 | 
						|
                FeatureConstructor &&
 | 
						|
                isEnabled(this.props)) {
 | 
						|
                this.features[key] = new FeatureConstructor(this);
 | 
						|
            }
 | 
						|
            /**
 | 
						|
             * If we have a feature, mount or update it.
 | 
						|
             */
 | 
						|
            if (this.features[key]) {
 | 
						|
                const feature = this.features[key];
 | 
						|
                if (feature.isMounted) {
 | 
						|
                    feature.update();
 | 
						|
                }
 | 
						|
                else {
 | 
						|
                    feature.mount();
 | 
						|
                    feature.isMounted = true;
 | 
						|
                }
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
    triggerBuild() {
 | 
						|
        this.build(this.renderState, this.latestValues, this.props);
 | 
						|
    }
 | 
						|
    /**
 | 
						|
     * Measure the current viewport box with or without transforms.
 | 
						|
     * Only measures axis-aligned boxes, rotate and skew must be manually
 | 
						|
     * removed with a re-render to work.
 | 
						|
     */
 | 
						|
    measureViewportBox() {
 | 
						|
        return this.current
 | 
						|
            ? this.measureInstanceViewportBox(this.current, this.props)
 | 
						|
            : createBox();
 | 
						|
    }
 | 
						|
    getStaticValue(key) {
 | 
						|
        return this.latestValues[key];
 | 
						|
    }
 | 
						|
    setStaticValue(key, value) {
 | 
						|
        this.latestValues[key] = value;
 | 
						|
    }
 | 
						|
    /**
 | 
						|
     * Update the provided props. Ensure any newly-added motion values are
 | 
						|
     * added to our map, old ones removed, and listeners updated.
 | 
						|
     */
 | 
						|
    update(props, presenceContext) {
 | 
						|
        if (props.transformTemplate || this.props.transformTemplate) {
 | 
						|
            this.scheduleRender();
 | 
						|
        }
 | 
						|
        this.prevProps = this.props;
 | 
						|
        this.props = props;
 | 
						|
        this.prevPresenceContext = this.presenceContext;
 | 
						|
        this.presenceContext = presenceContext;
 | 
						|
        /**
 | 
						|
         * Update prop event handlers ie onAnimationStart, onAnimationComplete
 | 
						|
         */
 | 
						|
        for (let i = 0; i < propEventHandlers.length; i++) {
 | 
						|
            const key = propEventHandlers[i];
 | 
						|
            if (this.propEventSubscriptions[key]) {
 | 
						|
                this.propEventSubscriptions[key]();
 | 
						|
                delete this.propEventSubscriptions[key];
 | 
						|
            }
 | 
						|
            const listenerName = ("on" + key);
 | 
						|
            const listener = props[listenerName];
 | 
						|
            if (listener) {
 | 
						|
                this.propEventSubscriptions[key] = this.on(key, listener);
 | 
						|
            }
 | 
						|
        }
 | 
						|
        this.prevMotionValues = updateMotionValuesFromProps(this, this.scrapeMotionValuesFromProps(props, this.prevProps, this), this.prevMotionValues);
 | 
						|
        if (this.handleChildMotionValue) {
 | 
						|
            this.handleChildMotionValue();
 | 
						|
        }
 | 
						|
        this.onUpdate && this.onUpdate(this);
 | 
						|
    }
 | 
						|
    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 = 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) {
 | 
						|
        var _a;
 | 
						|
        let value = 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);
 | 
						|
        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 (!findValueType(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;
 | 
						|
    }
 | 
						|
    /**
 | 
						|
     * Find the base target for a value thats been removed from all animation
 | 
						|
     * props.
 | 
						|
     */
 | 
						|
    getBaseTarget(key) {
 | 
						|
        var _a;
 | 
						|
        const { initial } = this.props;
 | 
						|
        let valueFromInitial;
 | 
						|
        if (typeof initial === "string" || typeof initial === "object") {
 | 
						|
            const variant = resolveVariantFromProps(this.props, initial, (_a = this.presenceContext) === null || _a === void 0 ? void 0 : _a.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 && !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 };
 |