From 03bc3066d42f39f44e241421e3282c802780abad Mon Sep 17 00:00:00 2001 From: MichaelSun48 Date: Tue, 13 May 2025 09:58:41 -0700 Subject: [PATCH] Add new performant hook for resizing --- static/app/utils/useResizable.tsx | 170 ++++++++++++++++++ static/app/views/nav/constants.tsx | 2 + .../views/nav/secondary/secondarySidebar.tsx | 87 +++++++-- 3 files changed, 245 insertions(+), 14 deletions(-) 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; diff --git a/static/app/views/nav/constants.tsx b/static/app/views/nav/constants.tsx index 7db808b3f97904..564b5a2ee38ab3 100644 --- a/static/app/views/nav/constants.tsx +++ b/static/app/views/nav/constants.tsx @@ -2,3 +2,5 @@ export const NAV_SIDEBAR_COLLAPSED_LOCAL_STORAGE_KEY = 'navigation-sidebar-is-co export const PRIMARY_SIDEBAR_WIDTH = 74; export const SECONDARY_SIDEBAR_WIDTH = 190; +export const SECONDARY_SIDEBAR_MIN_WIDTH = 100; +export const SECONDARY_SIDEBAR_MAX_WIDTH = 500; diff --git a/static/app/views/nav/secondary/secondarySidebar.tsx b/static/app/views/nav/secondary/secondarySidebar.tsx index 6ba8c45fd05954..75bd55afadacd2 100644 --- a/static/app/views/nav/secondary/secondarySidebar.tsx +++ b/static/app/views/nav/secondary/secondarySidebar.tsx @@ -1,6 +1,12 @@ +import {useRef} from 'react'; import styled from '@emotion/styled'; -import {SECONDARY_SIDEBAR_WIDTH} from 'sentry/views/nav/constants'; +import useResizable from 'sentry/utils/useResizable'; +import { + SECONDARY_SIDEBAR_MAX_WIDTH, + SECONDARY_SIDEBAR_MIN_WIDTH, + SECONDARY_SIDEBAR_WIDTH, +} from 'sentry/views/nav/constants'; import {useNavContext} from 'sentry/views/nav/context'; import { NavTourElement, @@ -13,28 +19,51 @@ export function SecondarySidebar() { const {setSecondaryNavEl} = useNavContext(); const {currentStepId} = useStackedNavigationTour(); const stepId = currentStepId ?? StackedNavigationTour.ISSUES; + const resizableContainerRef = useRef(null); + const resizeHandleRef = useRef(null); + + const { + onMouseDown: handleStartResize, + size, + onDoubleClick, + } = useResizable({ + ref: resizableContainerRef, + initialSize: SECONDARY_SIDEBAR_WIDTH, + minWidth: SECONDARY_SIDEBAR_MIN_WIDTH, + maxWidth: SECONDARY_SIDEBAR_MAX_WIDTH, + sizeStorageKey: 'secondary-sidebar-width', + }); return ( - - - + + + + + + ); } -const SecondarySidebarWrapper = styled(NavTourElement)` +const ResizeWrapper = styled('div')` position: relative; + right: 0; border-right: 1px solid ${p => (p.theme.isChonk ? p.theme.border : p.theme.translucentGray200)}; background: ${p => (p.theme.isChonk ? p.theme.background : p.theme.surface200)}; - width: ${SECONDARY_SIDEBAR_WIDTH}px; z-index: ${p => p.theme.zIndex.sidebarPanel}; height: 100%; `; @@ -44,3 +73,33 @@ const SecondarySidebarInner = styled('div')` grid-template-rows: auto 1fr auto; height: 100%; `; + +const ResizeHandle = styled('div')<{atMaxWidth: boolean; atMinWidth: boolean}>` + position: absolute; + right: 0px; + top: 0; + bottom: 0; + width: 8px; + border-radius: 8px; + z-index: ${p => p.theme.zIndex.drawer + 2}; + cursor: ${p => (p.atMinWidth ? 'e-resize' : p.atMaxWidth ? 'w-resize' : 'ew-resize')}; + + &:hover, + &:active { + &::after { + background: ${p => p.theme.purple400}; + } + } + + &::after { + content: ''; + position: absolute; + right: -2px; + top: 0; + bottom: 0; + width: 4px; + opacity: 0.8; + background: transparent; + transition: background 0.25s ease 0.1s; + } +`;