useResizableDrawer.tsx 5.2 KB

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