From 1445e1212415e6e6957fce4a8fc6908aed69a93c Mon Sep 17 00:00:00 2001 From: Alireza Date: Thu, 8 May 2025 12:51:30 -0400 Subject: [PATCH] feat(app): Decouple colorbar UI from service and enable action menu programatic control (#5018) --- .../src/Viewport/OHIFCornerstoneViewport.tsx | 17 +- .../components/OHIFViewportActionCorners.tsx | 55 +- .../AdvancedColorbarWithControls.tsx | 117 ++++ .../ViewportColorbar/ViewportColorbar.tsx | 126 ++++ .../ViewportColorbarsContainer.tsx | 140 +++++ .../src/components/ViewportColorbar/index.ts | 6 + .../ViewportDataOverlayMenuWrapper.tsx | 35 +- .../ViewportDataOverlaySettingMenu/index.tsx | 1 + .../ViewportOrientationMenu.tsx | 29 +- .../WindowLevelActionMenu/Colorbar.tsx | 9 +- .../WindowLevelActionMenu.tsx | 25 +- .../WindowLevelActionMenuWrapper.tsx | 138 +++++ .../getWindowLevelActionMenu.tsx | 62 +- .../components/WindowLevelActionMenu/index.ts | 11 + .../cornerstone/src/components/index.ts | 5 +- .../src/components/menus/menu-ids.ts | 5 + .../ViewportActionCornersProvider.tsx | 180 ------ .../customizations/colorbarCustomization.ts | 94 ++- .../viewportActionMenuCustomizations.ts | 18 +- extensions/cornerstone/src/hooks/index.ts | 3 +- .../src/hooks/useViewportActionCorners.ts | 113 ---- extensions/cornerstone/src/index.tsx | 8 +- .../ColorbarService/ColorbarService.ts | 219 +++---- .../ViewportActionCornersService.ts | 132 ++++- extensions/cornerstone/src/types/Colorbar.ts | 97 ++- .../viewportActionCornersCustomization.ts | 5 - .../default/src/getCustomizationModule.tsx | 2 - platform/app/src/App.tsx | 3 + platform/app/src/components/ViewportGrid.tsx | 12 +- platform/app/src/hooks/index.js | 5 +- platform/app/src/hooks/index.ts | 5 + .../hooks/useViewportActionCornersWithGrid.ts | 112 ++++ platform/app/src/routes/Mode/Mode.tsx | 2 +- platform/app/src/routes/WorkList/WorkList.tsx | 2 +- platform/core/src/types/DisplaySet.ts | 2 + .../sampleCustomizations.tsx | 29 +- .../Viewport/ViewportActionCorners.tsx | 133 ++++- .../ViewportActionCornersProvider.tsx | 550 ++++++++++++++++++ .../ui-next/src/contextProviders/index.ts | 5 + platform/ui-next/src/types/ActionCorners.ts | 18 + platform/ui-next/src/types/index.ts | 1 + 41 files changed, 1872 insertions(+), 659 deletions(-) create mode 100644 extensions/cornerstone/src/components/ViewportColorbar/AdvancedColorbarWithControls.tsx create mode 100644 extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbar.tsx create mode 100644 extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx create mode 100644 extensions/cornerstone/src/components/ViewportColorbar/index.ts create mode 100644 extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenuWrapper.tsx create mode 100644 extensions/cornerstone/src/components/WindowLevelActionMenu/index.ts create mode 100644 extensions/cornerstone/src/components/menus/menu-ids.ts delete mode 100644 extensions/cornerstone/src/contextProviders/ViewportActionCornersProvider.tsx delete mode 100644 extensions/cornerstone/src/hooks/useViewportActionCorners.ts delete mode 100644 extensions/default/src/customizations/viewportActionCornersCustomization.ts create mode 100644 platform/app/src/hooks/index.ts create mode 100644 platform/app/src/hooks/useViewportActionCornersWithGrid.ts create mode 100644 platform/ui-next/src/contextProviders/ViewportActionCornersProvider.tsx create mode 100644 platform/ui-next/src/types/ActionCorners.ts diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 73bb70375..c03e51be8 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -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} /> + {/* top offset of 24px to account for ViewportActionCorners. */}
diff --git a/extensions/cornerstone/src/components/OHIFViewportActionCorners.tsx b/extensions/cornerstone/src/components/OHIFViewportActionCorners.tsx index ab60510c0..e5bafac42 100644 --- a/extensions/cornerstone/src/components/OHIFViewportActionCorners.tsx +++ b/extensions/cornerstone/src/components/OHIFViewportActionCorners.tsx @@ -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 ( + + {cornerComponents + .filter(componentInfo => componentInfo.isVisible !== false) + .map(componentInfo => ( +
+ {componentInfo.component} +
+ ))} +
+ ); + }; + return ( - + + {renderCorner(ViewportActionCornersLocations.topLeft, ViewportActionCorners.TopLeft)} + {renderCorner(ViewportActionCornersLocations.topRight, ViewportActionCorners.TopRight)} + {renderCorner(ViewportActionCornersLocations.bottomLeft, ViewportActionCorners.BottomLeft)} + {renderCorner(ViewportActionCornersLocations.bottomRight, ViewportActionCorners.BottomRight)} + ); } diff --git a/extensions/cornerstone/src/components/ViewportColorbar/AdvancedColorbarWithControls.tsx b/extensions/cornerstone/src/components/ViewportColorbar/AdvancedColorbarWithControls.tsx new file mode 100644 index 000000000..a6f2e00ac --- /dev/null +++ b/extensions/cornerstone/src/components/ViewportColorbar/AdvancedColorbarWithControls.tsx @@ -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; +}; + +/** + * 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 ( +
+
+ + + +
+ +
+ {colorbars.map((colorbarInfo, index) => { + const { colorbar, displaySetInstanceUID } = colorbarInfo; + return ( + + ); + })} +
+ +
+ + +
+
+ ); +}; + +export default AdvancedColorbarWithControls; diff --git a/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbar.tsx b/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbar.tsx new file mode 100644 index 000000000..58c1d9baf --- /dev/null +++ b/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbar.tsx @@ -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; +}; + +/** + * 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(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 ( +
+ ); +}; + +export default ViewportColorbar; diff --git a/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx b/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx new file mode 100644 index 000000000..953c47fbb --- /dev/null +++ b/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx @@ -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; +}; + +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([]); + 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 ( +
+ {position !== 'bottom' ? ( +
+ {colorbars.map((colorbarInfo, index) => { + const { colorbar, displaySetInstanceUID } = colorbarInfo; + return ( + + ); + })} +
+ ) : ( + + )} +
+ ); +}; + +export default ViewportColorbarsContainer; diff --git a/extensions/cornerstone/src/components/ViewportColorbar/index.ts b/extensions/cornerstone/src/components/ViewportColorbar/index.ts new file mode 100644 index 000000000..ae84d89bf --- /dev/null +++ b/extensions/cornerstone/src/components/ViewportColorbar/index.ts @@ -0,0 +1,6 @@ +import ViewportColorbar from './ViewportColorbar'; +import ViewportColorbarsContainer from './ViewportColorbarsContainer'; +import AdvancedColorbarWithControls from './AdvancedColorbarWithControls'; + +export { ViewportColorbar, ViewportColorbarsContainer, AdvancedColorbarWithControls }; +export default ViewportColorbarsContainer; diff --git a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper.tsx b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper.tsx index efa155203..a2087774c 100644 --- a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper.tsx +++ b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper.tsx @@ -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 ( - + ): ReactNode { return ; diff --git a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx index 0ea4160f7..1abe593b5 100644 --- a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx +++ b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx @@ -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 ( - + + + + + + + ); +} + +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 }; +}; diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx index 0bb74feea..7b8897cb9 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx @@ -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 ( - - ); -} - -export function getWindowLevelActionMenu(props: WindowLevelActionMenuProps) { - return ; +export function getWindowLevelActionMenu( + props: withAppTypes<{ + viewportId: string; + element: HTMLElement; + location: string; + displaySets: Array; + }> +) { + return ; } diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/index.ts b/extensions/cornerstone/src/components/WindowLevelActionMenu/index.ts new file mode 100644 index 000000000..7e654c993 --- /dev/null +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/index.ts @@ -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'; diff --git a/extensions/cornerstone/src/components/index.ts b/extensions/cornerstone/src/components/index.ts index a3c25a7b7..6c5fceec7 100644 --- a/extensions/cornerstone/src/components/index.ts +++ b/extensions/cornerstone/src/components/index.ts @@ -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 }; diff --git a/extensions/cornerstone/src/components/menus/menu-ids.ts b/extensions/cornerstone/src/components/menus/menu-ids.ts new file mode 100644 index 000000000..230096bc4 --- /dev/null +++ b/extensions/cornerstone/src/components/menus/menu-ids.ts @@ -0,0 +1,5 @@ +export const MENU_IDS = { + ORIENTATION_MENU: 'orientationMenu', + DATA_OVERLAY_MENU: 'dataOverlayMenu', + WINDOW_LEVEL_MENU: 'windowLevelActionMenu', +}; diff --git a/extensions/cornerstone/src/contextProviders/ViewportActionCornersProvider.tsx b/extensions/cornerstone/src/contextProviders/ViewportActionCornersProvider.tsx deleted file mode 100644 index cdef450ac..000000000 --- a/extensions/cornerstone/src/contextProviders/ViewportActionCornersProvider.tsx +++ /dev/null @@ -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>>; - -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) => { - 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 ( - - {children} - - ); -} - -ViewportActionCornersProvider.propTypes = { - children: PropTypes.node, - service: PropTypes.instanceOf(ViewportActionCornersService).isRequired, -}; - -export const useViewportActionCornersContext = () => useContext(ViewportActionCornersContext); diff --git a/extensions/cornerstone/src/customizations/colorbarCustomization.ts b/extensions/cornerstone/src/customizations/colorbarCustomization.ts index c5bd6955d..d44aa7bd0 100644 --- a/extensions/cornerstone/src/customizations/colorbarCustomization.ts +++ b/extensions/cornerstone/src/customizations/colorbarCustomization.ts @@ -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 = { + colorbarTickPosition: getTickPositionForPosition(defaultPosition), + colormaps: colormaps as unknown as Record, + colorbarContainerPosition: defaultPosition, + colorbarInitialColormap: DefaultColormap, + positionStyles, + positionTickStyles, + containerStyles, + tickStyles, +}; + +export default { + 'cornerstone.colorbar': colorbarConfig, +}; diff --git a/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts b/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts index 7d91d47fd..af90eadf9 100644 --- a/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts +++ b/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts @@ -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, }, ], diff --git a/extensions/cornerstone/src/hooks/index.ts b/extensions/cornerstone/src/hooks/index.ts index 033962d35..ebf70eb49 100644 --- a/extensions/cornerstone/src/hooks/index.ts +++ b/extensions/cornerstone/src/hooks/index.ts @@ -1,5 +1,4 @@ -export { default as useViewportActionCorners } from './useViewportActionCorners'; export * from './useViewportActionCorners'; export * from './useActiveViewportSegmentationRepresentations'; export * from './useMeasurements'; -export * from './useSegmentations'; \ No newline at end of file +export * from './useSegmentations'; diff --git a/extensions/cornerstone/src/hooks/useViewportActionCorners.ts b/extensions/cornerstone/src/hooks/useViewportActionCorners.ts deleted file mode 100644 index 765053e73..000000000 --- a/extensions/cornerstone/src/hooks/useViewportActionCorners.ts +++ /dev/null @@ -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; - 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; diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 9deebc1e4..f2f54db70 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -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 { - 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); diff --git a/extensions/cornerstone/src/services/ColorbarService/ColorbarService.ts b/extensions/cornerstone/src/services/ColorbarService/ColorbarService.ts index 3514104b3..638a7132f 100644 --- a/extensions/cornerstone/src/services/ColorbarService/ColorbarService.ts +++ b/extensions/cornerstone/src/services/ColorbarService/ColorbarService.ts @@ -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; - } } diff --git a/extensions/cornerstone/src/services/ViewportActionCornersService/ViewportActionCornersService.ts b/extensions/cornerstone/src/services/ViewportActionCornersService/ViewportActionCornersService.ts index 0c95194c9..6c6ca1e5a 100644 --- a/extensions/cornerstone/src/services/ViewportActionCornersService/ViewportActionCornersService.ts +++ b/extensions/cornerstone/src/services/ViewportActionCornersService/ViewportActionCornersService.ts @@ -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) { + public addComponents(components: Array) { 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' }; diff --git a/extensions/cornerstone/src/types/Colorbar.ts b/extensions/cornerstone/src/types/Colorbar.ts index 9eea7553c..318d455c4 100644 --- a/extensions/cornerstone/src/types/Colorbar.ts +++ b/extensions/cornerstone/src/types/Colorbar.ts @@ -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; + position: ColorbarPositionType; + colormaps: Record; activeColormapName: string; - ticks: object; + ticks?: TickConfigType; width: string; }; +// Props for the Colorbar component export type ColorbarProps = { viewportId: string; displaySets: Array; colorbarProperties: ColorbarProperties; }; +// Extended properties with styling options export type ColorbarProperties = { width: string; - colorbarTickPosition: string; - colorbarContainerPosition: string; - colormaps: Array; + colorbarTickPosition: TickPositionType; + colorbarContainerPosition: ColorbarPositionType; + colormaps: Record; 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; + colorbarInitialColormap: string; + + // Styling properties + positionStyles: PositionStylesMapType; + positionTickStyles: PositionTickStylesMapType; + containerStyles: ContainerStyleType; + tickStyles: TickStyleType; +} + +// Event types for colorbar changes export enum ChangeTypes { Removed = 'removed', Added = 'added', diff --git a/extensions/default/src/customizations/viewportActionCornersCustomization.ts b/extensions/default/src/customizations/viewportActionCornersCustomization.ts deleted file mode 100644 index 0e99f9e2d..000000000 --- a/extensions/default/src/customizations/viewportActionCornersCustomization.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ViewportActionCorners } from '@ohif/ui-next'; - -export default { - 'ui.viewportActionCorner': ViewportActionCorners, -}; diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index a3e581eb9..8071cc7b2 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -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, diff --git a/platform/app/src/App.tsx b/platform/app/src/App.tsx index 8f8727463..4963b494b 100644 --- a/platform/app/src/App.tsx +++ b/platform/app/src/App.tsx @@ -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], ]; diff --git a/platform/app/src/components/ViewportGrid.tsx b/platform/app/src/components/ViewportGrid.tsx index bbc623647..7687d5c4b 100644 --- a/platform/app/src/components/ViewportGrid.tsx +++ b/platform/app/src/components/ViewportGrid.tsx @@ -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); + } }} />
@@ -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 diff --git a/platform/app/src/hooks/index.js b/platform/app/src/hooks/index.js index b2e97562e..44163c188 100644 --- a/platform/app/src/hooks/index.js +++ b/platform/app/src/hooks/index.js @@ -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 }; diff --git a/platform/app/src/hooks/index.ts b/platform/app/src/hooks/index.ts new file mode 100644 index 000000000..2eb9011e0 --- /dev/null +++ b/platform/app/src/hooks/index.ts @@ -0,0 +1,5 @@ +import useViewportActionCornersWithGrid from './useViewportActionCornersWithGrid'; +import useDebounce from './useDebounce'; +import useSearchParams from './useSearchParams'; + +export { useViewportActionCornersWithGrid, useDebounce, useSearchParams }; diff --git a/platform/app/src/hooks/useViewportActionCornersWithGrid.ts b/platform/app/src/hooks/useViewportActionCornersWithGrid.ts new file mode 100644 index 000000000..d0f75092c --- /dev/null +++ b/platform/app/src/hooks/useViewportActionCornersWithGrid.ts @@ -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>(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, + 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 }; +} diff --git a/platform/app/src/routes/Mode/Mode.tsx b/platform/app/src/routes/Mode/Mode.tsx index 11efb98c6..94f1a1e34 100644 --- a/platform/app/src/routes/Mode/Mode.tsx +++ b/platform/app/src/routes/Mode/Mode.tsx @@ -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'; diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index a37027595..0ea85fa38 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -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 { diff --git a/platform/core/src/types/DisplaySet.ts b/platform/core/src/types/DisplaySet.ts index 4bda89ac5..2c410d8ab 100644 --- a/platform/core/src/types/DisplaySet.ts +++ b/platform/core/src/types/DisplaySet.ts @@ -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: /** diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx index 4b70c0252..1bb0b3d10 100644 --- a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx +++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx @@ -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. diff --git a/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx b/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx index 4e44953e1..79738127b 100644 --- a/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx +++ b/platform/ui-next/src/components/Viewport/ViewportActionCorners.tsx @@ -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 ( -
{ - event.preventDefault(); - event.stopPropagation(); - }} - > - {Object.entries(cornerComponents).map(([location, locationArray]) => ( -
- {locationArray.map(componentInfo => ( -
{componentInfo.component}
- ))} -
- ))} -
+ + {children} +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + {Object.entries(corners).map(([location, children]) => { + if (!children) { + return null; + } + return ( +
+ {children} +
+ ); + })} +
+
); } -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 {children}; +} + +function TopRight({ children }: { children: ReactNode }) { + return {children}; +} + +function BottomLeft({ children }: { children: ReactNode }) { + return {children}; +} + +function BottomRight({ children }: { children: ReactNode }) { + return {children}; +} + +export const ViewportActionCorners = { + Container, + TopLeft, + TopRight, + BottomLeft, + BottomRight, +}; diff --git a/platform/ui-next/src/contextProviders/ViewportActionCornersProvider.tsx b/platform/ui-next/src/contextProviders/ViewportActionCornersProvider.tsx new file mode 100644 index 000000000..55fe7dc2d --- /dev/null +++ b/platform/ui-next/src/contextProviders/ViewportActionCornersProvider.tsx @@ -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>, +}; + +/** + * 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) => 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) => { + 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 ( + + {children} + + ); +} + +// Custom hook to use the ViewportActionCorners context +export const useViewportActionCorners = () => useContext(ViewportActionCornersContext); diff --git a/platform/ui-next/src/contextProviders/index.ts b/platform/ui-next/src/contextProviders/index.ts index 178f1d953..72a4726c3 100644 --- a/platform/ui-next/src/contextProviders/index.ts +++ b/platform/ui-next/src/contextProviders/index.ts @@ -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 }; diff --git a/platform/ui-next/src/types/ActionCorners.ts b/platform/ui-next/src/types/ActionCorners.ts new file mode 100644 index 000000000..06a4c5b38 --- /dev/null +++ b/platform/ui-next/src/types/ActionCorners.ts @@ -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'; +}; diff --git a/platform/ui-next/src/types/index.ts b/platform/ui-next/src/types/index.ts index 14053383d..8e18f5e7f 100644 --- a/platform/ui-next/src/types/index.ts +++ b/platform/ui-next/src/types/index.ts @@ -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.