diff --git a/static/app/utils/useResizable.tsx b/static/app/utils/useResizable.tsx new file mode 100644 index 00000000000000..41d7d8e561e5d4 --- /dev/null +++ b/static/app/utils/useResizable.tsx @@ -0,0 +1,158 @@ +import type {RefObject} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +interface UseResizableOptions { + /** + * The starting size of the container, and the size that is set in the onDoubleClick handler. + * + * If `sizeStorageKey` is provided and exists in local storage, + * then this will be ignored in favor of the size stored in local storage. + */ + initialSize: number; + /** + * The maximum width the container can be resized to. Defaults to Infinity. + */ + maxWidth: number; + /** + * The minimum width the container can be resized to. Defaults to 100. + */ + minWidth: number; + /** + * The ref to the element to be resized. + */ + ref: RefObject; + /** + * Triggered when the user finishes dragging the resize handle. + */ + onResizeEnd?: (newWidth: number) => void; + /** + * Triggered when the user starts dragging the resize handle. + */ + onResizeStart?: () => void; + /** + * The local storage key used to persist the size of the container. If not provided, + * the size will not be persisted and the defaultWidth will be used. + */ + sizeStorageKey?: string; +} + +interface UseResizableResult { + /** + * Whether the drag handle is held. + */ + isHeld: boolean; + /** + * Apply this to the drag handle element to include 'reset' functionality. + */ + onDoubleClick: () => void; + /** + * Attach this to the drag handle element's onMouseDown handler. + */ + onMouseDown: (e: React.MouseEvent) => void; + /** + * The current size of the container. This is NOT updated during the drag + * event, only after the user finishes dragging. + */ + size: number; +} + +/** + * Performant hook to support draggable container resizing. + * + * Currently only supports resizing width and not height. + */ +const useResizable = ({ + ref, + initialSize, + maxWidth, + minWidth, + onResizeEnd, + onResizeStart, + sizeStorageKey, +}: UseResizableOptions): UseResizableResult => { + const [isHeld, setIsHeld] = useState(false); + + const isDraggingRef = useRef(false); + const startXRef = useRef(0); + const startWidthRef = useRef(0); + + useEffect(() => { + if (ref.current) { + const storedSize = sizeStorageKey + ? parseInt(localStorage.getItem(sizeStorageKey) ?? '', 10) + : undefined; + + ref.current.style.width = `${storedSize ?? initialSize}px`; + } + }, [ref, initialSize, sizeStorageKey]); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDraggingRef.current) return; + + window.requestAnimationFrame(() => { + const deltaX = e.clientX - startXRef.current; + const newWidth = Math.max( + minWidth, + Math.min(maxWidth, startWidthRef.current + deltaX) + ); + + if (ref.current) { + ref.current.style.width = `${newWidth}px`; + } + }); + }, + [ref, minWidth, maxWidth] + ); + + const handleMouseUp = useCallback(() => { + setIsHeld(false); + const newSize = ref.current?.offsetWidth ?? initialSize; + isDraggingRef.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + onResizeEnd?.(newSize); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + if (sizeStorageKey) { + localStorage.setItem(sizeStorageKey, newSize.toString()); + } + }, [handleMouseMove, onResizeEnd, ref, sizeStorageKey, initialSize]); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + setIsHeld(true); + e.preventDefault(); + + const currentWidth = ref.current + ? parseInt(getComputedStyle(ref.current).width, 10) + : 0; + + isDraggingRef.current = true; + startXRef.current = e.clientX; + startWidthRef.current = currentWidth; + + document.body.style.cursor = 'ew-resize'; + document.body.style.userSelect = 'none'; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + onResizeStart?.(); + }, + [ref, onResizeStart, handleMouseMove, handleMouseUp] + ); + + const onDoubleClick = useCallback(() => { + if (ref.current) { + ref.current.style.width = `${initialSize}px`; + } + }, [ref, initialSize]); + + return { + isHeld, + size: ref.current?.offsetWidth ?? initialSize, + onMouseDown: handleMouseDown, + onDoubleClick, + }; +}; + +export default useResizable;