1- import { useCallback } from 'react'
1+ import { useCallback , useEffect , useRef } from 'react'
22import { SIDEBAR_WIDTH } from '@/stores/constants'
33import { useSidebarStore } from '@/stores/sidebar/store'
44
@@ -7,60 +7,87 @@ import { useSidebarStore } from '@/stores/sidebar/store'
77 *
88 * Architecture (confirmed industry best-practice for resize handles):
99 *
10- * mousedown → add `is-resizing` class directly to DOM (no React round-trip,
11- * so the CSS width transition is suppressed from the very first frame)
12- * mousemove → write to --sidebar-width CSS custom property inside a
13- * requestAnimationFrame callback (aligns work with the browser
14- * paint cycle; mousemove fires faster than 60fps on modern hardware
15- * so without RAF we'd do redundant writes between paints)
16- * mouseup → cancel any pending RAF, persist final width to Zustand once
17- * (one React re-render to save to localStorage and sync
18- * components that read sidebarWidth from state)
10+ * pointerdown → capture the pointer on the handle (so move/up keep arriving
11+ * even when the cursor leaves the window or crosses an iframe),
12+ * add `is-resizing` class directly to the DOM (no React
13+ * round-trip, so the CSS width transition is suppressed from the
14+ * very first frame)
15+ * pointermove → write to --sidebar-width inside a requestAnimationFrame
16+ * callback (aligns work with the browser paint cycle)
17+ * pointerup → cancel any pending RAF, tear down, persist final width to
18+ * Zustand once (one React re-render to save to localStorage)
19+ *
20+ * The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so an
21+ * interrupted gesture (release outside the window, alt-tab, context menu, the OS
22+ * stealing focus) can never leave the `is-resizing` / `sidebar-resizing` classes
23+ * stuck — which would otherwise freeze the sidebar at a tiny width with the
24+ * collapse transition permanently disabled. A single-flight guard prevents
25+ * stacking listeners across rapid presses, and an unmount cleanup tears down a
26+ * drag still in flight when the sidebar unmounts (e.g. route change).
1927 */
2028export function useSidebarResize ( ) {
2129 const setSidebarWidth = useSidebarStore ( ( s ) => s . setSidebarWidth )
30+ const cleanupRef = useRef < ( ( ) => void ) | null > ( null )
31+
32+ const handlePointerDown = useCallback (
33+ ( e : React . PointerEvent < HTMLElement > ) => {
34+ if ( cleanupRef . current ) return
2235
23- const handleMouseDown = useCallback ( ( ) => {
24- const sidebar = document . querySelector < HTMLElement > ( '.sidebar-container' )
25- sidebar ?. classList . add ( 'is-resizing' )
26- document . documentElement . classList . add ( 'sidebar-resizing' )
27- document . body . style . cursor = 'ew-resize'
28- document . body . style . userSelect = 'none'
36+ const handle = e . currentTarget
37+ const pointerId = e . pointerId
38+ const sidebar = document . querySelector < HTMLElement > ( '.sidebar-container' )
39+ sidebar ?. classList . add ( 'is-resizing' )
40+ document . documentElement . classList . add ( 'sidebar-resizing' )
41+ document . body . style . cursor = 'ew-resize'
42+ document . body . style . userSelect = 'none'
43+ handle . setPointerCapture ?.( pointerId )
2944
30- let rafId : number | null = null
45+ let rafId : number | null = null
3146
32- const onMouseMove = ( e : MouseEvent ) => {
33- if ( rafId !== null ) cancelAnimationFrame ( rafId )
34- rafId = requestAnimationFrame ( ( ) => {
35- const clamped = Math . min (
36- Math . max ( e . clientX , SIDEBAR_WIDTH . MIN ) ,
37- window . innerWidth * SIDEBAR_WIDTH . MAX_PERCENTAGE
38- )
39- document . documentElement . style . setProperty ( '--sidebar-width' , `${ clamped } px` )
40- rafId = null
41- } )
42- }
47+ const onPointerMove = ( ev : PointerEvent ) => {
48+ if ( rafId !== null ) cancelAnimationFrame ( rafId )
49+ rafId = requestAnimationFrame ( ( ) => {
50+ const max = Math . max ( SIDEBAR_WIDTH . MIN , window . innerWidth * SIDEBAR_WIDTH . MAX_PERCENTAGE )
51+ const clamped = Math . min ( Math . max ( ev . clientX , SIDEBAR_WIDTH . MIN ) , max )
52+ document . documentElement . style . setProperty ( '--sidebar-width' , `${ clamped } px` )
53+ rafId = null
54+ } )
55+ }
56+
57+ const cleanup = ( ) => {
58+ if ( rafId !== null ) {
59+ cancelAnimationFrame ( rafId )
60+ rafId = null
61+ }
62+ sidebar ?. classList . remove ( 'is-resizing' )
63+ document . documentElement . classList . remove ( 'sidebar-resizing' )
64+ document . body . style . cursor = ''
65+ document . body . style . userSelect = ''
66+ if ( handle . hasPointerCapture ?.( pointerId ) ) handle . releasePointerCapture ( pointerId )
67+ document . removeEventListener ( 'pointermove' , onPointerMove )
68+ document . removeEventListener ( 'pointerup' , endDrag )
69+ document . removeEventListener ( 'pointercancel' , endDrag )
70+ window . removeEventListener ( 'blur' , endDrag )
71+ cleanupRef . current = null
72+ }
4373
44- const onMouseUp = ( ) => {
45- if ( rafId !== null ) {
46- cancelAnimationFrame ( rafId )
47- rafId = null
74+ function endDrag ( ) {
75+ cleanup ( )
76+ const raw = document . documentElement . style . getPropertyValue ( '--sidebar-width' )
77+ const finalWidth = Number . parseFloat ( raw )
78+ if ( ! Number . isNaN ( finalWidth ) ) setSidebarWidth ( finalWidth )
4879 }
49- sidebar ?. classList . remove ( 'is-resizing' )
50- document . documentElement . classList . remove ( 'sidebar-resizing' )
51- document . body . style . cursor = ''
52- document . body . style . userSelect = ''
53- document . removeEventListener ( 'mousemove' , onMouseMove )
54- document . removeEventListener ( 'mouseup' , onMouseUp )
5580
56- const raw = document . documentElement . style . getPropertyValue ( '--sidebar-width' )
57- const finalWidth = Number . parseFloat ( raw )
58- if ( ! Number . isNaN ( finalWidth ) ) setSidebarWidth ( finalWidth )
59- }
81+ cleanupRef . current = cleanup
82+ document . addEventListener ( 'pointermove' , onPointerMove )
83+ document . addEventListener ( 'pointerup' , endDrag )
84+ document . addEventListener ( 'pointercancel' , endDrag )
85+ window . addEventListener ( 'blur' , endDrag )
86+ } ,
87+ [ setSidebarWidth ]
88+ )
6089
61- document . addEventListener ( 'mousemove' , onMouseMove )
62- document . addEventListener ( 'mouseup' , onMouseUp )
63- } , [ setSidebarWidth ] )
90+ useEffect ( ( ) => ( ) => cleanupRef . current ?.( ) , [ ] )
6491
65- return { handleMouseDown }
92+ return { handlePointerDown }
6693}
0 commit comments