| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477 |
- import { time, frame, cancelFrame, motionValue } from 'motion-dom';
- import { warnOnce, SubscriptionManager } from 'motion-utils';
- 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 { 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';
- 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() {
- 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);
- 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 && 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) {
- 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 (!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) {
- 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 && !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 };
|