feat(app): Decouple colorbar UI from service and enable action menu programatic control (#5018)

This commit is contained in:
Alireza 2025-05-08 12:51:30 -04:00 committed by GitHub
parent f14d9ebdd7
commit 1445e12124
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1872 additions and 659 deletions

View File

@ -13,11 +13,11 @@ import CinePlayer from '../components/CinePlayer';
import type { Types } from '@ohif/core';
import OHIFViewportActionCorners from '../components/OHIFViewportActionCorners';
import ViewportColorbarsContainer from '../components/ViewportColorbar';
import { getViewportPresentations } from '../utils/presentations/getViewportPresentations';
import { useSynchronizersStore } from '../stores/useSynchronizersStore';
import ActiveViewportBehavior from '../utils/ActiveViewportBehavior';
import { WITH_NAVIGATION } from '../services/ViewportService/CornerstoneViewportService';
import { useViewportActionCorners } from '../hooks';
const STACK = 'stack';
@ -38,7 +38,6 @@ const OHIFCornerstoneViewport = React.memo(
viewportOptions,
displaySetOptions,
servicesManager,
commandsManager,
onElementEnabled,
// eslint-disable-next-line react/prop-types
onElementDisabled,
@ -319,16 +318,6 @@ const OHIFCornerstoneViewport = React.memo(
};
}, [displaySets, elementRef, viewportId, isJumpToMeasurementDisabled, servicesManager]);
// Set up the window level action menu in the viewport action corners using the custom hook
useViewportActionCorners({
viewportId,
elementRef,
displaySets,
viewportActionCornersService,
customizationService,
commandsManager,
});
const Notification = customizationService.getCustomization('ui.notificationComponent');
return (
@ -357,6 +346,10 @@ const OHIFCornerstoneViewport = React.memo(
viewportId={viewportId}
servicesManager={servicesManager}
/>
<ViewportColorbarsContainer
viewportId={viewportId}
viewportElementRef={elementRef}
/>
</div>
{/* top offset of 24px to account for ViewportActionCorners. */}
<div className="absolute top-[24px] w-full">

View File

@ -1,31 +1,54 @@
import React from 'react';
import { useViewportActionCornersContext } from '../contextProviders/ViewportActionCornersProvider';
import { useSystem } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui-next';
import {
useViewportActionCorners,
ViewportActionCorners,
ViewportActionCornersLocations,
} from '@ohif/ui-next';
export type OHIFViewportActionCornersProps = {
viewportId: string;
};
function OHIFViewportActionCorners({ viewportId }: OHIFViewportActionCornersProps) {
const { servicesManager } = useSystem();
const [viewportActionCornersState] = useViewportActionCornersContext();
const [state] = useViewportActionCorners();
const [viewportGrid] = useViewportGrid();
const isActiveViewport = viewportGrid.activeViewportId === viewportId;
const ViewportActionCorners =
servicesManager.services.customizationService.getCustomization('ui.viewportActionCorner');
if (!viewportActionCornersState[viewportId]) {
if (!state.viewports[viewportId]) {
return null;
}
const components = state.viewports[viewportId];
const renderCorner = (location: ViewportActionCornersLocations, CornerComponent) => {
const cornerComponents = components[location];
if (!cornerComponents?.length) {
return null;
}
return (
<CornerComponent>
{cornerComponents
.filter(componentInfo => componentInfo.isVisible !== false)
.map(componentInfo => (
<div
key={componentInfo.id}
className={
componentInfo.isLocked === true ? 'pointer-events-none opacity-50' : undefined
}
>
{componentInfo.component}
</div>
))}
</CornerComponent>
);
};
return (
<ViewportActionCorners
cornerComponents={viewportActionCornersState[viewportId]}
isActiveViewport={isActiveViewport}
/>
<ViewportActionCorners.Container>
{renderCorner(ViewportActionCornersLocations.topLeft, ViewportActionCorners.TopLeft)}
{renderCorner(ViewportActionCornersLocations.topRight, ViewportActionCorners.TopRight)}
{renderCorner(ViewportActionCornersLocations.bottomLeft, ViewportActionCorners.BottomLeft)}
{renderCorner(ViewportActionCornersLocations.bottomRight, ViewportActionCorners.BottomRight)}
</ViewportActionCorners.Container>
);
}

View File

@ -0,0 +1,117 @@
import React from 'react';
import { Button, Icons } from '@ohif/ui-next';
import ViewportColorbar from './ViewportColorbar';
import {
ColorbarCustomization,
ColorbarPositionType,
TickPositionType,
} from '../../types/Colorbar';
import type { ColorMapPreset } from '../../types/Colormap';
type ColorbarData = {
colorbar: {
activeColormapName: string;
colormaps: ColorMapPreset[];
volumeId?: string;
};
displaySetInstanceUID: string;
};
type AdvancedColorbarWithControlsProps = {
viewportId: string;
colorbars: ColorbarData[];
position: string;
tickPosition: string;
colorbarCustomization: ColorbarCustomization;
onClose: (displaySetInstanceUID?: string) => void;
viewportElementRef?: React.RefObject<HTMLDivElement>;
};
/**
* AdvancedColorbarWithControls Component
* A specialized colorbar component with additional control buttons for advanced window level controls
* This component handles the rendering portion of the ViewportColorbarsContainer
*/
const AdvancedColorbarWithControls = ({
viewportId,
colorbars,
position,
tickPosition,
colorbarCustomization,
onClose,
viewportElementRef,
}: AdvancedColorbarWithControlsProps) => {
const handleClose = (displaySetInstanceUID?: string) => {
onClose(displaySetInstanceUID);
};
// Get bottom position styles from customization
const positionStyles = colorbarCustomization?.positionStyles || {};
const bottomPositionStyles = positionStyles.bottom || {};
const heightStyle = bottomPositionStyles.height;
return (
<div className="mx-auto flex h-[20px] w-1/2 flex-row items-center justify-between">
<div className="flex flex-shrink-0 flex-row">
<Button
size="icon"
variant="ghost"
onClick={() => handleClose()}
className="flex-shrink-0 p-0"
>
<Icons.Close />
</Button>
<Button
size="icon"
variant="ghost"
className="flex-shrink-0 p-0"
>
<Icons.ToolZoom />
</Button>
</div>
<div
className="mx-2"
style={{ width: 'calc(75% - 16px)', height: heightStyle }}
>
{colorbars.map((colorbarInfo, index) => {
const { colorbar, displaySetInstanceUID } = colorbarInfo;
return (
<ViewportColorbar
key={`colorbar-${viewportId}-${displaySetInstanceUID}`}
viewportId={viewportId}
viewportElementRef={viewportElementRef}
displaySetInstanceUID={displaySetInstanceUID}
colormaps={colorbar.colormaps}
activeColormapName={colorbar.activeColormapName}
volumeId={colorbar.volumeId}
position={position as ColorbarPositionType}
tickPosition={tickPosition as TickPositionType}
tickStyles={colorbarCustomization?.tickStyles}
/>
);
})}
</div>
<div className="flex flex-shrink-0 flex-row">
<Button
size="icon"
variant="ghost"
className="flex-shrink-0 p-0"
>
<Icons.Redo />
</Button>
<Button
size="icon"
variant="ghost"
className="flex-shrink-0 p-0"
>
<Icons.Pencil />
</Button>
</div>
</div>
);
};
export default AdvancedColorbarWithControls;

View File

@ -0,0 +1,126 @@
import React, { useEffect, useRef } from 'react';
import { utilities } from '@cornerstonejs/tools';
import { useSystem } from '@ohif/core';
import {
ColorbarPositionType,
TickPositionType,
ColorbarCustomization,
TickStyleType,
ContainerStyleType,
} from '../../types/Colorbar';
import { ColorbarRangeTextPosition } from '@cornerstonejs/tools/utilities/voi/colorbar/enums/ColorbarRangeTextPosition';
const { ViewportColorbar: CornerstoneViewportColorbar } = utilities.voi.colorbar;
type ColorbarProps = {
viewportId: string;
displaySetInstanceUID: string;
colormap?: any;
colormaps: any[];
activeColormapName: string;
volumeId?: string;
position: ColorbarPositionType;
tickPosition: TickPositionType;
tickStyles?: TickStyleType;
containerStyles?: ContainerStyleType;
viewportElementRef?: React.RefObject<HTMLDivElement>;
};
/**
* ViewportColorbar Component
* A React wrapper for the cornerstone ViewportColorbar that adds a close button
* positioned appropriately based on the colorbar position.
*/
const ViewportColorbar = ({
viewportId,
displaySetInstanceUID,
colormaps,
activeColormapName,
volumeId,
position,
tickPosition,
tickStyles,
viewportElementRef,
}: ColorbarProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { servicesManager } = useSystem();
const { customizationService } = servicesManager.services;
useEffect(() => {
if (!containerRef.current || !colormaps || !activeColormapName) {
return;
}
const viewportElement = viewportElementRef?.current;
if (!viewportElement) {
return;
}
const colorbarCustomization = customizationService.getCustomization(
'cornerstone.colorbar'
) as unknown as ColorbarCustomization;
const positionTickStyles = colorbarCustomization?.positionTickStyles?.[position];
const csColorbar = new CornerstoneViewportColorbar({
id: `Colorbar-${viewportId}-${displaySetInstanceUID}`,
element: viewportElement,
container: containerRef.current,
colormaps: colormaps,
activeColormapName: activeColormapName,
volumeId,
ticks: {
position: tickPosition as ColorbarRangeTextPosition,
style: {
...(colorbarCustomization?.tickStyles || {}),
...(positionTickStyles?.style || {}),
...(tickStyles || {}),
},
},
});
return () => {
if (csColorbar) {
csColorbar.destroy();
}
};
}, [
viewportId,
displaySetInstanceUID,
colormaps,
activeColormapName,
volumeId,
position,
tickPosition,
tickStyles,
viewportElementRef,
customizationService,
]);
// Get position styles from customization service
const colorbarCustomization = customizationService.getCustomization(
'cornerstone.colorbar'
) as unknown as ColorbarCustomization;
const positionStylesFromConfig = colorbarCustomization?.positionStyles?.[position] || {};
return (
<div
id={`colorbar-container-${viewportId}-${displaySetInstanceUID}`}
ref={containerRef}
style={{
width: position === 'bottom' ? '100%' : '20px',
height: position === 'bottom' ? '20px' : '100%',
position: 'relative',
zIndex: 1000,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
pointerEvents: 'auto',
...positionStylesFromConfig,
}}
></div>
);
};
export default ViewportColorbar;

View File

@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import { useSystem } from '@ohif/core';
import AdvancedColorbarWithControls from './AdvancedColorbarWithControls';
import { ColorbarCustomization } from '../../types/Colorbar';
import type { ColorMapPreset } from '../../types/Colormap';
import ViewportColorbar from './ViewportColorbar';
import { deepMerge } from '@cornerstonejs/core/utilities';
type ViewportColorbarsContainerProps = {
viewportId: string;
viewportElementRef?: React.RefObject<HTMLDivElement>;
};
type ColorbarData = {
colorbar: {
activeColormapName: string;
colormaps: ColorMapPreset[];
volumeId?: string;
};
displaySetInstanceUID: string;
};
/**
* Container component that manages multiple colorbars for a viewport
* It interacts with the colorbarService to get/set colorbar states
*/
const ViewportColorbarsContainer = ({
viewportId,
viewportElementRef,
}: ViewportColorbarsContainerProps) => {
const [colorbars, setColorbars] = useState<ColorbarData[]>([]);
const { servicesManager } = useSystem();
const { colorbarService, customizationService } = servicesManager.services;
useEffect(() => {
setColorbars(colorbarService.getViewportColorbar(viewportId) || []);
}, [viewportId, colorbarService]);
useEffect(() => {
const { unsubscribe } = colorbarService.subscribe(
colorbarService.EVENTS.STATE_CHANGED,
(event: { viewportId: string; displaySetInstanceUID?: string; changeType: string }) => {
if (event.viewportId === viewportId) {
setColorbars(colorbarService.getViewportColorbar(viewportId) || []);
}
}
);
return () => {
unsubscribe();
};
}, [viewportId, colorbarService]);
const handleClose = (displaySetInstanceUID?: string): void => {
if (displaySetInstanceUID) {
colorbarService.removeColorbar(viewportId, displaySetInstanceUID);
} else {
colorbarService.removeColorbar(viewportId);
}
};
if (!colorbars.length) {
return null;
}
const colorbarCustomization = customizationService.getCustomization(
'cornerstone.colorbar'
) as unknown as ColorbarCustomization;
const defaultPosition = colorbarCustomization?.colorbarContainerPosition;
const defaultTickPosition = colorbarCustomization?.colorbarTickPosition;
const position = colorbarCustomization?.colorbarContainerPosition || defaultPosition;
const tickPosition = colorbarCustomization?.colorbarTickPosition || defaultTickPosition;
const positionStyles = colorbarCustomization?.positionStyles;
const positionStyle = positionStyles?.[position];
const defaultPositionStyle =
position !== 'bottom'
? {
width: '20px',
height: colorbars.length === 1 ? '55%' : '75%',
top: '50%',
transform: 'translateY(-50%)',
}
: {
width: '100%',
height: '20px',
};
const finalPositionStyle = deepMerge(defaultPositionStyle, positionStyle);
return (
<div
className="absolute"
style={{
...finalPositionStyle,
pointerEvents: 'auto',
}}
>
{position !== 'bottom' ? (
<div
className="flex h-full flex-col items-center justify-center"
style={{ pointerEvents: 'auto' }}
>
{colorbars.map((colorbarInfo, index) => {
const { colorbar, displaySetInstanceUID } = colorbarInfo;
return (
<ViewportColorbar
key={`colorbar-${viewportId}-${displaySetInstanceUID}`}
viewportId={viewportId}
displaySetInstanceUID={displaySetInstanceUID}
colormaps={colorbar.colormaps}
activeColormapName={colorbar.activeColormapName}
volumeId={colorbar.volumeId}
viewportElementRef={viewportElementRef}
position={position}
tickPosition={tickPosition}
tickStyles={colorbarCustomization?.tickStyles}
/>
);
})}
</div>
) : (
<AdvancedColorbarWithControls
viewportId={viewportId}
colorbars={colorbars}
position={position}
tickPosition={defaultTickPosition}
colorbarCustomization={colorbarCustomization}
onClose={handleClose}
viewportElementRef={viewportElementRef}
/>
)}
</div>
);
};
export default ViewportColorbarsContainer;

View File

@ -0,0 +1,6 @@
import ViewportColorbar from './ViewportColorbar';
import ViewportColorbarsContainer from './ViewportColorbarsContainer';
import AdvancedColorbarWithControls from './AdvancedColorbarWithControls';
export { ViewportColorbar, ViewportColorbarsContainer, AdvancedColorbarWithControls };
export default ViewportColorbarsContainer;

View File

@ -7,10 +7,11 @@ import {
PopoverContent,
PopoverTrigger,
useViewportGrid,
useViewportActionCorners,
} from '@ohif/ui-next';
import ViewportDataOverlayMenu from './ViewportDataOverlayMenu';
import classNames from 'classnames';
import { useSystem } from '@ohif/core';
import { MENU_IDS } from '../menus/menu-ids';
export function ViewportDataOverlayMenuWrapper({
viewportId,
@ -19,15 +20,39 @@ export function ViewportDataOverlayMenuWrapper({
}: withAppTypes<{
viewportId: string;
element: HTMLElement;
location: string;
}>): ReactNode {
const { servicesManager } = useSystem();
const { viewportActionCornersService } = servicesManager.services;
const [viewportGrid] = useViewportGrid();
const [actionCornerState, viewportActionCornersAPI] = useViewportActionCorners();
const { align, side } = viewportActionCornersService.getAlignAndSide(location);
const isMenuOpen =
actionCornerState.viewports[viewportId]?.[location]?.find(
item => item.id === MENU_IDS.DATA_OVERLAY_MENU
)?.isOpen ?? false;
const handleOpenChange = (openState: boolean) => {
if (openState) {
viewportActionCornersAPI.openItem?.(viewportId, MENU_IDS.DATA_OVERLAY_MENU);
} else {
viewportActionCornersAPI.closeItem?.(viewportId, MENU_IDS.DATA_OVERLAY_MENU);
}
};
// Get proper alignment and side based on the location
let align = 'center';
let side = 'bottom';
if (location !== undefined) {
const positioning = viewportActionCornersAPI.getAlignAndSide(location);
align = positioning.align;
side = positioning.side;
}
return (
<Popover>
<Popover
open={isMenuOpen}
onOpenChange={handleOpenChange}
>
<PopoverTrigger
asChild
className="flex items-center justify-center"

View File

@ -5,6 +5,7 @@ export function getViewportDataOverlaySettingsMenu(
props: withAppTypes<{
viewportId: string;
element: HTMLElement;
location: string;
}>
): ReactNode {
return <ViewportDataOverlayMenuWrapper {...props} />;

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Icons, useViewportGrid } from '@ohif/ui-next';
import { Icons, useViewportActionCorners, useViewportGrid } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
import { Enums } from '@cornerstonejs/core';
import {
@ -10,14 +10,21 @@ import {
DropdownMenuLabel,
Button,
} from '@ohif/ui-next';
import { MENU_IDS } from '../menus/menu-ids';
function ViewportOrientationMenu({ location }: withAppTypes<{ location?: string }>) {
const { servicesManager, commandsManager } = useSystem();
const [viewportGridState, viewportGridService] = useViewportGrid();
const [actionCornerState, viewportActionCornersServiceAPI] = useViewportActionCorners();
const { cornerstoneViewportService, displaySetService } = servicesManager.services;
const viewportId = viewportGridState.activeViewportId;
const isMenuOpen =
actionCornerState.viewports[viewportId]?.[location]?.find(
item => item.id === MENU_IDS.ORIENTATION_MENU
)?.isOpen ?? false;
const handleOrientationChange = (orientation: string) => {
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
const currentViewportType = viewportInfo?.getViewportType();
@ -80,8 +87,19 @@ function ViewportOrientationMenu({ location }: withAppTypes<{ location?: string
orientation: orientationEnum,
});
}
// Close the menu after selection
viewportActionCornersServiceAPI.closeItem?.(viewportId, MENU_IDS.ORIENTATION_MENU);
};
const { viewportActionCornersService } = servicesManager.services;
const handleOpenChange = (openState: boolean) => {
if (openState) {
viewportActionCornersServiceAPI.openItem?.(viewportId, MENU_IDS.ORIENTATION_MENU);
} else {
viewportActionCornersServiceAPI.closeItem?.(viewportId, MENU_IDS.ORIENTATION_MENU);
}
};
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
const displaySets = displaySetUIDs
.map(uid => displaySetService.getDisplaySetByUID(uid))
@ -98,13 +116,16 @@ function ViewportOrientationMenu({ location }: withAppTypes<{ location?: string
let side = 'bottom';
if (location !== undefined) {
const positioning = viewportActionCornersService.getAlignAndSide(location);
const positioning = viewportActionCornersServiceAPI.getAlignAndSide(location);
align = positioning.align;
side = positioning.side;
}
return (
<DropdownMenu>
<DropdownMenu
open={isMenuOpen}
onOpenChange={handleOpenChange}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"

View File

@ -1,15 +1,14 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { Switch } from '@ohif/ui-next';
import { StackViewport } from '@cornerstonejs/core';
import { ColorbarProps } from '../../types/Colorbar';
import { ColorbarProps, ColorbarOptions } from '../../types/Colorbar';
import { utilities } from '@cornerstonejs/core';
import { useSystem } from '@ohif/core';
export function setViewportColorbar(
viewportId,
commandsManager,
viewportId: string,
commandsManager: any,
servicesManager: AppTypes.ServicesManager,
colorbarOptions
colorbarOptions: Partial<ColorbarOptions>
) {
const { cornerstoneViewportService, viewportGridService } = servicesManager.services;
const displaySetInstanceUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);

View File

@ -1,7 +1,6 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { AllInOneMenu } from '@ohif/ui-next';
import { AllInOneMenu, cn } from '@ohif/ui-next';
import { useViewportGrid } from '@ohif/ui-next';
import { Colormap } from './Colormap';
import { Colorbar } from './Colorbar';
@ -25,6 +24,7 @@ export type WindowLevelActionMenuProps = {
displaySets: Array<any>;
volumeRenderingPresets: Array<ViewportPreset>;
volumeRenderingQualityRange: VolumeRenderingQualityRange;
location: string;
};
export function WindowLevelActionMenu({
@ -111,20 +111,13 @@ export function WindowLevelActionMenu({
]);
return (
<AllInOneMenu.IconMenu
icon="viewport-window-level"
verticalDirection={verticalDirection}
horizontalDirection={horizontalDirection}
iconClassName={classNames(
activeViewportId === viewportId ? 'visible' : 'invisible group-hover/pane:visible',
'flex shrink-0 cursor-pointer rounded active:text-foreground text-highlight',
isLight ? ' hover:bg-primary/30' : 'hover:bg-primary/30'
)}
menuStyle={{ maxHeight: vpHeight - 32, minWidth: 218 }}
onVisibilityChange={() => {
setVpHeight(element.clientHeight);
}}
<AllInOneMenu.Menu
menuKey={menuKey}
key={menuKey}
// the visibility is handled by the parent component
isVisible={true}
horizontalDirection={horizontalDirection}
verticalDirection={verticalDirection}
>
<AllInOneMenu.ItemPanel>
{!is3DVolume && (
@ -180,6 +173,6 @@ export function WindowLevelActionMenu({
</AllInOneMenu.SubMenu>
)}
</AllInOneMenu.ItemPanel>
</AllInOneMenu.IconMenu>
</AllInOneMenu.Menu>
);
}

View File

@ -0,0 +1,138 @@
import React, { ReactNode } from 'react';
import { useSystem } from '@ohif/core';
import {
Button,
cn,
Icons,
Popover,
PopoverContent,
PopoverTrigger,
useViewportGrid,
useViewportActionCorners,
AllInOneMenu,
} from '@ohif/ui-next';
import { WindowLevelActionMenu } from './WindowLevelActionMenu';
import { MENU_IDS } from '../menus/menu-ids';
export function WindowLevelActionMenuWrapper({
viewportId,
element,
location,
displaySets,
}: withAppTypes<{
viewportId: string;
element: HTMLElement;
location: string;
displaySets: Array<AppTypes.DisplaySet>;
}>): ReactNode {
const [viewportGrid] = useViewportGrid();
const [actionCornerState, viewportActionCornersAPI] = useViewportActionCorners();
const isActiveViewport = viewportId === viewportGrid.activeViewportId;
const { servicesManager } = useSystem();
const { customizationService } = servicesManager.services;
const presets = customizationService.getCustomization('cornerstone.windowLevelPresets');
const colorbarProperties = customizationService.getCustomization('cornerstone.colorbar');
const { volumeRenderingPresets, volumeRenderingQualityRange } =
customizationService.getCustomization('cornerstone.3dVolumeRendering');
const isMenuOpen =
actionCornerState.viewports[viewportId]?.[location]?.find(
item => item.id === MENU_IDS.WINDOW_LEVEL_MENU
)?.isOpen ?? false;
const handleOpenChange = (openState: boolean) => {
if (openState) {
viewportActionCornersAPI.openItem?.(viewportId, MENU_IDS.WINDOW_LEVEL_MENU);
} else {
viewportActionCornersAPI.closeItem?.(viewportId, MENU_IDS.WINDOW_LEVEL_MENU);
}
};
const { align, side, horizontalDirection, verticalDirection } = getMenuDirections(
location,
viewportActionCornersAPI
);
const displaySetPresets = displaySets
.filter(displaySet => presets[displaySet.Modality])
.map(displaySet => {
return { [displaySet.Modality]: presets[displaySet.Modality] };
});
const modalities = displaySets.map(displaySet => displaySet.supportsWindowLevel);
if (modalities.length === 0) {
return null;
}
return (
<Popover
open={isMenuOpen}
onOpenChange={handleOpenChange}
>
<PopoverTrigger
asChild
className="flex items-center justify-center"
>
<Button
variant="ghost"
size="icon"
className={cn(
isActiveViewport ? 'visible' : 'invisible group-hover/pane:visible',
'text-highlight'
)}
>
<Icons.ByName name="viewport-window-level" />
</Button>
</PopoverTrigger>
<PopoverContent
className="border-none bg-transparent p-0 shadow-none"
side={side}
align={align}
alignOffset={0}
sideOffset={5}
>
<WindowLevelActionMenu
viewportId={viewportId}
element={element}
presets={displaySetPresets}
horizontalDirection={horizontalDirection}
verticalDirection={verticalDirection}
colorbarProperties={colorbarProperties}
displaySets={displaySets}
volumeRenderingPresets={volumeRenderingPresets}
volumeRenderingQualityRange={volumeRenderingQualityRange}
/>
</PopoverContent>
</Popover>
);
}
const getMenuDirections = (location, viewportActionCornersAPI) => {
let align = 'center';
let side = 'bottom';
if (location !== undefined) {
const positioning = viewportActionCornersAPI.getAlignAndSide(location);
align = positioning.align;
side = positioning.side;
}
let horizontalDirection;
let verticalDirection;
if (side === 'bottom') {
verticalDirection = AllInOneMenu.VerticalDirection.TopToBottom;
} else {
verticalDirection = AllInOneMenu.VerticalDirection.BottomToTop;
}
if (align === 'start') {
horizontalDirection = AllInOneMenu.HorizontalDirection.LeftToRight;
} else {
horizontalDirection = AllInOneMenu.HorizontalDirection.RightToLeft;
}
return { align, side, horizontalDirection, verticalDirection };
};

View File

@ -1,53 +1,13 @@
import React, { ReactNode } from 'react';
import { WindowLevelActionMenuProps } from './WindowLevelActionMenu';
import { useSystem } from '@ohif/core';
import { WindowLevelActionMenu as WindowLevelActionMenuComponent } from './WindowLevelActionMenu';
import React from 'react';
import { WindowLevelActionMenuWrapper } from './WindowLevelActionMenuWrapper';
function WindowLevelActionMenu({
viewportId,
element,
displaySets,
verticalDirection,
horizontalDirection,
}: withAppTypes<{
viewportId: string;
element: HTMLElement;
displaySets: AppTypes.DisplaySet[];
}>): ReactNode {
const { servicesManager } = useSystem();
const { customizationService } = servicesManager.services;
const presets = customizationService.getCustomization('cornerstone.windowLevelPresets');
const colorbarProperties = customizationService.getCustomization('cornerstone.colorbar');
const { volumeRenderingPresets, volumeRenderingQualityRange } =
customizationService.getCustomization('cornerstone.3dVolumeRendering');
const displaySetPresets = displaySets
.filter(displaySet => presets[displaySet.Modality])
.map(displaySet => {
return { [displaySet.Modality]: presets[displaySet.Modality] };
});
const modalities = displaySets.map(displaySet => displaySet.supportsWindowLevel);
if (modalities.length === 0) {
return null;
}
return (
<WindowLevelActionMenuComponent
viewportId={viewportId}
element={element}
presets={displaySetPresets}
verticalDirection={verticalDirection}
horizontalDirection={horizontalDirection}
colorbarProperties={colorbarProperties}
displaySets={displaySets}
volumeRenderingPresets={volumeRenderingPresets}
volumeRenderingQualityRange={volumeRenderingQualityRange}
/>
);
}
export function getWindowLevelActionMenu(props: WindowLevelActionMenuProps) {
return <WindowLevelActionMenu {...props} />;
export function getWindowLevelActionMenu(
props: withAppTypes<{
viewportId: string;
element: HTMLElement;
location: string;
displaySets: Array<any>;
}>
) {
return <WindowLevelActionMenuWrapper {...props} />;
}

View File

@ -0,0 +1,11 @@
export * from './Colorbar';
export * from './Colormap';
export * from './WindowLevel';
export * from './WindowLevelActionMenu';
export * from './WindowLevelActionMenuWrapper';
export * from './VolumeRenderingOptions';
export * from './VolumeRenderingPresets';
export * from './VolumeRenderingQuality';
export * from './VolumeLighting';
export * from './VolumeShade';
export * from './VolumeShift';

View File

@ -1,3 +1,4 @@
import DicomUpload from './DicomUpload/DicomUpload';
export * from './AccordionGroup';
export * from './MeasurementTableNested';
export * from './StudyMeasurements';
@ -5,6 +6,8 @@ export * from './MeasurementsMenu';
export * from './SeriesMeasurements';
export * from './StudyMeasurementsActions';
export * from './MeasurementsOrAdditionalFindings';
import DicomUpload from './DicomUpload/DicomUpload';
export * from './ViewportDataOverlaySettingMenu';
export * from './ViewportOrientationMenu';
export * from './WindowLevelActionMenu';
export { DicomUpload };

View File

@ -0,0 +1,5 @@
export const MENU_IDS = {
ORIENTATION_MENU: 'orientationMenu',
DATA_OVERLAY_MENU: 'dataOverlayMenu',
WINDOW_LEVEL_MENU: 'windowLevelActionMenu',
};

View File

@ -1,180 +0,0 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
} from 'react';
import PropTypes from 'prop-types';
import { Types, ViewportActionCornersLocations } from '@ohif/ui-next';
import ViewportActionCornersService, {
ActionComponentInfo,
} from '../services/ViewportActionCornersService/ViewportActionCornersService';
interface StateComponentInfo extends Types.ViewportActionCornersComponentInfo {
indexPriority: number;
}
type State = Record<string, Record<ViewportActionCornersLocations, Array<StateComponentInfo>>>;
const DEFAULT_STATE: State = {
// default here is the viewportId of the default viewport
default: {
[ViewportActionCornersLocations.topLeft]: [],
[ViewportActionCornersLocations.topRight]: [],
[ViewportActionCornersLocations.bottomLeft]: [],
[ViewportActionCornersLocations.bottomRight]: [],
},
// [anotherViewportId]: { ..... }
};
export const ViewportActionCornersContext = createContext(DEFAULT_STATE);
export function ViewportActionCornersProvider({ children, service }) {
const viewportActionCornersReducer = (state, action) => {
switch (action.type) {
case 'ADD_ACTION_COMPONENT': {
const { viewportId, id, component, location, indexPriority } = action.payload;
// Get the components at the specified location of the specified viewport.
let locationComponents = state?.[viewportId]?.[location]
? [...state[viewportId][location]]
: [];
// If the component (id) already exists at the location specified in the payload,
// then it must be replaced with the component in the payload so first
// remove it from that location.
const deletionIndex = locationComponents.findIndex(component => component.id === id);
if (deletionIndex !== -1) {
locationComponents = [
...locationComponents.slice(0, deletionIndex),
...locationComponents.slice(deletionIndex + 1),
];
}
// Insert the component from the payload but
// do not insert an undefined or null component.
if (component) {
let insertionIndex;
const isRightSide =
location === ViewportActionCornersLocations.topRight ||
location === ViewportActionCornersLocations.bottomRight;
if (indexPriority === undefined) {
// If no indexPriority is provided, add it to the appropriate end
insertionIndex = isRightSide ? 0 : locationComponents.length;
} else {
if (isRightSide) {
insertionIndex = locationComponents.findIndex(
component => indexPriority > component.indexPriority
);
} else {
insertionIndex = locationComponents.findIndex(
component => indexPriority <= component.indexPriority
);
}
if (insertionIndex === -1) {
// If no suitable position found, add to the appropriate end
insertionIndex = isRightSide ? 0 : locationComponents.length;
}
}
const defaultPriority = isRightSide ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER;
locationComponents = [
...locationComponents.slice(0, insertionIndex),
{
id,
component,
indexPriority: indexPriority ?? defaultPriority,
},
...locationComponents.slice(insertionIndex),
];
}
return {
...state,
[viewportId]: {
...state[viewportId],
[location]: locationComponents,
},
};
}
case 'CLEAR_ACTION_COMPONENTS': {
const viewportId = action.payload;
const nextState = { ...state };
delete nextState[viewportId];
return nextState;
}
default:
return { ...state };
}
};
const [viewportActionCornersState, dispatch] = useReducer(
viewportActionCornersReducer,
DEFAULT_STATE
);
const getState = useCallback(() => {
return viewportActionCornersState;
}, [viewportActionCornersState]);
const addComponent = useCallback(
(actionComponentInfo: ActionComponentInfo) => {
dispatch({ type: 'ADD_ACTION_COMPONENT', payload: actionComponentInfo });
},
[dispatch]
);
const addComponents = useCallback(
(actionComponentInfos: Array<ActionComponentInfo>) => {
actionComponentInfos.forEach(actionComponentInfo =>
dispatch({ type: 'ADD_ACTION_COMPONENT', payload: actionComponentInfo })
);
},
[dispatch]
);
const clear = useCallback(
(viewportId: string) => dispatch({ type: 'CLEAR_ACTION_COMPONENTS', payload: viewportId }),
[dispatch]
);
useEffect(() => {
if (service) {
service.setServiceImplementation({
getState,
addComponent,
addComponents,
clear,
});
}
}, [getState, service, addComponent, addComponents, clear]);
const viewportCornerActions = {
getState,
addComponent: props => service.addComponent(props),
addComponents: props => service.addComponents(props),
clear: props => service.clear(props),
};
const contextValue = useMemo(
() => [viewportActionCornersState, viewportCornerActions],
[viewportActionCornersState, viewportCornerActions]
);
return (
<ViewportActionCornersContext.Provider value={contextValue}>
{children}
</ViewportActionCornersContext.Provider>
);
}
ViewportActionCornersProvider.propTypes = {
children: PropTypes.node,
service: PropTypes.instanceOf(ViewportActionCornersService).isRequired,
};
export const useViewportActionCornersContext = () => useContext(ViewportActionCornersContext);

View File

@ -1,13 +1,93 @@
import { colormaps } from '../utils/colormaps';
import {
ColorbarPositionType,
TickPositionType,
PositionStylesMapType,
PositionTickStylesMapType,
ContainerStyleType,
TickStyleType,
ColorbarProperties,
} from '../types/Colorbar';
import { ColorMapPreset } from '../types/Colormap';
const defaultPosition: ColorbarPositionType = 'right';
const DefaultColormap = 'Grayscale';
export default {
'cornerstone.colorbar': {
width: '16px',
colorbarTickPosition: 'left',
colormaps,
colorbarContainerPosition: 'right',
colorbarInitialColormap: DefaultColormap,
const positionStyles: PositionStylesMapType = {
left: { left: '5%', width: '15px' },
right: { right: '5%', width: '15px' },
bottom: { bottom: '1%', height: '18px' },
};
// Typed position-specific tick styles
const positionTickStyles: PositionTickStylesMapType = {
bottom: {
position: 'top',
style: {
labelOffset: 5,
labelMargin: 13,
},
},
right: {
position: 'left',
style: {
labelMargin: 5,
},
},
left: {
position: 'right',
style: {
labelMargin: 5,
},
},
top: {
position: 'bottom',
style: {
labelMargin: 5,
},
},
};
// Get recommended tick position for a given colorbar position
const getTickPositionForPosition = (position: ColorbarPositionType): TickPositionType => {
if (position === 'bottom') {
return 'top';
} else if (position === 'top') {
return 'bottom';
} else if (position === 'left') {
return 'right';
} else if (position === 'right') {
return 'left';
}
return positionTickStyles[position]?.position || 'top';
};
// Container styles for colorbar
const containerStyles: ContainerStyleType = {
cursor: 'initial',
};
// Tick styling
const tickStyles: TickStyleType = {
font: '12px Arial',
color: '#fff',
maxNumTicks: 6,
tickSize: 5,
tickWidth: 1,
};
const colorbarConfig: Partial<ColorbarProperties> = {
colorbarTickPosition: getTickPositionForPosition(defaultPosition),
colormaps: colormaps as unknown as Record<string, ColorMapPreset>,
colorbarContainerPosition: defaultPosition,
colorbarInitialColormap: DefaultColormap,
positionStyles,
positionTickStyles,
containerStyles,
tickStyles,
};
export default {
'cornerstone.colorbar': colorbarConfig,
};

View File

@ -1,7 +1,8 @@
import { getWindowLevelActionMenu } from '../components/WindowLevelActionMenu/getWindowLevelActionMenu';
import { getViewportDataOverlaySettingsMenu } from '../components/ViewportDataOverlaySettingMenu';
import { getViewportOrientationMenu } from '../components/ViewportOrientationMenu';
import { AllInOneMenu } from '@ohif/ui-next';
import { ViewportActionCorners } from '@ohif/ui-next';
import { MENU_IDS } from '../components/menus/menu-ids';
// Generate component renderer functions for each component type
const createOrientationMenu = ({ viewportId, element, location }) => {
@ -21,31 +22,28 @@ const createDataOverlay = ({ viewportId, element, displaySets, location }) => {
});
};
const createWindowLevelMenu = ({ viewportId, element, displaySets }) => {
const createWindowLevelMenu = ({ viewportId, element, displaySets, location }) => {
return getWindowLevelActionMenu({
viewportId,
element,
displaySets,
verticalDirection: AllInOneMenu.VerticalDirection.TopToBottom,
horizontalDirection: AllInOneMenu.HorizontalDirection.LeftToRight,
location,
});
};
export default {
'ui.viewportActionCorner': ViewportActionCorners,
'viewportActionMenu.topLeft': [
{
id: 'orientationMenu',
enabled: true,
id: MENU_IDS.ORIENTATION_MENU,
component: createOrientationMenu,
},
{
id: 'dataOverlay',
enabled: true,
id: MENU_IDS.DATA_OVERLAY_MENU,
component: createDataOverlay,
},
{
id: 'windowLevelActionMenu',
enabled: true,
id: MENU_IDS.WINDOW_LEVEL_MENU,
component: createWindowLevelMenu,
},
],

View File

@ -1,5 +1,4 @@
export { default as useViewportActionCorners } from './useViewportActionCorners';
export * from './useViewportActionCorners';
export * from './useActiveViewportSegmentationRepresentations';
export * from './useMeasurements';
export * from './useSegmentations';
export * from './useSegmentations';

View File

@ -1,113 +0,0 @@
import { useEffect } from 'react';
import React from 'react';
import { ViewportActionCornersLocations } from '@ohif/ui-next';
import { useSystem } from '@ohif/core/src';
/**
* Viewport action corner location type
*/
export interface ViewportActionCornerLocation {
type: 'VIEWPORT';
id: string;
corner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
}
/**
* Viewport action corner service interface
*/
export interface ViewportActionCornerService {
addComponent: (params: {
viewportId: string;
id: string;
component: React.ReactNode;
location: ViewportActionCornersLocations | string;
indexPriority?: number;
}) => void;
clear: (viewportId: string) => void;
getAlignAndSide: (location: ViewportActionCornersLocations | string) => {
align: 'start' | 'center' | 'end';
side: 'top' | 'right' | 'bottom' | 'left';
};
}
/**
* Customization service interface
*/
export interface CustomizationService {
getCustomization: (name: string) => any;
}
/**
* Props for the useViewportActionCorners hook
*/
interface UseViewportActionCornersProps {
viewportId: string;
elementRef: React.MutableRefObject<HTMLDivElement>;
displaySets: AppTypes.DisplaySet[];
}
// Map of customization keys to their corresponding enum values
const locationMap = {
'viewportActionMenu.topLeft': ViewportActionCornersLocations.topLeft,
'viewportActionMenu.topRight': ViewportActionCornersLocations.topRight,
'viewportActionMenu.bottomLeft': ViewportActionCornersLocations.bottomLeft,
'viewportActionMenu.bottomRight': ViewportActionCornersLocations.bottomRight,
};
/**
* Hook to manage viewport action corners for a Cornerstone viewport
*/
export function useViewportActionCorners({
viewportId,
elementRef,
displaySets,
}: UseViewportActionCornersProps): void {
const { servicesManager } = useSystem();
const { viewportActionCornersService, customizationService } = servicesManager.services;
useEffect(() => {
// Process each location
Object.entries(locationMap).forEach(([locationKey, locationValue]) => {
const items = customizationService.getCustomization(locationKey);
if (!items || !items.length) {
return;
}
items.forEach(item => {
if (!item.enabled) {
return;
}
const componentId = item.id;
if (item.component) {
// Use the component renderer provided directly in the item
const component = item.component({
viewportId,
element: elementRef.current,
displaySets,
location: locationValue,
});
viewportActionCornersService.addComponent({
viewportId,
id: componentId,
component,
location: locationValue,
});
} else if (item.component) {
// Handle static components
viewportActionCornersService.addComponent({
viewportId,
id: item.id,
component: item.component,
location: locationValue,
});
}
});
});
}, [displaySets, viewportId, viewportActionCornersService, elementRef]);
}
export default useViewportActionCorners;

View File

@ -35,7 +35,6 @@ import RectangleROI from './utils/measurementServiceMappings/RectangleROI';
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
import ViewportActionCornersService from './services/ViewportActionCornersService/ViewportActionCornersService';
import { ViewportActionCornersProvider } from './contextProviders/ViewportActionCornersProvider';
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
import { findNearbyToolData } from './utils/findNearbyToolData';
import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer';
@ -153,7 +152,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
* @param configuration.csToolsConfig - Passed directly to `initCornerstoneTools`
*/
preRegistration: async function (props: Types.Extensions.ExtensionParams): Promise<void> {
const { servicesManager, serviceProvidersManager } = props;
const { servicesManager } = props;
servicesManager.registerService(CornerstoneViewportService.REGISTRATION);
servicesManager.registerService(ToolGroupService.REGISTRATION);
servicesManager.registerService(SyncGroupService.REGISTRATION);
@ -162,11 +161,6 @@ const cornerstoneExtension: Types.Extensions.Extension = {
servicesManager.registerService(ViewportActionCornersService.REGISTRATION);
servicesManager.registerService(ColorbarService.REGISTRATION);
serviceProvidersManager.registerProvider(
ViewportActionCornersService.REGISTRATION.name,
ViewportActionCornersProvider
);
const { syncGroupService } = servicesManager.services;
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);

View File

@ -1,47 +1,35 @@
import { PubSubService } from '@ohif/core';
import { PubSubService, Types as OhifTypes } from '@ohif/core';
import { RENDERING_ENGINE_ID } from '../ViewportService/constants';
import { StackViewport, VolumeViewport, getRenderingEngine } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
import { getRenderingEngine } from '@cornerstonejs/core';
import { ColorbarOptions, ChangeTypes } from '../../types/Colorbar';
const { ViewportColorbar } = utilities.voi.colorbar;
export default class ColorbarService extends PubSubService {
static EVENTS = {
STATE_CHANGED: 'event::ColorbarService:stateChanged',
};
static defaultStyles = {
position: 'absolute',
boxSizing: 'border-box',
border: 'solid 1px #555',
cursor: 'initial',
};
static positionStyles = {
left: { left: '5%' },
right: { right: '5%' },
top: { top: '5%' },
bottom: { bottom: '5%' },
};
static defaultTickStyles = {
position: 'left',
style: {
font: '12px Arial',
color: '#fff',
maxNumTicks: 8,
tickSize: 5,
tickWidth: 1,
labelMargin: 3,
},
};
public static REGISTRATION = {
name: 'colorbarService',
create: ({ servicesManager }: OhifTypes.Extensions.ExtensionParams) => {
return new ColorbarService(servicesManager);
},
};
/**
* Structure of colorbars state:
* {
* [viewportId]: [
* {
* displaySetInstanceUID: string,
* colorbar: {
* activeColormapName: string,
* colormaps: array,
* volumeId: string (optional),
* }
* }
* ]
* }
*/
colorbars = {};
servicesManager: AppTypes.ServicesManager;
@ -51,25 +39,37 @@ export default class ColorbarService extends PubSubService {
}
/**
* Gets the volume ID for a given identifier by searching through the viewport's volume IDs.
* @param viewport - The viewport instance to search volumes in
* @param searchId - The identifier to search for within volume IDs
* @returns The matching volume ID if found, null otherwise
* Gets the appropriate data ID for a viewport and display set
* @param viewport - The viewport instance
* @param displaySetInstanceUID - The display set instance UID to identify data
* @returns The appropriate data ID for the viewport type (volumeId for volume viewports, undefined for stack)
*/
private getVolumeIdForIdentifier(viewport, searchId: string): string | null {
const volumeIds = viewport.getAllVolumeIds?.() || [];
return volumeIds.length > 0 ? volumeIds.find(id => id.includes(searchId)) || null : null;
private getDataIdForViewport(viewport, displaySetInstanceUID: string): string | undefined {
// For volume viewports, find the matching volumeId
if (viewport.getAllVolumeIds) {
const volumeIds = viewport.getAllVolumeIds() || [];
return volumeIds.length > 0
? volumeIds.find(id => id.includes(displaySetInstanceUID)) || undefined
: undefined;
}
// For other viewports, no specific dataId is needed for now
return undefined;
}
/**
* Adds a colorbar to a specific viewport identified by `viewportId`, using the provided `displaySetInstanceUIDs` and `options`.
* This method sets up the colorbar, associates it with the viewport, and applies initial configurations based on the provided options.
* This method prepares the colorbar state that will be used by the ViewportColorbarsContainer component.
*
* @param viewportId The identifier for the viewport where the colorbar will be added.
* @param displaySetInstanceUIDs An array of display set instance UIDs to associate with the colorbar.
* @param options Configuration options for the colorbar, including position, colormaps, active colormap name, ticks, and width.
*/
public addColorbar(viewportId, displaySetInstanceUIDs, options = {} as ColorbarOptions) {
public addColorbar(
viewportId: string,
displaySetInstanceUIDs: string[],
options = {} as ColorbarOptions
) {
const { displaySetService } = this.servicesManager.services;
const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID);
const viewport = renderingEngine.getViewport(viewportId);
@ -78,35 +78,24 @@ export default class ColorbarService extends PubSubService {
return;
}
const { element } = viewport;
const actorEntries = viewport.getActors();
if (!actorEntries || actorEntries.length === 0) {
return;
}
const { position, width: thickness, activeColormapName, colormaps } = options;
const { activeColormapName, colormaps } = options;
const numContainers = displaySetInstanceUIDs.length;
const containers = this.createContainers(
numContainers,
element,
position,
thickness,
viewportId
);
displaySetInstanceUIDs.forEach((displaySetInstanceUID, index) => {
displaySetInstanceUIDs.forEach(displaySetInstanceUID => {
// don't show colorbar for overlay display sets (e.g. segmentation)
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (displaySet.isOverlayDisplaySet) {
return;
}
const volumeId = this.getVolumeIdForIdentifier(viewport, displaySetInstanceUID);
const properties = viewport?.getProperties(volumeId);
const dataId = this.getDataIdForViewport(viewport, displaySetInstanceUID);
const properties = dataId ? viewport.getProperties(dataId) : viewport.getProperties();
const colormap = properties?.colormap;
if (activeColormapName && !colormap) {
this.setViewportColormap(
viewportId,
@ -116,38 +105,43 @@ export default class ColorbarService extends PubSubService {
);
}
const colorbarContainer = containers[index];
const colorbar = new ViewportColorbar({
id: `ctColorbar-${viewportId}-${index}`,
element,
colormaps: options.colormaps || {},
// if there's an existing colormap set, we use it, otherwise we use the activeColormapName, otherwise, grayscale
// Prepare colorbar data for the React component
const colorbarData = {
activeColormapName: colormap?.name || options?.activeColormapName || 'Grayscale',
container: colorbarContainer,
ticks: {
...ColorbarService.defaultTickStyles,
...options.ticks,
},
volumeId: viewport instanceof VolumeViewport ? volumeId : undefined,
});
colormaps: options.colormaps ? Object.values(options.colormaps) : [],
volumeId: dataId,
dataId,
};
// Store the colorbar data in the service state
if (this.colorbars[viewportId]) {
this.colorbars[viewportId].push({
colorbar,
container: colorbarContainer,
displaySetInstanceUID,
});
// Check if there's already an entry for this displaySetInstanceUID
const existingIndex = this.colorbars[viewportId].findIndex(
item => item.displaySetInstanceUID === displaySetInstanceUID
);
if (existingIndex !== -1) {
// Update existing colorbar
this.colorbars[viewportId][existingIndex].colorbar = colorbarData;
} else {
// Add new colorbar
this.colorbars[viewportId].push({
colorbar: colorbarData,
displaySetInstanceUID,
});
}
} else {
// Create new colorbar array for this viewport
this.colorbars[viewportId] = [
{
colorbar,
container: colorbarContainer,
colorbar: colorbarData,
displaySetInstanceUID,
},
];
}
});
// Notify listeners about the state change
this._broadcastEvent(ColorbarService.EVENTS.STATE_CHANGED, {
viewportId,
changeType: ChangeTypes.Added,
@ -175,10 +169,6 @@ export default class ColorbarService extends PubSubService {
);
if (index !== -1) {
// Remove only the specific colorbar container
const { container } = colorbarInfo[index];
container.parentNode.removeChild(container);
// Remove the colorbar from the array
colorbarInfo.splice(index, 1);
@ -188,11 +178,6 @@ export default class ColorbarService extends PubSubService {
}
}
} else {
// Remove all colorbars for the viewport (original behavior)
colorbarInfo.forEach(({ container }) => {
container.parentNode.removeChild(container);
});
delete this.colorbars[viewportId];
}
@ -259,71 +244,15 @@ export default class ColorbarService extends PubSubService {
if (!viewport || !actorEntries || actorEntries.length === 0) {
return;
}
const setViewportProperties = (viewport, uid) => {
const volumeId = this.getVolumeIdForIdentifier(viewport, uid);
viewport.setProperties({ colormap }, volumeId);
};
if (viewport instanceof StackViewport) {
setViewportProperties(viewport, viewportId);
}
// Get the appropriate dataId for this viewport/displaySet combination
const dataId = this.getDataIdForViewport(viewport, displaySetInstanceUID);
if (viewport instanceof VolumeViewport) {
setViewportProperties(viewport, displaySetInstanceUID);
}
// Set properties with or without dataId based on what the viewport supports
viewport.setProperties({ colormap }, dataId);
if (immediate) {
viewport.render();
}
}
/**
* Creates the container elements for colorbars based on the specified parameters. This function dynamically
* generates and styles DOM elements to host the colorbars, positioning them according to the specified options.
*
* @param numContainers The number of containers to create, typically corresponding to the number of colorbars.
* @param element The DOM element within which the colorbar containers will be placed.
* @param position The position of the colorbar containers (e.g., 'top', 'bottom', 'left', 'right').
* @param thickness The thickness of the colorbar containers, affecting their width or height depending on their position.
* @param viewportId The identifier of the viewport for which the containers are being created.
* @returns An array of the created container DOM elements.
*/
private createContainers(numContainers, element, position, thickness, viewportId) {
const containers = [];
const dimensions = {
1: 50,
2: 33,
};
const dimension = dimensions[numContainers] || 50 / numContainers;
Array.from({ length: numContainers }).forEach((_, i) => {
const colorbarContainer = document.createElement('div');
colorbarContainer.id = `ctColorbarContainer-${viewportId}-${i + 1}`;
Object.assign(colorbarContainer.style, ColorbarService.defaultStyles);
if (['top', 'bottom'].includes(position)) {
Object.assign(colorbarContainer.style, {
width: `${dimension}%`,
height: thickness || '2.5%',
left: `${(i + 1) * dimension}%`,
transform: 'translateX(-50%)',
...ColorbarService.positionStyles[position],
});
} else if (['left', 'right'].includes(position)) {
Object.assign(colorbarContainer.style, {
height: `${dimension}%`,
width: thickness || '2.5%',
top: `${(i + 1) * dimension}%`,
transform: 'translateY(-50%)',
...ColorbarService.positionStyles[position],
});
}
element.appendChild(colorbarContainer);
containers.push(colorbarContainer);
});
return containers;
}
}

View File

@ -1,19 +1,5 @@
import { PubSubService } from '@ohif/core';
import { ViewportActionCornersLocations } from '@ohif/ui-next';
import { ReactNode } from 'react';
export type ActionComponentInfo = {
viewportId: string;
id: string;
component: ReactNode;
location: ViewportActionCornersLocations;
indexPriority?: number;
};
export type AlignAndSide = {
align: 'start' | 'end' | 'center';
side: 'top' | 'bottom' | 'left' | 'right';
};
import { ViewportActionCornersLocations, Types } from '@ohif/ui-next';
class ViewportActionCornersService extends PubSubService {
public static readonly EVENTS = {};
@ -40,39 +26,121 @@ class ViewportActionCornersService extends PubSubService {
getState: getStateImplementation,
addComponent: addComponentImplementation,
addComponents: addComponentsImplementation,
clear: clearComponentsImplementation,
clear: clearImplementation,
lockItem: lockItemImplementation,
unlockItem: unlockItemImplementation,
toggleLock: toggleLockImplementation,
isItemLocked: isItemLockedImplementation,
showItem: showItemImplementation,
hideItem: hideItemImplementation,
toggleVisibility: toggleVisibilityImplementation,
isItemVisible: isItemVisibleImplementation,
openItem: openItemImplementation,
closeItem: closeItemImplementation,
closeAllItems: closeAllItemsImplementation,
isItemOpen: isItemOpenImplementation,
}): void {
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
}
if (addComponentImplementation) {
this.serviceImplementation._addComponent = addComponentImplementation;
}
if (addComponentsImplementation) {
this.serviceImplementation._addComponents = addComponentsImplementation;
}
if (clearComponentsImplementation) {
this.serviceImplementation._clear = clearComponentsImplementation;
}
this.serviceImplementation._getState =
getStateImplementation ?? this.serviceImplementation._getState;
this.serviceImplementation._addComponent =
addComponentImplementation ?? this.serviceImplementation._addComponent;
this.serviceImplementation._addComponents =
addComponentsImplementation ?? this.serviceImplementation._addComponents;
this.serviceImplementation._clear = clearImplementation ?? this.serviceImplementation._clear;
this.serviceImplementation._lockItem =
lockItemImplementation ?? this.serviceImplementation._lockItem;
this.serviceImplementation._unlockItem =
unlockItemImplementation ?? this.serviceImplementation._unlockItem;
this.serviceImplementation._toggleLock =
toggleLockImplementation ?? this.serviceImplementation._toggleLock;
this.serviceImplementation._isItemLocked =
isItemLockedImplementation ?? this.serviceImplementation._isItemLocked;
this.serviceImplementation._showItem =
showItemImplementation ?? this.serviceImplementation._showItem;
this.serviceImplementation._hideItem =
hideItemImplementation ?? this.serviceImplementation._hideItem;
this.serviceImplementation._toggleVisibility =
toggleVisibilityImplementation ?? this.serviceImplementation._toggleVisibility;
this.serviceImplementation._isItemVisible =
isItemVisibleImplementation ?? this.serviceImplementation._isItemVisible;
this.serviceImplementation._openItem =
openItemImplementation ?? this.serviceImplementation._openItem;
this.serviceImplementation._closeItem =
closeItemImplementation ?? this.serviceImplementation._closeItem;
this.serviceImplementation._closeAllItems =
closeAllItemsImplementation ?? this.serviceImplementation._closeAllItems;
this.serviceImplementation._isItemOpen =
isItemOpenImplementation ?? this.serviceImplementation._isItemOpen;
}
public getState() {
return this.serviceImplementation._getState();
}
public addComponent(component: ActionComponentInfo) {
public addComponent(component: Types.ActionComponentInfo) {
this.serviceImplementation._addComponent(component);
}
public addComponents(components: Array<ActionComponentInfo>) {
public addComponents(components: Array<Types.ActionComponentInfo>) {
this.serviceImplementation._addComponents(components);
}
public clear(viewportId: string) {
public clear(viewportId: string): void {
this.serviceImplementation._clear(viewportId);
}
public getAlignAndSide(location: ViewportActionCornersLocations): AlignAndSide {
/* lock / unlock */
public lockItem(viewportId: string, itemId: string): void {
this.serviceImplementation._lockItem(viewportId, itemId);
}
public unlockItem(viewportId: string, itemId: string): void {
this.serviceImplementation._unlockItem(viewportId, itemId);
}
public toggleLock(viewportId: string, itemId: string): void {
this.serviceImplementation._toggleLock(viewportId, itemId);
}
public isItemLocked(viewportId: string, itemId: string): boolean {
return this.serviceImplementation._isItemLocked(viewportId, itemId);
}
/* visibility */
public showItem(viewportId: string, itemId: string): void {
this.serviceImplementation._showItem(viewportId, itemId);
}
public hideItem(viewportId: string, itemId: string): void {
this.serviceImplementation._hideItem(viewportId, itemId);
}
public toggleVisibility(viewportId: string, itemId: string): void {
this.serviceImplementation._toggleVisibility(viewportId, itemId);
}
public isItemVisible(viewportId: string, itemId: string): boolean {
return this.serviceImplementation._isItemVisible(viewportId, itemId);
}
/* open / close */
public openItem(viewportId: string, itemId: string): void {
this.serviceImplementation._openItem(viewportId, itemId);
}
public closeItem(viewportId: string, itemId: string): void {
this.serviceImplementation._closeItem(viewportId, itemId);
}
public closeAllItems(viewportId: string): void {
this.serviceImplementation._closeAllItems(viewportId);
}
public isItemOpen(viewportId: string, itemId: string): boolean {
return this.serviceImplementation._isItemOpen(viewportId, itemId);
}
public getAlignAndSide(location: ViewportActionCornersLocations): Types.AlignAndSide {
switch (location) {
case ViewportActionCornersLocations.topLeft:
return { align: 'start', side: 'bottom' };

View File

@ -1,27 +1,112 @@
import { ColorMapPreset } from './Colormap';
// Position options
export type ColorbarPositionType = 'top' | 'bottom' | 'left' | 'right';
// Tick position options
export type TickPositionType = 'top' | 'bottom' | 'left' | 'right';
// CSS style properties for ticks
export type TickStyleType = {
font?: string;
color?: string;
maxNumTicks?: number;
tickSize?: number;
tickWidth?: number;
labelMargin?: number;
labelOffset?: number;
};
// Position-specific styles
export type PositionStyleType = {
[key: string]: string;
};
// Styles for a specific position (like bottom)
export type PositionTickStyleType = {
position: TickPositionType;
style?: TickStyleType;
};
// Map of position to position-specific styles
export type PositionTickStylesMapType = {
[key in ColorbarPositionType]?: PositionTickStyleType;
};
// Map of position to position-specific CSS
export type PositionStylesMapType = {
[key in ColorbarPositionType]?: PositionStyleType;
};
// Container styles
export type ContainerStyleType = {
position?: string;
boxSizing?: string;
border?: string;
cursor?: string;
[key: string]: string | undefined;
};
// Dimension configuration
export type DimensionConfigType = {
bottomHeight: string;
defaultVerticalWidth: string;
defaultHorizontalHeight: string;
};
// Tick configuration
export type TickConfigType = {
position: TickPositionType;
style?: TickStyleType;
};
// Base options for colorbar
export type ColorbarOptions = {
position: string;
colormaps: Array<ColorMapPreset>;
position: ColorbarPositionType;
colormaps: Record<string, ColorMapPreset>;
activeColormapName: string;
ticks: object;
ticks?: TickConfigType;
width: string;
};
// Props for the Colorbar component
export type ColorbarProps = {
viewportId: string;
displaySets: Array<any>;
colorbarProperties: ColorbarProperties;
};
// Extended properties with styling options
export type ColorbarProperties = {
width: string;
colorbarTickPosition: string;
colorbarContainerPosition: string;
colormaps: Array<ColorMapPreset>;
colorbarTickPosition: TickPositionType;
colorbarContainerPosition: ColorbarPositionType;
colormaps: Record<string, ColorMapPreset>;
colorbarInitialColormap: string;
// Styling properties
positionStyles?: PositionStylesMapType;
positionTickStyles?: PositionTickStylesMapType;
containerStyles?: ContainerStyleType;
tickStyles?: TickStyleType;
};
// Type for the customization object from the customization service
export interface ColorbarCustomization {
width: string;
colorbarTickPosition: TickPositionType;
colorbarContainerPosition: ColorbarPositionType;
colormaps: Record<string, ColorMapPreset>;
colorbarInitialColormap: string;
// Styling properties
positionStyles: PositionStylesMapType;
positionTickStyles: PositionTickStylesMapType;
containerStyles: ContainerStyleType;
tickStyles: TickStyleType;
}
// Event types for colorbar changes
export enum ChangeTypes {
Removed = 'removed',
Added = 'added',

View File

@ -1,5 +0,0 @@
import { ViewportActionCorners } from '@ohif/ui-next';
export default {
'ui.viewportActionCorner': ViewportActionCorners,
};

View File

@ -15,7 +15,6 @@ import onDropHandlerCustomization from './customizations/onDropHandlerCustomizat
import loadingIndicatorProgressCustomization from './customizations/loadingIndicatorProgressCustomization';
import loadingIndicatorTotalPercentCustomization from './customizations/loadingIndicatorTotalPercentCustomization';
import progressLoadingBarCustomization from './customizations/progressLoadingBarCustomization';
import viewportActionCornersCustomization from './customizations/viewportActionCornersCustomization';
import labellingFlowCustomization from './customizations/labellingFlowCustomization';
import viewportNotificationCustomization from './customizations/notificationCustomization';
import aboutModalCustomization from './customizations/aboutModalCustomization';
@ -62,7 +61,6 @@ export default function getCustomizationModule({ servicesManager, extensionManag
...loadingIndicatorProgressCustomization,
...loadingIndicatorTotalPercentCustomization,
...progressLoadingBarCustomization,
...viewportActionCornersCustomization,
...labellingFlowCustomization,
...contextMenuUICustomization,
...viewportNotificationCustomization,

View File

@ -26,6 +26,7 @@ import {
ModalProvider,
ViewportDialogProvider,
UserAuthenticationProvider,
ViewportActionCornersProvider,
} from '@ohif/ui-next';
// Viewer Project
// TODO: Should this influence study list?
@ -104,6 +105,7 @@ function App({
userAuthenticationService,
uiNotificationService,
customizationService,
viewportActionCornersService,
} = servicesManager.services;
const providers = [
@ -119,6 +121,7 @@ function App({
[TooltipProvider],
[DialogProvider, { service: uiDialogService, dialog: ManagedDialog }],
[ModalProvider, { service: uiModalService, modal: ModalNext }],
[ViewportActionCornersProvider, { service: viewportActionCornersService }],
[ShepherdJourneyProvider],
];

View File

@ -4,11 +4,13 @@ import { ViewportGrid, ViewportPane } from '@ohif/ui-next';
import { useViewportGrid } from '@ohif/ui-next';
import EmptyViewport from './EmptyViewport';
import { useAppConfig } from '@state';
import { useViewportActionCornersWithGrid } from '../hooks';
function ViewerViewportGrid(props: withAppTypes) {
const { servicesManager, viewportComponents = [], dataSource, commandsManager } = props;
const [viewportGrid, viewportGridService] = useViewportGrid();
const [appConfig] = useAppConfig();
const { initializeViewportCorners } = useViewportActionCornersWithGrid();
const { layout, activeViewportId, viewports, isHangingProtocolLayout } = viewportGrid;
const { numCols, numRows } = layout;
@ -415,8 +417,14 @@ function ViewerViewportGrid(props: withAppTypes) {
displaySetOptions={displaySetOptions}
needsRerendering={displaySetsNeedsRerendering}
isHangingProtocolLayout={isHangingProtocolLayout}
onElementEnabled={() => {
onElementEnabled={evt => {
viewportGridService.setViewportIsReady(viewportId, true);
// Initialize the viewport action corners for this viewport
if (evt?.detail?.element) {
const elementRef = { current: evt.detail.element };
initializeViewportCorners(viewportId, elementRef, displaySets, commandsManager);
}
}}
/>
</div>
@ -425,7 +433,7 @@ function ViewerViewportGrid(props: withAppTypes) {
}
return viewportPanes;
}, [viewports, activeViewportId, viewportComponents, dataSource]);
}, [viewports, activeViewportId, viewportComponents, dataSource, initializeViewportCorners]);
/**
* Loading indicator until numCols and numRows are gotten from the HangingProtocolService

View File

@ -1,4 +1,5 @@
import useDebounce from './useDebounce.js';
import useDebounce from './useDebounce';
import useSearchParams from './useSearchParams';
import useViewportActionCornersWithGrid from './useViewportActionCornersWithGrid';
export { useDebounce, useSearchParams };
export { useDebounce, useSearchParams, useViewportActionCornersWithGrid };

View File

@ -0,0 +1,5 @@
import useViewportActionCornersWithGrid from './useViewportActionCornersWithGrid';
import useDebounce from './useDebounce';
import useSearchParams from './useSearchParams';
export { useViewportActionCornersWithGrid, useDebounce, useSearchParams };

View File

@ -0,0 +1,112 @@
import { useCallback, MutableRefObject, useRef } from 'react';
import { useViewportActionCorners, ViewportActionCornersLocations } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
/**
* Hook that manages viewport action corner components for all viewports in the grid
* @returns A function that can be called to initialize action corners for a viewport
*/
export default function useViewportActionCornersWithGrid() {
const [, api] = useViewportActionCorners();
const { servicesManager } = useSystem();
const { customizationService } = servicesManager.services;
// Keep a ref to track processed viewports to avoid duplicates
const processedViewports = useRef<Set<string>>(new Set());
// Map of customization keys to their corresponding enum values
const locationMap = {
'viewportActionMenu.topLeft': ViewportActionCornersLocations.topLeft,
'viewportActionMenu.topRight': ViewportActionCornersLocations.topRight,
'viewportActionMenu.bottomLeft': ViewportActionCornersLocations.bottomLeft,
'viewportActionMenu.bottomRight': ViewportActionCornersLocations.bottomRight,
};
// Function to process customizations for a viewport
const initializeViewportCorners = useCallback(
(
viewportId: string,
elementRef: MutableRefObject<HTMLDivElement>,
displaySets: any[],
commandsManager: any
) => {
if (!viewportId || !elementRef?.current) {
return;
}
// Prevent duplicate processing
if (processedViewports.current.has(viewportId)) {
return;
}
// Mark this viewport as processed
processedViewports.current.add(viewportId);
// Clear any existing components for this viewport
api.clear(viewportId);
// Process each location
Object.entries(locationMap).forEach(([locationKey, locationValue]) => {
const items = customizationService.getCustomization(locationKey);
if (!items || !items.length) {
return;
}
items.forEach(item => {
try {
if (typeof item.component === 'function') {
// Use the component renderer provided directly in the item
const component = item.component({
viewportId,
element: elementRef.current,
displaySets,
location: locationValue,
commandsManager,
});
if (component) {
api.addComponent({
viewportId,
id: item.id,
component,
location: locationValue,
indexPriority: item.indexPriority,
});
}
} else if (item.component) {
// Handle static components
api.addComponent({
viewportId,
id: item.id,
component: item.component,
location: locationValue,
indexPriority: item.indexPriority,
});
}
} catch (error) {
console.error(`Error adding component ${item.id} to viewport corner:`, error);
}
});
});
},
[api, customizationService]
);
// Cleanup function for unmounting viewports
const cleanupViewportCorners = useCallback(
(viewportId: string) => {
if (!viewportId) {
return;
}
// Remove from processed set
processedViewports.current.delete(viewportId);
// Clear from the store
api.clear(viewportId);
},
[api]
);
return { initializeViewportCorners, cleanupViewportCorners };
}

View File

@ -3,7 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router';
import PropTypes from 'prop-types';
import { utils } from '@ohif/core';
import { ImageViewerProvider, DragAndDropProvider } from '@ohif/ui-next';
import { useSearchParams } from '@hooks';
import { useSearchParams } from '../../hooks';
import { useAppConfig } from '@state';
import ViewportGrid from '@components/ViewportGrid';
import Compose from './Compose';

View File

@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
//
import filtersMeta from './filtersMeta.js';
import { useAppConfig } from '@state';
import { useDebounce, useSearchParams } from '@hooks';
import { useDebounce, useSearchParams } from '../../hooks';
import { utils, Types as coreTypes } from '@ohif/core';
import {

View File

@ -16,6 +16,8 @@ export type DisplaySet = {
label?: string;
/** Flag indicating if this is an overlay display set (e.g., SEG, RTSTRUCT) */
isOverlayDisplaySet?: boolean;
/** flag indicating if it supports window level */
supportsWindowLevel?: boolean;
// Details about how to display:
/**

View File

@ -201,6 +201,31 @@ window.config = {
],
`,
},
{
id: 'viewportActionMenu.windowLevelActionMenu',
description:
'Configures the display and location of the window level action menu in the viewport.',
image: windowLevelActionMenu,
default: null,
configuration: `
window.config = {
// rest of window config
customizationService: [
{
'viewportActionMenu.windowLevelActionMenu': {
$merge: {
location: 0, // Set the location of the menu in the viewport.
// 0: topLeft
// 1: topRight
// 2: bottomLeft
// 3: bottomRight
}
},
},
],
};
`,
},
{
id: 'measurementLabels',
description: 'Labels for measurement tools in the viewer that are automatically asked for.',
@ -937,7 +962,7 @@ window.config = {
`,
},
{
id: 'viewportActionMenu.dataOverlay',
id: 'viewportActionMenu.dataOverlayMenu',
description: 'Configures the display and location of the data overlay in the viewport.',
image: segmentationOverlay,
default: null,
@ -946,7 +971,7 @@ window.config = {
// rest of window config
customizationService: [
{
'viewportActionMenu.dataOverlay': {
'viewportActionMenu.dataOverlayMenu': {
$merge: {
enabled: true,
location: 1, // Set the location of the overlay in the viewport.

View File

@ -1,11 +1,13 @@
import React from 'react';
import React, {
createContext,
useContext,
ReactNode,
useState,
useEffect,
useCallback,
} from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
/**
* A small container that can render multiple "corner" items (like icons, status)
* in each corner of the viewport: top-left, top-right, bottom-left, bottom-right.
*/
export enum ViewportActionCornersLocations {
topLeft,
topRight,
@ -33,35 +35,102 @@ const locationClasses = {
),
};
function ViewportActionCorners({ cornerComponents }) {
if (!cornerComponents) {
return null;
}
const ViewportActionCornersContext = createContext<{
registerCorner: (location: ViewportActionCornersLocations, children: ReactNode) => void;
} | null>(null);
function Container({ children }: { children: ReactNode }) {
const [corners, setCorners] = useState({
[ViewportActionCornersLocations.topLeft]: null,
[ViewportActionCornersLocations.topRight]: null,
[ViewportActionCornersLocations.bottomLeft]: null,
[ViewportActionCornersLocations.bottomRight]: null,
});
const registerCorner = useCallback(
(location: ViewportActionCornersLocations, children: ReactNode) => {
setCorners(prev => {
// Only update if the children are different to avoid unnecessary renders
if (prev[location] === children) {
return prev;
}
return {
...prev,
[location]: children,
};
});
},
[]
);
return (
<div
className="pointer-events-none absolute h-full w-full select-none"
onDoubleClick={event => {
event.preventDefault();
event.stopPropagation();
}}
>
{Object.entries(cornerComponents).map(([location, locationArray]) => (
<div
key={location}
className={locationClasses[location]}
>
{locationArray.map(componentInfo => (
<div key={componentInfo.id}>{componentInfo.component}</div>
))}
</div>
))}
</div>
<ViewportActionCornersContext.Provider value={{ registerCorner }}>
{children}
<div
className="pointer-events-none absolute h-full w-full select-none"
onDoubleClick={event => {
event.preventDefault();
event.stopPropagation();
}}
>
{Object.entries(corners).map(([location, children]) => {
if (!children) {
return null;
}
return (
<div
key={location}
className={locationClasses[location]}
>
{children}
</div>
);
})}
</div>
</ViewportActionCornersContext.Provider>
);
}
ViewportActionCorners.propTypes = {
cornerComponents: PropTypes.object.isRequired,
};
function Corner({
location,
children,
}: {
location: ViewportActionCornersLocations;
children: ReactNode;
}) {
const context = useContext(ViewportActionCornersContext);
export { ViewportActionCorners };
if (!context) {
throw new Error('Corner component must be used within a ViewportActionCorners.Container');
}
useEffect(() => {
context.registerCorner(location, children);
}, [context, location, children]);
return null;
}
function TopLeft({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.topLeft}>{children}</Corner>;
}
function TopRight({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.topRight}>{children}</Corner>;
}
function BottomLeft({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.bottomLeft}>{children}</Corner>;
}
function BottomRight({ children }: { children: ReactNode }) {
return <Corner location={ViewportActionCornersLocations.bottomRight}>{children}</Corner>;
}
export const ViewportActionCorners = {
Container,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
};

View File

@ -0,0 +1,550 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useReducer,
ReactNode,
useMemo,
} from 'react';
import { ViewportActionCornersLocations } from '../components/Viewport/ViewportActionCorners';
import { ActionComponentInfo, AlignAndSide } from '../types';
const DEFAULT_STATE = {
viewports: {} as Record<string, Record<ViewportActionCornersLocations, ActionComponentInfo[]>>,
};
/**
* API for managing viewport action corner items.
* These methods control the state and behavior of the corner items themselves,
* not the content they might affect in the viewport.
*/
interface ViewportActionCornersApi {
getState: () => typeof DEFAULT_STATE;
addComponent: (component: ActionComponentInfo) => void;
addComponents: (components: Array<ActionComponentInfo>) => void;
clear: (viewportId: string) => void;
getAlignAndSide: (location: ViewportActionCornersLocations) => AlignAndSide;
lockItem: (viewportId: string, itemId: string) => void;
unlockItem: (viewportId: string, itemId: string) => void;
toggleLock: (viewportId: string, itemId: string) => void;
isItemLocked: (viewportId: string, itemId: string) => boolean;
showItem: (viewportId: string, itemId: string) => void;
hideItem: (viewportId: string, itemId: string) => void;
toggleVisibility: (viewportId: string, itemId: string) => void;
isItemVisible: (viewportId: string, itemId: string) => boolean;
openItem: (viewportId: string, itemId: string) => void;
closeItem: (viewportId: string, itemId: string) => void;
closeAllItems: (viewportId: string) => void;
isItemOpen: (viewportId: string, itemId: string) => boolean;
}
export const ViewportActionCornersContext = createContext<
[typeof DEFAULT_STATE, ViewportActionCornersApi]
>([DEFAULT_STATE, {} as ViewportActionCornersApi]);
interface ViewportActionCornersProviderProps {
children: ReactNode;
service: any;
}
export function ViewportActionCornersProvider({
children,
service,
}: ViewportActionCornersProviderProps) {
const viewportActionCornersReducer = (state = DEFAULT_STATE, action) => {
switch (action.type) {
case 'ADD_COMPONENT': {
const { viewportId, id, component, location, indexPriority = 0 } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
newState.viewports[viewportId] = {
[ViewportActionCornersLocations.topLeft]: [],
[ViewportActionCornersLocations.topRight]: [],
[ViewportActionCornersLocations.bottomLeft]: [],
[ViewportActionCornersLocations.bottomRight]: [],
};
}
if (!newState.viewports[viewportId][location]) {
newState.viewports[viewportId][location] = [];
}
const componentInfo = {
id,
component,
indexPriority,
isOpen: false,
isVisible: true,
isLocked: false,
};
const components = [...newState.viewports[viewportId][location]];
const index = components.findIndex(item => item.indexPriority > indexPriority);
if (index === -1) {
components.push(componentInfo);
} else {
components.splice(index, 0, componentInfo);
}
newState.viewports[viewportId][location] = components;
return newState;
}
case 'ADD_COMPONENTS': {
const components = action.payload;
let newState = { ...state };
components.forEach(component => {
newState = viewportActionCornersReducer(newState, {
type: 'ADD_COMPONENT',
payload: component,
});
});
return newState;
}
case 'CLEAR': {
const viewportId = action.payload;
const newState = { ...state };
if (newState.viewports[viewportId]) {
const newViewports = { ...newState.viewports };
delete newViewports[viewportId];
newState.viewports = newViewports;
}
return newState;
}
case 'SET_LOCKED': {
const { viewportId, itemId, lockedStatus } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isLocked: lockedStatus };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'SET_VISIBLE': {
const { viewportId, itemId, visibleStatus } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isVisible: visibleStatus };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'OPEN_ITEM': {
const { viewportId, itemId } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
// Update isOpen flag for the component with matching id in any location
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isOpen: true };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'CLOSE_ITEM': {
const { viewportId, itemId } = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
// Update isOpen flag for the component with matching id in any location
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = [...viewportCopy[location]];
const itemIndex = components.findIndex(item => item.id === itemId);
if (itemIndex !== -1) {
const updatedItem = { ...components[itemIndex], isOpen: false };
components[itemIndex] = updatedItem;
viewportCopy[location] = components;
}
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
case 'CLOSE_ALL_ITEMS': {
const viewportId = action.payload;
const newState = { ...state };
if (!newState.viewports[viewportId]) {
return state;
}
const viewportCopy = { ...newState.viewports[viewportId] };
// Set isOpen to false for all components in the viewport
Object.keys(viewportCopy).forEach(locationKey => {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = viewportCopy[location].map(item => ({
...item,
isOpen: false,
}));
viewportCopy[location] = components;
});
newState.viewports[viewportId] = viewportCopy;
return newState;
}
default:
return state;
}
};
const [state, dispatch] = useReducer(viewportActionCornersReducer, DEFAULT_STATE);
const getState = useCallback(() => {
return state;
}, [state]);
const addComponent = useCallback(
(component: ActionComponentInfo) => {
dispatch({
type: 'ADD_COMPONENT',
payload: component,
});
},
[dispatch]
);
const addComponents = useCallback(
(components: Array<ActionComponentInfo>) => {
dispatch({
type: 'ADD_COMPONENTS',
payload: components,
});
},
[dispatch]
);
const clear = useCallback(
(viewportId: string) => {
dispatch({
type: 'CLEAR',
payload: viewportId,
});
},
[dispatch]
);
const lockItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_LOCKED',
payload: { viewportId, itemId, lockedStatus: true },
});
},
[dispatch]
);
const unlockItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_LOCKED',
payload: { viewportId, itemId, lockedStatus: false },
});
},
[dispatch]
);
const toggleLock = useCallback(
(viewportId: string, itemId: string) => {
const currentLocked = isItemLocked(viewportId, itemId);
dispatch({
type: 'SET_LOCKED',
payload: { viewportId, itemId, lockedStatus: !currentLocked },
});
},
[dispatch]
);
const isItemLocked = useCallback(
(viewportId: string, itemId: string) => {
if (!state.viewports[viewportId]) {
return false; // Default to unlocked if viewport doesn't exist
}
for (const locationKey in state.viewports[viewportId]) {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = state.viewports[viewportId][location];
const item = components.find(item => item.id === itemId);
if (item && item.isLocked === true) {
return true;
}
}
return false; // Default to unlocked if item not found or isLocked is undefined
},
[state.viewports]
);
const showItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_VISIBLE',
payload: { viewportId, itemId, visibleStatus: true },
});
},
[dispatch]
);
const hideItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'SET_VISIBLE',
payload: { viewportId, itemId, visibleStatus: false },
});
},
[dispatch]
);
const toggleVisibility = useCallback(
(viewportId: string, itemId: string) => {
const currentVisible = isItemVisible(viewportId, itemId);
dispatch({
type: 'SET_VISIBLE',
payload: { viewportId, itemId, visibleStatus: !currentVisible },
});
},
[dispatch]
);
const isItemVisible = useCallback(
(viewportId: string, itemId: string) => {
if (!state.viewports[viewportId]) {
return true; // Default to visible if viewport doesn't exist
}
for (const locationKey in state.viewports[viewportId]) {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = state.viewports[viewportId][location];
const item = components.find(item => item.id === itemId);
if (item && item.isVisible === false) {
return false;
}
}
return true; // Default to visible if item not found or isVisible is undefined
},
[state.viewports]
);
const openItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'OPEN_ITEM',
payload: { viewportId, itemId },
});
},
[dispatch]
);
const closeItem = useCallback(
(viewportId: string, itemId: string) => {
dispatch({
type: 'CLOSE_ITEM',
payload: { viewportId, itemId },
});
},
[dispatch]
);
const closeAllItems = useCallback(
(viewportId: string) => {
dispatch({
type: 'CLOSE_ALL_ITEMS',
payload: viewportId,
});
},
[dispatch]
);
const isItemOpen = useCallback(
(viewportId: string, itemId: string) => {
if (!state.viewports[viewportId]) {
return false;
}
for (const locationKey in state.viewports[viewportId]) {
const location = Number(locationKey) as ViewportActionCornersLocations;
const components = state.viewports[viewportId][location];
const item = components.find(item => item.id === itemId);
if (item && item.isOpen === true) {
return true;
}
}
return false;
},
[state.viewports]
);
const getAlignAndSide = useCallback(
(location: ViewportActionCornersLocations) => {
return service.getAlignAndSide(location);
},
[service]
);
const api = useMemo(() => {
return {
getState,
addComponent,
addComponents,
clear,
getAlignAndSide,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
};
}, [
getState,
addComponent,
addComponents,
clear,
getAlignAndSide,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
]);
useEffect(() => {
if (service && service.setServiceImplementation) {
const implementation = {
getState,
addComponent,
addComponents,
clear,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
};
service.setServiceImplementation(implementation);
}
}, [
service,
getState,
addComponent,
addComponents,
clear,
lockItem,
unlockItem,
toggleLock,
isItemLocked,
showItem,
hideItem,
toggleVisibility,
isItemVisible,
openItem,
closeItem,
closeAllItems,
isItemOpen,
]);
return (
<ViewportActionCornersContext.Provider value={[state, api]}>
{children}
</ViewportActionCornersContext.Provider>
);
}
// Custom hook to use the ViewportActionCorners context
export const useViewportActionCorners = () => useContext(ViewportActionCornersContext);

View File

@ -9,6 +9,10 @@ import { UserAuthenticationProvider, useUserAuthentication } from './UserAuthent
import { ImageViewerContext, ImageViewerProvider, useImageViewer } from './ImageViewerProvider';
import DragAndDropProvider from './DragAndDropProvider';
import CineProvider, { useCine } from './CineProvider';
import {
ViewportActionCornersProvider,
useViewportActionCorners,
} from './ViewportActionCornersProvider';
export { useNotification, NotificationProvider };
export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
@ -20,3 +24,4 @@ export { UserAuthenticationProvider, useUserAuthentication };
export { ImageViewerContext, ImageViewerProvider, useImageViewer };
export { DragAndDropProvider };
export { CineProvider, useCine };
export { ViewportActionCornersProvider, useViewportActionCorners };

View File

@ -0,0 +1,18 @@
import { ReactNode } from 'react';
import { ViewportActionCornersLocations } from '../components/Viewport/ViewportActionCorners';
export type ActionComponentInfo = {
viewportId: string;
id: string;
component: ReactNode;
location: ViewportActionCornersLocations;
indexPriority?: number;
isLocked?: boolean;
isOpen?: boolean;
isVisible?: boolean;
};
export type AlignAndSide = {
align: 'start' | 'end' | 'center';
side: 'top' | 'bottom' | 'left' | 'right';
};

View File

@ -5,6 +5,7 @@ import ThumbnailType from './ThumbnailType';
export * from './Predicate';
export * from './ContextMenuItem';
export * from './ViewportActionCornersTypes';
export * from './ActionCorners';
/**
* StringNumber often comes back from DICOMweb for integer valued items.