fix(segmentation): Add segment jump for new segments and make panels scrollable (#4928)

This commit is contained in:
Alireza authored and GitHub committed 2025-04-07 16:45:29 -04:00
1 parent a8ac297e16
commit 3653de7487
23 files changed
+491 -368

No files matched your search

@@ -9,6 +9,8 @@ import { Button } from '../Button';
import { Icons } from '../Icons/Icons';
import { DropdownMenu, DropdownMenuTrigger } from '../DropdownMenu';
import { Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip/Tooltip';
import { ScrollArea } from '../../components';
import { useDynamicMaxHeight } from '../../hooks/useDynamicMaxHeight';
// The Header container component
const SegmentationExpandedHeader = ({ children }: { children: React.ReactNode }) => {
@@ -94,33 +96,44 @@ const SegmentationExpandedRoot = ({ children }) => {
const { data, activeSegmentationId, onSegmentationClick, mode } =
useSegmentationTableContext('SegmentationExpanded');
const { ref: scrollableContainerRef, maxHeight } = useDynamicMaxHeight(data);
// Check if we should render based on mode
if (mode !== 'expanded' || !data || data.length === 0) {
return null;
}
return (
<div className={`space-y-0 pl-0.5`}>
{data.map(segmentationInfo => {
const isActive = segmentationInfo.segmentation.segmentationId === activeSegmentationId;
<ScrollArea
className={`bg-bkg-low space-y-px`}
showArrows={true}
>
<div
ref={scrollableContainerRef}
style={{ maxHeight: maxHeight }}
className={`space-y-0 pl-0.5`}
>
{data.map(segmentationInfo => {
const isActive = segmentationInfo.segmentation.segmentationId === activeSegmentationId;
return (
<PanelSection
key={segmentationInfo.segmentation.segmentationId}
className=""
>
<SegmentationExpandedProvider
segmentation={segmentationInfo.segmentation}
representation={segmentationInfo.representation}
isActive={isActive}
onSegmentationClick={onSegmentationClick}
return (
<PanelSection
key={segmentationInfo.segmentation.segmentationId}
className=""
>
{children}
</SegmentationExpandedProvider>
</PanelSection>
);
})}
</div>
<SegmentationExpandedProvider
segmentation={segmentationInfo.segmentation}
representation={segmentationInfo.representation}
isActive={isActive}
onSegmentationClick={onSegmentationClick}
>
{children}
</SegmentationExpandedProvider>
</PanelSection>
);
})}
</div>
</ScrollArea>
);
};
@@ -3,6 +3,7 @@ import { ScrollArea, DataRow } from '../../components';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '../../components/HoverCard';
import { useSegmentationTableContext, useSegmentationExpanded } from './contexts';
import { SegmentStatistics } from './SegmentStatistics';
import { useDynamicMaxHeight } from '../../hooks/useDynamicMaxHeight';
export const SegmentationSegments = ({ children = null }: { children?: React.ReactNode }) => {
const {
@@ -35,101 +36,108 @@ export const SegmentationSegments = ({ children = null }: { children?: React.Rea
representation = segmentationInfo?.representation;
}
const segments = Object.values(representation.segments);
const isActiveSegmentation = segmentation.segmentationId === activeSegmentationId;
const { ref: scrollableContainerRef, maxHeight } = useDynamicMaxHeight(segments);
if (!representation || !segmentation) {
return null;
}
const segments = Object.values(representation.segments);
const isActiveSegmentation = segmentation.segmentationId === activeSegmentationId;
return (
<ScrollArea
className={`ohif-scrollbar invisible-scrollbar bg-bkg-low max-h-80 space-y-px`}
className={`bg-bkg-low space-y-px`}
showArrows={true}
>
{segments.map(segment => {
if (!segment) {
return null;
}
const { segmentIndex, color, visible } = segment as {
segmentIndex: number;
color: number[];
visible: boolean;
};
const segmentFromSegmentation = segmentation.segments[segmentIndex];
<div
ref={scrollableContainerRef}
style={{ maxHeight: maxHeight }}
>
{segments.map(segment => {
if (!segment) {
return null;
}
const { segmentIndex, color, visible } = segment as {
segmentIndex: number;
color: number[];
visible: boolean;
};
const segmentFromSegmentation = segmentation.segments[segmentIndex];
if (!segmentFromSegmentation) {
return null;
}
if (!segmentFromSegmentation) {
return null;
}
const { locked, active, label, displayText } = segmentFromSegmentation;
const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`;
const { locked, active, label, displayText } = segmentFromSegmentation;
const cssColor = `rgb(${color[0]},${color[1]},${color[2]})`;
const hasStats = segmentFromSegmentation.cachedStats?.namedStats;
const DataRowComponent = (
<DataRow
key={segmentIndex}
number={segmentIndex}
title={label}
details={displayText}
description={displayText}
colorHex={cssColor}
isSelected={active}
isVisible={visible}
isLocked={locked}
disableEditing={disableEditing}
className={!isActiveSegmentation ? 'opacity-80' : ''}
onColor={() => onSegmentColorClick(segmentation.segmentationId, segmentIndex)}
onToggleVisibility={() =>
onToggleSegmentVisibility(
segmentation.segmentationId,
segmentIndex,
representation.type
)
}
onToggleLocked={() => onToggleSegmentLock(segmentation.segmentationId, segmentIndex)}
onSelect={() => onSegmentClick(segmentation.segmentationId, segmentIndex)}
onRename={() => onSegmentEdit(segmentation.segmentationId, segmentIndex)}
onDelete={() => onSegmentDelete(segmentation.segmentationId, segmentIndex)}
/>
);
return hasStats ? (
<HoverCard
key={`hover-${segmentIndex}`}
openDelay={300}
>
<HoverCardTrigger asChild>
<div>{DataRowComponent}</div>
</HoverCardTrigger>
<HoverCardContent
side="left"
align="start"
className="w-72 border"
>
<div className="mb-4 flex items-center space-x-2">
<div
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: cssColor }}
></div>
<h3 className="text-muted-foreground break-words font-semibold">{label}</h3>
</div>
<SegmentStatistics
segment={{
...segmentFromSegmentation,
const hasStats = segmentFromSegmentation.cachedStats?.namedStats;
const DataRowComponent = (
<DataRow
key={segmentIndex}
number={segmentIndex}
title={label}
// details={displayText}
description={displayText}
colorHex={cssColor}
isSelected={active}
isVisible={visible}
isLocked={locked}
disableEditing={disableEditing}
className={!isActiveSegmentation ? 'opacity-80' : ''}
onColor={() => onSegmentColorClick(segmentation.segmentationId, segmentIndex)}
onToggleVisibility={() =>
onToggleSegmentVisibility(
segmentation.segmentationId,
segmentIndex,
}}
segmentationId={segmentation.segmentationId}
representation.type
)
}
onToggleLocked={() => onToggleSegmentLock(segmentation.segmentationId, segmentIndex)}
onSelect={() => onSegmentClick(segmentation.segmentationId, segmentIndex)}
onRename={() => onSegmentEdit(segmentation.segmentationId, segmentIndex)}
onDelete={() => onSegmentDelete(segmentation.segmentationId, segmentIndex)}
/>
);
return hasStats ? (
<HoverCard
key={`hover-${segmentIndex}`}
openDelay={300}
>
<HoverCardTrigger asChild>
<div>{DataRowComponent}</div>
</HoverCardTrigger>
<HoverCardContent
side="left"
align="start"
className="w-72 border"
>
{children}
</SegmentStatistics>
</HoverCardContent>
</HoverCard>
) : (
DataRowComponent
);
})}
<div className="mb-4 flex items-center space-x-2">
<div
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: cssColor }}
></div>
<h3 className="text-muted-foreground break-words font-semibold">{label}</h3>
</div>
<SegmentStatistics
segment={{
...segmentFromSegmentation,
segmentIndex,
}}
segmentationId={segmentation.segmentationId}
>
{children}
</SegmentStatistics>
</HoverCardContent>
</HoverCard>
) : (
DataRowComponent
);
})}
</div>
</ScrollArea>
);
};
@@ -2,6 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Thumbnail } from '../Thumbnail';
import { useDynamicMaxHeight } from '../../hooks/useDynamicMaxHeight';
import { ScrollArea } from '../ScrollArea';
const ThumbnailList = ({
thumbnails,
@@ -12,6 +14,9 @@ const ThumbnailList = ({
viewPreset,
ThumbnailMenuItems,
}) => {
// Use the dynamic height hook on the parent container
const { ref, maxHeight } = useDynamicMaxHeight(thumbnails);
// Filter thumbnails into list items and thumbnail items
const listItems = thumbnails?.filter(
({ componentType }) => componentType === 'thumbnailNoImage' || viewPreset === 'list'
@@ -22,63 +27,68 @@ const ThumbnailList = ({
);
return (
<div className="flex flex-col gap-[4px] pt-[4px] pr-[2.5px] pl-[5px] pb-[4px]">
{/* Thumbnail Items */}
{thumbnailItems.length > 0 && (
<div
id="ohif-thumbnail-list"
className="ohif-scrollbar bg-bkg-low grid grid-cols-[repeat(auto-fit,_minmax(0,135px))] place-items-start gap-[4px] overflow-y-hidden"
>
{thumbnailItems.map(item => {
const { displaySetInstanceUID, componentType, numInstances, ...rest } = item;
<ScrollArea className="flex flex-col gap-[4px] pt-[4px] pr-[2.5px] pl-[5px] pb-[4px]">
<div
ref={ref}
style={{ maxHeight: maxHeight, minHeight: '300px' }}
className="flex flex-col gap-[4px] pt-[4px] pr-[2.5px] pl-[5px] pb-[4px]"
>
{thumbnailItems.length > 0 && (
<div
id="ohif-thumbnail-list"
className="bg-bkg-low grid grid-cols-[repeat(auto-fit,_minmax(0,135px))] place-items-start gap-[4px]"
>
{thumbnailItems.map(item => {
const { displaySetInstanceUID, componentType, numInstances, ...rest } = item;
const isActive = activeDisplaySetInstanceUIDs.includes(displaySetInstanceUID);
return (
<Thumbnail
key={displaySetInstanceUID}
{...rest}
displaySetInstanceUID={displaySetInstanceUID}
numInstances={numInstances || 1}
isActive={isActive}
thumbnailType={componentType}
viewPreset="thumbnails"
onClick={onThumbnailClick.bind(null, displaySetInstanceUID)}
onDoubleClick={onThumbnailDoubleClick.bind(null, displaySetInstanceUID)}
onClickUntrack={onClickUntrack.bind(null, displaySetInstanceUID)}
ThumbnailMenuItems={ThumbnailMenuItems}
/>
);
})}
</div>
)}
{/* List Items */}
{listItems.length > 0 && (
<div
id="ohif-thumbnail-list"
className="ohif-scrollbar bg-bkg-low grid grid-cols-[repeat(auto-fit,_minmax(0,275px))] place-items-start gap-[2px] overflow-y-hidden"
>
{listItems.map(item => {
const { displaySetInstanceUID, componentType, numInstances, ...rest } = item;
const isActive = activeDisplaySetInstanceUIDs.includes(displaySetInstanceUID);
return (
<Thumbnail
key={displaySetInstanceUID}
{...rest}
displaySetInstanceUID={displaySetInstanceUID}
numInstances={numInstances || 1}
isActive={isActive}
thumbnailType={componentType}
viewPreset="list"
onClick={onThumbnailClick.bind(null, displaySetInstanceUID)}
onDoubleClick={onThumbnailDoubleClick.bind(null, displaySetInstanceUID)}
onClickUntrack={onClickUntrack.bind(null, displaySetInstanceUID)}
ThumbnailMenuItems={ThumbnailMenuItems}
/>
);
})}
</div>
)}
</div>
const isActive = activeDisplaySetInstanceUIDs.includes(displaySetInstanceUID);
return (
<Thumbnail
key={displaySetInstanceUID}
{...rest}
displaySetInstanceUID={displaySetInstanceUID}
numInstances={numInstances || 1}
isActive={isActive}
thumbnailType={componentType}
viewPreset="thumbnails"
onClick={onThumbnailClick.bind(null, displaySetInstanceUID)}
onDoubleClick={onThumbnailDoubleClick.bind(null, displaySetInstanceUID)}
onClickUntrack={onClickUntrack.bind(null, displaySetInstanceUID)}
ThumbnailMenuItems={ThumbnailMenuItems}
/>
);
})}
</div>
)}
{/* List Items */}
{listItems.length > 0 && (
<div
id="ohif-thumbnail-list"
className="bg-bkg-low grid grid-cols-[repeat(auto-fit,_minmax(0,275px))] place-items-start gap-[2px]"
>
{listItems.map(item => {
const { displaySetInstanceUID, componentType, numInstances, ...rest } = item;
const isActive = activeDisplaySetInstanceUIDs.includes(displaySetInstanceUID);
return (
<Thumbnail
key={displaySetInstanceUID}
{...rest}
displaySetInstanceUID={displaySetInstanceUID}
numInstances={numInstances || 1}
isActive={isActive}
thumbnailType={componentType}
viewPreset="list"
onClick={onThumbnailClick.bind(null, displaySetInstanceUID)}
onDoubleClick={onThumbnailDoubleClick.bind(null, displaySetInstanceUID)}
onClickUntrack={onClickUntrack.bind(null, displaySetInstanceUID)}
ThumbnailMenuItems={ThumbnailMenuItems}
/>
);
})}
</div>
)}
</div>
</ScrollArea>
);
};
@@ -0,0 +1,50 @@
import { useRef, useState, useEffect, RefObject } from 'react';
/**
* Calculates the maximum height for an element based on its position
* relative to the bottom of the viewport.
*
* @param data The data that, when changed, should trigger a recalculation.
* @param buffer Optional buffer space (in pixels) to leave below the element. Defaults to 20.
* @param minHeight Optional minimum height (in pixels) for the element. Defaults to 100.
* @returns An object containing:
* - `ref`: A RefObject to attach to the target DOM element.
* - `maxHeight`: The calculated maximum height string (e.g., "500px").
*/
export function useDynamicMaxHeight(
data: any,
buffer = 20,
minHeight = 100
): {
ref: RefObject<HTMLDivElement>;
maxHeight: string;
} {
const ref = useRef<HTMLDivElement>(null);
const [maxHeight, setMaxHeight] = useState<string>('100vh'); // Start with full viewport height initially
useEffect(() => {
const calculateMaxHeight = () => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
const availableHeight = window.innerHeight - rect.top - buffer;
setMaxHeight(`${Math.max(minHeight, availableHeight)}px`);
}
};
// Calculate initially
// Use requestAnimationFrame to ensure layout is stable after initial render
const rafId = requestAnimationFrame(calculateMaxHeight);
// Recalculate on window resize
window.addEventListener('resize', calculateMaxHeight);
// Cleanup listener and requestAnimationFrame on component unmount
return () => {
window.removeEventListener('resize', calculateMaxHeight);
cancelAnimationFrame(rafId);
};
// Dependencies: buffer, minHeight, and data.
}, [data, buffer, minHeight]);
return { ref, maxHeight };
}