'use client'; import { GripVerticalIcon } from 'lucide-react'; import * as React from 'react'; import { cn } from '../../shared/utils'; // --- Types --- type PanelData = { id: string; ref: React.MutableRefObject; defaultSize?: number; minSize?: number; maxSize?: number; collapsible?: boolean; onCollapse?: () => void; onExpand?: () => void; onResize?: (size: number) => void; }; type HandleData = { id: string; ref: React.MutableRefObject; }; type ResizableContextType = { direction: 'horizontal' | 'vertical'; registerPanel: (data: PanelData) => void; unregisterPanel: (id: string) => void; registerHandle: (data: HandleData) => void; unregisterHandle: (id: string) => void; isDragging: boolean; startDragging: (handleId: string, event: React.MouseEvent | React.TouchEvent) => void; getPanelStyle: (id: string) => React.CSSProperties; }; const ResizableContext = React.createContext(null); // --- Components --- /** * Drag-to-resize panel layout (IDE-style split panels). * * @description * Creates resizable panel architectures like IDE layouts (collapsible sidebar vs main window). * Custom implementation with full touch and mouse support. * * @ai-rules * 1. Always place `` between sibling `` components — it is what separates them. * 2. Configure `defaultSize` as a percentage (not pixels): e.g., `defaultSize={50}` for a 50/50 split. * 3. Use `minSize` and `maxSize` (also percentages) to constrain draggable bounds. */ const ResizablePanelGroup = ({ children, className, direction = 'horizontal', id, autoSaveId, storage, onLayout, ...props }: { children: React.ReactNode; className?: string; direction?: 'horizontal' | 'vertical'; id?: string; autoSaveId?: string; storage?: any; onLayout?: (sizes: number[]) => void; } & React.HTMLAttributes) => { const [panels, setPanels] = React.useState>(new Map()); const [handles, setHandles] = React.useState>(new Map()); const [sizes, setSizes] = React.useState>(new Map()); const [isDragging, setIsDragging] = React.useState(false); const containerRef = React.useRef(null); // Sorting helpers const getSortedItems = React.useCallback( }>(items: Map) => { if (!containerRef.current) return []; return Array.from(items.values()).sort((a, b) => { if (!a.ref.current || !b.ref.current) return 0; const position = a.ref.current.compareDocumentPosition(b.ref.current); if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; return 0; }); }, [] ); // Initialize layout React.useEffect(() => { const sortedPanels = getSortedItems(panels); if (sortedPanels.length === 0) return; // Check if we need to initialize sizes const uninitialized = sortedPanels.some(p => !sizes.has(p.id)); if (uninitialized) { const newSizes = new Map(sizes); const remainingSpace = 100; const defaultSizeCount = sortedPanels.filter(p => p.defaultSize).length; let usedSpace = 0; sortedPanels.forEach(p => { if (p.defaultSize) usedSpace += p.defaultSize; }); const spaceForOthers = Math.max(0, remainingSpace - usedSpace); const countOthers = sortedPanels.length - defaultSizeCount; const sizeForOthers = countOthers > 0 ? spaceForOthers / countOthers : 0; sortedPanels.forEach(p => { if (!newSizes.has(p.id)) { newSizes.set(p.id, p.defaultSize ?? sizeForOthers); } }); setSizes(newSizes); } }, [panels, sizes, getSortedItems]); const registerPanel = React.useCallback((data: PanelData) => { setPanels(prev => { const next = new Map(prev); next.set(data.id, data); return next; }); }, []); const unregisterPanel = React.useCallback((id: string) => { setPanels(prev => { const next = new Map(prev); next.delete(id); return next; }); setSizes(prev => { const next = new Map(prev); next.delete(id); return next; }); }, []); const registerHandle = React.useCallback((data: HandleData) => { setHandles(prev => { const next = new Map(prev); next.set(data.id, data); return next; }); }, []); const unregisterHandle = React.useCallback((id: string) => { setHandles(prev => { const next = new Map(prev); next.delete(id); return next; }); }, []); const startDragging = React.useCallback( (handleId: string, event: React.MouseEvent | React.TouchEvent) => { event.preventDefault(); setIsDragging(true); const sortedPanels = getSortedItems(panels); const sortedHandles = getSortedItems(handles); const handleIndex = sortedHandles.findIndex(h => h.id === handleId); if (handleIndex === -1) return; // Handle i usually sits between Panel i and Panel i+1 const leftPanel = sortedPanels[handleIndex]; const rightPanel = sortedPanels[handleIndex + 1]; if (!leftPanel || !rightPanel) return; const startX = 'touches' in event ? event.touches[0].clientX : event.clientX; const startY = 'touches' in event ? event.touches[0].clientY : event.clientY; const startSizeLeft = sizes.get(leftPanel.id) || 0; const startSizeRight = sizes.get(rightPanel.id) || 0; const containerSize = direction === 'horizontal' ? containerRef.current?.offsetWidth || 1 : containerRef.current?.offsetHeight || 1; const onMove = (e: MouseEvent | TouchEvent) => { const currentX = 'touches' in e ? e.touches[0].clientX : e.clientX; const currentY = 'touches' in e ? e.touches[0].clientY : e.clientY; const deltaPixels = direction === 'horizontal' ? currentX - startX : currentY - startY; const deltaPercent = (deltaPixels / containerSize) * 100; // Calculate tentative new sizes let finalLeft = startSizeLeft + deltaPercent; let finalRight = startSizeRight - deltaPercent; // Apply min constraints if (leftPanel.minSize !== undefined && finalLeft < leftPanel.minSize) { const diff = leftPanel.minSize - finalLeft; finalLeft = leftPanel.minSize; finalRight -= diff; } if (rightPanel.minSize !== undefined && finalRight < rightPanel.minSize) { const diff = rightPanel.minSize - finalRight; finalRight = rightPanel.minSize; finalLeft -= diff; } // Apply max constraints if (leftPanel.maxSize !== undefined && finalLeft > leftPanel.maxSize) { const diff = finalLeft - leftPanel.maxSize; finalLeft = leftPanel.maxSize; finalRight += diff; } if (rightPanel.maxSize !== undefined && finalRight > rightPanel.maxSize) { const diff = finalRight - rightPanel.maxSize; finalRight = rightPanel.maxSize; finalLeft -= diff; } // Hard clamp to 0-100 just in case finalLeft = Math.max(0, Math.min(100, finalLeft)); finalRight = Math.max(0, Math.min(100, finalRight)); setSizes(prev => { const next = new Map(prev); next.set(leftPanel.id, finalLeft); next.set(rightPanel.id, finalRight); return next; }); // Notify panels leftPanel.onResize?.(finalLeft); rightPanel.onResize?.(finalRight); }; const onUp = () => { setIsDragging(false); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); window.removeEventListener('touchmove', onMove); window.removeEventListener('touchend', onUp); // Trigger onLayout callback if (onLayout) { onLayout(sortedPanels.map(p => sizes.get(p.id) || 0)); } }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); window.addEventListener('touchmove', onMove); window.addEventListener('touchend', onUp); }, [panels, handles, sizes, direction, getSortedItems, onLayout] ); const getPanelStyle = React.useCallback( (id: string) => { const size = sizes.get(id); if (size === undefined) return { flex: '1 1 0%', overflow: 'hidden' }; return { flex: `${size} 1 0%`, overflow: 'hidden' }; }, [sizes] ); const contextValue = React.useMemo( () => ({ direction, registerPanel, unregisterPanel, registerHandle, unregisterHandle, isDragging, startDragging, getPanelStyle, }), [ direction, registerPanel, unregisterPanel, registerHandle, unregisterHandle, isDragging, startDragging, getPanelStyle, ] ); return (
{children}
); }; const ResizablePanel = ({ className, defaultSize, minSize = 0, maxSize = 100, collapsible, collapsedSize, onCollapse, onExpand, onResize, order, tagName, id: propId, children, ...props }: { defaultSize?: number; minSize?: number; maxSize?: number; collapsible?: boolean; collapsedSize?: number; onCollapse?: () => void; onExpand?: () => void; onResize?: (size: number) => void; order?: number; tagName?: string; } & React.HTMLAttributes) => { const context = React.useContext(ResizableContext); const ref = React.useRef(null); const [generatedId] = React.useState(() => Math.random().toString(36).substr(2, 9)); const id = propId || generatedId; // We destructure only stable functions for the dependency array to avoid infinite loops const registerPanel = context?.registerPanel; const unregisterPanel = context?.unregisterPanel; React.useLayoutEffect(() => { if (!registerPanel || !unregisterPanel) return; registerPanel({ id, ref, defaultSize, minSize, maxSize, collapsible, onCollapse, onExpand, onResize, }); return () => unregisterPanel(id); }, [ registerPanel, unregisterPanel, id, defaultSize, minSize, maxSize, collapsible, onCollapse, onExpand, onResize, ]); if (!context) { return (
{children}
); } return (
{children}
); }; const ResizableHandle = ({ withHandle, className, id: propId, tagName, ...props }: { withHandle?: boolean; tagName?: string; } & React.HTMLAttributes) => { const context = React.useContext(ResizableContext); const ref = React.useRef(null); const [generatedId] = React.useState(() => Math.random().toString(36).substr(2, 9)); const id = propId || generatedId; // We destructure only stable functions for the dependency array const registerHandle = context?.registerHandle; const unregisterHandle = context?.unregisterHandle; React.useLayoutEffect(() => { if (!registerHandle || !unregisterHandle) return; registerHandle({ id, ref }); return () => unregisterHandle(id); }, [registerHandle, unregisterHandle, id]); const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => { if (context) { context.startDragging(id, e); } }; if (!context) return null; return (
{withHandle && (
)}
); }; export { ResizablePanelGroup, ResizablePanel, ResizableHandle };