From 94ef3024097b8f06d6323e58d03a609ee5c04f50 Mon Sep 17 00:00:00 2001 From: MichaelSun48 Date: Tue, 13 May 2025 10:15:54 -0700 Subject: [PATCH 1/7] Add new useResizable hook --- static/app/utils/useResizable.tsx | 170 ++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 static/app/utils/useResizable.tsx diff --git a/static/app/utils/useResizable.tsx b/static/app/utils/useResizable.tsx new file mode 100644 index 00000000000000..dadca4c38432d0 --- /dev/null +++ b/static/app/utils/useResizable.tsx @@ -0,0 +1,170 @@ +import type {RefObject} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +export const RESIZABLE_DEFAULT_WIDTH = 200; +export const RESIZABLE_MIN_WIDTH = 100; +export const RESIZABLE_MAX_WIDTH = Infinity; + +interface UseResizableOptions { + /** + * The ref to the element to be resized. + */ + ref: RefObject; + + /** + * 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; + + /** + * 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; +} + +/** + * Performant hook to support draggable container resizing. + * + * Currently only supports resizing width and not height. + */ +export const useResizable = ({ + ref, + initialSize = RESIZABLE_DEFAULT_WIDTH, + maxWidth = RESIZABLE_MAX_WIDTH, + minWidth = RESIZABLE_MIN_WIDTH, + onResizeEnd, + onResizeStart, + sizeStorageKey, +}: UseResizableOptions): { + /** + * 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; +} => { + 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 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'; + onResizeStart?.(); + }, + [ref, onResizeStart] + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDraggingRef.current) return; + + 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); + if (sizeStorageKey) { + localStorage.setItem(sizeStorageKey, newSize.toString()); + } + }, [onResizeEnd, ref, sizeStorageKey, initialSize]); + + useEffect(() => { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [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; From 19050cff85215dd272b12fd35913eabe52ebb12a Mon Sep 17 00:00:00 2001 From: MichaelSun48 Date: Tue, 13 May 2025 10:18:47 -0700 Subject: [PATCH 2/7] Make width config non-optional --- static/app/utils/useResizable.tsx | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/static/app/utils/useResizable.tsx b/static/app/utils/useResizable.tsx index dadca4c38432d0..d6f1d4f1d8896a 100644 --- a/static/app/utils/useResizable.tsx +++ b/static/app/utils/useResizable.tsx @@ -1,33 +1,29 @@ import type {RefObject} from 'react'; import {useCallback, useEffect, useRef, useState} from 'react'; -export const RESIZABLE_DEFAULT_WIDTH = 200; -export const RESIZABLE_MIN_WIDTH = 100; -export const RESIZABLE_MAX_WIDTH = Infinity; - interface UseResizableOptions { - /** - * The ref to the element to be resized. - */ - ref: RefObject; - /** * 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; + initialSize: number; /** * The maximum width the container can be resized to. Defaults to Infinity. */ - maxWidth?: number; + maxWidth: number; /** * The minimum width the container can be resized to. Defaults to 100. */ - minWidth?: number; + minWidth: number; + + /** + * The ref to the element to be resized. + */ + ref: RefObject; /** * Triggered when the user finishes dragging the resize handle. @@ -53,9 +49,9 @@ interface UseResizableOptions { */ export const useResizable = ({ ref, - initialSize = RESIZABLE_DEFAULT_WIDTH, - maxWidth = RESIZABLE_MAX_WIDTH, - minWidth = RESIZABLE_MIN_WIDTH, + initialSize, + maxWidth, + minWidth, onResizeEnd, onResizeStart, sizeStorageKey, From 61170682631920581170befe8b24a6d4c71da6aa Mon Sep 17 00:00:00 2001 From: MichaelSun48 Date: Tue, 13 May 2025 10:22:29 -0700 Subject: [PATCH 3/7] Move return result to its own interface --- static/app/utils/useResizable.tsx | 32 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/static/app/utils/useResizable.tsx b/static/app/utils/useResizable.tsx index d6f1d4f1d8896a..d463bc373aacb5 100644 --- a/static/app/utils/useResizable.tsx +++ b/static/app/utils/useResizable.tsx @@ -42,20 +42,7 @@ interface UseResizableOptions { sizeStorageKey?: string; } -/** - * Performant hook to support draggable container resizing. - * - * Currently only supports resizing width and not height. - */ -export const useResizable = ({ - ref, - initialSize, - maxWidth, - minWidth, - onResizeEnd, - onResizeStart, - sizeStorageKey, -}: UseResizableOptions): { +interface UseResizableResult { /** * Whether the drag handle is held. */ @@ -73,7 +60,22 @@ export const useResizable = ({ * event, only after the user finishes dragging. */ size: number; -} => { +} + +/** + * Performant hook to support draggable container resizing. + * + * Currently only supports resizing width and not height. + */ +export const useResizable = ({ + ref, + initialSize, + maxWidth, + minWidth, + onResizeEnd, + onResizeStart, + sizeStorageKey, +}: UseResizableOptions): UseResizableResult => { const [isHeld, setIsHeld] = useState(false); const isDraggingRef = useRef(false); From 7cf13b3d192a76170dc908c12c4337250f39f096 Mon Sep 17 00:00:00 2001 From: MichaelSun48 Date: Tue, 13 May 2025 10:25:07 -0700 Subject: [PATCH 4/7] Wrap mousemove in animation frame --- static/app/utils/useResizable.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/static/app/utils/useResizable.tsx b/static/app/utils/useResizable.tsx index d463bc373aacb5..0034657b91c33a 100644 --- a/static/app/utils/useResizable.tsx +++ b/static/app/utils/useResizable.tsx @@ -116,15 +116,17 @@ export const useResizable = ({ (e: MouseEvent) => { if (!isDraggingRef.current) return; - 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`; - } + 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] ); From 6a5ec4f53b091685582bf709928d71ed0cdc063b Mon Sep 17 00:00:00 2001 From: MichaelSun48 Date: Tue, 13 May 2025 10:25:51 -0700 Subject: [PATCH 5/7] Remove whitespace between properties --- static/app/utils/useResizable.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/static/app/utils/useResizable.tsx b/static/app/utils/useResizable.tsx index 0034657b91c33a..57520d49e07b81 100644 --- a/static/app/utils/useResizable.tsx +++ b/static/app/utils/useResizable.tsx @@ -9,32 +9,26 @@ interface UseResizableOptions { * 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. From d4ca136419d2d0b13e4ada083f18f80be6f7fd17 Mon Sep 17 00:00:00 2001 From: MichaelSun48 Date: Tue, 13 May 2025 10:27:52 -0700 Subject: [PATCH 6/7] Only attach mousemove mouseup handlers on mousedown --- static/app/utils/useResizable.tsx | 54 ++++++++++++++----------------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/static/app/utils/useResizable.tsx b/static/app/utils/useResizable.tsx index 57520d49e07b81..7a1ec43bce803e 100644 --- a/static/app/utils/useResizable.tsx +++ b/static/app/utils/useResizable.tsx @@ -86,26 +86,6 @@ export const useResizable = ({ } }, [ref, initialSize, sizeStorageKey]); - 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'; - onResizeStart?.(); - }, - [ref, onResizeStart] - ); - const handleMouseMove = useCallback( (e: MouseEvent) => { if (!isDraggingRef.current) return; @@ -132,20 +112,34 @@ export const useResizable = ({ 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()); } - }, [onResizeEnd, ref, sizeStorageKey, initialSize]); + }, [handleMouseMove, onResizeEnd, ref, sizeStorageKey, initialSize]); - useEffect(() => { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [handleMouseMove, handleMouseUp]); + 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) { From 42d12c1989d6aba80400a10a21e8845a3222b441 Mon Sep 17 00:00:00 2001 From: MichaelSun48 Date: Tue, 13 May 2025 10:29:18 -0700 Subject: [PATCH 7/7] Rmeove double export --- static/app/utils/useResizable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/utils/useResizable.tsx b/static/app/utils/useResizable.tsx index 7a1ec43bce803e..41d7d8e561e5d4 100644 --- a/static/app/utils/useResizable.tsx +++ b/static/app/utils/useResizable.tsx @@ -61,7 +61,7 @@ interface UseResizableResult { * * Currently only supports resizing width and not height. */ -export const useResizable = ({ +const useResizable = ({ ref, initialSize, maxWidth,