157 lines
4.4 KiB
TypeScript
157 lines
4.4 KiB
TypeScript
|
|
/**
|
||
|
|
* useResizablePanel - Hook for creating resizable panels with persistence
|
||
|
|
*
|
||
|
|
* Features:
|
||
|
|
* - Drag to resize
|
||
|
|
* - Min/max constraints
|
||
|
|
* - localStorage persistence
|
||
|
|
* - Double-click to reset to default
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||
|
|
|
||
|
|
export interface ResizablePanelConfig {
|
||
|
|
/** Unique key for localStorage persistence */
|
||
|
|
storageKey: string;
|
||
|
|
/** Default width in pixels */
|
||
|
|
defaultWidth: number;
|
||
|
|
/** Minimum width in pixels */
|
||
|
|
minWidth: number;
|
||
|
|
/** Maximum width in pixels */
|
||
|
|
maxWidth: number;
|
||
|
|
/** Side of the panel ('left' or 'right') - affects resize direction */
|
||
|
|
side: 'left' | 'right';
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ResizablePanelState {
|
||
|
|
/** Current width in pixels */
|
||
|
|
width: number;
|
||
|
|
/** Whether user is currently dragging */
|
||
|
|
isDragging: boolean;
|
||
|
|
/** Start drag handler - attach to resize handle mousedown */
|
||
|
|
startDrag: (e: React.MouseEvent) => void;
|
||
|
|
/** Reset to default width */
|
||
|
|
resetWidth: () => void;
|
||
|
|
/** Set width programmatically */
|
||
|
|
setWidth: (width: number) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const STORAGE_PREFIX = 'atomizer-panel-';
|
||
|
|
|
||
|
|
function getStoredWidth(key: string, defaultWidth: number): number {
|
||
|
|
if (typeof window === 'undefined') return defaultWidth;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const stored = localStorage.getItem(STORAGE_PREFIX + key);
|
||
|
|
if (stored) {
|
||
|
|
const parsed = parseInt(stored, 10);
|
||
|
|
if (!isNaN(parsed)) return parsed;
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// localStorage not available
|
||
|
|
}
|
||
|
|
return defaultWidth;
|
||
|
|
}
|
||
|
|
|
||
|
|
function storeWidth(key: string, width: number): void {
|
||
|
|
if (typeof window === 'undefined') return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
localStorage.setItem(STORAGE_PREFIX + key, String(width));
|
||
|
|
} catch {
|
||
|
|
// localStorage not available
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function useResizablePanel(config: ResizablePanelConfig): ResizablePanelState {
|
||
|
|
const { storageKey, defaultWidth, minWidth, maxWidth, side } = config;
|
||
|
|
|
||
|
|
// Initialize from localStorage
|
||
|
|
const [width, setWidthState] = useState(() => {
|
||
|
|
const stored = getStoredWidth(storageKey, defaultWidth);
|
||
|
|
return Math.max(minWidth, Math.min(maxWidth, stored));
|
||
|
|
});
|
||
|
|
|
||
|
|
const [isDragging, setIsDragging] = useState(false);
|
||
|
|
|
||
|
|
// Track initial position for drag calculation
|
||
|
|
const dragStartRef = useRef<{ x: number; width: number } | null>(null);
|
||
|
|
|
||
|
|
// Clamp width within bounds
|
||
|
|
const clampWidth = useCallback((w: number) => {
|
||
|
|
return Math.max(minWidth, Math.min(maxWidth, w));
|
||
|
|
}, [minWidth, maxWidth]);
|
||
|
|
|
||
|
|
// Set width with clamping and persistence
|
||
|
|
const setWidth = useCallback((newWidth: number) => {
|
||
|
|
const clamped = clampWidth(newWidth);
|
||
|
|
setWidthState(clamped);
|
||
|
|
storeWidth(storageKey, clamped);
|
||
|
|
}, [clampWidth, storageKey]);
|
||
|
|
|
||
|
|
// Reset to default
|
||
|
|
const resetWidth = useCallback(() => {
|
||
|
|
setWidth(defaultWidth);
|
||
|
|
}, [defaultWidth, setWidth]);
|
||
|
|
|
||
|
|
// Start drag handler
|
||
|
|
const startDrag = useCallback((e: React.MouseEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
setIsDragging(true);
|
||
|
|
dragStartRef.current = { x: e.clientX, width };
|
||
|
|
}, [width]);
|
||
|
|
|
||
|
|
// Handle mouse move during drag
|
||
|
|
useEffect(() => {
|
||
|
|
if (!isDragging) return;
|
||
|
|
|
||
|
|
const handleMouseMove = (e: MouseEvent) => {
|
||
|
|
if (!dragStartRef.current) return;
|
||
|
|
|
||
|
|
const delta = e.clientX - dragStartRef.current.x;
|
||
|
|
|
||
|
|
// For left panels, positive delta increases width
|
||
|
|
// For right panels, negative delta increases width
|
||
|
|
const newWidth = side === 'left'
|
||
|
|
? dragStartRef.current.width + delta
|
||
|
|
: dragStartRef.current.width - delta;
|
||
|
|
|
||
|
|
setWidthState(clampWidth(newWidth));
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleMouseUp = () => {
|
||
|
|
if (dragStartRef.current) {
|
||
|
|
// Persist the final width
|
||
|
|
storeWidth(storageKey, width);
|
||
|
|
}
|
||
|
|
setIsDragging(false);
|
||
|
|
dragStartRef.current = null;
|
||
|
|
};
|
||
|
|
|
||
|
|
// Add listeners to document for smooth dragging
|
||
|
|
document.addEventListener('mousemove', handleMouseMove);
|
||
|
|
document.addEventListener('mouseup', handleMouseUp);
|
||
|
|
|
||
|
|
// Change cursor globally during drag
|
||
|
|
document.body.style.cursor = 'col-resize';
|
||
|
|
document.body.style.userSelect = 'none';
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
||
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
||
|
|
document.body.style.cursor = '';
|
||
|
|
document.body.style.userSelect = '';
|
||
|
|
};
|
||
|
|
}, [isDragging, side, clampWidth, storageKey, width]);
|
||
|
|
|
||
|
|
return {
|
||
|
|
width,
|
||
|
|
isDragging,
|
||
|
|
startDrag,
|
||
|
|
resetWidth,
|
||
|
|
setWidth,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export default useResizablePanel;
|