VisualElementDragControls.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import { frame, setDragLock } from 'motion-dom';
  2. import { invariant } from 'motion-utils';
  3. import { animateMotionValue } from '../../animation/interfaces/motion-value.mjs';
  4. import { addDomEvent } from '../../events/add-dom-event.mjs';
  5. import { addPointerEvent } from '../../events/add-pointer-event.mjs';
  6. import { extractEventInfo } from '../../events/event-info.mjs';
  7. import { convertBoxToBoundingBox, convertBoundingBoxToBox } from '../../projection/geometry/conversion.mjs';
  8. import { calcLength } from '../../projection/geometry/delta-calc.mjs';
  9. import { createBox } from '../../projection/geometry/models.mjs';
  10. import { eachAxis } from '../../projection/utils/each-axis.mjs';
  11. import { measurePageBox } from '../../projection/utils/measure.mjs';
  12. import { getContextWindow } from '../../utils/get-context-window.mjs';
  13. import { isRefObject } from '../../utils/is-ref-object.mjs';
  14. import { mixNumber } from '../../utils/mix/number.mjs';
  15. import { percent } from '../../value/types/numbers/units.mjs';
  16. import { addValueToWillChange } from '../../value/use-will-change/add-will-change.mjs';
  17. import { PanSession } from '../pan/PanSession.mjs';
  18. import { applyConstraints, calcRelativeConstraints, resolveDragElastic, rebaseAxisConstraints, calcViewportConstraints, calcOrigin, defaultElastic } from './utils/constraints.mjs';
  19. const elementDragControls = new WeakMap();
  20. /**
  21. *
  22. */
  23. // let latestPointerEvent: PointerEvent
  24. class VisualElementDragControls {
  25. constructor(visualElement) {
  26. this.openDragLock = null;
  27. this.isDragging = false;
  28. this.currentDirection = null;
  29. this.originPoint = { x: 0, y: 0 };
  30. /**
  31. * The permitted boundaries of travel, in pixels.
  32. */
  33. this.constraints = false;
  34. this.hasMutatedConstraints = false;
  35. /**
  36. * The per-axis resolved elastic values.
  37. */
  38. this.elastic = createBox();
  39. this.visualElement = visualElement;
  40. }
  41. start(originEvent, { snapToCursor = false } = {}) {
  42. /**
  43. * Don't start dragging if this component is exiting
  44. */
  45. const { presenceContext } = this.visualElement;
  46. if (presenceContext && presenceContext.isPresent === false)
  47. return;
  48. const onSessionStart = (event) => {
  49. const { dragSnapToOrigin } = this.getProps();
  50. // Stop or pause any animations on both axis values immediately. This allows the user to throw and catch
  51. // the component.
  52. dragSnapToOrigin ? this.pauseAnimation() : this.stopAnimation();
  53. if (snapToCursor) {
  54. this.snapToCursor(extractEventInfo(event).point);
  55. }
  56. };
  57. const onStart = (event, info) => {
  58. // Attempt to grab the global drag gesture lock - maybe make this part of PanSession
  59. const { drag, dragPropagation, onDragStart } = this.getProps();
  60. if (drag && !dragPropagation) {
  61. if (this.openDragLock)
  62. this.openDragLock();
  63. this.openDragLock = setDragLock(drag);
  64. // If we don 't have the lock, don't start dragging
  65. if (!this.openDragLock)
  66. return;
  67. }
  68. this.isDragging = true;
  69. this.currentDirection = null;
  70. this.resolveConstraints();
  71. if (this.visualElement.projection) {
  72. this.visualElement.projection.isAnimationBlocked = true;
  73. this.visualElement.projection.target = undefined;
  74. }
  75. /**
  76. * Record gesture origin
  77. */
  78. eachAxis((axis) => {
  79. let current = this.getAxisMotionValue(axis).get() || 0;
  80. /**
  81. * If the MotionValue is a percentage value convert to px
  82. */
  83. if (percent.test(current)) {
  84. const { projection } = this.visualElement;
  85. if (projection && projection.layout) {
  86. const measuredAxis = projection.layout.layoutBox[axis];
  87. if (measuredAxis) {
  88. const length = calcLength(measuredAxis);
  89. current = length * (parseFloat(current) / 100);
  90. }
  91. }
  92. }
  93. this.originPoint[axis] = current;
  94. });
  95. // Fire onDragStart event
  96. if (onDragStart) {
  97. frame.postRender(() => onDragStart(event, info));
  98. }
  99. addValueToWillChange(this.visualElement, "transform");
  100. const { animationState } = this.visualElement;
  101. animationState && animationState.setActive("whileDrag", true);
  102. };
  103. const onMove = (event, info) => {
  104. // latestPointerEvent = event
  105. const { dragPropagation, dragDirectionLock, onDirectionLock, onDrag, } = this.getProps();
  106. // If we didn't successfully receive the gesture lock, early return.
  107. if (!dragPropagation && !this.openDragLock)
  108. return;
  109. const { offset } = info;
  110. // Attempt to detect drag direction if directionLock is true
  111. if (dragDirectionLock && this.currentDirection === null) {
  112. this.currentDirection = getCurrentDirection(offset);
  113. // If we've successfully set a direction, notify listener
  114. if (this.currentDirection !== null) {
  115. onDirectionLock && onDirectionLock(this.currentDirection);
  116. }
  117. return;
  118. }
  119. // Update each point with the latest position
  120. this.updateAxis("x", info.point, offset);
  121. this.updateAxis("y", info.point, offset);
  122. /**
  123. * Ideally we would leave the renderer to fire naturally at the end of
  124. * this frame but if the element is about to change layout as the result
  125. * of a re-render we want to ensure the browser can read the latest
  126. * bounding box to ensure the pointer and element don't fall out of sync.
  127. */
  128. this.visualElement.render();
  129. /**
  130. * This must fire after the render call as it might trigger a state
  131. * change which itself might trigger a layout update.
  132. */
  133. onDrag && onDrag(event, info);
  134. };
  135. const onSessionEnd = (event, info) => this.stop(event, info);
  136. const resumeAnimation = () => eachAxis((axis) => this.getAnimationState(axis) === "paused" &&
  137. this.getAxisMotionValue(axis).animation?.play());
  138. const { dragSnapToOrigin } = this.getProps();
  139. this.panSession = new PanSession(originEvent, {
  140. onSessionStart,
  141. onStart,
  142. onMove,
  143. onSessionEnd,
  144. resumeAnimation,
  145. }, {
  146. transformPagePoint: this.visualElement.getTransformPagePoint(),
  147. dragSnapToOrigin,
  148. contextWindow: getContextWindow(this.visualElement),
  149. });
  150. }
  151. stop(event, info) {
  152. const isDragging = this.isDragging;
  153. this.cancel();
  154. if (!isDragging)
  155. return;
  156. const { velocity } = info;
  157. this.startAnimation(velocity);
  158. const { onDragEnd } = this.getProps();
  159. if (onDragEnd) {
  160. frame.postRender(() => onDragEnd(event, info));
  161. }
  162. }
  163. cancel() {
  164. this.isDragging = false;
  165. const { projection, animationState } = this.visualElement;
  166. if (projection) {
  167. projection.isAnimationBlocked = false;
  168. }
  169. this.panSession && this.panSession.end();
  170. this.panSession = undefined;
  171. const { dragPropagation } = this.getProps();
  172. if (!dragPropagation && this.openDragLock) {
  173. this.openDragLock();
  174. this.openDragLock = null;
  175. }
  176. animationState && animationState.setActive("whileDrag", false);
  177. }
  178. updateAxis(axis, _point, offset) {
  179. const { drag } = this.getProps();
  180. // If we're not dragging this axis, do an early return.
  181. if (!offset || !shouldDrag(axis, drag, this.currentDirection))
  182. return;
  183. const axisValue = this.getAxisMotionValue(axis);
  184. let next = this.originPoint[axis] + offset[axis];
  185. // Apply constraints
  186. if (this.constraints && this.constraints[axis]) {
  187. next = applyConstraints(next, this.constraints[axis], this.elastic[axis]);
  188. }
  189. axisValue.set(next);
  190. }
  191. resolveConstraints() {
  192. const { dragConstraints, dragElastic } = this.getProps();
  193. const layout = this.visualElement.projection &&
  194. !this.visualElement.projection.layout
  195. ? this.visualElement.projection.measure(false)
  196. : this.visualElement.projection?.layout;
  197. const prevConstraints = this.constraints;
  198. if (dragConstraints && isRefObject(dragConstraints)) {
  199. if (!this.constraints) {
  200. this.constraints = this.resolveRefConstraints();
  201. }
  202. }
  203. else {
  204. if (dragConstraints && layout) {
  205. this.constraints = calcRelativeConstraints(layout.layoutBox, dragConstraints);
  206. }
  207. else {
  208. this.constraints = false;
  209. }
  210. }
  211. this.elastic = resolveDragElastic(dragElastic);
  212. /**
  213. * If we're outputting to external MotionValues, we want to rebase the measured constraints
  214. * from viewport-relative to component-relative.
  215. */
  216. if (prevConstraints !== this.constraints &&
  217. layout &&
  218. this.constraints &&
  219. !this.hasMutatedConstraints) {
  220. eachAxis((axis) => {
  221. if (this.constraints !== false &&
  222. this.getAxisMotionValue(axis)) {
  223. this.constraints[axis] = rebaseAxisConstraints(layout.layoutBox[axis], this.constraints[axis]);
  224. }
  225. });
  226. }
  227. }
  228. resolveRefConstraints() {
  229. const { dragConstraints: constraints, onMeasureDragConstraints } = this.getProps();
  230. if (!constraints || !isRefObject(constraints))
  231. return false;
  232. const constraintsElement = constraints.current;
  233. invariant(constraintsElement !== null, "If `dragConstraints` is set as a React ref, that ref must be passed to another component's `ref` prop.");
  234. const { projection } = this.visualElement;
  235. // TODO
  236. if (!projection || !projection.layout)
  237. return false;
  238. const constraintsBox = measurePageBox(constraintsElement, projection.root, this.visualElement.getTransformPagePoint());
  239. let measuredConstraints = calcViewportConstraints(projection.layout.layoutBox, constraintsBox);
  240. /**
  241. * If there's an onMeasureDragConstraints listener we call it and
  242. * if different constraints are returned, set constraints to that
  243. */
  244. if (onMeasureDragConstraints) {
  245. const userConstraints = onMeasureDragConstraints(convertBoxToBoundingBox(measuredConstraints));
  246. this.hasMutatedConstraints = !!userConstraints;
  247. if (userConstraints) {
  248. measuredConstraints = convertBoundingBoxToBox(userConstraints);
  249. }
  250. }
  251. return measuredConstraints;
  252. }
  253. startAnimation(velocity) {
  254. const { drag, dragMomentum, dragElastic, dragTransition, dragSnapToOrigin, onDragTransitionEnd, } = this.getProps();
  255. const constraints = this.constraints || {};
  256. const momentumAnimations = eachAxis((axis) => {
  257. if (!shouldDrag(axis, drag, this.currentDirection)) {
  258. return;
  259. }
  260. let transition = (constraints && constraints[axis]) || {};
  261. if (dragSnapToOrigin)
  262. transition = { min: 0, max: 0 };
  263. /**
  264. * Overdamp the boundary spring if `dragElastic` is disabled. There's still a frame
  265. * of spring animations so we should look into adding a disable spring option to `inertia`.
  266. * We could do something here where we affect the `bounceStiffness` and `bounceDamping`
  267. * using the value of `dragElastic`.
  268. */
  269. const bounceStiffness = dragElastic ? 200 : 1000000;
  270. const bounceDamping = dragElastic ? 40 : 10000000;
  271. const inertia = {
  272. type: "inertia",
  273. velocity: dragMomentum ? velocity[axis] : 0,
  274. bounceStiffness,
  275. bounceDamping,
  276. timeConstant: 750,
  277. restDelta: 1,
  278. restSpeed: 10,
  279. ...dragTransition,
  280. ...transition,
  281. };
  282. // If we're not animating on an externally-provided `MotionValue` we can use the
  283. // component's animation controls which will handle interactions with whileHover (etc),
  284. // otherwise we just have to animate the `MotionValue` itself.
  285. return this.startAxisValueAnimation(axis, inertia);
  286. });
  287. // Run all animations and then resolve the new drag constraints.
  288. return Promise.all(momentumAnimations).then(onDragTransitionEnd);
  289. }
  290. startAxisValueAnimation(axis, transition) {
  291. const axisValue = this.getAxisMotionValue(axis);
  292. addValueToWillChange(this.visualElement, axis);
  293. return axisValue.start(animateMotionValue(axis, axisValue, 0, transition, this.visualElement, false));
  294. }
  295. stopAnimation() {
  296. eachAxis((axis) => this.getAxisMotionValue(axis).stop());
  297. }
  298. pauseAnimation() {
  299. eachAxis((axis) => this.getAxisMotionValue(axis).animation?.pause());
  300. }
  301. getAnimationState(axis) {
  302. return this.getAxisMotionValue(axis).animation?.state;
  303. }
  304. /**
  305. * Drag works differently depending on which props are provided.
  306. *
  307. * - If _dragX and _dragY are provided, we output the gesture delta directly to those motion values.
  308. * - Otherwise, we apply the delta to the x/y motion values.
  309. */
  310. getAxisMotionValue(axis) {
  311. const dragKey = `_drag${axis.toUpperCase()}`;
  312. const props = this.visualElement.getProps();
  313. const externalMotionValue = props[dragKey];
  314. return externalMotionValue
  315. ? externalMotionValue
  316. : this.visualElement.getValue(axis, (props.initial
  317. ? props.initial[axis]
  318. : undefined) || 0);
  319. }
  320. snapToCursor(point) {
  321. eachAxis((axis) => {
  322. const { drag } = this.getProps();
  323. // If we're not dragging this axis, do an early return.
  324. if (!shouldDrag(axis, drag, this.currentDirection))
  325. return;
  326. const { projection } = this.visualElement;
  327. const axisValue = this.getAxisMotionValue(axis);
  328. if (projection && projection.layout) {
  329. const { min, max } = projection.layout.layoutBox[axis];
  330. axisValue.set(point[axis] - mixNumber(min, max, 0.5));
  331. }
  332. });
  333. }
  334. /**
  335. * When the viewport resizes we want to check if the measured constraints
  336. * have changed and, if so, reposition the element within those new constraints
  337. * relative to where it was before the resize.
  338. */
  339. scalePositionWithinConstraints() {
  340. if (!this.visualElement.current)
  341. return;
  342. const { drag, dragConstraints } = this.getProps();
  343. const { projection } = this.visualElement;
  344. if (!isRefObject(dragConstraints) || !projection || !this.constraints)
  345. return;
  346. /**
  347. * Stop current animations as there can be visual glitching if we try to do
  348. * this mid-animation
  349. */
  350. this.stopAnimation();
  351. /**
  352. * Record the relative position of the dragged element relative to the
  353. * constraints box and save as a progress value.
  354. */
  355. const boxProgress = { x: 0, y: 0 };
  356. eachAxis((axis) => {
  357. const axisValue = this.getAxisMotionValue(axis);
  358. if (axisValue && this.constraints !== false) {
  359. const latest = axisValue.get();
  360. boxProgress[axis] = calcOrigin({ min: latest, max: latest }, this.constraints[axis]);
  361. }
  362. });
  363. /**
  364. * Update the layout of this element and resolve the latest drag constraints
  365. */
  366. const { transformTemplate } = this.visualElement.getProps();
  367. this.visualElement.current.style.transform = transformTemplate
  368. ? transformTemplate({}, "")
  369. : "none";
  370. projection.root && projection.root.updateScroll();
  371. projection.updateLayout();
  372. this.resolveConstraints();
  373. /**
  374. * For each axis, calculate the current progress of the layout axis
  375. * within the new constraints.
  376. */
  377. eachAxis((axis) => {
  378. if (!shouldDrag(axis, drag, null))
  379. return;
  380. /**
  381. * Calculate a new transform based on the previous box progress
  382. */
  383. const axisValue = this.getAxisMotionValue(axis);
  384. const { min, max } = this.constraints[axis];
  385. axisValue.set(mixNumber(min, max, boxProgress[axis]));
  386. });
  387. }
  388. addListeners() {
  389. if (!this.visualElement.current)
  390. return;
  391. elementDragControls.set(this.visualElement, this);
  392. const element = this.visualElement.current;
  393. /**
  394. * Attach a pointerdown event listener on this DOM element to initiate drag tracking.
  395. */
  396. const stopPointerListener = addPointerEvent(element, "pointerdown", (event) => {
  397. const { drag, dragListener = true } = this.getProps();
  398. drag && dragListener && this.start(event);
  399. });
  400. const measureDragConstraints = () => {
  401. const { dragConstraints } = this.getProps();
  402. if (isRefObject(dragConstraints) && dragConstraints.current) {
  403. this.constraints = this.resolveRefConstraints();
  404. }
  405. };
  406. const { projection } = this.visualElement;
  407. const stopMeasureLayoutListener = projection.addEventListener("measure", measureDragConstraints);
  408. if (projection && !projection.layout) {
  409. projection.root && projection.root.updateScroll();
  410. projection.updateLayout();
  411. }
  412. frame.read(measureDragConstraints);
  413. /**
  414. * Attach a window resize listener to scale the draggable target within its defined
  415. * constraints as the window resizes.
  416. */
  417. const stopResizeListener = addDomEvent(window, "resize", () => this.scalePositionWithinConstraints());
  418. /**
  419. * If the element's layout changes, calculate the delta and apply that to
  420. * the drag gesture's origin point.
  421. */
  422. const stopLayoutUpdateListener = projection.addEventListener("didUpdate", (({ delta, hasLayoutChanged }) => {
  423. if (this.isDragging && hasLayoutChanged) {
  424. eachAxis((axis) => {
  425. const motionValue = this.getAxisMotionValue(axis);
  426. if (!motionValue)
  427. return;
  428. this.originPoint[axis] += delta[axis].translate;
  429. motionValue.set(motionValue.get() + delta[axis].translate);
  430. });
  431. this.visualElement.render();
  432. }
  433. }));
  434. return () => {
  435. stopResizeListener();
  436. stopPointerListener();
  437. stopMeasureLayoutListener();
  438. stopLayoutUpdateListener && stopLayoutUpdateListener();
  439. };
  440. }
  441. getProps() {
  442. const props = this.visualElement.getProps();
  443. const { drag = false, dragDirectionLock = false, dragPropagation = false, dragConstraints = false, dragElastic = defaultElastic, dragMomentum = true, } = props;
  444. return {
  445. ...props,
  446. drag,
  447. dragDirectionLock,
  448. dragPropagation,
  449. dragConstraints,
  450. dragElastic,
  451. dragMomentum,
  452. };
  453. }
  454. }
  455. function shouldDrag(direction, drag, currentDirection) {
  456. return ((drag === true || drag === direction) &&
  457. (currentDirection === null || currentDirection === direction));
  458. }
  459. /**
  460. * Based on an x/y offset determine the current drag direction. If both axis' offsets are lower
  461. * than the provided threshold, return `null`.
  462. *
  463. * @param offset - The x/y offset from origin.
  464. * @param lockThreshold - (Optional) - the minimum absolute offset before we can determine a drag direction.
  465. */
  466. function getCurrentDirection(offset, lockThreshold = 10) {
  467. let direction = null;
  468. if (Math.abs(offset.y) > lockThreshold) {
  469. direction = "y";
  470. }
  471. else if (Math.abs(offset.x) > lockThreshold) {
  472. direction = "x";
  473. }
  474. return direction;
  475. }
  476. export { VisualElementDragControls, elementDragControls };