fix(segmentation): Add segment jump for new segments and make panels scrollable (#4928)
This commit is contained in:
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 };
|
||||
}
|
||||
Reference in new issue
Block a user