185 lines
5.6 KiB
TypeScript
185 lines
5.6 KiB
TypeScript
/**
|
|
* 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<HTMLDivElement | null>;
|
|
}
|
|
|
|
export function useResizablePanels({
|
|
storageKey,
|
|
defaultLeftWidth,
|
|
minLeftWidth,
|
|
maxLeftWidth,
|
|
direction = 'horizontal',
|
|
}: UseResizablePanelsOptions): UseResizablePanelsReturn {
|
|
// Initialize from LocalStorage or default
|
|
const [leftWidth, setLeftWidthState] = useState<number>(() => {
|
|
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<HTMLDivElement>(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;
|