feat(resizable-side-panels): Make the left and right side panels (optionally) resizable. (#4672)

This commit is contained in:
Joe Boccanfuso authored and GitHub committed 2025-01-21 11:20:34 -05:00
1 parent a518854a4c
commit d90a4cfb16
14 files changed
+694 -244

No files matched your search

@@ -7,47 +7,58 @@ export type SidePanelWithServicesProps = {
side: 'left' | 'right';
className?: string;
activeTabIndex: number;
tabs: any;
tabs?: any;
expandedWidth?: number;
onClose: () => void;
onOpen: () => void;
isExpanded: boolean;
collapsedWidth?: number;
expandedInsideBorderSize?: number;
collapsedInsideBorderSize?: number;
collapsedOutsideBorderSize?: number;
};
const SidePanelWithServices = ({
servicesManager,
side,
activeTabIndex: activeTabIndexProp,
isExpanded,
tabs: tabsProp,
expandedWidth,
onOpen,
onClose,
...props
}: SidePanelWithServicesProps) => {
const panelService = servicesManager?.services?.panelService;
// Tracks whether this SidePanel has been opened at least once since this SidePanel was inserted into the DOM.
// Thus going to the Study List page and back to the viewer resets this flag for a SidePanel.
const [sidePanelOpen, setSidePanelOpen] = useState(activeTabIndexProp !== null);
const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp);
const [sidePanelExpanded, setSidePanelExpanded] = useState(isExpanded);
const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp ?? 0);
const [closedManually, setClosedManually] = useState(false);
const [tabs, setTabs] = useState(tabsProp ?? panelService.getPanels(side));
const handleActiveTabIndexChange = useCallback(({ activeTabIndex }) => {
setActiveTabIndex(activeTabIndex);
setSidePanelOpen(activeTabIndex !== null);
}, []);
const handleOpen = useCallback(() => {
setSidePanelOpen(true);
// If panel is being opened but no tab is active, set first tab as active
if (activeTabIndex === null && tabs.length > 0) {
setActiveTabIndex(0);
}
}, [activeTabIndex, tabs]);
setSidePanelExpanded(true);
onOpen?.();
}, [onOpen]);
const handleClose = useCallback(() => {
setSidePanelOpen(false);
setActiveTabIndex(null);
}, []);
setSidePanelExpanded(false);
setClosedManually(true);
onClose?.();
}, [onClose]);
useEffect(() => {
setSidePanelExpanded(isExpanded);
}, [isExpanded]);
/** update the active tab index from outside */
useEffect(() => {
setActiveTabIndex(activeTabIndexProp);
setActiveTabIndex(activeTabIndexProp ?? 0);
}, [activeTabIndexProp]);
useEffect(() => {
@@ -71,9 +82,12 @@ const SidePanelWithServices = ({
const activatePanelSubscription = panelService.subscribe(
panelService.EVENTS.ACTIVATE_PANEL,
(activatePanelEvent: Types.ActivatePanelEvent) => {
if (sidePanelOpen || activatePanelEvent.forceActive) {
if (sidePanelExpanded || activatePanelEvent.forceActive) {
const tabIndex = tabs.findIndex(tab => tab.id === activatePanelEvent.panelId);
if (tabIndex !== -1) {
if (!closedManually) {
setSidePanelExpanded(true);
}
setActiveTabIndex(tabIndex);
}
}
@@ -83,7 +97,7 @@ const SidePanelWithServices = ({
return () => {
activatePanelSubscription.unsubscribe();
};
}, [tabs, sidePanelOpen, panelService]);
}, [tabs, sidePanelExpanded, panelService, closedManually]);
return (
<SidePanel
@@ -91,10 +105,10 @@ const SidePanelWithServices = ({
side={side}
tabs={tabs}
activeTabIndex={activeTabIndex}
isExpanded={sidePanelExpanded}
onOpen={handleOpen}
onClose={handleClose}
onActiveTabIndexChange={handleActiveTabIndexChange}
expandedWidth={expandedWidth}
/>
);
};
@@ -0,0 +1,325 @@
import { useState, useCallback, useLayoutEffect, useRef } from 'react';
import { getPanelElement, getPanelGroupElement } from 'react-resizable-panels';
// Id needed to grab the panel group for converting pixels to percentages
const viewerLayoutResizablePanelGroupId = 'viewerLayoutResizablePanelGroup';
const viewerLayoutResizableLeftPanelId = 'viewerLayoutResizableLeftPanel';
const viewerLayoutResizableRightPanelId = 'viewerLayoutResizableRightPanel';
const sidePanelExpandedDefaultWidth = 280;
const sidePanelExpandedInsideBorderSize = 4;
const sidePanelExpandedDefaultOffsetWidth =
sidePanelExpandedDefaultWidth + sidePanelExpandedInsideBorderSize;
const sidePanelCollapsedInsideBorderSize = 4;
const sidePanelCollapsedOutsideBorderSize = 8;
const sidePanelCollapsedWidth = 25;
const sidePanelCollapsedOffsetWidth =
sidePanelCollapsedWidth +
sidePanelCollapsedInsideBorderSize +
sidePanelCollapsedOutsideBorderSize;
/**
* Set the minimum and maximum css style width attributes for the given element.
* The two style attributes are cleared whenever the width
* arguments is undefined.
* <p>
* This utility is used as part of a HACK throughout the ViewerLayout component as
* the means of restricting the side panel widths during the resizing of the
* browser window. In general, the widths are always set unless the resize
* handle for either side panel is being dragged (i.e. a side panel is being resized).
*
* @param elem the element
* @param width the max and min width to set on the element
*/
const setMinMaxWidth = (elem, width?) => {
elem.style.minWidth = width === undefined ? '' : `${width}px`;
elem.style.maxWidth = elem.style.minWidth;
};
const useResizablePanels = (
leftPanelClosed,
setLeftPanelClosed,
rightPanelClosed,
setRightPanelClosed
) => {
const [leftPanelExpandedWidth, setLeftPanelExpandedWidth] = useState(
sidePanelExpandedDefaultWidth
);
const [rightPanelExpandedWidth, setRightPanelExpandedWidth] = useState(
sidePanelExpandedDefaultWidth
);
// Percentage sizes.
const [resizablePanelCollapsedSize, setResizablePanelCollapsedSize] = useState(0);
const [resizablePanelDefaultSize, setResizablePanelDefaultSize] = useState(0);
const resizablePanelGroupElemRef = useRef(null);
const resizableLeftPanelElemRef = useRef(null);
const resizableRightPanelElemRef = useRef(null);
const resizableLeftPanelAPIRef = useRef(null);
const resizableRightPanelAPIRef = useRef(null);
const isResizableHandleDraggingRef = useRef(false);
// This useLayoutEffect is used to...
// - Grab a reference to the various resizable panel elements needed for
// converting between percentages and pixels in various callbacks.
// - Expand those panels that are initially expanded.
useLayoutEffect(() => {
const panelGroupElem = getPanelGroupElement(viewerLayoutResizablePanelGroupId);
resizablePanelGroupElemRef.current = panelGroupElem;
const { width: panelGroupWidth } = panelGroupElem.getBoundingClientRect();
const leftPanelElem = getPanelElement(viewerLayoutResizableLeftPanelId);
resizableLeftPanelElemRef.current = leftPanelElem;
const rightPanelElem = getPanelElement(viewerLayoutResizableRightPanelId);
resizableRightPanelElemRef.current = rightPanelElem;
const resizablePanelExpandedSize =
(sidePanelExpandedDefaultOffsetWidth / panelGroupWidth) * 100;
// Since both resizable panels are collapsed by default (i.e. their default size is zero),
// on the very first render check if either/both side panels should be expanded.
if (!leftPanelClosed) {
resizableLeftPanelAPIRef?.current?.expand(resizablePanelExpandedSize);
setMinMaxWidth(leftPanelElem, sidePanelExpandedDefaultOffsetWidth);
}
if (!rightPanelClosed) {
resizableRightPanelAPIRef?.current?.expand(resizablePanelExpandedSize);
setMinMaxWidth(rightPanelElem, sidePanelExpandedDefaultOffsetWidth);
}
}, []); // no dependencies because this useLayoutEffect is only needed on the very first render
// This useLayoutEffect follows the pattern prescribed by the react-resizable-panels
// readme for converting between pixel values and percentages. An example of
// the pattern can be found here:
// https://github.com/bvaughn/react-resizable-panels/issues/46#issuecomment-1368108416
// This useLayoutEffect is used to...
// - Ensure that the percentage size is up-to-date with the pixel sizes
// - Add a resize observer to the resizable panel group to reset various state
// values whenever the resizable panel group is resized (e.g. whenever the
// browser window is resized).
useLayoutEffect(() => {
const { width: panelGroupWidth } = resizablePanelGroupElemRef.current.getBoundingClientRect();
// Ensure the side panels' percentage size is in synch with the pixel width of the
// expanded side panels. In general the two get out-of-sync during a browser
// window resize. Note that this code is here and NOT in the ResizeObserver
// because it has to be done AFTER the minimum percentage size for a panel is
// updated which occurs only AFTER the render following a resize. And by virtue
// of the dependency on the `resizablePanelDefaultSize` state, this code
// is executed on the render following an update of the minimum percentage size
// for a panel.
if (!resizableLeftPanelAPIRef.current.isCollapsed()) {
const leftSize =
((leftPanelExpandedWidth + sidePanelExpandedInsideBorderSize) / panelGroupWidth) * 100;
resizableLeftPanelAPIRef.current.resize(leftSize);
}
if (!resizableRightPanelAPIRef.current.isCollapsed()) {
const rightSize =
((rightPanelExpandedWidth + sidePanelExpandedInsideBorderSize) / panelGroupWidth) * 100;
resizableRightPanelAPIRef.current.resize(rightSize);
}
// This observer kicks in when the ViewportLayout resizable panel group
// component is resized. This typically occurs when the browser window resizes.
const observer = new ResizeObserver(() => {
const { width: panelGroupWidth } = resizablePanelGroupElemRef.current.getBoundingClientRect();
const defaultSize = (sidePanelExpandedDefaultOffsetWidth / panelGroupWidth) * 100;
// Set the new default and collapsed resizable panel sizes.
setResizablePanelDefaultSize(Math.min(50, defaultSize));
setResizablePanelCollapsedSize((sidePanelCollapsedOffsetWidth / panelGroupWidth) * 100);
if (
resizableLeftPanelAPIRef.current.isCollapsed() &&
resizableRightPanelAPIRef.current.isCollapsed()
) {
return;
}
// The code that follows is to handle cases when the group panel is resized to be
// too small to display either side panel at its current width.
// Determine the current widths of the two side panels.
let leftPanelOffsetWidth = resizableLeftPanelAPIRef.current.isCollapsed()
? sidePanelCollapsedOffsetWidth
: leftPanelExpandedWidth + sidePanelExpandedInsideBorderSize;
let rightPanelOffsetWidth = resizableRightPanelAPIRef.current.isCollapsed()
? sidePanelCollapsedOffsetWidth
: rightPanelExpandedWidth + sidePanelExpandedInsideBorderSize;
if (
!resizableLeftPanelAPIRef.current.isCollapsed() &&
leftPanelOffsetWidth + rightPanelOffsetWidth > panelGroupWidth
) {
// There is not enough space to show both panels at their pre-resize widths.
// Note that at this point, the viewport grid component is zero width.
// Reduce the left panel width so that both panels might fit.
leftPanelOffsetWidth = Math.max(
panelGroupWidth - rightPanelOffsetWidth,
sidePanelExpandedDefaultOffsetWidth
);
setLeftPanelExpandedWidth(leftPanelOffsetWidth - sidePanelExpandedInsideBorderSize);
setMinMaxWidth(resizableLeftPanelElemRef.current, leftPanelOffsetWidth);
}
if (
!resizableRightPanelAPIRef.current.isCollapsed() &&
rightPanelOffsetWidth + leftPanelOffsetWidth > panelGroupWidth
) {
// There is not enough space to show both panels at their pre-resize widths.
// Note that at this point, the viewport grid component is zero width.
// Reduce the right panel width so that both panels might fit.
rightPanelOffsetWidth = Math.max(
panelGroupWidth - leftPanelOffsetWidth,
sidePanelExpandedDefaultOffsetWidth
);
setRightPanelExpandedWidth(rightPanelOffsetWidth - sidePanelExpandedInsideBorderSize);
setMinMaxWidth(resizableRightPanelElemRef.current, rightPanelOffsetWidth);
}
});
observer.observe(resizablePanelGroupElemRef.current);
return () => {
observer.disconnect();
};
}, [leftPanelExpandedWidth, resizablePanelDefaultSize, rightPanelExpandedWidth]);
/**
* Handles dragging of either side panel resize handle.
*/
const onHandleDragging = useCallback(
isStartDrag => {
if (isStartDrag) {
isResizableHandleDraggingRef.current = true;
setMinMaxWidth(resizableLeftPanelElemRef.current);
setMinMaxWidth(resizableRightPanelElemRef.current);
} else {
isResizableHandleDraggingRef.current = false;
if (resizableLeftPanelAPIRef?.current?.isExpanded()) {
setMinMaxWidth(
resizableLeftPanelElemRef.current,
leftPanelExpandedWidth + sidePanelExpandedInsideBorderSize
);
}
if (resizableRightPanelAPIRef?.current?.isExpanded()) {
setMinMaxWidth(
resizableRightPanelElemRef.current,
rightPanelExpandedWidth + sidePanelExpandedInsideBorderSize
);
}
}
},
[leftPanelExpandedWidth, rightPanelExpandedWidth]
);
const onLeftPanelClose = useCallback(() => {
setLeftPanelClosed(true);
setMinMaxWidth(resizableLeftPanelElemRef.current);
resizableLeftPanelAPIRef?.current?.collapse();
}, [setLeftPanelClosed]);
const onLeftPanelOpen = useCallback(() => {
resizableLeftPanelAPIRef?.current?.expand();
if (!isResizableHandleDraggingRef.current) {
setMinMaxWidth(
resizableLeftPanelElemRef.current,
leftPanelExpandedWidth + sidePanelExpandedInsideBorderSize
);
}
setLeftPanelClosed(false);
}, [leftPanelExpandedWidth, setLeftPanelClosed]);
const onLeftPanelResize = useCallback(size => {
if (resizableLeftPanelAPIRef.current.isCollapsed()) {
return;
}
const { width: panelGroupWidth } = resizablePanelGroupElemRef.current.getBoundingClientRect();
setLeftPanelExpandedWidth((size / 100) * panelGroupWidth - sidePanelExpandedInsideBorderSize);
}, []);
const onRightPanelClose = useCallback(() => {
setRightPanelClosed(true);
setMinMaxWidth(resizableRightPanelElemRef.current);
resizableRightPanelAPIRef?.current?.collapse();
}, [setRightPanelClosed]);
const onRightPanelOpen = useCallback(() => {
resizableRightPanelAPIRef?.current?.expand();
if (!isResizableHandleDraggingRef.current) {
setMinMaxWidth(
resizableRightPanelElemRef.current,
rightPanelExpandedWidth + sidePanelExpandedInsideBorderSize
);
}
setRightPanelClosed(false);
}, [rightPanelExpandedWidth, setRightPanelClosed]);
const onRightPanelResize = useCallback(size => {
if (resizableRightPanelAPIRef?.current?.isCollapsed()) {
return;
}
const { width: panelGroupWidth } = resizablePanelGroupElemRef.current.getBoundingClientRect();
setRightPanelExpandedWidth((size / 100) * panelGroupWidth - sidePanelExpandedInsideBorderSize);
}, []);
return [
{
expandedWidth: leftPanelExpandedWidth,
collapsedWidth: sidePanelCollapsedWidth,
collapsedInsideBorderSize: sidePanelCollapsedInsideBorderSize,
collapsedOutsideBorderSize: sidePanelCollapsedOutsideBorderSize,
expandedInsideBorderSize: sidePanelExpandedInsideBorderSize,
onClose: onLeftPanelClose,
onOpen: onLeftPanelOpen,
},
{
expandedWidth: rightPanelExpandedWidth,
collapsedWidth: sidePanelCollapsedWidth,
collapsedInsideBorderSize: sidePanelCollapsedInsideBorderSize,
collapsedOutsideBorderSize: sidePanelCollapsedOutsideBorderSize,
expandedInsideBorderSize: sidePanelExpandedInsideBorderSize,
onClose: onRightPanelClose,
onOpen: onRightPanelOpen,
},
{ direction: 'horizontal', id: viewerLayoutResizablePanelGroupId },
{
defaultSize: resizablePanelDefaultSize,
minSize: resizablePanelDefaultSize,
onResize: onLeftPanelResize,
collapsible: true,
collapsedSize: resizablePanelCollapsedSize,
onCollapse: () => setLeftPanelClosed(true),
onExpand: () => setLeftPanelClosed(false),
ref: resizableLeftPanelAPIRef,
order: 0,
id: viewerLayoutResizableLeftPanelId,
},
{ order: 1, id: 'viewerLayoutResizableViewportGridPanel' },
{
defaultSize: resizablePanelDefaultSize,
minSize: resizablePanelDefaultSize,
onResize: onRightPanelResize,
collapsible: true,
collapsedSize: resizablePanelCollapsedSize,
onCollapse: () => setRightPanelClosed(true),
onExpand: () => setRightPanelClosed(false),
ref: resizableRightPanelAPIRef,
order: 2,
id: viewerLayoutResizableRightPanelId,
},
onHandleDragging,
];
};
export default useResizablePanels;
+69 -26
View File
@@ -6,7 +6,8 @@ import { HangingProtocolService, CommandsManager } from '@ohif/core';
import { useAppConfig } from '@state';
import ViewerHeader from './ViewerHeader';
import SidePanelWithServices from '../Components/SidePanelWithServices';
import { Onboarding } from '@ohif/ui-next';
import { Onboarding, ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@ohif/ui-next';
import useResizablePanels from './ResizablePanelsHook';
function ViewerLayout({
// From Extension Module Params
@@ -19,6 +20,8 @@ function ViewerLayout({
ViewportGridComp,
leftPanelClosed = false,
rightPanelClosed = false,
leftPanelResizable = false,
rightPanelResizable = false,
}: withAppTypes): React.FunctionComponent {
const [appConfig] = useAppConfig();
@@ -35,6 +38,21 @@ function ViewerLayout({
const [leftPanelClosedState, setLeftPanelClosed] = useState(leftPanelClosed);
const [rightPanelClosedState, setRightPanelClosed] = useState(rightPanelClosed);
const [
leftPanelProps,
rightPanelProps,
resizablePanelGroupProps,
resizableLeftPanelProps,
resizableViewportGridPanelProps,
resizableRightPanelProps,
onHandleDragging,
] = useResizablePanels(
leftPanelClosed,
setLeftPanelClosed,
rightPanelClosed,
setRightPanelClosed
);
/**
* Set body classes (tailwindcss) that don't allow vertical
* or horizontal overflow (no scrolling). Also guarantee window
@@ -123,31 +141,56 @@ function ViewerLayout({
>
<React.Fragment>
{showLoadingIndicator && <LoadingIndicatorProgress className="h-full w-full bg-black" />}
{/* LEFT SIDEPANELS */}
{hasLeftPanels ? (
<SidePanelWithServices
side="left"
activeTabIndex={leftPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
) : null}
{/* TOOLBAR + GRID */}
<div className="flex h-full flex-1 flex-col">
<div className="relative flex h-full flex-1 items-center justify-center overflow-hidden bg-black">
<ViewportGridComp
servicesManager={servicesManager}
viewportComponents={viewportComponents}
commandsManager={commandsManager}
/>
</div>
</div>
{hasRightPanels ? (
<SidePanelWithServices
side="right"
activeTabIndex={rightPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
) : null}
<ResizablePanelGroup {...resizablePanelGroupProps}>
{/* LEFT SIDEPANELS */}
{hasLeftPanels ? (
<>
<ResizablePanel {...resizableLeftPanelProps}>
<SidePanelWithServices
side="left"
isExpanded={!leftPanelClosedState}
servicesManager={servicesManager}
{...leftPanelProps}
/>
</ResizablePanel>
<ResizableHandle
onDragging={onHandleDragging}
disabled={!leftPanelResizable}
className="!w-0"
/>
</>
) : null}
{/* TOOLBAR + GRID */}
<ResizablePanel {...resizableViewportGridPanelProps}>
<div className="flex h-full flex-1 flex-col">
<div className="relative flex h-full flex-1 items-center justify-center overflow-hidden bg-black">
<ViewportGridComp
servicesManager={servicesManager}
viewportComponents={viewportComponents}
commandsManager={commandsManager}
/>
</div>
</div>
</ResizablePanel>
{hasRightPanels ? (
<>
<ResizableHandle
onDragging={onHandleDragging}
disabled={!rightPanelResizable}
className="!w-0"
/>
<ResizablePanel {...resizableRightPanelProps}>
<SidePanelWithServices
side="right"
isExpanded={!rightPanelClosedState}
servicesManager={servicesManager}
{...rightPanelProps}
/>
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</React.Fragment>
</div>
<Onboarding />