import type { RefObject } from 'react'; import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import type { ImperativePanelGroupHandle, ImperativePanelHandle, PanelOnCollapse, PanelOnExpand, PanelProps, PanelResizeHandleProps, } from 'react-resizable-panels'; import { getPanelGroupElement, getResizeHandleElementsForGroup } from 'react-resizable-panels'; type Direction = 'horizontal' | 'vertical'; export type UsePanelOptions = | { id: string; /** * The minimum size of the panel as a percentage. */ minSize: number; /** * The default size of the panel as a percentage. */ defaultSize?: number; /** * The unit of the minSize */ unit: 'percentages'; onCollapse?: (isCollapsed: boolean) => void; } | { id: string; /** * The minimum size of the panel in pixels. */ minSize: number; /** * The default size of the panel in pixels. */ defaultSize?: number; /** * The unit of the minSize. */ unit: 'pixels'; /** * The direction of the panel group. * This is required to accurately calculate the available space for the panel, minus the space taken by the handles. */ panelGroupDirection: Direction; /** * A ref to the panel group. */ panelGroupRef: RefObject; onCollapse?: (isCollapsed: boolean) => void; }; export type UsePanelReturn = { /** * Whether the panel is collapsed. */ isCollapsed: boolean; /** * Reset the panel to the minSize. */ reset: () => void; /** * Toggle the panel between collapsed and expanded. */ toggle: () => void; /** * Expand the panel. */ expand: () => void; /** * Collapse the panel. */ collapse: () => void; /** * Resize the panel to the given size in the same units as the minSize. */ resize: (size: number) => void; panelProps: Partial }>; resizeHandleProps: Partial; }; export const usePanel = (arg: UsePanelOptions): UsePanelReturn => { const panelHandleRef = useRef(null); const [_minSize, _setMinSize] = useState(arg.unit === 'percentages' ? arg.minSize : 0); const [_defaultSize, _setDefaultSize] = useState(arg.defaultSize ?? arg.minSize); // If the units are pixels, we need to calculate the min size as a percentage of the available space, // then resize the panel if it is too small. useLayoutEffect(() => { if (arg.unit === 'percentages' || !arg.panelGroupRef.current) { return; } const id = arg.panelGroupRef.current.getId(); const panelGroupElement = getPanelGroupElement(id); const panelGroupHandleElements = getResizeHandleElementsForGroup(id); if (!panelGroupElement) { return; } const resizeObserver = new ResizeObserver(() => { if (!panelHandleRef?.current) { return; } const minSizePct = getSizeAsPercentage(arg.minSize, arg.panelGroupRef, arg.panelGroupDirection); _setMinSize(minSizePct); if (arg.defaultSize && arg.defaultSize > minSizePct) { _setDefaultSize(defaultSizePct); } else { _setDefaultSize(minSizePct); } if (!panelHandleRef.current.isCollapsed() && panelHandleRef.current.getSize() < minSizePct && minSizePct > 0) { panelHandleRef.current.resize(minSizePct); } }); resizeObserver.observe(panelGroupElement); panelGroupHandleElements.forEach((el) => resizeObserver.observe(el)); // Resize the panel to the min size once on startup const defaultSizePct = arg.defaultSize ?? getSizeAsPercentage(arg.minSize, arg.panelGroupRef, arg.panelGroupDirection); panelHandleRef.current?.resize(defaultSizePct); return () => { resizeObserver.disconnect(); }; }, [arg]); const [isCollapsed, setIsCollapsed] = useState(() => Boolean(panelHandleRef.current?.isCollapsed())); const onCollapse = useCallback(() => { setIsCollapsed(true); arg.onCollapse?.(true); }, [arg]); const onExpand = useCallback(() => { setIsCollapsed(false); arg.onCollapse?.(false); }, [arg]); const toggle = useCallback(() => { if (panelHandleRef.current?.isCollapsed()) { panelHandleRef.current?.expand(); } else { panelHandleRef.current?.collapse(); } }, []); const expand = useCallback(() => { panelHandleRef.current?.expand(); }, []); const collapse = useCallback(() => { panelHandleRef.current?.collapse(); }, []); const resize = useCallback( (size: number) => { // If we are using percentages, we can just resize to the given size if (arg.unit === 'percentages') { panelHandleRef.current?.resize(size); return; } // If we are using pixels, we need to calculate the size as a percentage of the available space const sizeAsPct = getSizeAsPercentage(size, arg.panelGroupRef, arg.panelGroupDirection); panelHandleRef.current?.resize(sizeAsPct); }, [arg] ); const reset = useCallback(() => { panelHandleRef.current?.resize(_minSize); }, [_minSize]); const cycleState = useCallback(() => { // If the panel is really super close to the min size, collapse it if (Math.abs((panelHandleRef.current?.getSize() ?? 0) - _defaultSize) < 0.01) { collapse(); return; } // Otherwise, resize to the min size panelHandleRef.current?.resize(_defaultSize); }, [_defaultSize, collapse]); return { isCollapsed, reset, toggle, expand, collapse, resize, panelProps: { id: arg.id, defaultSize: _defaultSize, onCollapse, onExpand, ref: panelHandleRef, minSize: _minSize, }, resizeHandleProps: { onDoubleClick: cycleState, }, }; }; /** * For a desired size in pixels, calculates the size of the panel as a percentage of the available space. * @param sizeInPixels The desired size of the panel in pixels. * @param panelGroupHandleRef The ref to the panel group handle. * @param panelGroupDirection The direction of the panel group. * @returns The size of the panel as a percentage. */ const getSizeAsPercentage = ( sizeInPixels: number, panelGroupHandleRef: RefObject, panelGroupDirection: Direction ) => { if (!panelGroupHandleRef.current) { // No panel group handle ref, so we can't calculate the size return 0; } const id = panelGroupHandleRef.current.getId(); const panelGroupElement = getPanelGroupElement(id); if (!panelGroupElement) { // No panel group element, size is 0 return 0; } // The available space is the width/height of the panel group... let availableSpace = panelGroupDirection === 'horizontal' ? panelGroupElement.offsetWidth : panelGroupElement.offsetHeight; // ...minus the width/height of the resize handles getResizeHandleElementsForGroup(id).forEach((el) => { availableSpace -= panelGroupDirection === 'horizontal' ? el.offsetWidth : el.offsetHeight; }); // The final value is a percentage of the available space return (sizeInPixels / availableSpace) * 100; };