/** * useResizablePanels * * Hook for creating resizable panel layouts with drag-divider. * Supports LocalStorage persistence and min/max constraints. */ import { useState, useCallback, useEffect, useRef } from 'react'; interface UseResizablePanelsOptions { /** Key for LocalStorage persistence */ storageKey: string; /** Default width of left panel in percent (0-100) */ defaultLeftWidth: number; /** Minimum width of left panel in percent */ minLeftWidth: number; /** Maximum width of left panel in percent */ maxLeftWidth: number; /** Direction of resize - horizontal or vertical */ direction?: 'horizontal' | 'vertical'; } interface UseResizablePanelsReturn { /** Current width/height of left/top panel in percent */ leftWidth: number; /** Whether user is currently dragging the divider */ isDragging: boolean; /** Handler for mouse down on divider */ handleMouseDown: (e: React.MouseEvent) => void; /** Programmatically set the left width */ setLeftWidth: (width: number) => void; /** Reset to default width */ resetToDefault: () => void; /** Container ref to attach to the parent container */ containerRef: React.RefObject; } export function useResizablePanels({ storageKey, defaultLeftWidth, minLeftWidth, maxLeftWidth, direction = 'horizontal', }: UseResizablePanelsOptions): UseResizablePanelsReturn { // Initialize from LocalStorage or default const [leftWidth, setLeftWidthState] = useState(() => { try { const stored = localStorage.getItem(storageKey); if (stored) { const parsed = parseFloat(stored); if (!isNaN(parsed) && parsed >= minLeftWidth && parsed <= maxLeftWidth) { return parsed; } } } catch { // Ignore localStorage errors } return defaultLeftWidth; }); const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(null); // Store start position and width for drag calculation const dragStartRef = useRef<{ startPos: number; startWidth: number; containerSize: number; } | null>(null); // Set width with clamping and persistence const setLeftWidth = useCallback((width: number) => { const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, width)); setLeftWidthState(clampedWidth); // Persist to LocalStorage try { localStorage.setItem(storageKey, clampedWidth.toString()); } catch { // Ignore localStorage errors } }, [storageKey, minLeftWidth, maxLeftWidth]); // Reset to default const resetToDefault = useCallback(() => { setLeftWidth(defaultLeftWidth); }, [defaultLeftWidth, setLeftWidth]); // Mouse down handler for starting drag const handleMouseDown = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const container = containerRef.current; if (!container) return; const containerRect = container.getBoundingClientRect(); const containerSize = direction === 'horizontal' ? containerRect.width : containerRect.height; const startPos = direction === 'horizontal' ? e.clientX : e.clientY; dragStartRef.current = { startPos, startWidth: leftWidth, containerSize, }; setIsDragging(true); }, [leftWidth, direction]); // Handle mouse move during drag useEffect(() => { if (!isDragging) return; const handleMouseMove = (e: MouseEvent) => { if (!dragStartRef.current || !containerRef.current) return; const { startPos, startWidth, containerSize } = dragStartRef.current; const currentPos = direction === 'horizontal' ? e.clientX : e.clientY; // Calculate delta in pixels and convert to percent const deltaPixels = currentPos - startPos; const deltaPercent = (deltaPixels / containerSize) * 100; // Calculate new width const newWidth = startWidth + deltaPercent; // Clamp between min and max const clampedWidth = Math.max(minLeftWidth, Math.min(maxLeftWidth, newWidth)); setLeftWidthState(clampedWidth); }; const handleMouseUp = () => { setIsDragging(false); dragStartRef.current = null; // Persist final width to LocalStorage try { localStorage.setItem(storageKey, leftWidth.toString()); } catch { // Ignore localStorage errors } }; // Add event listeners to document for capturing mouse events outside container document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); // Add cursor style to body during drag document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize'; document.body.style.userSelect = 'none'; return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; }, [isDragging, leftWidth, direction, storageKey, minLeftWidth, maxLeftWidth]); // Save to localStorage when leftWidth changes (debounced by drag end) useEffect(() => { // Only save when not dragging (save happens on mouse up) if (!isDragging) { try { localStorage.setItem(storageKey, leftWidth.toString()); } catch { // Ignore localStorage errors } } }, [leftWidth, isDragging, storageKey]); return { leftWidth, isDragging, handleMouseDown, setLeftWidth, resetToDefault, containerRef, }; } export default useResizablePanels;