usePanning.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import { useEffect, useRef, RefObject, useState, useMemo } from 'react';
  2. import useMountedState from 'react-use/lib/useMountedState';
  3. import usePrevious from 'react-use/lib/usePrevious';
  4. import { Bounds } from './utils';
  5. export interface State {
  6. isPanning: boolean;
  7. position: {
  8. x: number;
  9. y: number;
  10. };
  11. }
  12. interface Options {
  13. scale?: number;
  14. bounds?: Bounds;
  15. focus?: {
  16. x: number;
  17. y: number;
  18. };
  19. }
  20. /**
  21. * Based on https://github.com/streamich/react-use/blob/master/src/useSlider.ts
  22. * Returns position x/y coordinates which can be directly used in transform: translate().
  23. * @param scale - Can be used when we want to scale the movement if we are moving a scaled element. We need to do it
  24. * here because we don't want to change the pos when scale changes.
  25. * @param bounds - If set the panning cannot go outside of those bounds.
  26. * @param focus - Position to focus on.
  27. */
  28. export function usePanning<T extends Element>({ scale = 1, bounds, focus }: Options = {}): {
  29. state: State;
  30. ref: RefObject<T>;
  31. } {
  32. const isMounted = useMountedState();
  33. const isPanning = useRef(false);
  34. const frame = useRef(0);
  35. const panRef = useRef<T>(null);
  36. const initial = { x: 0, y: 0 };
  37. // As we return a diff of the view port to be applied we need as translate coordinates we have to invert the
  38. // bounds of the content to get the bounds of the view port diff.
  39. const viewBounds = useMemo(
  40. () => ({
  41. right: bounds ? -bounds.left : Infinity,
  42. left: bounds ? -bounds.right : -Infinity,
  43. bottom: bounds ? -bounds.top : -Infinity,
  44. top: bounds ? -bounds.bottom : Infinity,
  45. }),
  46. [bounds]
  47. );
  48. // We need to keep some state so we can compute the position diff and add that to the previous position.
  49. const startMousePosition = useRef(initial);
  50. const prevPosition = useRef(initial);
  51. // We cannot use the state as that would rerun the effect on each state change which we don't want so we have to keep
  52. // separate variable for the state that won't cause useEffect eval
  53. const currentPosition = useRef(initial);
  54. const [state, setState] = useState<State>({
  55. isPanning: false,
  56. position: initial,
  57. });
  58. useEffect(() => {
  59. const startPanning = (event: Event) => {
  60. if (!isPanning.current && isMounted()) {
  61. isPanning.current = true;
  62. // Snapshot the current position of both mouse pointer and the element
  63. startMousePosition.current = getEventXY(event);
  64. prevPosition.current = { ...currentPosition.current };
  65. setState((state) => ({ ...state, isPanning: true }));
  66. bindEvents();
  67. }
  68. };
  69. const stopPanning = () => {
  70. if (isPanning.current && isMounted()) {
  71. isPanning.current = false;
  72. setState((state) => ({ ...state, isPanning: false }));
  73. unbindEvents();
  74. }
  75. };
  76. const onPanStart = (event: Event) => {
  77. startPanning(event);
  78. onPan(event);
  79. };
  80. const bindEvents = () => {
  81. document.addEventListener('mousemove', onPan);
  82. document.addEventListener('mouseup', stopPanning);
  83. document.addEventListener('touchmove', onPan);
  84. document.addEventListener('touchend', stopPanning);
  85. };
  86. const unbindEvents = () => {
  87. document.removeEventListener('mousemove', onPan);
  88. document.removeEventListener('mouseup', stopPanning);
  89. document.removeEventListener('touchmove', onPan);
  90. document.removeEventListener('touchend', stopPanning);
  91. };
  92. const onPan = (event: Event) => {
  93. cancelAnimationFrame(frame.current);
  94. const pos = getEventXY(event);
  95. frame.current = requestAnimationFrame(() => {
  96. if (isMounted() && panRef.current) {
  97. // Get the diff by which we moved the mouse.
  98. let xDiff = pos.x - startMousePosition.current.x;
  99. let yDiff = pos.y - startMousePosition.current.y;
  100. // Add the diff to the position from the moment we started panning.
  101. currentPosition.current = {
  102. x: inBounds(prevPosition.current.x + xDiff / scale, viewBounds.left, viewBounds.right),
  103. y: inBounds(prevPosition.current.y + yDiff / scale, viewBounds.top, viewBounds.bottom),
  104. };
  105. setState((state) => ({
  106. ...state,
  107. position: {
  108. ...currentPosition.current,
  109. },
  110. }));
  111. }
  112. });
  113. };
  114. const ref = panRef.current;
  115. if (ref) {
  116. ref.addEventListener('mousedown', onPanStart);
  117. ref.addEventListener('touchstart', onPanStart);
  118. }
  119. return () => {
  120. if (ref) {
  121. ref.removeEventListener('mousedown', onPanStart);
  122. ref.removeEventListener('touchstart', onPanStart);
  123. }
  124. };
  125. }, [scale, viewBounds, isMounted]);
  126. const previousFocus = usePrevious(focus);
  127. // We need to update the state in case need to focus on something but we want to do it only once when the focus
  128. // changes to something new.
  129. useEffect(() => {
  130. if (focus && previousFocus?.x !== focus.x && previousFocus?.y !== focus.y) {
  131. const position = {
  132. x: inBounds(focus.x, viewBounds.left, viewBounds.right),
  133. y: inBounds(focus.y, viewBounds.top, viewBounds.bottom),
  134. };
  135. setState({
  136. position,
  137. isPanning: false,
  138. });
  139. currentPosition.current = position;
  140. prevPosition.current = position;
  141. }
  142. }, [focus, previousFocus, viewBounds, currentPosition, prevPosition]);
  143. let position = state.position;
  144. // This part prevents an ugly jump from initial position to the focused one as the set state in the effects is after
  145. // initial render.
  146. if (focus && previousFocus?.x !== focus.x && previousFocus?.y !== focus.y) {
  147. position = focus;
  148. }
  149. return {
  150. state: {
  151. ...state,
  152. position: {
  153. x: inBounds(position.x, viewBounds.left, viewBounds.right),
  154. y: inBounds(position.y, viewBounds.top, viewBounds.bottom),
  155. },
  156. },
  157. ref: panRef,
  158. };
  159. }
  160. function inBounds(value: number, min: number | undefined, max: number | undefined) {
  161. return Math.min(Math.max(value, min ?? -Infinity), max ?? Infinity);
  162. }
  163. function getEventXY(event: Event): { x: number; y: number } {
  164. if ((event as any).changedTouches) {
  165. const e = event as TouchEvent;
  166. return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
  167. } else {
  168. const e = event as MouseEvent;
  169. return { x: e.clientX, y: e.clientY };
  170. }
  171. }