VisualElement.mjs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import { time, frame, cancelFrame, motionValue } from 'motion-dom';
  2. import { warnOnce, SubscriptionManager } from 'motion-utils';
  3. import { featureDefinitions } from '../motion/features/definitions.mjs';
  4. import { createBox } from '../projection/geometry/models.mjs';
  5. import { isNumericalString } from '../utils/is-numerical-string.mjs';
  6. import { isZeroValueString } from '../utils/is-zero-value-string.mjs';
  7. import { initPrefersReducedMotion } from '../utils/reduced-motion/index.mjs';
  8. import { hasReducedMotionListener, prefersReducedMotion } from '../utils/reduced-motion/state.mjs';
  9. import { complex } from '../value/types/complex/index.mjs';
  10. import { isMotionValue } from '../value/utils/is-motion-value.mjs';
  11. import { getAnimatableNone } from './dom/value-types/animatable-none.mjs';
  12. import { findValueType } from './dom/value-types/find.mjs';
  13. import { transformProps } from './html/utils/keys-transform.mjs';
  14. import { visualElementStore } from './store.mjs';
  15. import { isControllingVariants, isVariantNode } from './utils/is-controlling-variants.mjs';
  16. import { KeyframeResolver } from './utils/KeyframesResolver.mjs';
  17. import { updateMotionValuesFromProps } from './utils/motion-values.mjs';
  18. import { resolveVariantFromProps } from './utils/resolve-variants.mjs';
  19. const propEventHandlers = [
  20. "AnimationStart",
  21. "AnimationComplete",
  22. "Update",
  23. "BeforeLayoutMeasure",
  24. "LayoutMeasure",
  25. "LayoutAnimationStart",
  26. "LayoutAnimationComplete",
  27. ];
  28. /**
  29. * A VisualElement is an imperative abstraction around UI elements such as
  30. * HTMLElement, SVGElement, Three.Object3D etc.
  31. */
  32. class VisualElement {
  33. /**
  34. * This method takes React props and returns found MotionValues. For example, HTML
  35. * MotionValues will be found within the style prop, whereas for Three.js within attribute arrays.
  36. *
  37. * This isn't an abstract method as it needs calling in the constructor, but it is
  38. * intended to be one.
  39. */
  40. scrapeMotionValuesFromProps(_props, _prevProps, _visualElement) {
  41. return {};
  42. }
  43. constructor({ parent, props, presenceContext, reducedMotionConfig, blockInitialAnimation, visualState, }, options = {}) {
  44. /**
  45. * A reference to the current underlying Instance, e.g. a HTMLElement
  46. * or Three.Mesh etc.
  47. */
  48. this.current = null;
  49. /**
  50. * A set containing references to this VisualElement's children.
  51. */
  52. this.children = new Set();
  53. /**
  54. * Determine what role this visual element should take in the variant tree.
  55. */
  56. this.isVariantNode = false;
  57. this.isControllingVariants = false;
  58. /**
  59. * Decides whether this VisualElement should animate in reduced motion
  60. * mode.
  61. *
  62. * TODO: This is currently set on every individual VisualElement but feels
  63. * like it could be set globally.
  64. */
  65. this.shouldReduceMotion = null;
  66. /**
  67. * A map of all motion values attached to this visual element. Motion
  68. * values are source of truth for any given animated value. A motion
  69. * value might be provided externally by the component via props.
  70. */
  71. this.values = new Map();
  72. this.KeyframeResolver = KeyframeResolver;
  73. /**
  74. * Cleanup functions for active features (hover/tap/exit etc)
  75. */
  76. this.features = {};
  77. /**
  78. * A map of every subscription that binds the provided or generated
  79. * motion values onChange listeners to this visual element.
  80. */
  81. this.valueSubscriptions = new Map();
  82. /**
  83. * A reference to the previously-provided motion values as returned
  84. * from scrapeMotionValuesFromProps. We use the keys in here to determine
  85. * if any motion values need to be removed after props are updated.
  86. */
  87. this.prevMotionValues = {};
  88. /**
  89. * An object containing a SubscriptionManager for each active event.
  90. */
  91. this.events = {};
  92. /**
  93. * An object containing an unsubscribe function for each prop event subscription.
  94. * For example, every "Update" event can have multiple subscribers via
  95. * VisualElement.on(), but only one of those can be defined via the onUpdate prop.
  96. */
  97. this.propEventSubscriptions = {};
  98. this.notifyUpdate = () => this.notify("Update", this.latestValues);
  99. this.render = () => {
  100. if (!this.current)
  101. return;
  102. this.triggerBuild();
  103. this.renderInstance(this.current, this.renderState, this.props.style, this.projection);
  104. };
  105. this.renderScheduledAt = 0.0;
  106. this.scheduleRender = () => {
  107. const now = time.now();
  108. if (this.renderScheduledAt < now) {
  109. this.renderScheduledAt = now;
  110. frame.render(this.render, false, true);
  111. }
  112. };
  113. const { latestValues, renderState, onUpdate } = visualState;
  114. this.onUpdate = onUpdate;
  115. this.latestValues = latestValues;
  116. this.baseTarget = { ...latestValues };
  117. this.initialValues = props.initial ? { ...latestValues } : {};
  118. this.renderState = renderState;
  119. this.parent = parent;
  120. this.props = props;
  121. this.presenceContext = presenceContext;
  122. this.depth = parent ? parent.depth + 1 : 0;
  123. this.reducedMotionConfig = reducedMotionConfig;
  124. this.options = options;
  125. this.blockInitialAnimation = Boolean(blockInitialAnimation);
  126. this.isControllingVariants = isControllingVariants(props);
  127. this.isVariantNode = isVariantNode(props);
  128. if (this.isVariantNode) {
  129. this.variantChildren = new Set();
  130. }
  131. this.manuallyAnimateOnMount = Boolean(parent && parent.current);
  132. /**
  133. * Any motion values that are provided to the element when created
  134. * aren't yet bound to the element, as this would technically be impure.
  135. * However, we iterate through the motion values and set them to the
  136. * initial values for this component.
  137. *
  138. * TODO: This is impure and we should look at changing this to run on mount.
  139. * Doing so will break some tests but this isn't necessarily a breaking change,
  140. * more a reflection of the test.
  141. */
  142. const { willChange, ...initialMotionValues } = this.scrapeMotionValuesFromProps(props, {}, this);
  143. for (const key in initialMotionValues) {
  144. const value = initialMotionValues[key];
  145. if (latestValues[key] !== undefined && isMotionValue(value)) {
  146. value.set(latestValues[key], false);
  147. }
  148. }
  149. }
  150. mount(instance) {
  151. this.current = instance;
  152. visualElementStore.set(instance, this);
  153. if (this.projection && !this.projection.instance) {
  154. this.projection.mount(instance);
  155. }
  156. if (this.parent && this.isVariantNode && !this.isControllingVariants) {
  157. this.removeFromVariantTree = this.parent.addVariantChild(this);
  158. }
  159. this.values.forEach((value, key) => this.bindToMotionValue(key, value));
  160. if (!hasReducedMotionListener.current) {
  161. initPrefersReducedMotion();
  162. }
  163. this.shouldReduceMotion =
  164. this.reducedMotionConfig === "never"
  165. ? false
  166. : this.reducedMotionConfig === "always"
  167. ? true
  168. : prefersReducedMotion.current;
  169. if (process.env.NODE_ENV !== "production") {
  170. warnOnce(this.shouldReduceMotion !== true, "You have Reduced Motion enabled on your device. Animations may not appear as expected.");
  171. }
  172. if (this.parent)
  173. this.parent.children.add(this);
  174. this.update(this.props, this.presenceContext);
  175. }
  176. unmount() {
  177. this.projection && this.projection.unmount();
  178. cancelFrame(this.notifyUpdate);
  179. cancelFrame(this.render);
  180. this.valueSubscriptions.forEach((remove) => remove());
  181. this.valueSubscriptions.clear();
  182. this.removeFromVariantTree && this.removeFromVariantTree();
  183. this.parent && this.parent.children.delete(this);
  184. for (const key in this.events) {
  185. this.events[key].clear();
  186. }
  187. for (const key in this.features) {
  188. const feature = this.features[key];
  189. if (feature) {
  190. feature.unmount();
  191. feature.isMounted = false;
  192. }
  193. }
  194. this.current = null;
  195. }
  196. bindToMotionValue(key, value) {
  197. if (this.valueSubscriptions.has(key)) {
  198. this.valueSubscriptions.get(key)();
  199. }
  200. const valueIsTransform = transformProps.has(key);
  201. if (valueIsTransform && this.onBindTransform) {
  202. this.onBindTransform();
  203. }
  204. const removeOnChange = value.on("change", (latestValue) => {
  205. this.latestValues[key] = latestValue;
  206. this.props.onUpdate && frame.preRender(this.notifyUpdate);
  207. if (valueIsTransform && this.projection) {
  208. this.projection.isTransformDirty = true;
  209. }
  210. });
  211. const removeOnRenderRequest = value.on("renderRequest", this.scheduleRender);
  212. let removeSyncCheck;
  213. if (window.MotionCheckAppearSync) {
  214. removeSyncCheck = window.MotionCheckAppearSync(this, key, value);
  215. }
  216. this.valueSubscriptions.set(key, () => {
  217. removeOnChange();
  218. removeOnRenderRequest();
  219. if (removeSyncCheck)
  220. removeSyncCheck();
  221. if (value.owner)
  222. value.stop();
  223. });
  224. }
  225. sortNodePosition(other) {
  226. /**
  227. * If these nodes aren't even of the same type we can't compare their depth.
  228. */
  229. if (!this.current ||
  230. !this.sortInstanceNodePosition ||
  231. this.type !== other.type) {
  232. return 0;
  233. }
  234. return this.sortInstanceNodePosition(this.current, other.current);
  235. }
  236. updateFeatures() {
  237. let key = "animation";
  238. for (key in featureDefinitions) {
  239. const featureDefinition = featureDefinitions[key];
  240. if (!featureDefinition)
  241. continue;
  242. const { isEnabled, Feature: FeatureConstructor } = featureDefinition;
  243. /**
  244. * If this feature is enabled but not active, make a new instance.
  245. */
  246. if (!this.features[key] &&
  247. FeatureConstructor &&
  248. isEnabled(this.props)) {
  249. this.features[key] = new FeatureConstructor(this);
  250. }
  251. /**
  252. * If we have a feature, mount or update it.
  253. */
  254. if (this.features[key]) {
  255. const feature = this.features[key];
  256. if (feature.isMounted) {
  257. feature.update();
  258. }
  259. else {
  260. feature.mount();
  261. feature.isMounted = true;
  262. }
  263. }
  264. }
  265. }
  266. triggerBuild() {
  267. this.build(this.renderState, this.latestValues, this.props);
  268. }
  269. /**
  270. * Measure the current viewport box with or without transforms.
  271. * Only measures axis-aligned boxes, rotate and skew must be manually
  272. * removed with a re-render to work.
  273. */
  274. measureViewportBox() {
  275. return this.current
  276. ? this.measureInstanceViewportBox(this.current, this.props)
  277. : createBox();
  278. }
  279. getStaticValue(key) {
  280. return this.latestValues[key];
  281. }
  282. setStaticValue(key, value) {
  283. this.latestValues[key] = value;
  284. }
  285. /**
  286. * Update the provided props. Ensure any newly-added motion values are
  287. * added to our map, old ones removed, and listeners updated.
  288. */
  289. update(props, presenceContext) {
  290. if (props.transformTemplate || this.props.transformTemplate) {
  291. this.scheduleRender();
  292. }
  293. this.prevProps = this.props;
  294. this.props = props;
  295. this.prevPresenceContext = this.presenceContext;
  296. this.presenceContext = presenceContext;
  297. /**
  298. * Update prop event handlers ie onAnimationStart, onAnimationComplete
  299. */
  300. for (let i = 0; i < propEventHandlers.length; i++) {
  301. const key = propEventHandlers[i];
  302. if (this.propEventSubscriptions[key]) {
  303. this.propEventSubscriptions[key]();
  304. delete this.propEventSubscriptions[key];
  305. }
  306. const listenerName = ("on" + key);
  307. const listener = props[listenerName];
  308. if (listener) {
  309. this.propEventSubscriptions[key] = this.on(key, listener);
  310. }
  311. }
  312. this.prevMotionValues = updateMotionValuesFromProps(this, this.scrapeMotionValuesFromProps(props, this.prevProps, this), this.prevMotionValues);
  313. if (this.handleChildMotionValue) {
  314. this.handleChildMotionValue();
  315. }
  316. this.onUpdate && this.onUpdate(this);
  317. }
  318. getProps() {
  319. return this.props;
  320. }
  321. /**
  322. * Returns the variant definition with a given name.
  323. */
  324. getVariant(name) {
  325. return this.props.variants ? this.props.variants[name] : undefined;
  326. }
  327. /**
  328. * Returns the defined default transition on this component.
  329. */
  330. getDefaultTransition() {
  331. return this.props.transition;
  332. }
  333. getTransformPagePoint() {
  334. return this.props.transformPagePoint;
  335. }
  336. getClosestVariantNode() {
  337. return this.isVariantNode
  338. ? this
  339. : this.parent
  340. ? this.parent.getClosestVariantNode()
  341. : undefined;
  342. }
  343. /**
  344. * Add a child visual element to our set of children.
  345. */
  346. addVariantChild(child) {
  347. const closestVariantNode = this.getClosestVariantNode();
  348. if (closestVariantNode) {
  349. closestVariantNode.variantChildren &&
  350. closestVariantNode.variantChildren.add(child);
  351. return () => closestVariantNode.variantChildren.delete(child);
  352. }
  353. }
  354. /**
  355. * Add a motion value and bind it to this visual element.
  356. */
  357. addValue(key, value) {
  358. // Remove existing value if it exists
  359. const existingValue = this.values.get(key);
  360. if (value !== existingValue) {
  361. if (existingValue)
  362. this.removeValue(key);
  363. this.bindToMotionValue(key, value);
  364. this.values.set(key, value);
  365. this.latestValues[key] = value.get();
  366. }
  367. }
  368. /**
  369. * Remove a motion value and unbind any active subscriptions.
  370. */
  371. removeValue(key) {
  372. this.values.delete(key);
  373. const unsubscribe = this.valueSubscriptions.get(key);
  374. if (unsubscribe) {
  375. unsubscribe();
  376. this.valueSubscriptions.delete(key);
  377. }
  378. delete this.latestValues[key];
  379. this.removeValueFromRenderState(key, this.renderState);
  380. }
  381. /**
  382. * Check whether we have a motion value for this key
  383. */
  384. hasValue(key) {
  385. return this.values.has(key);
  386. }
  387. getValue(key, defaultValue) {
  388. if (this.props.values && this.props.values[key]) {
  389. return this.props.values[key];
  390. }
  391. let value = this.values.get(key);
  392. if (value === undefined && defaultValue !== undefined) {
  393. value = motionValue(defaultValue === null ? undefined : defaultValue, { owner: this });
  394. this.addValue(key, value);
  395. }
  396. return value;
  397. }
  398. /**
  399. * If we're trying to animate to a previously unencountered value,
  400. * we need to check for it in our state and as a last resort read it
  401. * directly from the instance (which might have performance implications).
  402. */
  403. readValue(key, target) {
  404. let value = this.latestValues[key] !== undefined || !this.current
  405. ? this.latestValues[key]
  406. : this.getBaseTargetFromProps(this.props, key) ??
  407. this.readValueFromInstance(this.current, key, this.options);
  408. if (value !== undefined && value !== null) {
  409. if (typeof value === "string" &&
  410. (isNumericalString(value) || isZeroValueString(value))) {
  411. // If this is a number read as a string, ie "0" or "200", convert it to a number
  412. value = parseFloat(value);
  413. }
  414. else if (!findValueType(value) && complex.test(target)) {
  415. value = getAnimatableNone(key, target);
  416. }
  417. this.setBaseTarget(key, isMotionValue(value) ? value.get() : value);
  418. }
  419. return isMotionValue(value) ? value.get() : value;
  420. }
  421. /**
  422. * Set the base target to later animate back to. This is currently
  423. * only hydrated on creation and when we first read a value.
  424. */
  425. setBaseTarget(key, value) {
  426. this.baseTarget[key] = value;
  427. }
  428. /**
  429. * Find the base target for a value thats been removed from all animation
  430. * props.
  431. */
  432. getBaseTarget(key) {
  433. const { initial } = this.props;
  434. let valueFromInitial;
  435. if (typeof initial === "string" || typeof initial === "object") {
  436. const variant = resolveVariantFromProps(this.props, initial, this.presenceContext?.custom);
  437. if (variant) {
  438. valueFromInitial = variant[key];
  439. }
  440. }
  441. /**
  442. * If this value still exists in the current initial variant, read that.
  443. */
  444. if (initial && valueFromInitial !== undefined) {
  445. return valueFromInitial;
  446. }
  447. /**
  448. * Alternatively, if this VisualElement config has defined a getBaseTarget
  449. * so we can read the value from an alternative source, try that.
  450. */
  451. const target = this.getBaseTargetFromProps(this.props, key);
  452. if (target !== undefined && !isMotionValue(target))
  453. return target;
  454. /**
  455. * If the value was initially defined on initial, but it doesn't any more,
  456. * return undefined. Otherwise return the value as initially read from the DOM.
  457. */
  458. return this.initialValues[key] !== undefined &&
  459. valueFromInitial === undefined
  460. ? undefined
  461. : this.baseTarget[key];
  462. }
  463. on(eventName, callback) {
  464. if (!this.events[eventName]) {
  465. this.events[eventName] = new SubscriptionManager();
  466. }
  467. return this.events[eventName].add(callback);
  468. }
  469. notify(eventName, ...args) {
  470. if (this.events[eventName]) {
  471. this.events[eventName].notify(...args);
  472. }
  473. }
  474. }
  475. export { VisualElement };