frontend_nyla/src/hooks/useResizablePanels.ts
2026-01-29 10:14:33 +01:00

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;