Files
InvokeAI/invokeai/frontend/web/src/features/ui/hooks/usePanel.ts

250 lines
7.2 KiB
TypeScript

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<ImperativePanelGroupHandle>;
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<PanelProps & { ref: RefObject<ImperativePanelHandle> }>;
resizeHandleProps: Partial<PanelResizeHandleProps>;
};
export const usePanel = (arg: UsePanelOptions): UsePanelReturn => {
const panelHandleRef = useRef<ImperativePanelHandle>(null);
const [_minSize, _setMinSize] = useState<number>(arg.unit === 'percentages' ? arg.minSize : 0);
const [_defaultSize, _setDefaultSize] = useState<number>(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<PanelOnCollapse>(() => {
setIsCollapsed(true);
arg.onCollapse?.(true);
}, [arg]);
const onExpand = useCallback<PanelOnExpand>(() => {
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<ImperativePanelGroupHandle>,
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;
};