useResizableDrawer.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import {useCallback, useLayoutEffect, useRef, useState} from 'react';
  2. export interface UseResizableDrawerOptions {
  3. /**
  4. * When dragging, which direction should be used for the delta
  5. */
  6. direction: 'right' | 'left' | 'down' | 'up';
  7. /**
  8. * The starting size of the container
  9. */
  10. initialSize: number;
  11. /**
  12. * The minimum sizes the container may be dragged to
  13. */
  14. min: number;
  15. /**
  16. * Triggered while dragging
  17. */
  18. onResize: (
  19. newSize: number,
  20. maybeOldSize: number | undefined,
  21. userEvent: boolean
  22. ) => void;
  23. /**
  24. * The local storage key used to persist the size of the container
  25. */
  26. sizeStorageKey?: string;
  27. }
  28. /**
  29. * Hook to support draggable container resizing
  30. *
  31. * This only resizes one dimension at a time.
  32. */
  33. export function useResizableDrawer(options: UseResizableDrawerOptions): {
  34. /**
  35. * Indicates the drag handle is held. Useful to apply a styled to your handle
  36. * that will not be removed if the mouse moves outside of the hitbox of your
  37. * handle.
  38. */
  39. isHeld: boolean;
  40. /**
  41. * Apply this to include 'reset' functionality on the drag handle
  42. */
  43. onDoubleClick: React.MouseEventHandler<HTMLElement>;
  44. /**
  45. * Apply to the drag handle element
  46. */
  47. onMouseDown: React.MouseEventHandler<HTMLElement>;
  48. /**
  49. * Call this function to manually set the size of the drawer.
  50. */
  51. setSize: (newSize: number, userEvent?: boolean) => void;
  52. /**
  53. * The resulting size of the container axis. Updated while dragging.
  54. *
  55. * NOTE: Be careful using this as this as react state updates are not
  56. * synchronous, you may want to update the element size using onResize instead
  57. */
  58. size: number;
  59. } {
  60. const rafIdRef = useRef<number | null>(null);
  61. const currentMouseVectorRaf = useRef<[number, number] | null>(null);
  62. const [size, setSize] = useState<number>(() => {
  63. const storedSize = options.sizeStorageKey
  64. ? parseInt(localStorage.getItem(options.sizeStorageKey) ?? '', 10)
  65. : undefined;
  66. return storedSize || options.initialSize;
  67. });
  68. const [isHeld, setIsHeld] = useState(false);
  69. const updateSize = useCallback(
  70. (newSize: number, userEvent: boolean = false) => {
  71. setSize(newSize);
  72. options.onResize(newSize, undefined, userEvent);
  73. if (options.sizeStorageKey) {
  74. localStorage.setItem(options.sizeStorageKey, newSize.toString());
  75. }
  76. },
  77. [options]
  78. );
  79. // We intentionally fire this once at mount to ensure the dimensions are set and
  80. // any potentional values set by CSS will be overriden. If no initialDimensions are provided,
  81. // invoke the onResize callback with the previously stored dimensions.
  82. useLayoutEffect(() => {
  83. options.onResize(options.initialSize ?? 0, size, false);
  84. setSize(options.initialSize ?? 0);
  85. // eslint-disable-next-line react-hooks/exhaustive-deps
  86. }, [options.direction]);
  87. const sizeRef = useRef<number>(size);
  88. sizeRef.current = size;
  89. const onMouseMove = useCallback(
  90. (event: MouseEvent) => {
  91. event.stopPropagation();
  92. const isXAxis = options.direction === 'left' || options.direction === 'right';
  93. const isInverted = options.direction === 'down' || options.direction === 'left';
  94. document.body.style.pointerEvents = 'none';
  95. document.body.style.userSelect = 'none';
  96. // We've disabled pointerEvents on the body, the cursor needs to be
  97. // applied to the root most element to work
  98. document.documentElement.style.cursor = isXAxis ? 'ew-resize' : 'ns-resize';
  99. if (rafIdRef.current !== null) {
  100. window.cancelAnimationFrame(rafIdRef.current);
  101. }
  102. rafIdRef.current = window.requestAnimationFrame(() => {
  103. if (!currentMouseVectorRaf.current) {
  104. return;
  105. }
  106. const newPositionVector: [number, number] = [event.clientX, event.clientY];
  107. const newAxisPosition = isXAxis ? newPositionVector[0] : newPositionVector[1];
  108. const currentAxisPosition = isXAxis
  109. ? currentMouseVectorRaf.current[0]
  110. : currentMouseVectorRaf.current[1];
  111. const positionDelta = currentAxisPosition - newAxisPosition;
  112. currentMouseVectorRaf.current = newPositionVector;
  113. // Round to 1px precision
  114. const newSize = Math.round(
  115. Math.max(options.min, sizeRef.current + positionDelta * (isInverted ? -1 : 1))
  116. );
  117. updateSize(newSize, true);
  118. });
  119. },
  120. [options.direction, options.min, updateSize]
  121. );
  122. const onMouseUp = useCallback(() => {
  123. document.body.style.pointerEvents = '';
  124. document.body.style.userSelect = '';
  125. document.documentElement.style.cursor = '';
  126. document.removeEventListener('mousemove', onMouseMove);
  127. document.removeEventListener('mouseup', onMouseUp);
  128. setIsHeld(false);
  129. }, [onMouseMove]);
  130. const onMouseDown = useCallback(
  131. (evt: React.MouseEvent<HTMLElement>) => {
  132. setIsHeld(true);
  133. currentMouseVectorRaf.current = [evt.clientX, evt.clientY];
  134. document.addEventListener('mousemove', onMouseMove, {passive: true});
  135. document.addEventListener('mouseup', onMouseUp);
  136. },
  137. [onMouseMove, onMouseUp]
  138. );
  139. const onDoubleClick = useCallback(() => {
  140. updateSize(options.initialSize, true);
  141. }, [updateSize, options.initialSize]);
  142. useLayoutEffect(() => {
  143. return () => {
  144. if (rafIdRef.current !== null) {
  145. window.cancelAnimationFrame(rafIdRef.current);
  146. }
  147. };
  148. });
  149. return {size, isHeld, onMouseDown, onDoubleClick, setSize: updateSize};
  150. }