510 lines
20 KiB
JavaScript
510 lines
20 KiB
JavaScript
|
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 };
|