From 7c15bb8901001eefedc72498176ac24f6662bfab Mon Sep 17 00:00:00 2001 From: Alireza Date: Thu, 15 May 2025 12:08:12 -0400 Subject: [PATCH] feat(actions): simplify action corner api through the toolbarService (#5033) --- bun.lock | 288 +++--- .../cornerstone-dicom-pmap/package.json | 4 +- .../viewports/OHIFCornerstonePMAPViewport.tsx | 20 +- .../src/utils/promptHydrateRT.ts | 88 +- .../viewports/OHIFCornerstoneRTViewport.tsx | 254 ++--- .../src/viewports/_getStatusComponent.tsx | 59 -- extensions/cornerstone-dicom-seg/package.json | 4 +- .../src/utils/promptHydrateSEG.ts | 89 +- .../viewports/OHIFCornerstoneSEGViewport.tsx | 174 +--- .../src/viewports/_getStatusComponent.tsx | 59 -- extensions/cornerstone-dicom-sr/package.json | 6 +- .../src/commandsModule.ts | 28 +- .../OHIFCornerstoneSRMeasurementViewport.tsx | 249 +---- .../cornerstone-dicom-sr/src/onModeEnter.tsx | 26 +- .../src/utils/addSRAnnotation.ts | 1 - .../cornerstone-dynamic-volume/package.json | 4 +- extensions/cornerstone/package.json | 14 +- .../src/Viewport/OHIFCornerstoneViewport.tsx | 56 +- .../Overlays/ViewportOrientationMarkers.css | 10 +- extensions/cornerstone/src/commandsModule.ts | 128 ++- .../AdvancedRenderingControls.tsx | 116 +++ .../AdvancedRenderingControls/index.ts | 1 + .../ModalityLoadBadge/ModalityLoadBadge.tsx | 168 ++++ .../src/components/ModalityLoadBadge/index.ts | 1 + .../NavigationComponent.tsx | 151 +++ .../components/NavigationComponent/index.ts | 3 + .../components/OHIFViewportActionCorners.tsx | 121 ++- .../components/OpacityMenu/OpacityMenu.tsx | 54 ++ .../OpacityMenu/OpacityMenuWrapper.tsx | 85 ++ .../src/components/OpacityMenu/index.ts | 2 + .../src/components/SelectItemWithModality.tsx | 21 + .../ThresholdMenu/ThresholdMenu.tsx | 104 +++ .../ThresholdMenu/ThresholdMenuWrapper.tsx | 85 ++ .../src/components/ThresholdMenu/index.ts | 2 + .../TrackingStatus/TrackingStatus.tsx | 32 + .../src/components/TrackingStatus/index.ts | 1 + .../VOIManualControlMenu.tsx | 190 ++++ .../VOIManualControlMenuWrapper.tsx | 85 ++ .../components/VOIManualControlMenu/index.ts | 2 + .../AdvancedColorbarWithControls.tsx | 117 --- .../ViewportColorbar/ViewportColorbar.tsx | 90 +- .../ViewportColorbarsContainer.tsx | 142 ++- .../src/components/ViewportColorbar/index.ts | 3 +- .../ViewportDataOverlayMenu.tsx | 66 +- .../ViewportDataOverlayMenuWrapper.tsx | 79 +- .../ViewportDataOverlaySettingMenu/index.tsx | 12 - .../ViewportDataOverlaySettingMenu/utils.ts | 10 +- .../ViewportOrientationMenu.tsx | 174 ++-- .../ViewportOrientationMenuWrapper.tsx | 42 +- .../ViewportOrientationMenu/index.tsx | 14 - .../ViewportWindowLevel.tsx | 9 +- .../WindowLevelActionMenu/Colorbar.tsx | 108 +-- .../WindowLevelActionMenu/Colormap.tsx | 182 ++-- .../VolumeRenderingOptions.tsx | 8 +- .../VolumeRenderingPresets.tsx | 8 +- .../WindowLevelActionMenu/WindowLevel.tsx | 160 +++- .../WindowLevelActionMenu.tsx | 167 +--- .../WindowLevelActionMenuWrapper.tsx | 169 ++-- .../getWindowLevelActionMenu.tsx | 13 - .../cornerstone/src/components/index.ts | 3 +- .../src/components/menus/menu-ids.ts | 5 - .../customizations/colorbarCustomization.ts | 8 +- .../viewportActionMenuCustomizations.ts | 53 -- .../src/getCustomizationModule.tsx | 2 - .../cornerstone/src/getToolbarModule.tsx | 339 ++++++- extensions/cornerstone/src/hooks/index.ts | 6 +- ...tiveViewportSegmentationRepresentations.ts | 218 +---- .../src/hooks/useMeasurementTracking.ts | 132 +++ .../cornerstone/src/hooks/useMeasurements.ts | 7 +- .../src/hooks/useViewportDisplaySets.ts | 36 +- .../cornerstone/src/hooks/useViewportHover.ts | 77 ++ .../src/hooks/useViewportRendering.tsx | 882 ++++++++++++++++++ .../src/hooks/useViewportSegmentations.ts | 221 +++++ extensions/cornerstone/src/index.tsx | 5 +- extensions/cornerstone/src/init.tsx | 5 + .../src/panels/PanelMeasurement.tsx | 8 +- .../src/panels/PanelSegmentation.tsx | 4 +- .../SegmentationService.ts | 3 +- .../ViewportActionCornersService.ts | 160 ---- .../CornerstoneViewportService.ts | 10 +- extensions/cornerstone/src/types/AppTypes.ts | 7 +- .../src/types/CornerstoneServices.ts | 2 - .../cornerstone/src/types/VolumeLighting.ts | 7 + .../src/types/VolumeRenderingConfig.ts | 8 + extensions/cornerstone/src/types/index.ts | 12 +- extensions/cornerstone/src/utils/index.ts | 8 + .../measurementServiceMappings/Length.ts | 2 +- .../LivewireContour.ts | 40 +- .../src/utils/promptHydrationDialog.ts | 189 ++++ .../cornerstone/src/utils/segmentUtils.ts | 5 +- .../ProgressDropdownWithService.tsx | 4 +- .../Panels/StudyBrowser/PanelStudyBrowser.tsx | 10 +- .../default/src/Toolbar/ToolBoxWrapper.tsx | 6 +- .../src/Toolbar/ToolButtonListWrapper.tsx | 23 +- .../default/src/Toolbar/ToolButtonWrapper.tsx | 32 + .../default/src/Toolbar/ToolRowWrapper.tsx | 41 + extensions/default/src/Toolbar/Toolbar.tsx | 43 +- .../ToolbarButtonGroupWithServices.tsx | 37 - .../ToolbarSplitButtonWithServices.tsx | 89 -- extensions/default/src/Toolbar/index.ts | 7 + .../default/src/ViewerLayout/ViewerHeader.tsx | 9 +- extensions/default/src/getToolbarModule.tsx | 13 +- .../default/src/hooks/usePatientInfo.tsx | 5 +- extensions/default/src/init.ts | 11 +- extensions/default/src/utils/Toolbox.tsx | 1 - .../viewports/OHIFCornerstonePdfViewport.tsx | 14 +- extensions/measurement-tracking/package.json | 4 +- .../TrackedMeasurementsContext.tsx | 14 +- .../promptHydrateStructuredReport.js | 97 -- .../promptHydrateStructuredReport.ts | 30 + extensions/measurement-tracking/src/index.tsx | 43 +- .../panels/PanelMeasurementTableTracking.tsx | 8 +- .../TrackedMeasurementsService.ts | 168 ++++ .../TrackedMeasurementsService/index.ts | 1 + .../src/services/index.ts | 1 + .../src/types/AppTypes.ts | 11 + .../measurement-tracking/src/types/index.ts | 1 + .../viewports/TrackedCornerstoneViewport.tsx | 82 +- extensions/tmtv/src/Panels/PanelPetSUV.tsx | 7 +- .../PanelROIThresholdExport.tsx | 2 +- modes/basic-dev-mode/src/index.ts | 4 +- modes/basic-dev-mode/src/toolbarButtons.ts | 2 - modes/basic-test-mode/src/index.ts | 45 +- modes/basic-test-mode/src/toolbarButtons.ts | 137 ++- modes/longitudinal/src/index.ts | 35 +- modes/longitudinal/src/toolbarButtons.ts | 141 ++- modes/microscopy/src/index.tsx | 6 +- modes/microscopy/src/toolbarButtons.ts | 1 - modes/preclinical-4d/src/index.tsx | 36 +- modes/preclinical-4d/src/toolbarButtons.tsx | 150 ++- modes/segmentation/src/index.tsx | 43 +- modes/segmentation/src/toolbarButtons.ts | 141 ++- modes/tmtv/src/index.ts | 42 +- modes/tmtv/src/toolbarButtons.ts | 140 ++- package.json | 2 +- platform/app/package.json | 2 +- platform/app/src/App.tsx | 5 +- platform/app/src/components/ViewportGrid.tsx | 10 +- platform/app/src/hooks/index.js | 3 +- platform/app/src/hooks/index.ts | 3 +- .../hooks/useViewportActionCornersWithGrid.ts | 112 --- platform/app/src/routes/Mode/Mode.tsx | 4 +- platform/cli/templates/mode/src/index.tsx | 8 +- platform/core/package.json | 4 +- .../core/src/extensions/ExtensionManager.ts | 4 + platform/core/src/hooks/index.ts | 5 + platform/core/src/hooks/types.ts | 27 + .../src/hooks/useActiveViewportDisplaySets.ts | 5 +- platform/core/src/hooks/useToolbar.tsx | 180 +++- .../src/hooks/useViewportMousePosition.ts | 67 ++ platform/core/src/hooks/useViewportRef.ts | 58 ++ platform/core/src/hooks/useViewportSize.ts | 97 ++ platform/core/src/index.ts | 7 +- .../services/ToolBarService/ToolbarService.ts | 163 +++- .../core/src/services/ToolBarService/index.ts | 4 +- .../core/src/services/ToolBarService/types.ts | 16 + .../WorkflowStepsService.ts | 2 +- platform/core/src/types/DisplaySet.ts | 10 + .../migration-guide/3p10-to-3p11/commands.md | 72 ++ .../migration-guide/3p10-to-3p11/hydration.md | 85 ++ .../migration-guide/3p10-to-3p11/index.md | 14 - .../3p10-to-3p11/toolbarService.md | 153 +++ .../docs/migration-guide/3p10-to-3p11/ui.md | 60 ++ .../3p10-to-3p11/viewport-action-menu.md | 194 ++-- platform/docs/docs/platform/hooks/index.md | 33 + .../platform/hooks/useDynamicMaxHeight.md | 65 ++ .../platform/hooks/useMeasurementTracking.md | 79 ++ .../docs/platform/hooks/useMeasurements.md | 68 ++ .../docs/platform/hooks/usePatientInfo.md | 68 ++ .../docs/platform/hooks/useSearchParams.md | 69 ++ .../docs/platform/hooks/useSessionStorage.md | 84 ++ .../platform/hooks/useViewportDisplaySets.md | 98 ++ .../docs/platform/hooks/useViewportHover.md | 57 ++ .../hooks/useViewportSegmentations.md | 95 ++ .../sampleCustomizations.tsx | 76 -- .../src/components/AllInOneMenu/Menu.tsx | 23 +- .../src/components/CinePlayer/CinePlayer.tsx | 31 +- .../components/DoubleSlider/DoubleSlider.tsx | 259 +---- .../ui-next/src/components/Header/Header.tsx | 132 +-- .../ui-next/src/components/Icons/Icons.tsx | 6 + .../src/components/Icons/Sources/Close.tsx | 20 +- .../src/components/Icons/Sources/Opacity.tsx | 106 +++ .../components/Icons/Sources/Threshold.tsx | 38 + .../Icons/Sources/WindowLevelAdvanced.tsx | 30 + .../src/components/Numeric/Numeric.tsx | 7 +- .../ui-next/src/components/Select/Select.tsx | 8 +- .../src/components/ToolButton/ToolButton.tsx | 18 +- .../components/ToolButton/ToolButtonList.tsx | 1 - .../Viewport/ViewportActionCorners.tsx | 49 +- .../components/Viewport/ViewportOverlay.css | 2 +- .../IconPresentationProvider.tsx | 88 ++ .../ViewportActionCornersProvider.tsx | 550 ----------- .../ui-next/src/contextProviders/index.ts | 6 +- tests/SRHydration.spec.ts | 3 + .../threeDFourUpDisplayedCorrectly.png | Bin 330575 -> 331204 bytes .../threeDMainDisplayedCorrectly.png | Bin 295891 -> 296128 bytes .../threeDOnlyDisplayedCorrectly.png | Bin 310982 -> 311017 bytes .../threeDPrimaryDisplayedCorrectly.png | Bin 304673 -> 304125 bytes .../Angle.spec.ts/angleDisplayedCorrectly.png | Bin 255605 -> 255906 bytes .../axialPrimaryDisplayedCorrectly.png | Bin 323115 -> 322870 bytes .../bidirectionalDisplayedCorrectly.png | Bin 257877 -> 258225 bytes .../circleDisplayedCorrectly.png | Bin 262317 -> 262673 bytes .../cobbangleDisplayedCorrectly.png | Bin 255509 -> 255816 bytes .../crosshairsNewDisplayset.png | Bin 255641 -> 255772 bytes .../Crosshairs.spec.ts/crosshairsRendered.png | Bin 214970 -> 215068 bytes .../crosshairsResetToolbar.png | Bin 212035 -> 212131 bytes .../Crosshairs.spec.ts/crosshairsRotated.png | Bin 213912 -> 213942 bytes .../crosshairsSlabThickness.png | Bin 188472 -> 188900 bytes .../dicomTagBrowserDisplayedCorrectly.png | Bin 148042 -> 148123 bytes .../ellipseDisplayedCorrectly.png | Bin 261652 -> 261985 bytes .../flipHorizontalDisplayedCorrectly.png | Bin 134906 -> 135050 bytes .../invertDisplayedCorrectly.png | Bin 252756 -> 253391 bytes ...jumpToMeasurementMPR-changeSeriesInMPR.png | Bin 274491 -> 275037 bytes .../jumpToMeasurementMPR-initialDraw.png | Bin 223054 -> 223348 bytes .../jumpToMeasurementMPR-jumpInMPR.png | Bin 202997 -> 203034 bytes ...MPR-jumpToMeasurementAfterSeriesChange.png | Bin 202997 -> 203034 bytes ...oMeasurementMPR-jumpToMeasurementStack.png | Bin 222990 -> 223287 bytes .../jumpToMeasurementMPR-scrollAway.png | Bin 139244 -> 139621 bytes .../lengthDisplayedCorrectly.png | Bin 254626 -> 254957 bytes .../livewireDisplayedCorrectly.png | Bin 259601 -> 259860 bytes .../MPR.spec.ts/mprDisplayedCorrectly.png | Bin 212036 -> 211899 bytes .../Probe.spec.ts/probeDisplayedCorrectly.png | Bin 255855 -> 256178 bytes .../RTHydration.spec.ts/rtJumpToStructure.png | Bin 88222 -> 88975 bytes .../RTHydration.spec.ts/rtPostHydration.png | Bin 86654 -> 86847 bytes .../RTHydration.spec.ts/rtPreHydration.png | Bin 91828 -> 91966 bytes .../RTHydration2.spec.ts/rtPostHydration.png | Bin 95613 -> 95493 bytes .../RTHydration2.spec.ts/rtPreHydration.png | Bin 96560 -> 97840 bytes .../rectangleDisplayedCorrectly.png | Bin 259741 -> 259985 bytes .../Reset.spec.ts/resetDisplayedCorrectly.png | Bin 135522 -> 135275 bytes .../rotateRightDisplayedCorrectly.png | Bin 240903 -> 241597 bytes .../SEGHydration.spec.ts/segPostHydration.png | Bin 255735 -> 256069 bytes .../SEGHydration.spec.ts/segPreHydration.png | Bin 227084 -> 227101 bytes .../mprAfterSEG.png | Bin 266903 -> 266218 bytes .../mprAfterSegHydrated.png | Bin 257840 -> 258068 bytes .../mprAfterSegHydratedAfterLayoutChange.png | Bin 304443 -> 305220 bytes .../mprBeforeSEG.png | Bin 218283 -> 218127 bytes .../segPostHydration.png | Bin 222330 -> 222226 bytes .../segPostHydrationMPRAxialPrimary.png | Bin 303197 -> 304718 bytes .../srJumpToMeasurement.png | Bin 288448 -> 287496 bytes .../SRHydration.spec.ts/srPostHydration.png | Bin 302619 -> 286803 bytes .../SRHydration.spec.ts/srPreHydration.png | Bin 222101 -> 222098 bytes .../splineDisplayedCorrectly.png | Bin 258597 -> 258836 bytes .../tmtvDisplayedCorrectly.png | Bin 101637 -> 0 bytes .../mpr2.spec.ts/mprDisplayedCorrectly.png | Bin 216798 -> 216685 bytes .../mprDisplayedCorrectlyZoomed.png | Bin 309380 -> 309378 bytes yarn.lock | 96 +- 246 files changed, 7930 insertions(+), 4270 deletions(-) delete mode 100644 extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx delete mode 100644 extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx create mode 100644 extensions/cornerstone/src/components/AdvancedRenderingControls/AdvancedRenderingControls.tsx create mode 100644 extensions/cornerstone/src/components/AdvancedRenderingControls/index.ts create mode 100644 extensions/cornerstone/src/components/ModalityLoadBadge/ModalityLoadBadge.tsx create mode 100644 extensions/cornerstone/src/components/ModalityLoadBadge/index.ts create mode 100644 extensions/cornerstone/src/components/NavigationComponent/NavigationComponent.tsx create mode 100644 extensions/cornerstone/src/components/NavigationComponent/index.ts create mode 100644 extensions/cornerstone/src/components/OpacityMenu/OpacityMenu.tsx create mode 100644 extensions/cornerstone/src/components/OpacityMenu/OpacityMenuWrapper.tsx create mode 100644 extensions/cornerstone/src/components/OpacityMenu/index.ts create mode 100644 extensions/cornerstone/src/components/SelectItemWithModality.tsx create mode 100644 extensions/cornerstone/src/components/ThresholdMenu/ThresholdMenu.tsx create mode 100644 extensions/cornerstone/src/components/ThresholdMenu/ThresholdMenuWrapper.tsx create mode 100644 extensions/cornerstone/src/components/ThresholdMenu/index.ts create mode 100644 extensions/cornerstone/src/components/TrackingStatus/TrackingStatus.tsx create mode 100644 extensions/cornerstone/src/components/TrackingStatus/index.ts create mode 100644 extensions/cornerstone/src/components/VOIManualControlMenu/VOIManualControlMenu.tsx create mode 100644 extensions/cornerstone/src/components/VOIManualControlMenu/VOIManualControlMenuWrapper.tsx create mode 100644 extensions/cornerstone/src/components/VOIManualControlMenu/index.ts delete mode 100644 extensions/cornerstone/src/components/ViewportColorbar/AdvancedColorbarWithControls.tsx delete mode 100644 extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/index.tsx delete mode 100644 extensions/cornerstone/src/components/ViewportOrientationMenu/index.tsx delete mode 100644 extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx delete mode 100644 extensions/cornerstone/src/components/menus/menu-ids.ts delete mode 100644 extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts create mode 100644 extensions/cornerstone/src/hooks/useMeasurementTracking.ts create mode 100644 extensions/cornerstone/src/hooks/useViewportHover.ts create mode 100644 extensions/cornerstone/src/hooks/useViewportRendering.tsx create mode 100644 extensions/cornerstone/src/hooks/useViewportSegmentations.ts delete mode 100644 extensions/cornerstone/src/services/ViewportActionCornersService/ViewportActionCornersService.ts create mode 100644 extensions/cornerstone/src/types/VolumeLighting.ts create mode 100644 extensions/cornerstone/src/types/VolumeRenderingConfig.ts create mode 100644 extensions/cornerstone/src/utils/promptHydrationDialog.ts create mode 100644 extensions/default/src/Toolbar/ToolButtonWrapper.tsx create mode 100644 extensions/default/src/Toolbar/ToolRowWrapper.tsx delete mode 100644 extensions/default/src/Toolbar/ToolbarButtonGroupWithServices.tsx delete mode 100644 extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx create mode 100644 extensions/default/src/Toolbar/index.ts delete mode 100644 extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js create mode 100644 extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.ts create mode 100644 extensions/measurement-tracking/src/services/TrackedMeasurementsService/TrackedMeasurementsService.ts create mode 100644 extensions/measurement-tracking/src/services/TrackedMeasurementsService/index.ts create mode 100644 extensions/measurement-tracking/src/services/index.ts create mode 100644 extensions/measurement-tracking/src/types/AppTypes.ts create mode 100644 extensions/measurement-tracking/src/types/index.ts delete mode 100644 platform/app/src/hooks/useViewportActionCornersWithGrid.ts create mode 100644 platform/core/src/hooks/index.ts create mode 100644 platform/core/src/hooks/types.ts create mode 100644 platform/core/src/hooks/useViewportMousePosition.ts create mode 100644 platform/core/src/hooks/useViewportRef.ts create mode 100644 platform/core/src/hooks/useViewportSize.ts create mode 100644 platform/docs/docs/migration-guide/3p10-to-3p11/commands.md create mode 100644 platform/docs/docs/migration-guide/3p10-to-3p11/hydration.md create mode 100644 platform/docs/docs/migration-guide/3p10-to-3p11/toolbarService.md create mode 100644 platform/docs/docs/migration-guide/3p10-to-3p11/ui.md create mode 100644 platform/docs/docs/platform/hooks/index.md create mode 100644 platform/docs/docs/platform/hooks/useDynamicMaxHeight.md create mode 100644 platform/docs/docs/platform/hooks/useMeasurementTracking.md create mode 100644 platform/docs/docs/platform/hooks/useMeasurements.md create mode 100644 platform/docs/docs/platform/hooks/usePatientInfo.md create mode 100644 platform/docs/docs/platform/hooks/useSearchParams.md create mode 100644 platform/docs/docs/platform/hooks/useSessionStorage.md create mode 100644 platform/docs/docs/platform/hooks/useViewportDisplaySets.md create mode 100644 platform/docs/docs/platform/hooks/useViewportHover.md create mode 100644 platform/docs/docs/platform/hooks/useViewportSegmentations.md create mode 100644 platform/ui-next/src/components/Icons/Sources/Opacity.tsx create mode 100644 platform/ui-next/src/components/Icons/Sources/Threshold.tsx create mode 100644 platform/ui-next/src/components/Icons/Sources/WindowLevelAdvanced.tsx create mode 100644 platform/ui-next/src/contextProviders/IconPresentationProvider.tsx delete mode 100644 platform/ui-next/src/contextProviders/ViewportActionCornersProvider.tsx delete mode 100644 tests/screenshots/chromium/TMTVRendering.spec.ts/tmtvDisplayedCorrectly.png diff --git a/bun.lock b/bun.lock index 22d42d7dd..be5ab0b1a 100644 --- a/bun.lock +++ b/bun.lock @@ -34,13 +34,13 @@ "optionalDependencies": { "@percy/cypress": "^3.1.1", "@playwright/test": "^1.48.0", - "cypress": "^14.1.0", + "cypress": "14.3.1", "cypress-file-upload": "^5.0.8", }, }, "addOns/externals/devDependencies": { "name": "@externals/devDependencies", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "@kitware/vtk.js": "32.12.0", @@ -127,22 +127,22 @@ }, "addOns/externals/dicom-microscopy-viewer": { "name": "@externals/dicom-microscopy-viewer", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "dicom-microscopy-viewer": "^0.46.1", }, }, "extensions/cornerstone": { "name": "@ohif/extension-cornerstone", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^3.12.2", - "@cornerstonejs/ai": "^3.12.2", - "@cornerstonejs/core": "^3.12.2", - "@cornerstonejs/labelmap-interpolation": "^3.12.2", - "@cornerstonejs/polymorphic-segmentation": "^3.12.2", - "@cornerstonejs/tools": "^3.12.2", + "@cornerstonejs/adapters": "^3.15.1", + "@cornerstonejs/ai": "^3.15.1", + "@cornerstonejs/core": "^3.15.1", + "@cornerstonejs/labelmap-interpolation": "^3.15.1", + "@cornerstonejs/polymorphic-segmentation": "^3.15.1", + "@cornerstonejs/tools": "^3.15.1", "@itk-wasm/morphological-contour-interpolation": "1.1.0", "@kitware/vtk.js": "32.12.0", "html2canvas": "^1.4.1", @@ -159,9 +159,9 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", - "@cornerstonejs/dicom-image-loader": "^3.12.2", - "@ohif/core": "3.11.0-beta.27", - "@ohif/ui": "3.11.0-beta.27", + "@cornerstonejs/dicom-image-loader": "^3.15.1", + "@ohif/core": "3.11.0-beta.32", + "@ohif/ui": "3.11.0-beta.32", "dcmjs": "*", "dicom-parser": "^1.8.21", "hammerjs": "^2.0.8", @@ -173,19 +173,19 @@ }, "extensions/cornerstone-dicom-pmap": { "name": "@ohif/extension-cornerstone-dicom-pmap", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^3.12.2", - "@cornerstonejs/core": "^3.12.2", + "@cornerstonejs/adapters": "^3.15.1", + "@cornerstonejs/core": "^3.15.1", "@kitware/vtk.js": "32.12.0", "react-color": "^2.19.3", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/i18n": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/i18n": "3.11.0-beta.32", "prop-types": "^15.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -196,16 +196,16 @@ }, "extensions/cornerstone-dicom-rt": { "name": "@ohif/extension-cornerstone-dicom-rt", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "react-color": "^2.19.3", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/i18n": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/i18n": "3.11.0-beta.32", "prop-types": "^15.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -216,19 +216,19 @@ }, "extensions/cornerstone-dicom-seg": { "name": "@ohif/extension-cornerstone-dicom-seg", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^3.12.2", - "@cornerstonejs/core": "^3.12.2", + "@cornerstonejs/adapters": "^3.15.1", + "@cornerstonejs/core": "^3.15.1", "@kitware/vtk.js": "32.12.0", "react-color": "^2.19.3", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/i18n": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/i18n": "3.11.0-beta.32", "prop-types": "^15.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -239,19 +239,19 @@ }, "extensions/cornerstone-dicom-sr": { "name": "@ohif/extension-cornerstone-dicom-sr", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^3.12.2", - "@cornerstonejs/core": "^3.12.2", - "@cornerstonejs/tools": "^3.12.2", + "@cornerstonejs/adapters": "^3.15.1", + "@cornerstonejs/core": "^3.15.1", + "@cornerstonejs/tools": "^3.15.1", "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone": "3.11.0-beta.27", - "@ohif/extension-measurement-tracking": "3.11.0-beta.27", - "@ohif/ui": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone": "3.11.0-beta.32", + "@ohif/extension-measurement-tracking": "3.11.0-beta.32", + "@ohif/ui": "3.11.0-beta.32", "dcmjs": "*", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -261,19 +261,19 @@ }, "extensions/cornerstone-dynamic-volume": { "name": "@ohif/extension-cornerstone-dynamic-volume", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/core": "^3.12.2", - "@cornerstonejs/tools": "^3.12.2", + "@cornerstonejs/core": "^3.15.1", + "@cornerstonejs/tools": "^3.15.1", "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/i18n": "3.11.0-beta.27", - "@ohif/ui": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/i18n": "3.11.0-beta.32", + "@ohif/ui": "3.11.0-beta.32", "dcmjs": "*", "dicom-parser": "^1.8.21", "hammerjs": "^2.0.8", @@ -283,7 +283,7 @@ }, "extensions/default": { "name": "@ohif/extension-default", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/calculate-suv": "^1.1.0", @@ -291,8 +291,8 @@ "lodash.uniqby": "^4.7.0", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/i18n": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/i18n": "3.11.0-beta.32", "dcmjs": "*", "dicomweb-client": "^0.10.4", "prop-types": "^15.6.2", @@ -306,7 +306,7 @@ }, "extensions/dicom-microscopy": { "name": "@ohif/extension-dicom-microscopy", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/codec-charls": "^1.2.3", @@ -317,10 +317,10 @@ "mathjs": "^12.4.2", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/i18n": "3.11.0-beta.27", - "@ohif/ui": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/i18n": "3.11.0-beta.32", + "@ohif/ui": "3.11.0-beta.32", "prop-types": "^15.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -331,14 +331,14 @@ }, "extensions/dicom-pdf": { "name": "@ohif/extension-dicom-pdf", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/ui": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/ui": "3.11.0-beta.32", "dcmjs": "*", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -348,14 +348,14 @@ }, "extensions/dicom-video": { "name": "@ohif/extension-dicom-video", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/ui": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/ui": "3.11.0-beta.32", "dcmjs": "*", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -365,20 +365,20 @@ }, "extensions/measurement-tracking": { "name": "@ohif/extension-measurement-tracking", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", - "@ohif/ui": "3.11.0-beta.27", + "@ohif/ui": "3.11.0-beta.32", "@xstate/react": "^3.2.2", "xstate": "^4.10.0", }, "peerDependencies": { - "@cornerstonejs/core": "^3.12.2", - "@cornerstonejs/tools": "^3.12.2", - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/ui": "3.11.0-beta.27", + "@cornerstonejs/core": "^3.15.1", + "@cornerstonejs/tools": "^3.15.1", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/ui": "3.11.0-beta.32", "classnames": "^2.3.2", "dcmjs": "*", "lodash.debounce": "^4.0.8", @@ -391,14 +391,14 @@ }, "extensions/test-extension": { "name": "@ohif/extension-test", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/ui": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/ui": "3.11.0-beta.32", "dcmjs": "0.38.0", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -408,7 +408,7 @@ }, "extensions/tmtv": { "name": "@ohif/extension-tmtv", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", @@ -425,7 +425,7 @@ }, "modes/basic-dev-mode": { "name": "@ohif/mode-basic-dev-mode", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -435,17 +435,17 @@ "webpack-merge": "^5.7.3", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone": "3.11.0-beta.27", - "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/extension-dicom-pdf": "3.11.0-beta.27", - "@ohif/extension-dicom-video": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone": "3.11.0-beta.32", + "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/extension-dicom-pdf": "3.11.0-beta.32", + "@ohif/extension-dicom-video": "3.11.0-beta.32", }, }, "modes/basic-test-mode": { "name": "@ohif/mode-test", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -455,19 +455,19 @@ "webpack-merge": "^5.7.3", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone": "3.11.0-beta.27", - "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/extension-dicom-pdf": "3.11.0-beta.27", - "@ohif/extension-dicom-video": "3.11.0-beta.27", - "@ohif/extension-measurement-tracking": "3.11.0-beta.27", - "@ohif/extension-test": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone": "3.11.0-beta.32", + "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/extension-dicom-pdf": "3.11.0-beta.32", + "@ohif/extension-dicom-video": "3.11.0-beta.32", + "@ohif/extension-measurement-tracking": "3.11.0-beta.32", + "@ohif/extension-test": "3.11.0-beta.32", }, }, "modes/longitudinal": { "name": "@ohif/mode-longitudinal", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -477,32 +477,32 @@ "webpack-merge": "^5.7.3", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone": "3.11.0-beta.27", - "@ohif/extension-cornerstone-dicom-rt": "3.11.0-beta.27", - "@ohif/extension-cornerstone-dicom-seg": "3.11.0-beta.27", - "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/extension-dicom-pdf": "3.11.0-beta.27", - "@ohif/extension-dicom-video": "3.11.0-beta.27", - "@ohif/extension-measurement-tracking": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone": "3.11.0-beta.32", + "@ohif/extension-cornerstone-dicom-rt": "3.11.0-beta.32", + "@ohif/extension-cornerstone-dicom-seg": "3.11.0-beta.32", + "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/extension-dicom-pdf": "3.11.0-beta.32", + "@ohif/extension-dicom-video": "3.11.0-beta.32", + "@ohif/extension-measurement-tracking": "3.11.0-beta.32", }, }, "modes/microscopy": { "name": "@ohif/mode-microscopy", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", }, "peerDependencies": { - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-dicom-microscopy": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-dicom-microscopy": "3.11.0-beta.32", }, }, "modes/preclinical-4d": { "name": "@ohif/mode-preclinical-4d", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", }, @@ -521,7 +521,7 @@ }, "modes/segmentation": { "name": "@ohif/mode-segmentation", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -565,7 +565,7 @@ }, "modes/tmtv": { "name": "@ohif/mode-tmtv", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -586,32 +586,32 @@ }, "platform/app": { "name": "@ohif/app", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/codec-charls": "^1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", - "@cornerstonejs/dicom-image-loader": "^3.12.2", + "@cornerstonejs/dicom-image-loader": "^3.15.1", "@emotion/serialize": "^1.1.3", - "@ohif/core": "3.11.0-beta.27", - "@ohif/extension-cornerstone": "3.11.0-beta.27", - "@ohif/extension-cornerstone-dicom-rt": "3.11.0-beta.27", - "@ohif/extension-cornerstone-dicom-seg": "3.11.0-beta.27", - "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.27", - "@ohif/extension-default": "3.11.0-beta.27", - "@ohif/extension-dicom-microscopy": "3.11.0-beta.27", - "@ohif/extension-dicom-pdf": "3.11.0-beta.27", - "@ohif/extension-dicom-video": "3.11.0-beta.27", - "@ohif/extension-test": "3.11.0-beta.27", - "@ohif/i18n": "3.11.0-beta.27", - "@ohif/mode-basic-dev-mode": "3.11.0-beta.27", - "@ohif/mode-longitudinal": "3.11.0-beta.27", - "@ohif/mode-microscopy": "3.11.0-beta.27", - "@ohif/mode-test": "3.11.0-beta.27", - "@ohif/ui": "3.11.0-beta.27", - "@ohif/ui-next": "3.11.0-beta.27", + "@ohif/core": "3.11.0-beta.32", + "@ohif/extension-cornerstone": "3.11.0-beta.32", + "@ohif/extension-cornerstone-dicom-rt": "3.11.0-beta.32", + "@ohif/extension-cornerstone-dicom-seg": "3.11.0-beta.32", + "@ohif/extension-cornerstone-dicom-sr": "3.11.0-beta.32", + "@ohif/extension-default": "3.11.0-beta.32", + "@ohif/extension-dicom-microscopy": "3.11.0-beta.32", + "@ohif/extension-dicom-pdf": "3.11.0-beta.32", + "@ohif/extension-dicom-video": "3.11.0-beta.32", + "@ohif/extension-test": "3.11.0-beta.32", + "@ohif/i18n": "3.11.0-beta.32", + "@ohif/mode-basic-dev-mode": "3.11.0-beta.32", + "@ohif/mode-longitudinal": "3.11.0-beta.32", + "@ohif/mode-microscopy": "3.11.0-beta.32", + "@ohif/mode-test": "3.11.0-beta.32", + "@ohif/ui": "3.11.0-beta.32", + "@ohif/ui-next": "3.11.0-beta.32", "@svgr/webpack": "^8.1.0", "@types/react": "^18.3.3", "classnames": "^2.3.2", @@ -652,7 +652,7 @@ }, "platform/cli": { "name": "@ohif/cli", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "bin": { "ohif-cli": "src/index.js", }, @@ -676,7 +676,7 @@ }, "platform/core": { "name": "@ohif/core", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "dcmjs": "*", @@ -701,16 +701,16 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", - "@cornerstonejs/core": "^3.12.2", - "@cornerstonejs/dicom-image-loader": "^3.12.2", - "@ohif/ui": "3.11.0-beta.27", + "@cornerstonejs/core": "^3.15.1", + "@cornerstonejs/dicom-image-loader": "^3.15.1", + "@ohif/ui": "3.11.0-beta.32", "cornerstone-math": "0.1.9", "dicom-parser": "^1.8.21", }, }, "platform/i18n": { "name": "@ohif/i18n", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@babel/runtime": "^7.20.13", "i18next-locize-backend": "^2.0.0", @@ -735,7 +735,7 @@ }, "platform/ui": { "name": "@ohif/ui", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@testing-library/react": "^13.1.0", "browser-detect": "^0.2.28", @@ -795,7 +795,7 @@ }, "platform/ui-next": { "name": "@ohif/ui-next", - "version": "3.11.0-beta.27", + "version": "3.11.0-beta.32", "dependencies": { "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-checkbox": "^1.1.1", @@ -1106,9 +1106,9 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@cornerstonejs/adapters": ["@cornerstonejs/adapters@3.12.2", "", { "dependencies": { "@babel/runtime-corejs2": "^7.17.8", "buffer": "^6.0.3", "dcmjs": "^0.29.8", "gl-matrix": "^3.4.3", "ndarray": "^1.0.19" }, "peerDependencies": { "@cornerstonejs/core": "^3.12.2", "@cornerstonejs/tools": "^3.12.2" } }, "sha512-yKPT9U1/CUtXu/h3xXB8KYX3x3bHgedw3nPX6eOn8oDhynfjuvA+i+4xJxKtO182mi2WnAQwFPnzkgmEVyrSqA=="], + "@cornerstonejs/adapters": ["@cornerstonejs/adapters@3.15.1", "", { "dependencies": { "@babel/runtime-corejs2": "^7.17.8", "buffer": "^6.0.3", "dcmjs": "^0.40.0", "gl-matrix": "^3.4.3", "ndarray": "^1.0.19" }, "peerDependencies": { "@cornerstonejs/core": "^3.15.1", "@cornerstonejs/tools": "^3.15.1" } }, "sha512-nwUPk2SXukPkkKrpzAOIOMI0lNqwTl1euI1iTLP1DB1gt8r2wy1V1vYu7+TVT9CfifhfJ+yrJnYCo1VwqnnPDA=="], - "@cornerstonejs/ai": ["@cornerstonejs/ai@3.12.2", "", { "dependencies": { "@babel/runtime-corejs2": "^7.17.8", "buffer": "^6.0.3", "dcmjs": "^0.29.8", "gl-matrix": "^3.4.3", "lodash.clonedeep": "^4.5.0", "ndarray": "^1.0.19", "onnxruntime-common": "1.17.1", "onnxruntime-web": "1.17.1" }, "peerDependencies": { "@cornerstonejs/core": "^3.12.2", "@cornerstonejs/tools": "^3.12.2" } }, "sha512-Btyey9KNcJqzIG7trFqDHo08zJVO3XtVEbfbJiUfRoztwQTKrtxOJ40dsz3pU3+PNQgG0bmTPhe9yRSpcVdwRA=="], + "@cornerstonejs/ai": ["@cornerstonejs/ai@3.15.1", "", { "dependencies": { "@babel/runtime-corejs2": "^7.17.8", "buffer": "^6.0.3", "dcmjs": "^0.40.0", "gl-matrix": "^3.4.3", "lodash.clonedeep": "^4.5.0", "ndarray": "^1.0.19", "onnxruntime-common": "1.17.1", "onnxruntime-web": "1.17.1" }, "peerDependencies": { "@cornerstonejs/core": "^3.15.1", "@cornerstonejs/tools": "^3.15.1" } }, "sha512-rFcS8Hcu7Bjr+BLYMR8Y0m/eno+ynEE3Wd2gM48VNV5pXQ4HfjbA4kl7r4JyZKqyPZYgV2Api42v0vKLeSSZ6g=="], "@cornerstonejs/calculate-suv": ["@cornerstonejs/calculate-suv@1.1.0", "", {}, "sha512-Q9XraiDJif9aMFArD2iEuDO/HXbcRVCqB7KfaHgDrdTTjgDFovS91Psbdim7crypRSvE6dh/+HKeFNHdvNkA6w=="], @@ -1120,15 +1120,15 @@ "@cornerstonejs/codec-openjph": ["@cornerstonejs/codec-openjph@2.4.7", "", {}, "sha512-qvP4q4JDib7mi9r7LqKOwqz7YZ8gjtDX4ZCezeYf8+eb7MBXCz5uXAMeVF3yz9Axw4XiIMdB/pqXkm8tqCl13w=="], - "@cornerstonejs/core": ["@cornerstonejs/core@3.12.2", "", { "dependencies": { "@kitware/vtk.js": "32.12.1", "comlink": "^4.4.1", "gl-matrix": "^3.4.3", "loglevel": "^1.9.2" } }, "sha512-AKFHr7XF5zKUo1BiclJmUz913yD9NiQgLl3krTmvtlWPuOUhpxoCfipW0De5dMeVuQ86iQDV1F+p7HuWOWfdwg=="], + "@cornerstonejs/core": ["@cornerstonejs/core@3.15.1", "", { "dependencies": { "@kitware/vtk.js": "32.12.1", "comlink": "^4.4.1", "gl-matrix": "^3.4.3", "loglevel": "^1.9.2" } }, "sha512-yXYjNyAlbKOfx6Xjd2xXnRuFrOCEHRP3yC6SQDywp8/WJ4aIiIXqU8Hgxht+/22UDjjoe5DcF2H+d5yX6SZLMg=="], - "@cornerstonejs/dicom-image-loader": ["@cornerstonejs/dicom-image-loader@3.12.2", "", { "dependencies": { "@cornerstonejs/codec-charls": "^1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.2", "@cornerstonejs/codec-openjph": "^2.4.5", "comlink": "^4.4.1", "dicom-parser": "^1.8.9", "jpeg-lossless-decoder-js": "^2.1.0", "pako": "^2.0.4", "uuid": "^9.0.0" }, "peerDependencies": { "@cornerstonejs/core": "^3.12.2" } }, "sha512-LaKnv5OPMlRPzkuqReVl1Gpa9ERTQJXl/1/Iku9wbWjnGrkzdF4Ef8bG0DseLx57qMOJPJP++PsxeXCaOgXvyw=="], + "@cornerstonejs/dicom-image-loader": ["@cornerstonejs/dicom-image-loader@3.15.1", "", { "dependencies": { "@cornerstonejs/codec-charls": "^1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.2", "@cornerstonejs/codec-openjph": "^2.4.5", "comlink": "^4.4.1", "dicom-parser": "^1.8.9", "jpeg-lossless-decoder-js": "^2.1.0", "pako": "^2.0.4", "uuid": "^9.0.0" }, "peerDependencies": { "@cornerstonejs/core": "^3.15.1" } }, "sha512-/L2pL8121Vx4OgCw4R5ZxLLfZbBSGTItWASFDeImlT9jI++KHnOpVQRugYHChMj4dHb45yX8dAify+AhSMNkXg=="], - "@cornerstonejs/labelmap-interpolation": ["@cornerstonejs/labelmap-interpolation@3.12.2", "", { "dependencies": { "@itk-wasm/morphological-contour-interpolation": "1.1.0", "itk-wasm": "1.0.0-b.165" }, "peerDependencies": { "@cornerstonejs/core": "^3.12.2", "@cornerstonejs/tools": "^3.12.2", "@kitware/vtk.js": "32.12.1" } }, "sha512-5T2OtNyV2jMy66k2clG/Krk1x4xFOxlS9sGxT1eMOrELClzpY+cL+IuA6f0aD6vTwkp1fFojEIa0IFbwuCK8HQ=="], + "@cornerstonejs/labelmap-interpolation": ["@cornerstonejs/labelmap-interpolation@3.15.1", "", { "dependencies": { "@itk-wasm/morphological-contour-interpolation": "1.1.0", "itk-wasm": "1.0.0-b.165" }, "peerDependencies": { "@cornerstonejs/core": "^3.15.1", "@cornerstonejs/tools": "^3.15.1", "@kitware/vtk.js": "32.12.1" } }, "sha512-94zxc91G9V+Dp3dodqSVde5ia9sUZNaP7Eud3Tsyej4unu4frIIx2/fRGVNSdq3pLNM7MT0KLfGeut+I7S3cHQ=="], - "@cornerstonejs/polymorphic-segmentation": ["@cornerstonejs/polymorphic-segmentation@3.12.2", "", { "dependencies": { "@icr/polyseg-wasm": "0.4.0" }, "peerDependencies": { "@cornerstonejs/core": "^3.12.2", "@cornerstonejs/tools": "^3.12.2", "@kitware/vtk.js": "32.12.1" } }, "sha512-pmOpmOKUaAM3EIklZLufWJ9IVTRpp54MF+B6cnirGEYYobjFT+uTrLwrExwVC1kEVHi8o4n5X8SJnawtIu/ugA=="], + "@cornerstonejs/polymorphic-segmentation": ["@cornerstonejs/polymorphic-segmentation@3.15.1", "", { "dependencies": { "@icr/polyseg-wasm": "0.4.0" }, "peerDependencies": { "@cornerstonejs/core": "^3.15.1", "@cornerstonejs/tools": "^3.15.1", "@kitware/vtk.js": "32.12.1" } }, "sha512-C/A/SLhOkUi0I7yXkWwu96LhaajQSIcMZX6kmU2KEuBIwRNZ2oaZm4vDF2srTJZtxKeJ/gaD0BJEn5LJFYHFfw=="], - "@cornerstonejs/tools": ["@cornerstonejs/tools@3.12.2", "", { "dependencies": { "@types/offscreencanvas": "2019.7.3", "comlink": "^4.4.1", "lodash.get": "^4.4.2" }, "peerDependencies": { "@cornerstonejs/core": "^3.12.2", "@kitware/vtk.js": "32.12.1", "@types/d3-array": "^3.0.4", "@types/d3-interpolate": "^3.0.1", "d3-array": "^3.2.3", "d3-interpolate": "^3.0.1", "gl-matrix": "^3.4.3" } }, "sha512-Zx9BFl2OAuCQGrr6o+e1tV3fMSoAARC7HlVWprUrGWwDA29LVRxQT+cc+Wo76GjxcKSMo5RuLt1+sgRj0+CsAA=="], + "@cornerstonejs/tools": ["@cornerstonejs/tools@3.15.1", "", { "dependencies": { "@types/offscreencanvas": "2019.7.3", "comlink": "^4.4.1", "lodash.get": "^4.4.2" }, "peerDependencies": { "@cornerstonejs/core": "^3.15.1", "@kitware/vtk.js": "32.12.1", "@types/d3-array": "^3.0.4", "@types/d3-interpolate": "^3.0.1", "d3-array": "^3.2.3", "d3-interpolate": "^3.0.1", "gl-matrix": "^3.4.3" } }, "sha512-QyowzqiitrwFomZGKvIeAbVMpjlzPjOOiMkLZUAGoSvLCBCjS9zDZFtnZ33cRgIqzVrzmVFWAn1Fi3fzqugQZQ=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], @@ -2674,7 +2674,7 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - "cypress": ["cypress@14.3.0", "", { "dependencies": { "@cypress/request": "^3.0.8", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", "ci-info": "^4.1.0", "cli-cursor": "^3.1.0", "cli-table3": "~0.6.5", "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", "debug": "^4.3.4", "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", "executable": "^4.1.1", "extract-zip": "2.0.1", "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", "lodash": "^4.17.21", "log-symbols": "^4.0.0", "minimist": "^1.2.8", "ospath": "^1.2.2", "pretty-bytes": "^5.6.0", "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", "semver": "^7.7.1", "supports-color": "^8.1.1", "tmp": "~0.2.3", "tree-kill": "1.2.2", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, "bin": { "cypress": "bin/cypress" } }, "sha512-rRfPl9Z0/CczuYybBEoLbDVuT1OGkhYaJ0+urRCshgiDRz6QnoA0KQIQnPx7MJ3zy+VCsbUU1pV74n+6cbJEdg=="], + "cypress": ["cypress@14.3.1", "", { "dependencies": { "@cypress/request": "^3.0.8", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", "ci-info": "^4.1.0", "cli-cursor": "^3.1.0", "cli-table3": "~0.6.5", "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", "debug": "^4.3.4", "enquirer": "^2.3.6", "eventemitter2": "6.4.7", "execa": "4.1.0", "executable": "^4.1.1", "extract-zip": "2.0.1", "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", "lodash": "^4.17.21", "log-symbols": "^4.0.0", "minimist": "^1.2.8", "ospath": "^1.2.2", "pretty-bytes": "^5.6.0", "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", "semver": "^7.7.1", "supports-color": "^8.1.1", "tmp": "~0.2.3", "tree-kill": "1.2.2", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, "bin": { "cypress": "bin/cypress" } }, "sha512-/2q06qvHMK3PNiadnRW1Je0lJ43gAFPQJUAK2zIxjr22kugtWxVQznTBLVu1AvRH+RP3oWZhCdWqiEi+0NuqCg=="], "cypress-file-upload": ["cypress-file-upload@5.0.8", "", { "peerDependencies": { "cypress": ">3.0.0" } }, "sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g=="], @@ -5454,11 +5454,11 @@ "@cornerstonejs/adapters/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - "@cornerstonejs/adapters/dcmjs": ["dcmjs@0.29.13", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevelnext": "^3.0.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-Bf9tKzJNWqk4kbV210N5TLEHDqaZvO3S+MH9vezFAU8WKcG4cR6z4/II3TQVqhLI185eNUL+lhfPCVH1Uu2yTA=="], + "@cornerstonejs/adapters/dcmjs": ["dcmjs@0.40.0", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevel": "^1.8.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-FId8dPpDg45ynIjpWF1mXlA77YcQBcE/eQGgkQpJqB0k9NF4simMYbSm9pJ5ORizYdDzOzjZpVNVym/vnFQJzQ=="], "@cornerstonejs/ai/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], - "@cornerstonejs/ai/dcmjs": ["dcmjs@0.29.13", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevelnext": "^3.0.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-Bf9tKzJNWqk4kbV210N5TLEHDqaZvO3S+MH9vezFAU8WKcG4cR6z4/II3TQVqhLI185eNUL+lhfPCVH1Uu2yTA=="], + "@cornerstonejs/ai/dcmjs": ["dcmjs@0.40.0", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevel": "^1.8.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-FId8dPpDg45ynIjpWF1mXlA77YcQBcE/eQGgkQpJqB0k9NF4simMYbSm9pJ5ORizYdDzOzjZpVNVym/vnFQJzQ=="], "@cornerstonejs/core/@kitware/vtk.js": ["@kitware/vtk.js@32.12.1", "", { "dependencies": { "@babel/runtime": "7.22.11", "@types/webxr": "^0.5.5", "commander": "9.2.0", "d3-scale": "4.0.2", "fast-deep-equal": "^3.1.3", "fflate": "0.7.3", "gl-matrix": "3.4.3", "globalthis": "1.0.3", "seedrandom": "3.0.5", "shader-loader": "1.3.1", "shelljs": "0.8.5", "spark-md5": "3.0.2", "stream-browserify": "3.0.0", "utif": "3.1.0", "webworker-promise": "0.5.0", "worker-loader": "3.0.8", "xmlbuilder2": "3.0.2" }, "peerDependencies": { "@babel/preset-env": "^7.17.10", "autoprefixer": "^10.4.7", "wslink": ">=1.1.0 || ^2.0.0" }, "bin": { "xml2json": "Utilities/XMLConverter/xml2json-cli.js", "vtkDataConverter": "Utilities/DataGenerator/convert-cli.js" } }, "sha512-IF8WK7WDu84035ws3HZXz8wys8O81mmeFayYFbdBEfKjgZ4k0EGCK25YCGc6Q47Zxq1iutjs3Zs2xAgGjTgGQg=="], @@ -5596,6 +5596,8 @@ "@ohif/app/core-js": ["core-js@3.42.0", "", {}, "sha512-Sz4PP4ZA+Rq4II21qkNqOEDTDrCvcANId3xpIgB34NDkWc3UduWj2dqEtN9yZIq8Dk3HyPI33x9sqqU5C8sr0g=="], + "@ohif/extension-test/dcmjs": ["dcmjs@0.40.0", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevel": "^1.8.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-FId8dPpDg45ynIjpWF1mXlA77YcQBcE/eQGgkQpJqB0k9NF4simMYbSm9pJ5ORizYdDzOzjZpVNVym/vnFQJzQ=="], + "@ohif/mode-segmentation/@babel/preset-env": ["@babel/preset-env@7.23.2", "", { "dependencies": { "@babel/compat-data": "^7.23.2", "@babel/helper-compilation-targets": "^7.22.15", "@babel/helper-plugin-utils": "^7.22.5", "@babel/helper-validator-option": "^7.22.15", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.15", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.15", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-namespace-from": "^7.8.3", "@babel/plugin-syntax-import-assertions": "^7.22.5", "@babel/plugin-syntax-import-attributes": "^7.22.5", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.22.5", "@babel/plugin-transform-async-generator-functions": "^7.23.2", "@babel/plugin-transform-async-to-generator": "^7.22.5", "@babel/plugin-transform-block-scoped-functions": "^7.22.5", "@babel/plugin-transform-block-scoping": "^7.23.0", "@babel/plugin-transform-class-properties": "^7.22.5", "@babel/plugin-transform-class-static-block": "^7.22.11", "@babel/plugin-transform-classes": "^7.22.15", "@babel/plugin-transform-computed-properties": "^7.22.5", "@babel/plugin-transform-destructuring": "^7.23.0", "@babel/plugin-transform-dotall-regex": "^7.22.5", "@babel/plugin-transform-duplicate-keys": "^7.22.5", "@babel/plugin-transform-dynamic-import": "^7.22.11", "@babel/plugin-transform-exponentiation-operator": "^7.22.5", "@babel/plugin-transform-export-namespace-from": "^7.22.11", "@babel/plugin-transform-for-of": "^7.22.15", "@babel/plugin-transform-function-name": "^7.22.5", "@babel/plugin-transform-json-strings": "^7.22.11", "@babel/plugin-transform-literals": "^7.22.5", "@babel/plugin-transform-logical-assignment-operators": "^7.22.11", "@babel/plugin-transform-member-expression-literals": "^7.22.5", "@babel/plugin-transform-modules-amd": "^7.23.0", "@babel/plugin-transform-modules-commonjs": "^7.23.0", "@babel/plugin-transform-modules-systemjs": "^7.23.0", "@babel/plugin-transform-modules-umd": "^7.22.5", "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", "@babel/plugin-transform-new-target": "^7.22.5", "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11", "@babel/plugin-transform-numeric-separator": "^7.22.11", "@babel/plugin-transform-object-rest-spread": "^7.22.15", "@babel/plugin-transform-object-super": "^7.22.5", "@babel/plugin-transform-optional-catch-binding": "^7.22.11", "@babel/plugin-transform-optional-chaining": "^7.23.0", "@babel/plugin-transform-parameters": "^7.22.15", "@babel/plugin-transform-private-methods": "^7.22.5", "@babel/plugin-transform-private-property-in-object": "^7.22.11", "@babel/plugin-transform-property-literals": "^7.22.5", "@babel/plugin-transform-regenerator": "^7.22.10", "@babel/plugin-transform-reserved-words": "^7.22.5", "@babel/plugin-transform-shorthand-properties": "^7.22.5", "@babel/plugin-transform-spread": "^7.22.5", "@babel/plugin-transform-sticky-regex": "^7.22.5", "@babel/plugin-transform-template-literals": "^7.22.5", "@babel/plugin-transform-typeof-symbol": "^7.22.5", "@babel/plugin-transform-unicode-escapes": "^7.22.10", "@babel/plugin-transform-unicode-property-regex": "^7.22.5", "@babel/plugin-transform-unicode-regex": "^7.22.5", "@babel/plugin-transform-unicode-sets-regex": "^7.22.5", "@babel/preset-modules": "0.1.6-no-external-plugins", "@babel/types": "^7.23.0", "babel-plugin-polyfill-corejs2": "^0.4.6", "babel-plugin-polyfill-corejs3": "^0.8.5", "babel-plugin-polyfill-regenerator": "^0.5.3", "core-js-compat": "^3.31.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BW3gsuDD+rvHL2VO2SjAUNTBe5YrjsTiDyqamPDWY723na3/yPQ65X5oQkFVJZ0o50/2d+svm1rkPoJeR1KxVQ=="], "@ohif/mode-segmentation/babel-eslint": ["babel-eslint@10.1.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "@babel/parser": "^7.7.0", "@babel/traverse": "^7.7.0", "@babel/types": "^7.7.0", "eslint-visitor-keys": "^1.0.0", "resolve": "^1.12.0" }, "peerDependencies": { "eslint": ">= 4.12.1" } }, "sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg=="], diff --git a/extensions/cornerstone-dicom-pmap/package.json b/extensions/cornerstone-dicom-pmap/package.json index 5c9621987..0e7586b7f 100644 --- a/extensions/cornerstone-dicom-pmap/package.json +++ b/extensions/cornerstone-dicom-pmap/package.json @@ -46,8 +46,8 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^3.12.2", - "@cornerstonejs/core": "^3.12.2", + "@cornerstonejs/adapters": "^3.15.1", + "@cornerstonejs/core": "^3.15.1", "@kitware/vtk.js": "32.12.0", "react-color": "^2.19.3" } diff --git a/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx b/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx index 2d33fe3ed..f5480e3ee 100644 --- a/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx +++ b/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx @@ -1,16 +1,10 @@ import PropTypes from 'prop-types'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useViewportGrid } from '@ohif/ui-next'; +import { OHIFCornerstoneViewport } from '@ohif/extension-cornerstone'; function OHIFCornerstonePMAPViewport(props: withAppTypes) { - const { - displaySets, - children, - viewportOptions, - displaySetOptions, - servicesManager, - extensionManager, - } = props; + const { displaySets, children, viewportOptions, displaySetOptions, servicesManager } = props; const viewportId = viewportOptions.viewportId; const { displaySetService, segmentationService, uiNotificationService, customizationService } = servicesManager.services; @@ -59,9 +53,6 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) { const getCornerstoneViewport = useCallback(() => { const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current; - const { component: Component } = extensionManager.getModuleEntry( - '@ohif/extension-cornerstone.viewportModule.cornerstone' - ); displaySetOptions.unshift({}); const [pmapDisplaySetOptions] = displaySetOptions; @@ -93,7 +84,7 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) { }); return ( - + /> ); }, [ - extensionManager, displaySetOptions, props, pmapDisplaySet, viewportOptions.orientation, viewportOptions.viewportId, + viewportOptions.presentationIds, + uiNotificationService, ]); // Cleanup the PMAP viewport when the viewport is destroyed diff --git a/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts index 781cf85dd..a01fdd3ac 100644 --- a/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts +++ b/extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts @@ -1,8 +1,4 @@ -const RESPONSE = { - NO_NEVER: -1, - CANCEL: 0, - HYDRATE_SEG: 5, -}; +import { utils, Types } from '@ohif/extension-cornerstone'; function promptHydrateRT({ servicesManager, @@ -10,74 +6,20 @@ function promptHydrateRT({ viewportId, preHydrateCallbacks, hydrateRTDisplaySet, -}: withAppTypes) { - const { uiViewportDialogService, customizationService } = servicesManager.services; - const extensionManager = servicesManager._extensionManager; - const appConfig = extensionManager._appConfig; - return new Promise(async function (resolve, reject) { - const promptResult = appConfig?.disableConfirmationPrompts - ? RESPONSE.HYDRATE_SEG - : await _askHydrate(uiViewportDialogService, customizationService, viewportId); - - if (promptResult === RESPONSE.HYDRATE_SEG) { - preHydrateCallbacks?.forEach(callback => { - callback(); - }); - - const isHydrated = await hydrateRTDisplaySet({ - rtDisplaySet, - viewportId, - servicesManager, - }); - - resolve(isHydrated); - } - }); -} - -function _askHydrate( - uiViewportDialogService: AppTypes.UIViewportDialogService, - customizationService: AppTypes.CustomizationService, - viewportId -) { - return new Promise(function (resolve, reject) { - const message = customizationService.getCustomization('viewportNotification.hydrateRTMessage'); - const actions = [ - { - id: 'no-hydrate', - type: 'secondary', - text: 'No', - value: RESPONSE.CANCEL, - }, - { - id: 'yes-hydrate', - type: 'primary', - text: 'Yes', - value: RESPONSE.HYDRATE_SEG, - }, - ]; - const onSubmit = result => { - uiViewportDialogService.hide(); - resolve(result); - }; - - uiViewportDialogService.show({ - id: 'promptHydrateRT', - viewportId, - type: 'info', - message, - actions, - onSubmit, - onOutsideClick: () => { - uiViewportDialogService.hide(); - resolve(RESPONSE.CANCEL); - }, - onKeyPress: event => { - if (event.key === 'Enter') { - onSubmit(RESPONSE.HYDRATE_SEG); - } - }, - }); +}: { + servicesManager: AppTypes.ServicesManager; + rtDisplaySet: AppTypes.DisplaySet; + viewportId: string; + preHydrateCallbacks?: Types.HydrationCallback[]; + hydrateRTDisplaySet: Types.HydrationCallback; +}) { + return utils.promptHydrationDialog({ + servicesManager, + viewportId, + displaySet: rtDisplaySet, + preHydrateCallbacks, + hydrateCallback: hydrateRTDisplaySet, + type: 'RTSTRUCT', }); } diff --git a/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx index 980afb4d7..e2448cd2d 100644 --- a/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx +++ b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx @@ -1,35 +1,27 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { Component, useCallback, useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import { ViewportActionArrows } from '@ohif/ui-next'; import { useViewportGrid } from '@ohif/ui-next'; -import { utils } from '@ohif/extension-cornerstone'; +import { + utils, + usePositionPresentationStore, + OHIFCornerstoneViewport, +} from '@ohif/extension-cornerstone'; import promptHydrateRT from '../utils/promptHydrateRT'; -import _getStatusComponent from './_getStatusComponent'; - import createRTToolGroupAndAddTools from '../utils/initRTToolGroup'; -import { usePositionPresentationStore } from '@ohif/extension-cornerstone'; - +import { useSystem } from '@ohif/core/src'; const RT_TOOLGROUP_BASE_NAME = 'RTToolGroup'; function OHIFCornerstoneRTViewport(props: withAppTypes) { - const { - children, - displaySets, - viewportOptions, - servicesManager, - extensionManager, - commandsManager, - } = props; + const { servicesManager, commandsManager } = useSystem(); + const { children, displaySets, viewportOptions } = props as { + children: React.ReactNode; + displaySets: AppTypes.DisplaySet[]; + viewportOptions: AppTypes.ViewportOptions; + }; - const { - displaySetService, - toolGroupService, - segmentationService, - uiNotificationService, - customizationService, - viewportActionCornersService, - } = servicesManager.services; + const { displaySetService, toolGroupService, segmentationService, customizationService } = + servicesManager.services; const viewportId = viewportOptions.viewportId; @@ -46,30 +38,19 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { const rtDisplaySet = displaySets[0]; - const [viewportGrid, viewportGridService] = useViewportGrid(); + const [{ viewports, activeViewportId }, viewportGridService] = useViewportGrid(); // States const selectedSegmentObjectIndex: number = 0; const { setPositionPresentation } = usePositionPresentationStore(); - - // Hydration means that the RT is opened and segments are loaded into the - // segmentation panel, and RT is also rendered on any viewport that is in the - // same frameOfReferenceUID as the referencedSeriesUID of the RT. However, - // loading basically means RT loading over network and bit unpacking of the - // RT data. - const [isHydrated, setIsHydrated] = useState(rtDisplaySet.isHydrated); const [rtIsLoading, setRtIsLoading] = useState(!rtDisplaySet.isLoaded); - const [element, setElement] = useState(null); const [processingProgress, setProcessingProgress] = useState({ percentComplete: null, totalSegments: null, }); - // refs const referencedDisplaySetRef = useRef(null); - const { viewports, activeViewportId } = viewportGrid; - const referencedDisplaySetInstanceUID = rtDisplaySet.referencedDisplaySetInstanceUID; const referencedDisplaySet = displaySetService.getDisplaySetByUID( referencedDisplaySetInstanceUID @@ -80,100 +61,31 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { displaySet: referencedDisplaySet, metadata: referencedDisplaySetMetadata, }; - /** - * OnElementEnabled callback which is called after the cornerstoneExtension - * has enabled the element. Note: we delegate all the image rendering to - * cornerstoneExtension, so we don't need to do anything here regarding - * the image rendering, element enabling etc. - */ - const onElementEnabled = evt => { - setElement(evt.detail.element); - }; - - const onElementDisabled = () => { - setElement(null); - }; - - const storePresentationState = useCallback(() => { - viewportGrid?.viewports.forEach(({ viewportId }) => { - commandsManager.runCommand('storePresentation', { - viewportId, - }); - }); - }, [viewportGrid]); - - const hydrateRTDisplaySet = useCallback( - ({ rtDisplaySet, viewportId }) => { - commandsManager.runCommand('hydrateRTSDisplaySet', { - displaySet: rtDisplaySet, - viewportId, - }); - }, - [commandsManager] - ); - - const getCornerstoneViewport = useCallback(() => { - const { component: Component } = extensionManager.getModuleEntry( - '@ohif/extension-cornerstone.viewportModule.cornerstone' - ); - - const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current; - - // Todo: jump to the center of the first segment - return ( - { - props.onElementEnabled?.(evt); - onElementEnabled(evt); - }} - onElementDisabled={onElementDisabled} - > - ); - }, [viewportId, rtDisplaySet, toolGroupId]); - - const onSegmentChange = useCallback( - direction => { - utils.handleSegmentChange({ - direction, - segDisplaySet: rtDisplaySet, - viewportId, - selectedSegmentObjectIndex, - segmentationService, - }); - }, - [selectedSegmentObjectIndex] - ); useEffect(() => { if (rtIsLoading) { return; } + // if not active viewport, return + if (viewportId !== activeViewportId) { + return; + } + promptHydrateRT({ servicesManager, viewportId, rtDisplaySet, - preHydrateCallbacks: [storePresentationState], - hydrateRTDisplaySet, - }).then(isHydrated => { - if (isHydrated) { - setIsHydrated(true); - } + hydrateRTDisplaySet: async () => { + return commandsManager.runCommand('hydrateSecondaryDisplaySet', { + displaySet: rtDisplaySet, + viewportId, + }); + }, }); - }, [servicesManager, viewportId, rtDisplaySet, rtIsLoading]); + }, [servicesManager, viewportId, rtDisplaySet, rtIsLoading, commandsManager, activeViewportId]); useEffect(() => { - // I'm not sure what is this, since in RT we support Overlapping segments - // via contours const { unsubscribe } = segmentationService.subscribe( segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE, evt => { @@ -193,14 +105,6 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { viewPresentation: {}, }); } - - if (evt.overlappingSegments) { - uiNotificationService.show({ - title: 'Overlapping Segments', - message: 'Overlapping segments detected which is not currently supported', - type: 'warning', - }); - } } ); @@ -210,7 +114,7 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { }, [rtDisplaySet]); useEffect(() => { - const { unsubscribe } = segmentationService.subscribe( + const segmentLoadingSubscription = segmentationService.subscribe( segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, ({ percentComplete, numSegments }) => { setProcessingProgress({ @@ -220,16 +124,7 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { } ); - return () => { - unsubscribe(); - }; - }, [rtDisplaySet]); - - /** - Cleanup the SEG viewport when the viewport is destroyed - */ - useEffect(() => { - const onDisplaySetsRemovedSubscription = displaySetService.subscribe( + const displaySetsRemovedSubscription = displaySetService.subscribe( displaySetService.EVENTS.DISPLAY_SETS_REMOVED, ({ displaySetInstanceUIDs }) => { const activeViewport = viewports.get(activeViewportId); @@ -243,9 +138,10 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { ); return () => { - onDisplaySetsRemovedSubscription.unsubscribe(); + segmentLoadingSubscription.unsubscribe(); + displaySetsRemovedSubscription.unsubscribe(); }; - }, []); + }, [rtDisplaySet, displaySetService, viewports, activeViewportId, viewportGridService]); useEffect(() => { let toolGroup = toolGroupService.getToolGroup(toolGroupId); @@ -259,38 +155,33 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { return () => { // remove the segmentation representations if seg displayset changed segmentationService.removeSegmentationRepresentations(viewportId); - + referencedDisplaySetRef.current = null; toolGroupService.destroyToolGroup(toolGroupId); }; }, []); - useEffect(() => { - setIsHydrated(rtDisplaySet.isHydrated); + const getCornerstoneViewport = useCallback(() => { + const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current; - return () => { - // remove the segmentation representations if seg displayset changed - segmentationService.removeSegmentationRepresentations(viewportId); - referencedDisplaySetRef.current = null; - }; - }, [rtDisplaySet]); + // Todo: jump to the center of the first segment + return ( + { + props.onElementEnabled?.(evt); + }} + /> + ); + }, [viewportId, rtDisplaySet, toolGroupId]); - const onStatusClick = useCallback(async () => { - // Before hydrating a RT and make it added to all viewports in the grid - // that share the same frameOfReferenceUID, we need to store the viewport grid - // presentation state, so that we can restore it after hydrating the RT. This is - // required if the user has changed the viewport (other viewport than RT viewport) - // presentation state (w/l and invert) and then opens the RT. If we don't store - // the presentation state, the viewport will be reset to the default presentation - storePresentationState(); - const isHydrated = await hydrateRTDisplaySet({ - rtDisplaySet, - viewportId, - }); - - setIsHydrated(isHydrated); - }, [hydrateRTDisplaySet, rtDisplaySet, storePresentationState, viewportId]); - - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ let childrenWithProps = null; if ( @@ -313,43 +204,6 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { }); } - useEffect(() => { - viewportActionCornersService.addComponents([ - { - viewportId, - id: 'viewportStatusComponent', - component: _getStatusComponent({ - isHydrated, - onStatusClick, - }), - indexPriority: -100, - location: viewportActionCornersService.LOCATIONS.topRight, - }, - { - viewportId, - id: 'viewportActionArrowsComponent', - component: ( - - ), - indexPriority: 0, - location: viewportActionCornersService.LOCATIONS.topRight, - }, - ]); - }, [ - activeViewportId, - isHydrated, - onSegmentChange, - onStatusClick, - viewportActionCornersService, - viewportId, - ]); - return ( <>
diff --git a/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx b/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx deleted file mode 100644 index d6158c75c..000000000 --- a/extensions/cornerstone-dicom-rt/src/viewports/_getStatusComponent.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { ViewportActionButton } from '@ohif/ui-next'; -import { Icons, Tooltip, TooltipTrigger, TooltipContent } from '@ohif/ui-next'; - -export default function _getStatusComponent({ isHydrated, onStatusClick }) { - let ToolTipMessage = null; - let StatusIcon = null; - - switch (isHydrated) { - case true: - StatusIcon = () => ; - ToolTipMessage = () =>
This Segmentation is loaded in the segmentation panel
; - break; - case false: - StatusIcon = () => ( - - ); - ToolTipMessage = () =>
Click LOAD to load RTSTRUCT.
; - } - - const StatusArea = () => { - const { t } = useTranslation('Common'); - const loadStr = t('LOAD'); - - return ( -
-
- - RTSTRUCT -
- {!isHydrated && ( - {loadStr} - )} -
- ); - }; - - return ( - <> - {ToolTipMessage && ( - - - - - - - - - - - )} - {!ToolTipMessage && } - - ); -} diff --git a/extensions/cornerstone-dicom-seg/package.json b/extensions/cornerstone-dicom-seg/package.json index 840c50623..3b246a385 100644 --- a/extensions/cornerstone-dicom-seg/package.json +++ b/extensions/cornerstone-dicom-seg/package.json @@ -46,8 +46,8 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^3.12.2", - "@cornerstonejs/core": "^3.12.2", + "@cornerstonejs/adapters": "^3.15.1", + "@cornerstonejs/core": "^3.15.1", "@kitware/vtk.js": "32.12.0", "react-color": "^2.19.3" } diff --git a/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts b/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts index 1e43e9fd8..09d71b544 100644 --- a/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts +++ b/extensions/cornerstone-dicom-seg/src/utils/promptHydrateSEG.ts @@ -1,8 +1,4 @@ -const RESPONSE = { - NO_NEVER: -1, - CANCEL: 0, - HYDRATE_SEG: 5, -}; +import { utils, Types } from '@ohif/extension-cornerstone'; function promptHydrateSEG({ servicesManager, @@ -10,75 +6,20 @@ function promptHydrateSEG({ viewportId, preHydrateCallbacks, hydrateCallback, -}: withAppTypes) { - const { uiViewportDialogService, customizationService } = servicesManager.services; - const extensionManager = servicesManager._extensionManager; - const appConfig = extensionManager._appConfig; - - return new Promise(async function (resolve, reject) { - const promptResult = appConfig?.disableConfirmationPrompts - ? RESPONSE.HYDRATE_SEG - : await _askHydrate(uiViewportDialogService, customizationService, viewportId); - - if (promptResult === RESPONSE.HYDRATE_SEG) { - preHydrateCallbacks?.forEach(callback => { - callback(); - }); - - window.setTimeout(async () => { - const isHydrated = await hydrateCallback({ - segDisplaySet, - viewportId, - }); - - resolve(isHydrated); - }, 0); - } - }); -} - -function _askHydrate( - uiViewportDialogService: AppTypes.UIViewportDialogService, - customizationService: AppTypes.CustomizationService, - viewportId -) { - return new Promise(function (resolve, reject) { - const message = customizationService.getCustomization('viewportNotification.hydrateSEGMessage'); - const actions = [ - { - id: 'no-hydrate', - type: 'secondary', - text: 'No', - value: RESPONSE.CANCEL, - }, - { - id: 'yes-hydrate', - type: 'primary', - text: 'Yes', - value: RESPONSE.HYDRATE_SEG, - }, - ]; - const onSubmit = result => { - uiViewportDialogService.hide(); - resolve(result); - }; - - uiViewportDialogService.show({ - viewportId, - type: 'info', - message, - actions, - onSubmit, - onOutsideClick: () => { - uiViewportDialogService.hide(); - resolve(RESPONSE.CANCEL); - }, - onKeyPress: event => { - if (event.key === 'Enter') { - onSubmit(RESPONSE.HYDRATE_SEG); - } - }, - }); +}: { + servicesManager: AppTypes.ServicesManager; + segDisplaySet: AppTypes.DisplaySet; + viewportId: string; + preHydrateCallbacks?: Types.HydrationCallback[]; + hydrateCallback: Types.HydrationCallback; +}) { + return utils.promptHydrationDialog({ + servicesManager, + viewportId, + displaySet: segDisplaySet as AppTypes.DisplaySet, + preHydrateCallbacks, + hydrateCallback, + type: 'SEG', }); } diff --git a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx index 4c7ceffc4..bd5e65dd9 100644 --- a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx +++ b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx @@ -1,36 +1,24 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ViewportActionArrows } from '@ohif/ui-next'; import { useViewportGrid } from '@ohif/ui-next'; import createSEGToolGroupAndAddTools from '../utils/initSEGToolGroup'; import promptHydrateSEG from '../utils/promptHydrateSEG'; -import _getStatusComponent from './_getStatusComponent'; -import { usePositionPresentationStore } from '@ohif/extension-cornerstone'; +import { usePositionPresentationStore, OHIFCornerstoneViewport } from '@ohif/extension-cornerstone'; import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; -import { utils } from '@ohif/extension-cornerstone'; +import { useSystem } from '@ohif/core/src/contextProviders/SystemProvider'; const SEG_TOOLGROUP_BASE_NAME = 'SEGToolGroup'; function OHIFCornerstoneSEGViewport(props: withAppTypes) { - const { - children, - displaySets, - viewportOptions, - servicesManager, - extensionManager, - commandsManager, - } = props; - - const { t } = useTranslation('SEGViewport'); + const { servicesManager, commandsManager } = useSystem(); + const { children, displaySets, viewportOptions } = props as { + children: React.ReactNode; + displaySets: AppTypes.DisplaySet[]; + viewportOptions: AppTypes.ViewportOptions; + }; const viewportId = viewportOptions.viewportId; - const { - displaySetService, - toolGroupService, - segmentationService, - customizationService, - viewportActionCornersService, - } = servicesManager.services; + const { displaySetService, toolGroupService, segmentationService, customizationService } = + servicesManager.services; const LoadingIndicatorTotalPercent = customizationService.getCustomization( 'ui.loadingIndicatorTotalPercent' @@ -47,7 +35,6 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) { const [viewportGrid, viewportGridService] = useViewportGrid(); // States - const selectedSegmentObjectIndex: number = 0; const { setPositionPresentation } = usePositionPresentationStore(); // Hydration means that the SEG is opened and segments are loaded into the @@ -55,9 +42,7 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) { // same frameOfReferenceUID as the referencedSeriesUID of the SEG. However, // loading basically means SEG loading over network and bit unpacking of the // SEG data. - const [isHydrated, setIsHydrated] = useState(segDisplaySet.isHydrated); const [segIsLoading, setSegIsLoading] = useState(!segDisplaySet.isLoaded); - const [element, setElement] = useState(null); const [processingProgress, setProcessingProgress] = useState({ percentComplete: null, totalSegments: null, @@ -82,36 +67,10 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) { displaySet: referencedDisplaySet, metadata: referencedDisplaySetMetadata, }; - /** - * OnElementEnabled callback which is called after the cornerstoneExtension - * has enabled the element. Note: we delegate all the image rendering to - * cornerstoneExtension, so we don't need to do anything here regarding - * the image rendering, element enabling etc. - */ - const onElementEnabled = evt => { - setElement(evt.detail.element); - }; - - const onElementDisabled = () => { - setElement(null); - }; - - const storePresentationState = useCallback(() => { - viewportGrid?.viewports.forEach(({ viewportId }) => { - commandsManager.runCommand('storePresentation', { - viewportId, - }); - }); - }, [viewportGrid]); const getCornerstoneViewport = useCallback(() => { - const { component: Component } = extensionManager.getModuleEntry( - '@ohif/extension-cornerstone.viewportModule.cornerstone' - ); - - // Todo: jump to the center of the first segment return ( - { props.onElementEnabled?.(evt); - onElementEnabled(evt); }} - onElementDisabled={onElementDisabled} - > + /> ); - }, [viewportId, segDisplaySet, toolGroupId]); - - const onSegmentChange = useCallback( - direction => { - utils.handleSegmentChange({ - direction, - segDisplaySet: segDisplaySet, - viewportId, - selectedSegmentObjectIndex, - segmentationService, - }); - }, - [selectedSegmentObjectIndex] - ); - - const hydrateSEG = useCallback(() => { - // update the previously stored segmentationPresentation with the new viewportId - // presentation so that when we put the referencedDisplaySet back in the viewport - // it will have the correct segmentation representation hydrated - commandsManager.runCommand('updateStoredSegmentationPresentation', { - displaySet: segDisplaySet, - type: SegmentationRepresentations.Labelmap, - }); - - // update the previously stored positionPresentation with the new viewportId - // presentation so that when we put the referencedDisplaySet back in the viewport - // it will be in the correct position zoom and pan - commandsManager.runCommand('updateStoredPositionPresentation', { - viewportId, - displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID], - }); - - commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', { - viewportId, - displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID], - }); - }, [commandsManager, viewportId, referencedDisplaySet, segDisplaySet]); + }, [viewportId, segDisplaySet, toolGroupId, props, viewportOptions]); useEffect(() => { if (segIsLoading) { return; } + // if not active viewport, return + if (viewportId !== activeViewportId) { + return; + } + promptHydrateSEG({ servicesManager, viewportId, segDisplaySet, - preHydrateCallbacks: [storePresentationState], - hydrateCallback: hydrateSEG, - }).then(isHydrated => { - if (isHydrated) { - setIsHydrated(true); - } + hydrateCallback: async () => { + await commandsManager.runCommand('hydrateSecondaryDisplaySet', { + displaySet: segDisplaySet, + viewportId, + }); + + return true; + }, }); - }, [servicesManager, viewportId, segDisplaySet, segIsLoading, hydrateSEG]); + }, [servicesManager, viewportId, segDisplaySet, segIsLoading, commandsManager, activeViewportId]); useEffect(() => { // on new seg display set, remove all segmentations from all viewports @@ -283,54 +211,6 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) { }; }, []); - const onStatusClick = useCallback(async () => { - // Before hydrating a SEG and make it added to all viewports in the grid - // that share the same frameOfReferenceUID, we need to store the viewport grid - // presentation state, so that we can restore it after hydrating the SEG. This is - // required if the user has changed the viewport (other viewport than SEG viewport) - // presentation state (w/l and invert) and then opens the SEG. If we don't store - // the presentation state, the viewport will be reset to the default presentation - storePresentationState(); - hydrateSEG(); - }, [storePresentationState, hydrateSEG]); - - useEffect(() => { - viewportActionCornersService.addComponents([ - { - viewportId, - id: 'viewportStatusComponent', - component: _getStatusComponent({ - isHydrated, - onStatusClick, - }), - indexPriority: -100, - location: viewportActionCornersService.LOCATIONS.topRight, - }, - { - viewportId, - id: 'viewportActionArrowsComponent', - component: ( - - ), - indexPriority: 0, - location: viewportActionCornersService.LOCATIONS.topRight, - }, - ]); - }, [ - activeViewportId, - isHydrated, - onSegmentChange, - onStatusClick, - viewportActionCornersService, - viewportId, - ]); - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ let childrenWithProps = null; diff --git a/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx b/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx deleted file mode 100644 index de321c647..000000000 --- a/extensions/cornerstone-dicom-seg/src/viewports/_getStatusComponent.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { ViewportActionButton } from '@ohif/ui-next'; -import { Icons, Tooltip, TooltipTrigger, TooltipContent } from '@ohif/ui-next'; - -export default function _getStatusComponent({ isHydrated, onStatusClick }) { - let ToolTipMessage = null; - let StatusIcon = null; - - switch (isHydrated) { - case true: - StatusIcon = () => ; - ToolTipMessage = () =>
This Segmentation is loaded in the segmentation panel
; - break; - case false: - StatusIcon = () => ( - - ); - ToolTipMessage = () =>
Click LOAD to load segmentation.
; - } - - const StatusArea = () => { - const { t } = useTranslation('Common'); - const loadStr = t('LOAD'); - - return ( -
-
- - SEG -
- {!isHydrated && ( - {loadStr} - )} -
- ); - }; - - return ( - <> - {ToolTipMessage && ( - - - - - - - - - - - )} - {!ToolTipMessage && } - - ); -} diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index 544bd29a5..a01af1ed4 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -46,9 +46,9 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^3.12.2", - "@cornerstonejs/core": "^3.12.2", - "@cornerstonejs/tools": "^3.12.2", + "@cornerstonejs/adapters": "^3.15.1", + "@cornerstonejs/core": "^3.15.1", + "@cornerstonejs/tools": "^3.15.1", "classnames": "^2.3.2" } } diff --git a/extensions/cornerstone-dicom-sr/src/commandsModule.ts b/extensions/cornerstone-dicom-sr/src/commandsModule.ts index d3febffa3..aa250e19c 100644 --- a/extensions/cornerstone-dicom-sr/src/commandsModule.ts +++ b/extensions/cornerstone-dicom-sr/src/commandsModule.ts @@ -160,36 +160,18 @@ const commandsModule = (props: withAppTypes) => { * Loads measurements by hydrating and loading the SR for the given display set instance UID * and displays it in the active viewport. */ - loadSRMeasurements: ({ displaySetInstanceUID }) => { - const { SeriesInstanceUIDs } = hydrateStructuredReport( + hydrateStructuredReport: ({ displaySetInstanceUID }) => { + return hydrateStructuredReport( { servicesManager, extensionManager, commandsManager }, displaySetInstanceUID ); - - const displaySets = displaySetService.getDisplaySetsForSeries(SeriesInstanceUIDs[0]); - if (displaySets.length) { - commandsManager.run('setDisplaySetsForViewports', { - viewportsToUpdate: [ - { - viewportId: viewportGridService.getActiveViewportId(), - displaySetInstanceUIDs: [displaySets[0].displaySetInstanceUID], - }, - ], - }); - } }, }; const definitions = { - downloadReport: { - commandFn: actions.downloadReport, - }, - storeMeasurements: { - commandFn: actions.storeMeasurements, - }, - loadSRMeasurements: { - commandFn: actions.loadSRMeasurements, - }, + downloadReport: actions.downloadReport, + storeMeasurements: actions.storeMeasurements, + hydrateStructuredReport: actions.hydrateStructuredReport, }; return { diff --git a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRMeasurementViewport.tsx b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRMeasurementViewport.tsx index 50ea47557..f98f4f0a3 100644 --- a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRMeasurementViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRMeasurementViewport.tsx @@ -1,25 +1,24 @@ import PropTypes from 'prop-types'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ExtensionManager, useToolbar } from '@ohif/core'; - +import React, { useCallback, useEffect, useState } from 'react'; import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule'; -import { ViewportActionArrows } from '@ohif/ui-next'; import createReferencedImageDisplaySet from '../utils/createReferencedImageDisplaySet'; -import { usePositionPresentationStore } from '@ohif/extension-cornerstone'; +import { usePositionPresentationStore, OHIFCornerstoneViewport } from '@ohif/extension-cornerstone'; import { useViewportGrid } from '@ohif/ui-next'; -import { Icons, Tooltip, TooltipTrigger, TooltipContent } from '@ohif/ui-next'; - -const MEASUREMENT_TRACKING_EXTENSION_ID = '@ohif/extension-measurement-tracking'; +import { useSystem } from '@ohif/core/src/contextProviders/SystemProvider'; const SR_TOOLGROUP_BASE_NAME = 'SRToolGroup'; -function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) { - const { children, dataSource, displaySets, viewportOptions, servicesManager, extensionManager } = - props; +function OHIFCornerstoneSRMeasurementViewport(props) { + const { servicesManager } = useSystem(); + const { children, dataSource, displaySets, viewportOptions } = props as { + children: React.ReactNode; + dataSource: unknown; + displaySets: AppTypes.DisplaySet[]; + viewportOptions: AppTypes.ViewportOptions; + }; - const { displaySetService, viewportActionCornersService } = servicesManager.services; + const { displaySetService } = servicesManager.services; const viewportId = viewportOptions.viewportId; @@ -34,40 +33,11 @@ function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) { const [viewportGrid, viewportGridService] = useViewportGrid(); const [measurementSelected, setMeasurementSelected] = useState(0); - const [measurementCount, setMeasurementCount] = useState(1); const [activeImageDisplaySetData, setActiveImageDisplaySetData] = useState(null); const [referencedDisplaySetMetadata, setReferencedDisplaySetMetadata] = useState(null); const [element, setElement] = useState(null); const { viewports, activeViewportId } = viewportGrid; - const { t } = useTranslation('Common'); - - // Optional hook into tracking extension, if present. - let trackedMeasurements; - - const hasMeasurementTrackingExtension = extensionManager.registeredExtensionIds.includes( - MEASUREMENT_TRACKING_EXTENSION_ID - ); - - if (hasMeasurementTrackingExtension) { - const contextModule = extensionManager.getModuleEntry( - '@ohif/extension-measurement-tracking.contextModule.TrackedMeasurementsContext' - ); - - const tracked = useContext(contextModule.context); - trackedMeasurements = tracked?.[0]; - } - - /** - * Todo: what is this, not sure what it does regarding the react aspect, - * it is updating a local variable? which is not state. - */ - const [isLocked, setIsLocked] = useState(trackedMeasurements?.context?.trackedSeries?.length > 0); - /** - * Store the tracking identifiers per viewport in order to be able to - * show the SR measurements on the referenced image on the correct viewport, - * when multiple viewports are used. - */ const setTrackingIdentifiers = useCallback( measurementSelected => { const { measurements } = srDisplaySet; @@ -105,10 +75,6 @@ function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) { console.warn('More than one SOPClassUID in the same series is not yet supported.'); } - // if (!srDisplaySet.measurements || !srDisplaySet.measurements.length) { - // return; - // } - _getViewportReferencedDisplaySetData( srDisplaySet, newMeasurementSelected, @@ -140,10 +106,6 @@ function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) { return null; } - const { component: Component } = extensionManager.getModuleEntry( - '@ohif/extension-cornerstone.viewportModule.cornerstone' - ); - const { measurements } = srDisplaySet; const measurement = measurements[measurementSelected]; @@ -152,7 +114,7 @@ function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) { } return ( - + /> ); }, [activeImageDisplaySetData, viewportId, measurementSelected]); - const onMeasurementChange = useCallback( - direction => { - let newMeasurementSelected = measurementSelected; - - newMeasurementSelected += direction; - if (newMeasurementSelected >= measurementCount) { - newMeasurementSelected = 0; - } else if (newMeasurementSelected < 0) { - newMeasurementSelected = measurementCount - 1; - } - - setTrackingIdentifiers(newMeasurementSelected); - updateViewport(newMeasurementSelected); - }, - [measurementSelected, measurementCount, updateViewport, setTrackingIdentifiers] - ); - /** Cleanup the SR viewport when the viewport is destroyed */ @@ -231,8 +176,6 @@ function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) { if (!srDisplaySet.isLoaded) { await srDisplaySet.load(); } - const numMeasurements = srDisplaySet.measurements.length; - setMeasurementCount(numMeasurements); updateViewport(measurementSelected); }; loadSR(); @@ -255,42 +198,6 @@ function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) { updateSR(); }, [measurementSelected, element, setTrackingIdentifiers, srDisplaySet]); - useEffect(() => { - setIsLocked(trackedMeasurements?.context?.trackedSeries?.length > 0); - }, [trackedMeasurements]); - - useEffect(() => { - viewportActionCornersService.addComponents([ - { - viewportId, - id: 'viewportStatusComponent', - component: _getStatusComponent({ - srDisplaySet, - viewportId, - isRehydratable: srDisplaySet.isRehydratable, - isLocked, - t, - servicesManager, - }), - indexPriority: -100, - location: viewportActionCornersService.LOCATIONS.topRight, - }, - { - viewportId, - id: 'viewportActionArrowsComponent', - index: 0, - component: ( - - ), - indexPriority: 0, - location: viewportActionCornersService.LOCATIONS.topRight, - }, - ]); - }, [isLocked, onMeasurementChange, srDisplaySet, t, viewportActionCornersService, viewportId]); - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ let childrenWithProps = null; @@ -327,8 +234,6 @@ OHIFCornerstoneSRMeasurementViewport.propTypes = { children: PropTypes.node, viewportLabel: PropTypes.string, viewportOptions: PropTypes.object, - servicesManager: PropTypes.object.isRequired, - extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired, }; async function _getViewportReferencedDisplaySetData( @@ -372,130 +277,4 @@ async function _getViewportReferencedDisplaySetData( return { referencedDisplaySetMetadata, referencedDisplaySet }; } -function _getStatusComponent({ - srDisplaySet, - viewportId, - isRehydratable, - isLocked, - t, - servicesManager, -}) { - const loadStr = t('LOAD'); - - // 1 - Incompatible - // 2 - Locked - // 3 - Rehydratable / Open - const state = isRehydratable && !isLocked ? 3 : isRehydratable && isLocked ? 2 : 1; - let ToolTipMessage = null; - let StatusIcon = null; - - switch (state) { - case 1: - StatusIcon = () => ( - - ); - - ToolTipMessage = () => ( -
- This structured report is not compatible -
- with this application. -
- ); - break; - case 2: - StatusIcon = () => ( - - ); - - ToolTipMessage = () => ( -
- This structured report is currently read-only -
- because you are tracking measurements in -
- another viewport. -
- ); - break; - case 3: - StatusIcon = () => ( - - ); - - ToolTipMessage = () =>
{`Click ${loadStr} to restore measurements.`}
; - } - - const StatusArea = () => { - const { toolbarButtons: loadSRMeasurementsButtons, onInteraction } = useToolbar({ - servicesManager, - buttonSection: 'loadSRMeasurements', - }); - - const commandOptions = { - displaySetInstanceUID: srDisplaySet.displaySetInstanceUID, - viewportId, - }; - - return ( -
-
- - SR -
- {state === 3 && ( - <> - {loadSRMeasurementsButtons.map(toolDef => { - if (!toolDef) { - return null; - } - const { id, Component, componentProps } = toolDef; - const tool = ( - onInteraction({ ...args, ...commandOptions })} - {...componentProps} - /> - ); - - return
{tool}
; - })} - - )} -
- ); - }; - - return ( - <> - {ToolTipMessage && ( - - - - - - - - - - - )} - {!ToolTipMessage && } - - ); -} - export default OHIFCornerstoneSRMeasurementViewport; diff --git a/extensions/cornerstone-dicom-sr/src/onModeEnter.tsx b/extensions/cornerstone-dicom-sr/src/onModeEnter.tsx index fec9efaed..a23877204 100644 --- a/extensions/cornerstone-dicom-sr/src/onModeEnter.tsx +++ b/extensions/cornerstone-dicom-sr/src/onModeEnter.tsx @@ -1,11 +1,7 @@ -import React from 'react'; - import { SOPClassHandlerId, SOPClassHandlerId3D } from './id'; -import { ViewportActionButton } from '@ohif/ui-next'; -import i18n from '@ohif/i18n'; export default function onModeEnter({ servicesManager }) { - const { displaySetService, toolbarService } = servicesManager.services; + const { displaySetService } = servicesManager.services; const displaySetCache = displaySetService.getDisplaySetCache(); const srDisplaySets = [...displaySetCache.values()].filter( @@ -16,24 +12,4 @@ export default function onModeEnter({ servicesManager }) { // New mode route, allow SRs to be hydrated again ds.isHydrated = false; }); - - toolbarService.addButtons([ - { - // A base/default button for loading measurements. It is added to the toolbar below. - // Customizations to this button can be made in the mode or by another extension. - // For example, the button label can be changed and/or the command to clear - // the measurements can be dropped. - id: 'loadSRMeasurements', - component: props => ( - {i18n.t('Common:LOAD')} - ), - props: { - commands: ['clearMeasurements', 'loadSRMeasurements'], - }, - }, - ]); - - // The toolbar used in the viewport's status bar. Modes and extensions can further customize - // it to optionally add other buttons. - toolbarService.createButtonSection('loadSRMeasurements', ['loadSRMeasurements']); } diff --git a/extensions/cornerstone-dicom-sr/src/utils/addSRAnnotation.ts b/extensions/cornerstone-dicom-sr/src/utils/addSRAnnotation.ts index f50a0ccb3..0eb3b6a41 100644 --- a/extensions/cornerstone-dicom-sr/src/utils/addSRAnnotation.ts +++ b/extensions/cornerstone-dicom-sr/src/utils/addSRAnnotation.ts @@ -63,5 +63,4 @@ export default function addSRAnnotation(measurement, imageId, frameNumber) { * was not triggering annotation_added events. */ annotation.state.addAnnotation(SRAnnotation); - console.debug('Adding SR annotation:', SRAnnotation); } diff --git a/extensions/cornerstone-dynamic-volume/package.json b/extensions/cornerstone-dynamic-volume/package.json index 68cda1028..c40d2c039 100644 --- a/extensions/cornerstone-dynamic-volume/package.json +++ b/extensions/cornerstone-dynamic-volume/package.json @@ -42,8 +42,8 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/core": "^3.12.2", - "@cornerstonejs/tools": "^3.12.2", + "@cornerstonejs/core": "^3.15.1", + "@cornerstonejs/tools": "^3.15.1", "classnames": "^2.3.2" } } diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index b2f8bc16b..3edc121ac 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -38,7 +38,7 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", - "@cornerstonejs/dicom-image-loader": "^3.12.2", + "@cornerstonejs/dicom-image-loader": "^3.15.1", "@ohif/core": "3.11.0-beta.32", "@ohif/ui": "3.11.0-beta.32", "dcmjs": "*", @@ -51,12 +51,12 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^3.12.2", - "@cornerstonejs/ai": "^3.12.2", - "@cornerstonejs/core": "^3.12.2", - "@cornerstonejs/labelmap-interpolation": "^3.12.2", - "@cornerstonejs/polymorphic-segmentation": "^3.12.2", - "@cornerstonejs/tools": "^3.12.2", + "@cornerstonejs/adapters": "^3.15.1", + "@cornerstonejs/ai": "^3.15.1", + "@cornerstonejs/core": "^3.15.1", + "@cornerstonejs/labelmap-interpolation": "^3.15.1", + "@cornerstonejs/polymorphic-segmentation": "^3.15.1", + "@cornerstonejs/tools": "^3.15.1", "@itk-wasm/morphological-contour-interpolation": "1.1.0", "@kitware/vtk.js": "32.12.0", "html2canvas": "^1.4.1", diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index c03e51be8..da06ec4c4 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useCallback, useState } from 'react'; import * as cs3DTools from '@cornerstonejs/tools'; import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core'; -import { MeasurementService } from '@ohif/core'; +import { MeasurementService, useViewportRef } from '@ohif/core'; import { useViewportDialog } from '@ohif/ui-next'; import type { Types as csTypes } from '@cornerstonejs/core'; @@ -21,6 +21,9 @@ import { WITH_NAVIGATION } from '../services/ViewportService/CornerstoneViewport const STACK = 'stack'; +// Cache for viewport dimensions, persists across component remounts +const viewportDimensions = new Map(); + // Todo: This should be done with expose of internal API similar to react-vtkjs-viewport // Then we don't need to worry about the re-renders if the props change. const OHIFCornerstoneViewport = React.memo( @@ -79,6 +82,7 @@ const OHIFCornerstoneViewport = React.memo( const [scrollbarHeight, setScrollbarHeight] = useState('100px'); const [enabledVPElement, setEnabledVPElement] = useState(null); const elementRef = useRef() as React.MutableRefObject; + const viewportRef = useViewportRef(viewportId); const { displaySetService, @@ -88,7 +92,6 @@ const OHIFCornerstoneViewport = React.memo( cornerstoneViewportService, segmentationService, cornerstoneCacheService, - viewportActionCornersService, customizationService, measurementService, } = servicesManager.services; @@ -101,12 +104,28 @@ const OHIFCornerstoneViewport = React.memo( }, [elementRef]); // useCallback for onResize - const onResize = useCallback(() => { - if (elementRef.current) { - cornerstoneViewportService.resize(); - setImageScrollBarHeight(); - } - }, [elementRef, cornerstoneViewportService, setImageScrollBarHeight]); + const onResize = useCallback( + (entries: ResizeObserverEntry[]) => { + if (elementRef.current && entries?.length) { + const entry = entries[0]; + const { width, height } = entry.contentRect; + + const prevDimensions = viewportDimensions.get(viewportId) || { width: 0, height: 0 }; + + // Check if dimensions actually changed and then only resize if they have changed + const hasDimensionsChanged = + prevDimensions.width !== width || prevDimensions.height !== height; + + if (width > 0 && height > 0 && hasDimensionsChanged) { + viewportDimensions.set(viewportId, { width, height }); + // Perform resize operations + cornerstoneViewportService.resize(); + setImageScrollBarHeight(); + } + } + }, + [viewportId, elementRef, cornerstoneViewportService, setImageScrollBarHeight] + ); useEffect(() => { const element = elementRef.current; @@ -133,16 +152,8 @@ const OHIFCornerstoneViewport = React.memo( syncGroupService.removeViewportFromSyncGroup(viewportId, renderingEngineId, syncGroups); segmentationService.clearSegmentationRepresentations(viewportId); - - viewportActionCornersService.clear(viewportId); }, - [ - viewportId, - segmentationService, - syncGroupService, - toolGroupService, - viewportActionCornersService, - ] + [viewportId, segmentationService, syncGroupService, toolGroupService] ); const elementEnabledHandler = useCallback( @@ -210,6 +221,7 @@ const OHIFCornerstoneViewport = React.memo( } cornerstoneViewportService.disableElement(viewportId); + viewportRef.unregister(); eventTarget.removeEventListener(Enums.Events.ELEMENT_ENABLED, elementEnabledHandler); }; @@ -328,7 +340,11 @@ const OHIFCornerstoneViewport = React.memo( style={{ height: '100%', width: '100%' }} onContextMenu={e => e.preventDefault()} onMouseDown={e => e.preventDefault()} - ref={elementRef} + data-viewportId={viewportId} + ref={el => { + elementRef.current = el; + if (el) viewportRef.register(el); + }} >
- {/* top offset of 24px to account for ViewportActionCorners. */}
diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.css b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.css index 13958ea60..b56e4736e 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.css +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.css @@ -11,18 +11,22 @@ .ViewportOrientationMarkers .top-mid { top: 0.38rem; left: 50%; + transform: translateX(-50%); } .ViewportOrientationMarkers .left-mid { - top: 47%; + top: 50%; left: 0.38rem; + transform: translateY(-50%); } .ViewportOrientationMarkers .right-mid { - top: 47%; + top: 50%; left: calc(100% - var(--marker-width) - var(--scrollbar-width)); + transform: translateY(-50%); } .ViewportOrientationMarkers .bottom-mid { top: calc(100% - var(--marker-height) - 0.6rem); - left: 47%; + left: 50%; + transform: translateX(-50%); } .ViewportOrientationMarkers .right-mid .orientation-marker-value { display: flex; diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index e461bb106..864201457 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -37,6 +37,7 @@ import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownload import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats'; import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport'; import { getUpdatedViewportsForSegmentation } from './utils/hydrationUtils'; +import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; const { DefaultHistoryMemo } = csUtils.HistoryMemo; const toggleSyncFunctions = { @@ -127,6 +128,68 @@ function commandsModule({ } const actions = { + hydrateSecondaryDisplaySet: async ({ displaySet, viewportId }) => { + if (!displaySet) { + return; + } + + if (displaySet.isOverlayDisplaySet) { + // update the previously stored segmentationPresentation with the new viewportId + // presentation so that when we put the referencedDisplaySet back in the viewport + // it will have the correct segmentation representation hydrated + commandsManager.runCommand('updateStoredSegmentationPresentation', { + displaySet, + type: + displaySet.Modality === 'SEG' + ? SegmentationRepresentations.Labelmap + : SegmentationRepresentations.Contour, + }); + } + + const referencedDisplaySetInstanceUID = displaySet.referencedDisplaySetInstanceUID; + + const storePositionPresentation = refDisplaySet => { + // update the previously stored positionPresentation with the new viewportId + // presentation so that when we put the referencedDisplaySet back in the viewport + // it will be in the correct position zoom and pan + commandsManager.runCommand('updateStoredPositionPresentation', { + viewportId, + displaySetInstanceUIDs: [refDisplaySet.displaySetInstanceUID], + }); + }; + + if (displaySet.Modality === 'SEG' || displaySet.Modality === 'RTSTRUCT') { + const referencedDisplaySet = displaySetService.getDisplaySetByUID( + referencedDisplaySetInstanceUID + ); + storePositionPresentation(referencedDisplaySet); + return commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', { + viewportId, + displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID], + }); + } else if (displaySet.Modality === 'SR') { + const results = commandsManager.runCommand('hydrateStructuredReport', { + displaySetInstanceUID: displaySet.displaySetInstanceUID, + }); + const { SeriesInstanceUIDs } = results; + const referencedDisplaySets = displaySetService.getDisplaySetsForSeries( + SeriesInstanceUIDs[0] + ); + referencedDisplaySets.forEach(storePositionPresentation); + + if (referencedDisplaySets.length) { + actions.setDisplaySetsForViewports({ + viewportsToUpdate: [ + { + viewportId: viewportGridService.getActiveViewportId(), + displaySetInstanceUIDs: [referencedDisplaySets[0].displaySetInstanceUID], + }, + ], + }); + } + return results; + } + }, runSegmentBidirectional: async ({ segmentationId, segmentIndex } = {}) => { // Get active segmentation if not specified const targetSegmentation = @@ -576,10 +639,20 @@ function commandsModule({ viewports.forEach((_, index) => cineService.setCine({ id: index, isPlaying: false })); }, - setViewportWindowLevel({ viewportId, window, level }) { + setViewportWindowLevel({ + viewportId, + windowWidth, + windowCenter, + displaySetInstanceUID, + }: { + viewportId: string; + windowWidth: number; + windowCenter: number; + displaySetInstanceUID?: string; + }) { // convert to numbers - const windowWidthNum = Number(window); - const windowCenterNum = Number(level); + const windowWidthNum = Number(windowWidth); + const windowCenterNum = Number(windowCenter); // get actor from the viewport const renderingEngine = cornerstoneViewportService.getRenderingEngine(); @@ -587,12 +660,28 @@ function commandsModule({ const { lower, upper } = csUtils.windowLevel.toLowHighRange(windowWidthNum, windowCenterNum); - viewport.setProperties({ - voiRange: { - upper, - lower, - }, - }); + if (viewport instanceof BaseVolumeViewport) { + const volumeId = actions.getVolumeIdForDisplaySet({ + viewportId, + displaySetInstanceUID, + }); + viewport.setProperties( + { + voiRange: { + upper, + lower, + }, + }, + volumeId + ); + } else { + viewport.setProperties({ + voiRange: { + upper, + lower, + }, + }); + } viewport.render(); }, toggleViewportColorbar: ({ viewportId, displaySetInstanceUIDs, options = {} }) => { @@ -641,10 +730,19 @@ function commandsModule({ actions.setViewportWindowLevel({ viewportId: activeViewport, - window: windowLevelPreset.window, - level: windowLevelPreset.level, + windowWidth: windowLevelPreset.window, + windowCenter: windowLevelPreset.level, }); }, + getVolumeIdForDisplaySet: ({ viewportId, displaySetInstanceUID }) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (viewport instanceof BaseVolumeViewport) { + const volumeIds = viewport.getAllVolumeIds(); + const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID)); + return volumeId; + } + return null; + }, setToolEnabled: ({ toolName, toggle, toolGroupId }) => { const { viewports } = viewportGridService.getState(); @@ -1753,11 +1851,11 @@ function commandsModule({ displaySetInstanceUIDs, }); - updatedViewports.forEach(viewport => { - viewportGridService.setDisplaySetsForViewport({ + actions.setDisplaySetsForViewports({ + viewportsToUpdate: updatedViewports.map(viewport => ({ viewportId: viewport.viewportId, displaySetInstanceUIDs: viewport.displaySetInstanceUIDs, - }); + })), }); }, setViewportOrientation: ({ viewportId, orientation }) => { @@ -2065,6 +2163,8 @@ function commandsModule({ addNewSegment: actions.addNewSegment, loadSegmentationDisplaySetsForViewport: actions.loadSegmentationDisplaySetsForViewport, setViewportOrientation: actions.setViewportOrientation, + hydrateSecondaryDisplaySet: actions.hydrateSecondaryDisplaySet, + getVolumeIdForDisplaySet: actions.getVolumeIdForDisplaySet, }; return { diff --git a/extensions/cornerstone/src/components/AdvancedRenderingControls/AdvancedRenderingControls.tsx b/extensions/cornerstone/src/components/AdvancedRenderingControls/AdvancedRenderingControls.tsx new file mode 100644 index 000000000..7b638cc78 --- /dev/null +++ b/extensions/cornerstone/src/components/AdvancedRenderingControls/AdvancedRenderingControls.tsx @@ -0,0 +1,116 @@ +import { useToolbar, useViewportMousePosition } from '@ohif/core/src/hooks'; +import React, { useState, useEffect, useRef } from 'react'; +import { useViewportRendering } from '../../hooks'; + +function AdvancedRenderingControls({ + viewportId, + location, + buttonSection, +}: { + viewportId: string; + location: number; + buttonSection: string; +}) { + const { + onInteraction, + toolbarButtons, + isItemOpen, + isItemLocked, + openItem, + closeItem, + toggleLock, + } = useToolbar({ + buttonSection, + }); + + const mousePosition = useViewportMousePosition(viewportId); + const [isAtBottom, setIsAtBottom] = useState(false); + const [showAllIcons, setShowAllIcons] = useState(true); + const firstMountRef = useRef(true); + const { hasColorbar } = useViewportRendering(viewportId); + + useEffect(() => { + if (firstMountRef.current) { + firstMountRef.current = false; + + const timer = setTimeout(() => { + setShowAllIcons(false); + }, 3000); + + return () => clearTimeout(timer); + } + }, []); + + useEffect(() => { + if (!showAllIcons && mousePosition.isInViewport) { + if (mousePosition.isInBottomPercentage(10)) { + setIsAtBottom(true); + } else { + setIsAtBottom(false); + } + } + }, [mousePosition, showAllIcons]); + + if (!toolbarButtons?.length) { + return null; + } + + if (!hasColorbar) { + return null; + } + + return ( +
+ {toolbarButtons.map(toolDef => { + if (!toolDef) { + return null; + } + + const { id, Component, componentProps } = toolDef; + + // Enhanced props with state and actions - respecting viewport specificity + const enhancedProps = { + ...componentProps, + isOpen: isItemOpen(id, viewportId), + isLocked: isItemLocked(id, viewportId), + onOpen: () => openItem(id, viewportId), + onClose: () => closeItem(id, viewportId), + onToggleLock: () => toggleLock(id, viewportId), + viewportId, + }; + + const tool = ( + { + onInteraction({ + ...args, + itemId: id, + viewportId, + }); + }} + {...enhancedProps} + /> + ); + + // Always show all icons on first mount for 3 seconds + // After that, always show Colorbar, show others only when mouse is at bottom + const shouldBeVisible = showAllIcons || id === 'Colorbar' || isAtBottom; + + return ( +
+ {tool} +
+ ); + })} +
+ ); +} + +export default AdvancedRenderingControls; diff --git a/extensions/cornerstone/src/components/AdvancedRenderingControls/index.ts b/extensions/cornerstone/src/components/AdvancedRenderingControls/index.ts new file mode 100644 index 000000000..dff754e97 --- /dev/null +++ b/extensions/cornerstone/src/components/AdvancedRenderingControls/index.ts @@ -0,0 +1 @@ +export { default } from './AdvancedRenderingControls'; diff --git a/extensions/cornerstone/src/components/ModalityLoadBadge/ModalityLoadBadge.tsx b/extensions/cornerstone/src/components/ModalityLoadBadge/ModalityLoadBadge.tsx new file mode 100644 index 000000000..530e17041 --- /dev/null +++ b/extensions/cornerstone/src/components/ModalityLoadBadge/ModalityLoadBadge.tsx @@ -0,0 +1,168 @@ +import React, { useMemo, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ViewportActionButton } from '@ohif/ui-next'; +import { Icons, Tooltip, TooltipTrigger, TooltipContent } from '@ohif/ui-next'; +import { useSystem } from '@ohif/core/src'; +import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; +import { useMeasurementTracking } from '../../hooks/useMeasurementTracking'; + +/** + * ModalityLoadBadge displays the status and actionable buttons for viewports containing + * special displaySets (SR, SEG, RTSTRUCT) or when tracking measurements + */ +function ModalityLoadBadge({ viewportId }: { viewportId: string }) { + const { commandsManager } = useSystem(); + const { t } = useTranslation('Common'); + const loadStr = t('LOAD'); + + const { isTracked, isLocked } = useMeasurementTracking({ viewportId }); + + const { backgroundDisplaySet, overlayDisplaySets } = useViewportDisplaySets(viewportId); + + const [specialDisplaySet, setSpecialDisplaySet] = useState(null); + + const allDisplaySets = useMemo(() => { + return [backgroundDisplaySet, ...overlayDisplaySets].filter(Boolean); + }, [backgroundDisplaySet, overlayDisplaySets]); + + useEffect(() => { + const displaySet = allDisplaySets.find(ds => ds.isOverlayDisplaySet || ds?.Modality === 'SR'); + + setSpecialDisplaySet(displaySet || null); + }, [allDisplaySets]); + + const statusInfo = useMemo(() => { + if (isTracked && !specialDisplaySet) { + return { + type: 'SR', + isHydrated: true, + isTracked, + displaySet: null, + tooltip: 'Currently tracking measurements in this viewport', + }; + } + + if (!specialDisplaySet) { + return { + type: null, + }; + } + + const type = specialDisplaySet.Modality; + const isHydrated = specialDisplaySet.isHydrated || false; + let tooltip = null; + const isRehydratable = specialDisplaySet.isRehydratable || false; + + if (type === 'SEG') { + tooltip = isHydrated + ? 'This Segmentation is loaded in the segmentation panel' + : 'Click LOAD to load segmentation.'; + } else if (type === 'RTSTRUCT') { + tooltip = isHydrated + ? 'This RTSTRUCT is loaded in the segmentation panel' + : 'Click LOAD to load RTSTRUCT.'; + } else if (type === 'SR') { + if (!isRehydratable) { + tooltip = 'This structured report is not compatible with this application.'; + } else if (isRehydratable && isLocked) { + tooltip = + 'This structured report is currently read-only because you are tracking measurements in another viewport.'; + } else { + tooltip = `Click ${loadStr} to restore measurements.`; + } + } + + return { + type, + isHydrated, + displaySet: specialDisplaySet, + isRehydratable, + tooltip, + }; + }, [specialDisplaySet, loadStr, isLocked, isTracked]); + + // Nothing to show if there's no special display set type or tracking + if (!statusInfo.type && !isTracked) { + return null; + } + + const StatusIcon = () => { + if (statusInfo.type === 'SR') { + if (statusInfo.isRehydratable && !isLocked) { + return ( + + ); + } + + if (statusInfo.isRehydratable && isLocked) { + return ( + + ); + } else { + return ( + + ); + } + } else { + return ; + } + }; + + const StatusArea = () => { + if (!statusInfo.isHydrated) { + return ( +
+
+ + {statusInfo.type} +
+ {/* We don't show the load button for SRs because we handle it in the SR extension + via the tracked measurement context that works with state machine, this is not a regression right now */} + {statusInfo.type !== 'SR' && ( + { + commandsManager.runCommand('hydrateSecondaryDisplaySet', { + displaySet: statusInfo.displaySet, + viewportId, + }); + }} + > + {loadStr} + + )} +
+ ); + } + + return null; + }; + + return ( + <> + {statusInfo.tooltip && ( + + + + + + + +
{statusInfo.tooltip}
+
+
+ )} + {!statusInfo.tooltip && } + + ); +} + +export default ModalityLoadBadge; diff --git a/extensions/cornerstone/src/components/ModalityLoadBadge/index.ts b/extensions/cornerstone/src/components/ModalityLoadBadge/index.ts new file mode 100644 index 000000000..f76c38a9a --- /dev/null +++ b/extensions/cornerstone/src/components/ModalityLoadBadge/index.ts @@ -0,0 +1 @@ +export { default } from './ModalityLoadBadge'; diff --git a/extensions/cornerstone/src/components/NavigationComponent/NavigationComponent.tsx b/extensions/cornerstone/src/components/NavigationComponent/NavigationComponent.tsx new file mode 100644 index 000000000..6fb85cf20 --- /dev/null +++ b/extensions/cornerstone/src/components/NavigationComponent/NavigationComponent.tsx @@ -0,0 +1,151 @@ +import React, { useCallback, useState } from 'react'; +import { ViewportActionArrows } from '@ohif/ui-next'; +import { useSystem } from '@ohif/core/src'; +import { utils } from '../..'; +import { useViewportSegmentations } from '../../hooks'; +import { useMeasurementTracking } from '../../hooks/useMeasurementTracking'; +import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; + +/** + * NavigationComponent provides navigation controls for viewports containing + * special displaySets (SR, SEG, RTSTRUCT) to navigate between segments or measurements + */ +function NavigationComponent({ viewportId }: { viewportId: string }) { + const { servicesManager } = useSystem(); + const { segmentationService, cornerstoneViewportService, measurementService } = + servicesManager.services; + + // Get tracking information + const { isTracked, trackedMeasurementUIDs } = useMeasurementTracking({ viewportId }); + const { viewportDisplaySets } = useViewportDisplaySets(viewportId); + const [measurementSelected, setMeasurementSelected] = useState(0); + const isSRDisplaySet = viewportDisplaySets.some(displaySet => displaySet?.Modality === 'SR'); + const cornerstoneViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + // Get segmentation information + const { segmentationsWithRepresentations } = useViewportSegmentations({ + viewportId, + }); + + const hasSegmentations = segmentationsWithRepresentations.length > 0; + + // prefer segment navigation if available + const navigationMode = hasSegmentations + ? 'segment' + : isSRDisplaySet + ? 'measurement' + : isTracked + ? 'measurement' + : null; + + const handleMeasurementNavigation = useCallback( + (direction: number) => { + const measurementDisplaySet = viewportDisplaySets.find( + displaySet => displaySet?.Modality === 'SR' + ); + + if (measurementDisplaySet) { + const measurements = measurementDisplaySet.measurements; + if (measurements.length <= 0) { + return; + } + + const newIndex = getNextIndex(measurementSelected, direction, measurements.length); + setMeasurementSelected(newIndex); + + const measurement = measurements[newIndex]; + cornerstoneViewport.setViewReference({ + referencedImageId: measurement.imageId, + }); + return; + } + + if (isTracked && trackedMeasurementUIDs.length > 0) { + const newIndex = getNextIndex( + measurementSelected, + direction, + trackedMeasurementUIDs.length + ); + setMeasurementSelected(newIndex); + measurementService.jumpToMeasurement(viewportId, trackedMeasurementUIDs[newIndex]); + } + }, + [ + viewportId, + cornerstoneViewport, + measurementSelected, + measurementService, + isTracked, + trackedMeasurementUIDs, + viewportDisplaySets, + ] + ); + + const handleSegmentNavigation = useCallback( + (direction: number) => { + if (!segmentationsWithRepresentations.length) { + return; + } + + const segmentationId = segmentationsWithRepresentations[0].segmentation.segmentationId; + + utils.handleSegmentChange({ + direction, + segmentationId, + viewportId, + selectedSegmentObjectIndex: 0, + segmentationService, + }); + }, + [segmentationsWithRepresentations, viewportId, segmentationService] + ); + + // Handle navigation between segments/measurements + const handleNavigate = useCallback( + (direction: number) => { + if (navigationMode === 'segment') { + handleSegmentNavigation(direction); + } else if (navigationMode === 'measurement') { + handleMeasurementNavigation(direction); + } + }, + [navigationMode, handleSegmentNavigation, handleMeasurementNavigation] + ); + + // Only render if we have a navigation mode + if (!navigationMode) { + return null; + } + + return ( + + ); +} + +/** + * Calculate the next index with circular navigation support + * @param currentIndex Current index position + * @param direction Direction of movement (1 for next, -1 for previous) + * @param totalItems Total number of items to navigate through + * @returns The next index with wrap-around support + */ +function getNextIndex(currentIndex: number, direction: number, totalItems: number): number { + if (totalItems <= 0) { + return 0; + } + + // Use modulo to handle circular navigation + let nextIndex = (currentIndex + direction) % totalItems; + + // Handle negative index when going backwards from index 0 + if (nextIndex < 0) { + nextIndex = totalItems - 1; + } + + return nextIndex; +} + +export default NavigationComponent; diff --git a/extensions/cornerstone/src/components/NavigationComponent/index.ts b/extensions/cornerstone/src/components/NavigationComponent/index.ts new file mode 100644 index 000000000..0edfd8fd4 --- /dev/null +++ b/extensions/cornerstone/src/components/NavigationComponent/index.ts @@ -0,0 +1,3 @@ +import NavigationComponent from './NavigationComponent'; + +export default NavigationComponent; \ No newline at end of file diff --git a/extensions/cornerstone/src/components/OHIFViewportActionCorners.tsx b/extensions/cornerstone/src/components/OHIFViewportActionCorners.tsx index e5bafac42..c01227375 100644 --- a/extensions/cornerstone/src/components/OHIFViewportActionCorners.tsx +++ b/extensions/cornerstone/src/components/OHIFViewportActionCorners.tsx @@ -1,55 +1,94 @@ -import React from 'react'; -import { - useViewportActionCorners, - ViewportActionCorners, - ViewportActionCornersLocations, -} from '@ohif/ui-next'; +import React, { memo } from 'react'; +import { ViewportActionCorners, IconPresentationProvider, ToolButton } from '@ohif/ui-next'; +import { Toolbar } from '@ohif/extension-default/src/Toolbar/Toolbar'; +import { ButtonLocation } from '@ohif/core/src/services/ToolBarService/ToolbarService'; +import { useViewportHover } from '../hooks'; export type OHIFViewportActionCornersProps = { viewportId: string; }; -function OHIFViewportActionCorners({ viewportId }: OHIFViewportActionCornersProps) { - const [state] = useViewportActionCorners(); +function OHIFViewportActionCornersComponent({ viewportId }: OHIFViewportActionCornersProps) { + // Use the viewport hover hook to track if viewport is hovered or active + const { isHovered, isActive } = useViewportHover(viewportId); - if (!state.viewports[viewportId]) { + const shouldShowCorners = isHovered || isActive; + + if (!shouldShowCorners) { 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)} - + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } +const OHIFViewportActionCorners = memo(OHIFViewportActionCornersComponent); + export default OHIFViewportActionCorners; diff --git a/extensions/cornerstone/src/components/OpacityMenu/OpacityMenu.tsx b/extensions/cornerstone/src/components/OpacityMenu/OpacityMenu.tsx new file mode 100644 index 000000000..0442b46b7 --- /dev/null +++ b/extensions/cornerstone/src/components/OpacityMenu/OpacityMenu.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Numeric } from '@ohif/ui-next'; +import { useViewportDisplaySets, useViewportRendering } from '../../hooks'; + +interface OpacityMenuProps { + viewportId: string; + className?: string; +} + +function OpacityMenu({ viewportId, className }: OpacityMenuProps) { + const { backgroundDisplaySet, foregroundDisplaySets } = useViewportDisplaySets(viewportId, { + includeForeground: true, + includeBackground: true, + }); + const { opacityLinear, setOpacityLinear } = useViewportRendering(viewportId, { + displaySetInstanceUID: foregroundDisplaySets[0].displaySetInstanceUID, + }); + + const opacityValue = opacityLinear !== undefined ? opacityLinear : 0; + const backgroundModality = backgroundDisplaySet?.Modality || ''; + const foregroundModality = foregroundDisplaySets[0]?.Modality || ''; + + return ( +
+
+
+ Opacity +
+
+ { + if (typeof val === 'number') { + setOpacityLinear(val); + } + }} + min={0} + max={1} + step={0.0001} + > +
+ {backgroundModality} + + {foregroundModality} +
+
+
+
+
+ ); +} + +export default OpacityMenu; diff --git a/extensions/cornerstone/src/components/OpacityMenu/OpacityMenuWrapper.tsx b/extensions/cornerstone/src/components/OpacityMenu/OpacityMenuWrapper.tsx new file mode 100644 index 000000000..2973ef304 --- /dev/null +++ b/extensions/cornerstone/src/components/OpacityMenu/OpacityMenuWrapper.tsx @@ -0,0 +1,85 @@ +import React, { ReactNode } from 'react'; +import { useSystem } from '@ohif/core'; +import { + Button, + Icons, + Popover, + PopoverContent, + PopoverTrigger, + useIconPresentation, +} from '@ohif/ui-next'; +import OpacityMenu from './OpacityMenu'; + +type OpacityMenuWrapperProps = { + viewportId: string; + location: string; + isOpen?: boolean; + onOpen?: () => void; + onClose?: () => void; + disabled?: boolean; +}; + +export function OpacityMenuWrapper(props: OpacityMenuWrapperProps): ReactNode { + const { viewportId, location, isOpen = false, onOpen, onClose, disabled, ...rest } = props; + const { IconContainer, className: iconClassName, containerProps } = useIconPresentation(); + + const handleOpenChange = (openState: boolean) => { + if (openState) { + onOpen?.(); + } else { + onClose?.(); + } + }; + + const { servicesManager } = useSystem(); + const { toolbarService } = servicesManager.services; + + const { align, side } = toolbarService.getAlignAndSide(location); + + const Icon = ; + + return ( + + +
+ {IconContainer ? ( + + {Icon} + + ) : ( + + )} +
+
+ + + +
+ ); +} \ No newline at end of file diff --git a/extensions/cornerstone/src/components/OpacityMenu/index.ts b/extensions/cornerstone/src/components/OpacityMenu/index.ts new file mode 100644 index 000000000..796210257 --- /dev/null +++ b/extensions/cornerstone/src/components/OpacityMenu/index.ts @@ -0,0 +1,2 @@ +export { OpacityMenuWrapper } from './OpacityMenuWrapper'; +export { default as OpacityMenu } from './OpacityMenu'; \ No newline at end of file diff --git a/extensions/cornerstone/src/components/SelectItemWithModality.tsx b/extensions/cornerstone/src/components/SelectItemWithModality.tsx new file mode 100644 index 000000000..b97506d17 --- /dev/null +++ b/extensions/cornerstone/src/components/SelectItemWithModality.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +// should be used in a Select component +const SelectItemWithModality = ({ + displaySet, + showModality = true, +}: { + displaySet: AppTypes.DisplaySet; + showModality?: boolean; +}): JSX.Element => ( +
+ {displaySet.label} + {showModality && displaySet.Modality && ( + + {displaySet.Modality} + + )} +
+); + +export default SelectItemWithModality; diff --git a/extensions/cornerstone/src/components/ThresholdMenu/ThresholdMenu.tsx b/extensions/cornerstone/src/components/ThresholdMenu/ThresholdMenu.tsx new file mode 100644 index 000000000..90c4d7ff6 --- /dev/null +++ b/extensions/cornerstone/src/components/ThresholdMenu/ThresholdMenu.tsx @@ -0,0 +1,104 @@ +import React, { useState, useEffect } from 'react'; +import { + Numeric, + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, + Button, +} from '@ohif/ui-next'; +import { useViewportRendering } from '../../hooks'; +import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; +import SelectItemWithModality from '../SelectItemWithModality'; + +interface ThresholdMenuProps { + viewportId: string; + className?: string; +} + +function ThresholdMenu({ viewportId, className }: ThresholdMenuProps) { + const { viewportDisplaySets, foregroundDisplaySets } = useViewportDisplaySets(viewportId); + const [selectedDisplaySetUID, setSelectedDisplaySetUID] = useState( + foregroundDisplaySets.length > 0 ? foregroundDisplaySets[0].displaySetInstanceUID : undefined + ); + + const { threshold, setThreshold, pixelValueRange } = useViewportRendering(viewportId, { + displaySetInstanceUID: selectedDisplaySetUID, + }); + + const { min, max } = pixelValueRange; + const thresholdValue = threshold ?? min; + + useEffect(() => { + if (foregroundDisplaySets.length > 0 && !selectedDisplaySetUID) { + setSelectedDisplaySetUID(foregroundDisplaySets[0].displaySetInstanceUID); + } + }, [foregroundDisplaySets, selectedDisplaySetUID]); + + const selectedDisplaySet = viewportDisplaySets.find( + ds => ds.displaySetInstanceUID === selectedDisplaySetUID + ); + + return ( +
+
+
+
+ Threshold + {viewportDisplaySets.length > 1 && ( +
+ +
+ )} +
+ +
+ { + if (typeof val === 'number') { + setThreshold(val); + } + }} + min={min} + max={max} + step={0.01} + > + +
+ {min.toFixed(0)} + {max.toFixed(0)} +
+
+
+
+ ); +} + +export default ThresholdMenu; diff --git a/extensions/cornerstone/src/components/ThresholdMenu/ThresholdMenuWrapper.tsx b/extensions/cornerstone/src/components/ThresholdMenu/ThresholdMenuWrapper.tsx new file mode 100644 index 000000000..ba829d9da --- /dev/null +++ b/extensions/cornerstone/src/components/ThresholdMenu/ThresholdMenuWrapper.tsx @@ -0,0 +1,85 @@ +import React, { ReactNode } from 'react'; +import { useSystem } from '@ohif/core'; +import { + Button, + Icons, + Popover, + PopoverContent, + PopoverTrigger, + useIconPresentation, +} from '@ohif/ui-next'; +import ThresholdMenu from './ThresholdMenu'; + +type ThresholdMenuWrapperProps = { + viewportId: string; + location: string; + isOpen?: boolean; + onOpen?: () => void; + onClose?: () => void; + disabled?: boolean; +}; + +export function ThresholdMenuWrapper(props: ThresholdMenuWrapperProps): ReactNode { + const { viewportId, location, isOpen = false, onOpen, onClose, disabled, ...rest } = props; + const { IconContainer, className: iconClassName, containerProps } = useIconPresentation(); + + const handleOpenChange = (openState: boolean) => { + if (openState) { + onOpen?.(); + } else { + onClose?.(); + } + }; + + const { servicesManager } = useSystem(); + const { toolbarService } = servicesManager.services; + + const { align, side } = toolbarService.getAlignAndSide(location); + + const Icon = ; + + return ( + + +
+ {IconContainer ? ( + + {Icon} + + ) : ( + + )} +
+
+ + + +
+ ); +} \ No newline at end of file diff --git a/extensions/cornerstone/src/components/ThresholdMenu/index.ts b/extensions/cornerstone/src/components/ThresholdMenu/index.ts new file mode 100644 index 000000000..a1c361952 --- /dev/null +++ b/extensions/cornerstone/src/components/ThresholdMenu/index.ts @@ -0,0 +1,2 @@ +export { ThresholdMenuWrapper } from './ThresholdMenuWrapper'; +export { default as ThresholdMenu } from './ThresholdMenu'; \ No newline at end of file diff --git a/extensions/cornerstone/src/components/TrackingStatus/TrackingStatus.tsx b/extensions/cornerstone/src/components/TrackingStatus/TrackingStatus.tsx new file mode 100644 index 000000000..148e77717 --- /dev/null +++ b/extensions/cornerstone/src/components/TrackingStatus/TrackingStatus.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Icons, Tooltip, TooltipTrigger, TooltipContent } from '@ohif/ui-next'; +import { useMeasurementTracking } from '../../hooks/useMeasurementTracking'; + +/** + * TrackingStatus displays an indicator icon for viewports that have + * tracked measurements + */ +function TrackingStatus({ viewportId }: { viewportId: string }) { + const { isTracked } = useMeasurementTracking({ viewportId }); + + if (!isTracked) { + return null; + } + + return ( + <> + + + + + + + +
Tracking
+
+
+ + ); +} + +export default TrackingStatus; diff --git a/extensions/cornerstone/src/components/TrackingStatus/index.ts b/extensions/cornerstone/src/components/TrackingStatus/index.ts new file mode 100644 index 000000000..32266dd2e --- /dev/null +++ b/extensions/cornerstone/src/components/TrackingStatus/index.ts @@ -0,0 +1 @@ +export { default } from './TrackingStatus'; diff --git a/extensions/cornerstone/src/components/VOIManualControlMenu/VOIManualControlMenu.tsx b/extensions/cornerstone/src/components/VOIManualControlMenu/VOIManualControlMenu.tsx new file mode 100644 index 000000000..6e6325c55 --- /dev/null +++ b/extensions/cornerstone/src/components/VOIManualControlMenu/VOIManualControlMenu.tsx @@ -0,0 +1,190 @@ +import React, { useState, useEffect } from 'react'; +import { + Tabs, + TabsList, + TabsTrigger, + TabsContent, + Numeric, + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@ohif/ui-next'; +import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; +import { useViewportRendering } from '../../hooks/useViewportRendering'; +import { useSystem } from '@ohif/core'; +import { cache } from '@cornerstonejs/core'; + +interface VOIManualControlMenuProps { + viewportId: string; + className?: string; +} + +const TABS = { + MINMAX: 'minmax', + MANUAL: 'manual', +}; + +function VOIManualControlMenu({ viewportId, className }: VOIManualControlMenuProps) { + const { servicesManager } = useSystem(); + const { displaySetService } = servicesManager.services; + const [activeTab, setActiveTab] = useState(TABS.MINMAX); + const { viewportDisplaySets } = useViewportDisplaySets(viewportId); + const [selectedDisplaySetUID, setSelectedDisplaySetUID] = useState( + viewportDisplaySets.length > 0 ? viewportDisplaySets[0].displaySetInstanceUID : undefined + ); + + const { voiRange, setVOIRange, windowLevel, setWindowLevel } = useViewportRendering(viewportId, { + displaySetInstanceUID: selectedDisplaySetUID, + }); + + useEffect(() => { + if (viewportDisplaySets.length > 0 && !selectedDisplaySetUID) { + setSelectedDisplaySetUID(viewportDisplaySets[0].displaySetInstanceUID); + } + }, [viewportDisplaySets, selectedDisplaySetUID]); + + if (!voiRange) { + return null; + } + + const { upper, lower } = voiRange; + const { windowWidth, windowCenter } = windowLevel; + + const selectedViewportImageIds = + displaySetService.getDisplaySetByUID(selectedDisplaySetUID)?.imageIds; + + let min = Infinity; + let max = -Infinity; + + if (selectedViewportImageIds) { + for (const imageId of selectedViewportImageIds) { + const image = cache.getImage(imageId); + if (image) { + min = Math.min(min, image.minPixelValue); + max = Math.max(max, image.maxPixelValue); + } + } + } + + // Provide reasonable defaults if min/max couldn't be determined + if (min === Infinity || max === -Infinity) { + min = 0; + max = 255; + } + + const selectedDisplaySet = viewportDisplaySets.find( + ds => ds.displaySetInstanceUID === selectedDisplaySetUID + ); + + return ( +
+
+ +
+ {viewportDisplaySets.length > 1 && ( + + )} + + + Min/Max + + + Manual + + +
+ + + { + const [newLower, newUpper] = vals; + if (newLower !== lower || newUpper !== upper) { + setVOIRange({ lower: newLower, upper: newUpper }); + } + }} + > + + + + +
+ { + if (val !== windowWidth) { + setWindowLevel({ windowWidth: val, windowCenter }); + } + }} + > +
+ W: + +
+
+ + { + if (val !== windowCenter) { + setWindowLevel({ windowWidth, windowCenter: val }); + } + }} + > +
+ L: + +
+
+
+
+
+
+
+ ); +} + +export default VOIManualControlMenu; diff --git a/extensions/cornerstone/src/components/VOIManualControlMenu/VOIManualControlMenuWrapper.tsx b/extensions/cornerstone/src/components/VOIManualControlMenu/VOIManualControlMenuWrapper.tsx new file mode 100644 index 000000000..c45804857 --- /dev/null +++ b/extensions/cornerstone/src/components/VOIManualControlMenu/VOIManualControlMenuWrapper.tsx @@ -0,0 +1,85 @@ +import React, { ReactNode } from 'react'; +import { useSystem } from '@ohif/core'; +import { + Button, + Icons, + Popover, + PopoverContent, + PopoverTrigger, + useIconPresentation, +} from '@ohif/ui-next'; +import VOIManualControlMenu from './VOIManualControlMenu'; + +type VOIManualControlMenuWrapperProps = { + viewportId: string; + location: string; + isOpen?: boolean; + onOpen?: () => void; + onClose?: () => void; + disabled?: boolean; +}; + +export function VOIManualControlMenuWrapper(props: VOIManualControlMenuWrapperProps): ReactNode { + const { viewportId, location, isOpen = false, onOpen, onClose, disabled, ...rest } = props; + const { IconContainer, className: iconClassName, containerProps } = useIconPresentation(); + + const handleOpenChange = (openState: boolean) => { + if (openState) { + onOpen?.(); + } else { + onClose?.(); + } + }; + + const { servicesManager } = useSystem(); + const { toolbarService } = servicesManager.services; + + const { align, side } = toolbarService.getAlignAndSide(location); + + const Icon = ; + + return ( + + +
+ {IconContainer ? ( + + {Icon} + + ) : ( + + )} +
+
+ + + +
+ ); +} diff --git a/extensions/cornerstone/src/components/VOIManualControlMenu/index.ts b/extensions/cornerstone/src/components/VOIManualControlMenu/index.ts new file mode 100644 index 000000000..5e4f76323 --- /dev/null +++ b/extensions/cornerstone/src/components/VOIManualControlMenu/index.ts @@ -0,0 +1,2 @@ +export { VOIManualControlMenuWrapper } from './VOIManualControlMenuWrapper'; +export { default as VOIManualControlMenu } from './VOIManualControlMenu'; diff --git a/extensions/cornerstone/src/components/ViewportColorbar/AdvancedColorbarWithControls.tsx b/extensions/cornerstone/src/components/ViewportColorbar/AdvancedColorbarWithControls.tsx deleted file mode 100644 index a6f2e00ac..000000000 --- a/extensions/cornerstone/src/components/ViewportColorbar/AdvancedColorbarWithControls.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 index 58c1d9baf..24021c4d5 100644 --- a/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbar.tsx +++ b/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbar.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useMemo, memo } from 'react'; import { utilities } from '@cornerstonejs/tools'; -import { useSystem } from '@ohif/core'; +import { useSystem, useViewportRef, useViewportSize } from '@ohif/core'; import { ColorbarPositionType, TickPositionType, @@ -24,6 +24,7 @@ type ColorbarProps = { tickStyles?: TickStyleType; containerStyles?: ContainerStyleType; viewportElementRef?: React.RefObject; + numColorbars: number; }; /** @@ -31,7 +32,7 @@ type ColorbarProps = { * A React wrapper for the cornerstone ViewportColorbar that adds a close button * positioned appropriately based on the colorbar position. */ -const ViewportColorbar = ({ +const ViewportColorbar = memo(function ViewportColorbar({ viewportId, displaySetInstanceUID, colormaps, @@ -40,11 +41,50 @@ const ViewportColorbar = ({ position, tickPosition, tickStyles, - viewportElementRef, -}: ColorbarProps) => { + numColorbars, +}: ColorbarProps) { const containerRef = useRef(null); const { servicesManager } = useSystem(); const { customizationService } = servicesManager.services; + const viewportElementRef = useViewportRef(viewportId); + const { height, width } = useViewportSize(viewportId); + + // Memoize colorbar customization to prevent rerenders from unrelated customization changes + const colorbarCustomization = useMemo(() => { + return customizationService.getCustomization( + 'cornerstone.colorbar' + ) as unknown as ColorbarCustomization; + }, [customizationService]); + + const appropriateTickPosition = useMemo(() => { + let tickPos = tickPosition; + if (position === 'left' || position === 'right') { + tickPos = position === 'left' ? 'right' : 'left'; + } else { + tickPos = position === 'top' ? 'bottom' : 'top'; + } + return tickPos; + }, [position, tickPosition]); + + const positionTickStyles = useMemo(() => { + return colorbarCustomization?.positionTickStyles?.[position]; + }, [colorbarCustomization, position]); + + const positionStylesFromConfig = useMemo(() => { + return colorbarCustomization?.positionStyles?.[position] || {}; + }, [colorbarCustomization, position]); + + const mergedTickStyles = useMemo(() => { + return { + ...(colorbarCustomization?.tickStyles || {}), + ...(positionTickStyles?.style || {}), + ...(tickStyles || {}), + }; + }, [colorbarCustomization, positionTickStyles, tickStyles]); + + const colorbarId = useMemo(() => { + return `Colorbar-${viewportId}-${displaySetInstanceUID}`; + }, [viewportId, displaySetInstanceUID]); useEffect(() => { if (!containerRef.current || !colormaps || !activeColormapName) { @@ -53,29 +93,21 @@ const ViewportColorbar = ({ const viewportElement = viewportElementRef?.current; - if (!viewportElement) { + if (!viewportElement || !colormaps?.length) { return; } - const colorbarCustomization = customizationService.getCustomization( - 'cornerstone.colorbar' - ) as unknown as ColorbarCustomization; - - const positionTickStyles = colorbarCustomization?.positionTickStyles?.[position]; + // Using stable references from memoized values const csColorbar = new CornerstoneViewportColorbar({ - id: `Colorbar-${viewportId}-${displaySetInstanceUID}`, + id: colorbarId, element: viewportElement, container: containerRef.current, colormaps: colormaps, activeColormapName: activeColormapName, volumeId, ticks: { - position: tickPosition as ColorbarRangeTextPosition, - style: { - ...(colorbarCustomization?.tickStyles || {}), - ...(positionTickStyles?.style || {}), - ...(tickStyles || {}), - }, + position: appropriateTickPosition as ColorbarRangeTextPosition, + style: mergedTickStyles, }, }); @@ -90,37 +122,33 @@ const ViewportColorbar = ({ colormaps, activeColormapName, volumeId, - position, - tickPosition, - tickStyles, + colorbarId, + appropriateTickPosition, + mergedTickStyles, viewportElementRef, - customizationService, ]); - // Get position styles from customization service - const colorbarCustomization = customizationService.getCustomization( - 'cornerstone.colorbar' - ) as unknown as ColorbarCustomization; - - const positionStylesFromConfig = colorbarCustomization?.positionStyles?.[position] || {}; + if (!height || !width) { + return null; + } return (
); -}; +}); export default ViewportColorbar; diff --git a/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx b/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx index 953c47fbb..74eafd407 100644 --- a/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx +++ b/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx @@ -1,14 +1,13 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo, memo, useCallback } 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'; - +import useViewportRendering from '../../hooks/useViewportRendering'; +import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; type ViewportColorbarsContainerProps = { viewportId: string; - viewportElementRef?: React.RefObject; + location: number; }; type ColorbarData = { @@ -24,18 +23,38 @@ type ColorbarData = { * Container component that manages multiple colorbars for a viewport * It interacts with the colorbarService to get/set colorbar states */ -const ViewportColorbarsContainer = ({ +const ViewportColorbarsContainer = memo(function ViewportColorbarsContainer({ viewportId, - viewportElementRef, -}: ViewportColorbarsContainerProps) => { + location, +}: ViewportColorbarsContainerProps) { const [colorbars, setColorbars] = useState([]); const { servicesManager } = useSystem(); - const { colorbarService, customizationService } = servicesManager.services; + const { colorbarService, customizationService, displaySetService } = servicesManager.services; + const { viewportDisplaySets, backgroundDisplaySet, foregroundDisplaySets } = + useViewportDisplaySets(viewportId); + const { colorbarPosition: position, opacity } = useViewportRendering(viewportId, { + location, + }); + // Memoize the customization to prevent recomputation + const colorbarCustomization = useMemo(() => { + return customizationService.getCustomization( + 'cornerstone.colorbar' + ) as unknown as ColorbarCustomization; + }, [customizationService]); + + // Memoize tick position + const tickPosition = useMemo(() => { + const defaultTickPosition = colorbarCustomization?.colorbarTickPosition; + return colorbarCustomization?.colorbarTickPosition || defaultTickPosition; + }, [colorbarCustomization]); + + // Initial load of colorbars useEffect(() => { setColorbars(colorbarService.getViewportColorbar(viewportId) || []); }, [viewportId, colorbarService]); + // Subscribe to colorbar state changes useEffect(() => { const { unsubscribe } = colorbarService.subscribe( colorbarService.EVENTS.STATE_CHANGED, @@ -51,90 +70,59 @@ const ViewportColorbarsContainer = ({ }; }, [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 isBottom = position === 'bottom'; - const defaultPosition = colorbarCustomization?.colorbarContainerPosition; - const defaultTickPosition = colorbarCustomization?.colorbarTickPosition; + const isSingleViewport = viewportDisplaySets.length === 1; + const showFullList = isSingleViewport || !isBottom; - const position = colorbarCustomization?.colorbarContainerPosition || defaultPosition; - const tickPosition = colorbarCustomization?.colorbarTickPosition || defaultTickPosition; - const positionStyles = colorbarCustomization?.positionStyles; + const colorbarsToUse = showFullList + ? colorbars + : colorbars.filter(({ displaySetInstanceUID }) => { + const { displaySetInstanceUID: dsUID } = + displaySetService.getDisplaySetByUID(displaySetInstanceUID) ?? {}; - const positionStyle = positionStyles?.[position]; + const targetUID = + opacity === 0 || opacity == null + ? backgroundDisplaySet?.displaySetInstanceUID + : foregroundDisplaySets[0].displaySetInstanceUID; - 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 dsUID === targetUID; + }); return (
- {position !== 'bottom' ? ( -
- {colorbars.map((colorbarInfo, index) => { - const { colorbar, displaySetInstanceUID } = colorbarInfo; - return ( - - ); - })} -
- ) : ( - - )} +
+ {colorbarsToUse.map(colorbarInfo => { + 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 index ae84d89bf..25a4c3706 100644 --- a/extensions/cornerstone/src/components/ViewportColorbar/index.ts +++ b/extensions/cornerstone/src/components/ViewportColorbar/index.ts @@ -1,6 +1,5 @@ import ViewportColorbar from './ViewportColorbar'; import ViewportColorbarsContainer from './ViewportColorbarsContainer'; -import AdvancedColorbarWithControls from './AdvancedColorbarWithControls'; -export { ViewportColorbar, ViewportColorbarsContainer, AdvancedColorbarWithControls }; +export { ViewportColorbar, ViewportColorbarsContainer }; export default ViewportColorbarsContainer; diff --git a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenu.tsx b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenu.tsx index b8d032ccf..69dcbe280 100644 --- a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenu.tsx +++ b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenu.tsx @@ -16,14 +16,16 @@ import { import { useSystem } from '@ohif/core'; import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; +import SelectItemWithModality from '../SelectItemWithModality'; +import { useViewportRendering } from '../../hooks'; function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: string }>) { const { commandsManager, servicesManager } = useSystem(); const [pendingForegrounds, setPendingForegrounds] = useState([]); const [pendingSegmentations, setPendingSegmentations] = useState([]); - const [thresholdOpacityEnabled, setThresholdOpacityEnabled] = useState(false); + const { toggleColorbar } = useViewportRendering(viewportId); - const { hangingProtocolService } = servicesManager.services; + const { hangingProtocolService, toolbarService } = servicesManager.services; const { backgroundDisplaySet, @@ -34,6 +36,8 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri foregroundDisplaySets, } = useViewportDisplaySets(viewportId); + const [thresholdOpacityEnabled, setThresholdOpacityEnabled] = useState(false); + /** * Change the background display set */ @@ -48,21 +52,6 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri }); }; - /** - * Handle threshold and opacity toggle - */ - const handleThresholdOpacityToggle = (checked: boolean) => { - setThresholdOpacityEnabled(checked); - - if (foregroundDisplaySets.length > 0) { - // Example implementation of threshold/opacity adjustment - commandsManager.runCommand('setForegroundThresholdOpacity', { - viewportId, - enabled: checked, - }); - } - }; - /** * Replace a display set layer with a new one */ @@ -182,6 +171,18 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri } }; + // Check if the advanced window level components exist in toolbar + const hasAdvancedRenderingControls = !!toolbarService.getButton('advancedRenderingControls'); + const hasOpacityMenu = !!toolbarService.getButton('opacityMenu'); + + const handleThresholdOpacityToggle = () => { + const newValue = !thresholdOpacityEnabled; + if (hasAdvancedRenderingControls) { + toggleColorbar(); + } + setThresholdOpacityEnabled(newValue); + }; + return (
{/* Top buttons row */} @@ -232,15 +233,17 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri - {displaySet.label} + {potentialOverlayDisplaySets.map(item => ( - {item.label} + ))} @@ -284,8 +287,9 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri - {item.label} + ))} @@ -334,15 +338,17 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri - {displaySet.label} + {potentialForegroundDisplaySets.map(item => ( - {item.label} + ))} @@ -386,8 +392,9 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri - {item.label} + ))} @@ -420,7 +427,7 @@ function ViewportDataOverlayMenu({ viewportId }: withAppTypes<{ viewportId: stri
- {/* {foregroundDisplaySets.length > 0 && ( + {foregroundDisplaySets.length > 0 && (hasAdvancedRenderingControls || hasOpacityMenu) && (
- )} */} + )}
); } diff --git a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper.tsx b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper.tsx index a2087774c..a7cc6e96f 100644 --- a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper.tsx +++ b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper.tsx @@ -1,73 +1,74 @@ import React, { ReactNode } from 'react'; +import { useSystem } from '@ohif/core'; import { Button, - cn, Icons, Popover, PopoverContent, PopoverTrigger, - useViewportGrid, - useViewportActionCorners, + useIconPresentation, } from '@ohif/ui-next'; import ViewportDataOverlayMenu from './ViewportDataOverlayMenu'; -import classNames from 'classnames'; -import { MENU_IDS } from '../menus/menu-ids'; +import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; -export function ViewportDataOverlayMenuWrapper({ - viewportId, - displaySets, - location, -}: withAppTypes<{ +type DataOverlayMenuProps = { viewportId: string; - element: HTMLElement; location: string; -}>): ReactNode { - const [viewportGrid] = useViewportGrid(); - const [actionCornerState, viewportActionCornersAPI] = useViewportActionCorners(); + isOpen?: boolean; + onOpen?: () => void; + onClose?: () => void; + disabled?: boolean; +}; - const isMenuOpen = - actionCornerState.viewports[viewportId]?.[location]?.find( - item => item.id === MENU_IDS.DATA_OVERLAY_MENU - )?.isOpen ?? false; +export function ViewportDataOverlayMenuWrapper(props: DataOverlayMenuProps): ReactNode { + const { viewportId, location, isOpen = false, onOpen, onClose, disabled, ...rest } = props; + const { viewportDisplaySets: displaySets } = useViewportDisplaySets(viewportId); + const { IconContainer, className: iconClassName, containerProps } = useIconPresentation(); const handleOpenChange = (openState: boolean) => { if (openState) { - viewportActionCornersAPI.openItem?.(viewportId, MENU_IDS.DATA_OVERLAY_MENU); + onOpen?.(); } else { - viewportActionCornersAPI.closeItem?.(viewportId, MENU_IDS.DATA_OVERLAY_MENU); + onClose?.(); } }; - // Get proper alignment and side based on the location - let align = 'center'; - let side = 'bottom'; + const { servicesManager } = useSystem(); + const { toolbarService } = servicesManager.services; - if (location !== undefined) { - const positioning = viewportActionCornersAPI.getAlignAndSide(location); - align = positioning.align; - side = positioning.side; - } + const { align, side } = toolbarService.getAlignAndSide(location); + + const Icon = ; return ( - )} - > - - + -): ReactNode { - return ; -} diff --git a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/utils.ts b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/utils.ts index 17d1a9a33..d797e773c 100644 --- a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/utils.ts +++ b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/utils.ts @@ -42,6 +42,14 @@ export function getModalityOverlayColormap(customizationService, modality) { export function getEnhancedDisplaySets({ viewportId, services }) { const { displaySetService, viewportGridService } = services; const displaySetsUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + + if (!displaySetsUIDs?.length) { + return { + viewportDisplaySets: [], + enhancedDisplaySets: [], + }; + } + const allDisplaySets = displaySetService.getActiveDisplaySets(); const otherDisplaySets = allDisplaySets.filter( @@ -52,7 +60,7 @@ export function getEnhancedDisplaySets({ viewportId, services }) { displaySetService.getDisplaySetByUID(displaySetUID) ); - const backgroundCanBeVolume = csUtils.isValidVolume(viewportDisplaySets[0].imageIds); + const backgroundCanBeVolume = csUtils.isValidVolume(viewportDisplaySets[0].imageIds || []); const backgroundDisplaySet = viewportDisplaySets[0]; const enhancedDisplaySets = otherDisplaySets.map(displaySet => { diff --git a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx index 1abe593b5..fa35a624e 100644 --- a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx +++ b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx @@ -1,40 +1,37 @@ import React from 'react'; -import { Icons, useViewportActionCorners, useViewportGrid } from '@ohif/ui-next'; +import { cn, Icons, ToolButton, useIconPresentation } from '@ohif/ui-next'; import { useSystem } from '@ohif/core'; import { Enums } from '@cornerstonejs/core'; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - Button, -} from '@ohif/ui-next'; -import { MENU_IDS } from '../menus/menu-ids'; +import { Popover, PopoverTrigger, PopoverContent, Button, useViewportGrid } from '@ohif/ui-next'; -function ViewportOrientationMenu({ location }: withAppTypes<{ location?: string }>) { +function ViewportOrientationMenu({ + location, + viewportId, + displaySets, + isOpen = false, + onOpen, + onClose, + disabled, + ...props +}: withAppTypes<{ + location?: string; + viewportId: string; + displaySets: AppTypes.DisplaySet[]; + isOpen?: boolean; + onOpen?: () => void; + onClose?: () => void; + disabled?: boolean; +}>) { + const [gridState] = useViewportGrid(); + const viewportIdToUse = viewportId || gridState.activeViewportId; + const { IconContainer, className: iconClassName, containerProps } = useIconPresentation(); 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 { cornerstoneViewportService, toolbarService } = servicesManager.services; const handleOrientationChange = (orientation: string) => { - const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId); + const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportIdToUse); const currentViewportType = viewportInfo?.getViewportType(); - // Get the displaySets in this viewport - const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); - const displaySets = displaySetUIDs - .map(uid => displaySetService.getDisplaySetByUID(uid)) - .filter(Boolean); - if (!displaySets.length) { return; } @@ -63,11 +60,13 @@ function ViewportOrientationMenu({ location }: withAppTypes<{ location?: string orientationEnum = Enums.OrientationAxis.ACQUISITION; } + const displaySetUIDs = displaySets.map(ds => ds.displaySetInstanceUID); + // If viewport is not already a volume type, we need to convert it if (currentViewportType !== Enums.ViewportType.ORTHOGRAPHIC) { // Configure the viewport to be a volume viewport with current display sets const updatedViewport = { - viewportId, + viewportId: viewportIdToUse, displaySetInstanceUIDs: displaySetUIDs, viewportOptions: { viewportType: Enums.ViewportType.ORTHOGRAPHIC, @@ -83,78 +82,99 @@ function ViewportOrientationMenu({ location }: withAppTypes<{ location?: string } else { // Set the viewport orientation commandsManager.runCommand('setViewportOrientation', { - viewportId, + viewportId: viewportIdToUse, orientation: orientationEnum, }); } // Close the menu after selection - viewportActionCornersServiceAPI.closeItem?.(viewportId, MENU_IDS.ORIENTATION_MENU); + onClose?.(); }; const handleOpenChange = (openState: boolean) => { if (openState) { - viewportActionCornersServiceAPI.openItem?.(viewportId, MENU_IDS.ORIENTATION_MENU); + onOpen?.(); } else { - viewportActionCornersServiceAPI.closeItem?.(viewportId, MENU_IDS.ORIENTATION_MENU); + onClose?.(); } }; - const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); - const displaySets = displaySetUIDs - .map(uid => displaySetService.getDisplaySetByUID(uid)) - .filter(Boolean); - if (!displaySets.length) { return null; } - const hasReconstructableDisplaySet = displaySets.some(ds => ds.isReconstructable); - - // Get proper alignment and side based on the location - let align = 'center'; - let side = 'bottom'; - - if (location !== undefined) { - const positioning = viewportActionCornersServiceAPI.getAlignAndSide(location); - align = positioning.align; - side = positioning.side; - } + // Get proper alignment and side based on the location using toolbar service + const { align, side } = toolbarService.getAlignAndSide(Number(location)); + const Icon = ; return ( - - - - - +
+ {IconContainer ? ( + + {Icon} + + ) : ( + + )} +
+ + - Orientation - handleOrientationChange('axial')}>Axial - handleOrientationChange('sagittal')}> - Sagittal - - handleOrientationChange('coronal')}> - Coronal - - handleOrientationChange('acquisition')}> - Acquisition - -
-
+
+ + + + +
+
+
); } diff --git a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenuWrapper.tsx b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenuWrapper.tsx index efd85b3cb..20f419ac2 100644 --- a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenuWrapper.tsx +++ b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenuWrapper.tsx @@ -1,27 +1,29 @@ import React, { ReactNode } from 'react'; -import { useViewportGrid } from '@ohif/ui-next'; -import { cn } from '@ohif/ui-next'; import ViewportOrientationMenu from './ViewportOrientationMenu'; +import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; -export function ViewportOrientationMenuWrapper({ - viewportId, - location, -}: withAppTypes<{ - viewportId: string; - element: HTMLElement; - location: string; -}>): ReactNode { - const [viewportGrid] = useViewportGrid(); - const isActiveViewport = viewportId === viewportGrid.activeViewportId; +export function ViewportOrientationMenuWrapper( + props: withAppTypes<{ + viewportId: string; + location: string; + isOpen?: boolean; + onOpen?: () => void; + onClose?: () => void; + iconSize?: number; + disabled?: boolean; + }> +): ReactNode { + const { viewportId } = props; + const { viewportDisplaySets } = useViewportDisplaySets(viewportId); + + if (!viewportDisplaySets.length) { + return null; + } return ( -
- -
+ ); } diff --git a/extensions/cornerstone/src/components/ViewportOrientationMenu/index.tsx b/extensions/cornerstone/src/components/ViewportOrientationMenu/index.tsx deleted file mode 100644 index 2e0c23e93..000000000 --- a/extensions/cornerstone/src/components/ViewportOrientationMenu/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { ReactNode } from 'react'; -import { ViewportOrientationMenuWrapper } from './ViewportOrientationMenuWrapper'; - -export function getViewportOrientationMenu( - props: withAppTypes<{ - viewportId: string; - element: HTMLElement; - location: string; - }> -): ReactNode { - return ; -} - -export * from './ViewportOrientationMenu'; diff --git a/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx b/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx index c03677f24..51cfe3ee6 100644 --- a/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx +++ b/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useCallback, useState, ReactElement, useMemo } from ' import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; import { PanelSection, WindowLevel } from '@ohif/ui-next'; -import { Enums, eventTarget } from '@cornerstonejs/core'; +import { BaseVolumeViewport, Enums, eventTarget } from '@cornerstonejs/core'; import { useActiveViewportDisplaySets } from '@ohif/core'; import { getNodeOpacity, @@ -22,7 +22,7 @@ const ViewportWindowLevel = ({ const { cornerstoneViewportService } = servicesManager.services; const [windowLevels, setWindowLevels] = useState([]); const [isLoading, setIsLoading] = useState(true); - const displaySets = useActiveViewportDisplaySets({ servicesManager }); + const displaySets = useActiveViewportDisplaySets(); const getViewportsWithVolumeIds = useCallback( (volumeIds: string[]) => { @@ -30,7 +30,7 @@ const ViewportWindowLevel = ({ const viewports = renderingEngine.getVolumeViewports(); return viewports.filter(vp => { - const viewportVolumeIds = vp.getActors().map(actor => actor.referencedId); + const viewportVolumeIds = vp instanceof BaseVolumeViewport ? vp.getAllVolumeIds() : []; return ( volumeIds.length === viewportVolumeIds.length && volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId)) @@ -124,7 +124,8 @@ const ViewportWindowLevel = ({ return; } - const viewportVolumeIds = viewport.getActors().map(actor => actor.referencedId); + const viewportVolumeIds = + viewport instanceof BaseVolumeViewport ? viewport.getAllVolumeIds() : []; const viewports = getViewportsWithVolumeIds(viewportVolumeIds); viewports.forEach(vp => { diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/Colorbar.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/Colorbar.tsx index 60da32b62..e6deb33be 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/Colorbar.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/Colorbar.tsx @@ -1,103 +1,27 @@ -import React, { ReactElement, useCallback, useEffect, useState } from 'react'; +import React, { ReactElement, useCallback } from 'react'; import { Switch } from '@ohif/ui-next'; -import { ColorbarProps, ColorbarOptions } from '../../types/Colorbar'; -import { utilities } from '@cornerstonejs/core'; -import { useSystem } from '@ohif/core'; +import { useViewportRendering } from '../../hooks/useViewportRendering'; -export function setViewportColorbar( - viewportId: string, - commandsManager: any, - servicesManager: AppTypes.ServicesManager, - colorbarOptions: Partial -) { - const { cornerstoneViewportService, viewportGridService } = servicesManager.services; - const displaySetInstanceUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); +export function Colorbar({ viewportId }: { viewportId?: string } = {}): ReactElement { + const { hasColorbar, toggleColorbar } = useViewportRendering(viewportId); - const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId); - const backgroundColor = viewportInfo.getViewportOptions().background; - const isLight = backgroundColor ? utilities.isEqual(backgroundColor, [1, 1, 1]) : false; - - if (isLight) { - colorbarOptions.ticks = { - position: 'left', - style: { - font: '13px Inter', - color: '#000000', - maxNumTicks: 8, - tickSize: 5, - tickWidth: 1, - labelMargin: 3, - }, - }; - } - - commandsManager.run('toggleViewportColorbar', { - viewportId, - options: colorbarOptions, - displaySetInstanceUIDs, - }); -} - -export function Colorbar({ - viewportId, - colorbarProperties, -}: withAppTypes): ReactElement { - const { servicesManager, commandsManager } = useSystem(); - const { colorbarService } = servicesManager.services; - const { - width: colorbarWidth, - colorbarTickPosition, - colorbarContainerPosition, - colormaps, - colorbarInitialColormap, - } = colorbarProperties; - const [showColorbar, setShowColorbar] = useState(colorbarService.hasColorbar(viewportId)); - - const onSetColorbar = useCallback(() => { - setViewportColorbar(viewportId, commandsManager, servicesManager, { - viewportId, - colormaps, - ticks: { - position: colorbarTickPosition, - }, - width: colorbarWidth, - position: colorbarContainerPosition, - activeColormapName: colorbarInitialColormap, - }); - }, [commandsManager]); - - useEffect(() => { - const updateColorbarState = () => { - setShowColorbar(colorbarService.hasColorbar(viewportId)); - }; - - const { unsubscribe } = colorbarService.subscribe( - colorbarService.EVENTS.STATE_CHANGED, - updateColorbarState - ); - - return () => { - unsubscribe(); - }; - }, [viewportId]); + const handleToggle = useCallback(() => { + toggleColorbar(); + }, [toggleColorbar]); return ( -
{ - e.stopPropagation(); - onSetColorbar(); - }} - > +
- Display Color bar + + Display Color bar + { - e.stopPropagation(); - onSetColorbar(); - }} + checked={!!hasColorbar} + onCheckedChange={handleToggle} />
); diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/Colormap.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/Colormap.tsx index 6b15d85d9..b8e7904bb 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/Colormap.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/Colormap.tsx @@ -1,91 +1,66 @@ -import React, { ReactElement, useCallback, useEffect, useRef, useState } from 'react'; -import { AllInOneMenu, Switch, Tabs, TabsList, TabsTrigger } from '@ohif/ui-next'; +import React, { ReactElement, useEffect, useRef, useState } from 'react'; +import { AllInOneMenu, ScrollArea, Switch, Tabs, TabsList, TabsTrigger } from '@ohif/ui-next'; +import { useViewportRendering } from '../../hooks/useViewportRendering'; -import { StackViewport, Types } from '@cornerstonejs/core'; -import { ColormapProps } from '../../types/Colormap'; -import { useSystem } from '@ohif/core'; +export function Colormap({ viewportId }: { viewportId?: string } = {}): ReactElement { + const { viewportDisplaySets } = useViewportRendering(viewportId); -export function Colormap({ colormaps, viewportId, displaySets }: ColormapProps): ReactElement { - const { servicesManager, commandsManager } = useSystem(); - const { cornerstoneViewportService } = servicesManager.services; + const [activeDisplaySetUID, setActiveDisplaySetUID] = useState( + viewportDisplaySets?.[0]?.displaySetInstanceUID + ); - const [activeDisplaySet, setActiveDisplaySet] = useState(displaySets[0]); + // Use the hook with the active display set + const { colorbarProperties, setColormap } = useViewportRendering(viewportId, { + displaySetInstanceUID: activeDisplaySetUID, + }); + + const { colormaps } = colorbarProperties; const [showPreview, setShowPreview] = useState(false); const [prePreviewColormap, setPrePreviewColormap] = useState(null); + const [currentColormap, setCurrentColormap] = useState(null); const showPreviewRef = useRef(showPreview); showPreviewRef.current = showPreview; const prePreviewColormapRef = useRef(prePreviewColormap); prePreviewColormapRef.current = prePreviewColormap; - const activeDisplaySetRef = useRef(activeDisplaySet); - activeDisplaySetRef.current = activeDisplaySet; - - const onSetColorLUT = useCallback( - props => { - // TODO: Better way to check if it's a fusion - const oneOpacityColormaps = ['Grayscale', 'X Ray']; - const opacity = - displaySets.length > 1 && !oneOpacityColormaps.includes(props.colormap.name) ? 0.5 : 1; - commandsManager.run({ - commandName: 'setViewportColormap', - commandOptions: { - ...props, - opacity, - immediate: true, - }, - context: 'CORNERSTONE', - }); - }, - [commandsManager] - ); - - const getViewportColormap = (viewportId, displaySet) => { - const { displaySetInstanceUID } = displaySet; - const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - if (viewport instanceof StackViewport) { - const { colormap } = viewport.getProperties(); - if (!colormap) { - return colormaps.find(c => c.Name === 'Grayscale') || colormaps[0]; - } - return colormap; - } - const actorEntries = viewport.getActors(); - const actorEntry = actorEntries?.find(entry => - entry.referencedId.includes(displaySetInstanceUID) - ); - const { colormap } = (viewport as Types.IVolumeViewport).getProperties(actorEntry.referencedId); - if (!colormap) { - return colormaps.find(c => c.Name === 'Grayscale') || colormaps[0]; - } - return colormap; - }; - - const activeIndex = displaySets.findIndex( - ds => ds.displaySetInstanceUID === activeDisplaySetRef.current.displaySetInstanceUID - ); + const currentColormapRef = useRef(currentColormap); + currentColormapRef.current = currentColormap; useEffect(() => { - setActiveDisplaySet(displaySets[displaySets.length - 1]); - }, [displaySets]); + setCurrentColormap(null); + setPrePreviewColormap(null); + }, [activeDisplaySetUID]); + + const handleSetColorLUT = (colormap, immediate = true) => { + // Check if it's a fusion viewport + const oneOpacityColormaps = ['Grayscale', 'X Ray']; + const opacity = + viewportDisplaySets.length > 1 && !oneOpacityColormaps.includes(colormap.name) ? 0.5 : 1; + + setColormap({ + colormap, + opacity, + immediate, + }); + }; return ( <> - {displaySets.length > 1 && ( + {viewportDisplaySets && viewportDisplaySets.length > 1 && (
{ - const index = parseInt(val, 10); - setActiveDisplaySet(displaySets[index]); + value={activeDisplaySetUID} + onValueChange={displaySetUID => { + setActiveDisplaySetUID(displaySetUID); setPrePreviewColormap(null); }} > - {displaySets.map((ds, i) => ( + {viewportDisplaySets.map(ds => ( {ds.Modality} @@ -94,6 +69,7 @@ export function Colormap({ colormaps, viewportId, displaySets }: ColormapProps):
)} +
setShowPreview(!showPreview)} @@ -104,49 +80,47 @@ export function Colormap({ colormaps, viewportId, displaySets }: ColormapProps): checked={showPreview} onCheckedChange={checked => { setShowPreview(checked); + + if (!checked && currentColormapRef.current) { + handleSetColorLUT(currentColormapRef.current); + } }} />
+ - - {colormaps.map((colormap, index) => ( - { - onSetColorLUT({ - viewportId, - colormap, - displaySetInstanceUID: activeDisplaySetRef.current.displaySetInstanceUID, - }); - setPrePreviewColormap(null); - }} - onMouseEnter={() => { - if (showPreviewRef.current) { - setPrePreviewColormap(getViewportColormap(viewportId, activeDisplaySetRef.current)); - onSetColorLUT({ - viewportId, - colormap, - displaySetInstanceUID: activeDisplaySetRef.current.displaySetInstanceUID, - }); - } - }} - onMouseLeave={() => { - if (showPreviewRef.current && prePreviewColormapRef.current) { - onSetColorLUT({ - viewportId, - colormap: prePreviewColormapRef.current, - displaySetInstanceUID: activeDisplaySetRef.current.displaySetInstanceUID, - }); - } - }} - > - ))} - + +
+ +
+ {colormaps.map((colormap, index) => ( + { + setCurrentColormap(colormap); + handleSetColorLUT(colormap); + setPrePreviewColormap(null); + }} + onMouseEnter={() => { + if (showPreviewRef.current) { + if (!prePreviewColormapRef.current) { + setPrePreviewColormap(colormap); + } + handleSetColorLUT(colormap); + } + }} + onMouseLeave={() => { + if (showPreviewRef.current && prePreviewColormapRef.current) { + handleSetColorLUT(prePreviewColormapRef.current); + } + }} + /> + ))} +
+
+
); } diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingOptions.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingOptions.tsx index f55428240..34e877ca9 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingOptions.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingOptions.tsx @@ -1,15 +1,13 @@ import React, { ReactElement, useState } from 'react'; import { AllInOneMenu } from '@ohif/ui-next'; -import { VolumeRenderingOptionsProps } from '../../types/ViewportPresets'; import { VolumeRenderingQuality } from './VolumeRenderingQuality'; import { VolumeShift } from './VolumeShift'; import { VolumeLighting } from './VolumeLighting'; import { VolumeShade } from './VolumeShade'; +import { useViewportRendering } from '../../hooks/useViewportRendering'; -export function VolumeRenderingOptions({ - viewportId, - volumeRenderingQualityRange, -}: VolumeRenderingOptionsProps): ReactElement { +export function VolumeRenderingOptions({ viewportId }: { viewportId?: string } = {}): ReactElement { + const { volumeRenderingQualityRange } = useViewportRendering(viewportId); const [hasShade, setShade] = useState(false); return ( diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx index 195f57b67..52eeacc6c 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx @@ -1,14 +1,12 @@ import { AllInOneMenu } from '@ohif/ui-next'; import { Icons } from '@ohif/ui-next'; import React, { ReactElement } from 'react'; -import { VolumeRenderingPresetsProps } from '../../types/ViewportPresets'; import { VolumeRenderingPresetsContent } from './VolumeRenderingPresetsContent'; import { useSystem } from '@ohif/core'; +import { useViewportRendering } from '../../hooks/useViewportRendering'; -export function VolumeRenderingPresets({ - viewportId, - volumeRenderingPresets, -}: VolumeRenderingPresetsProps): ReactElement { +export function VolumeRenderingPresets({ viewportId }: { viewportId?: string } = {}): ReactElement { + const { volumeRenderingPresets } = useViewportRendering(viewportId); const { servicesManager } = useSystem(); const { uiDialogService } = servicesManager.services; diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx index ada20bac0..d9ded5ca4 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx @@ -1,54 +1,122 @@ -import React, { ReactElement, useCallback } from 'react'; -import { AllInOneMenu } from '@ohif/ui-next'; +import React, { ReactElement, useEffect, useRef, useState } from 'react'; +import { AllInOneMenu, ScrollArea, Switch, Tabs, TabsList, TabsTrigger } from '@ohif/ui-next'; +import { useViewportRendering } from '../../hooks/useViewportRendering'; import { WindowLevelPreset } from '../../types/WindowLevel'; -import { useSystem } from '@ohif/core'; -import { useTranslation } from 'react-i18next'; -export type WindowLevelProps = { - viewportId: string; - presets: Array>>; -}; - -export function WindowLevel({ viewportId, presets }: WindowLevelProps): ReactElement { - const { commandsManager } = useSystem(); - const { t } = useTranslation('WindowLevelActionMenu'); - - const onSetWindowLevel = useCallback( - props => { - commandsManager.run({ - commandName: 'setViewportWindowLevel', - commandOptions: { - ...props, - viewportId, - }, - context: 'CORNERSTONE', - }); - }, - [commandsManager, viewportId] +export function WindowLevel({ viewportId }: { viewportId?: string } = {}): ReactElement { + const { viewportDisplaySets } = useViewportRendering(viewportId); + const [activeDisplaySetUID, setActiveDisplaySetUID] = useState( + viewportDisplaySets?.[0]?.displaySetInstanceUID ); + // Use the hook with the active display set + const { windowLevelPresets, setWindowLevel } = useViewportRendering(viewportId, { + displaySetInstanceUID: activeDisplaySetUID, + }); + + const [showPreview, setShowPreview] = useState(false); + const [prePreviewPreset, setPrePreviewPreset] = useState(null); + const [currentPreset, setCurrentPreset] = useState(null); + + const showPreviewRef = useRef(showPreview); + showPreviewRef.current = showPreview; + const prePreviewPresetRef = useRef(prePreviewPreset); + prePreviewPresetRef.current = prePreviewPreset; + const currentPresetRef = useRef(currentPreset); + currentPresetRef.current = currentPreset; + + // Reset presets when active display set changes + useEffect(() => { + setCurrentPreset(null); + setPrePreviewPreset(null); + }, [activeDisplaySetUID]); + + // Handle applying window level preset + const handleSetWindowLevel = (preset: WindowLevelPreset, immediate = false) => { + setWindowLevel({ + windowWidth: Number(preset.window), + windowCenter: Number(preset.level), + immediate, + }); + }; + return ( - - {presets.map((modalityPresets, modalityIndex) => ( - - {Object.entries(modalityPresets).map(([modality, presetsArray]) => ( - - - {t('Modality Presets', { modality })} - - {presetsArray.map((preset, index) => ( - onSetWindowLevel(preset)} - /> + <> + {viewportDisplaySets && viewportDisplaySets.length > 1 && ( +
+ { + setActiveDisplaySetUID(displaySetUID); + }} + > + + {viewportDisplaySets.map(ds => ( + + {ds.Modality} + ))} - - ))} - - ))} - + + +
+ )} + +
setShowPreview(!showPreview)} + > + Preview in viewport + { + setShowPreview(checked); + + // When turning off preview, restore the current preset if one exists + if (!checked && currentPresetRef.current) { + handleSetWindowLevel(currentPresetRef.current, true); + } + }} + /> +
+ + + +
+ +
+ {windowLevelPresets.map((preset, index) => ( + { + setCurrentPreset(preset); + handleSetWindowLevel(preset, true); + setPrePreviewPreset(null); + }} + onMouseEnter={() => { + if (showPreviewRef.current) { + if (!prePreviewPresetRef.current) { + setPrePreviewPreset(currentPresetRef.current || preset); + } + handleSetWindowLevel(preset, true); + } + }} + onMouseLeave={() => { + if (showPreviewRef.current && prePreviewPresetRef.current) { + handleSetWindowLevel(prePreviewPresetRef.current, true); + } + }} + /> + ))} +
+
+
+ ); } diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenu.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenu.tsx index 749b9e7a6..0634d15bf 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenu.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenu.tsx @@ -1,114 +1,55 @@ -import React, { ReactElement, useCallback, useEffect, useState } from 'react'; +import React, { ReactElement, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { AllInOneMenu, cn } from '@ohif/ui-next'; -import { useViewportGrid } from '@ohif/ui-next'; +import { AllInOneMenu } from '@ohif/ui-next'; import { Colormap } from './Colormap'; import { Colorbar } from './Colorbar'; -import { setViewportColorbar } from './Colorbar'; -import { WindowLevelPreset } from '../../types/WindowLevel'; -import { ColorbarProperties } from '../../types/Colorbar'; -import { VolumeRenderingQualityRange } from '../../types/ViewportPresets'; import { WindowLevel } from './WindowLevel'; import { VolumeRenderingPresets } from './VolumeRenderingPresets'; import { VolumeRenderingOptions } from './VolumeRenderingOptions'; -import { ViewportPreset } from '../../types/ViewportPresets'; -import { VolumeViewport3D } from '@cornerstonejs/core'; -import { utilities } from '@cornerstonejs/core'; -import { useSystem } from '@ohif/core'; +import { useViewportRendering } from '../../hooks/useViewportRendering'; export type WindowLevelActionMenuProps = { viewportId: string; - element: HTMLElement; - presets: Array>>; - colorbarProperties: ColorbarProperties; - displaySets: Array; - volumeRenderingPresets: Array; - volumeRenderingQualityRange: VolumeRenderingQualityRange; - location: string; + element?: HTMLElement; + align?: 'start' | 'end' | 'center'; + side?: 'top' | 'bottom' | 'left' | 'right'; }; export function WindowLevelActionMenu({ viewportId, element, - presets, - verticalDirection, - horizontalDirection, - colorbarProperties, - displaySets, - volumeRenderingPresets, - volumeRenderingQualityRange, -}: withAppTypes): ReactElement { - const { commandsManager, servicesManager } = useSystem(); - const { - colormaps, - colorbarContainerPosition, - colorbarInitialColormap, - colorbarTickPosition, - width: colorbarWidth, - } = colorbarProperties; - - const { colorbarService, cornerstoneViewportService } = servicesManager.services; - const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId); - const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - const backgroundColor = viewportInfo?.getViewportOptions().background; - const isLight = backgroundColor ? utilities.isEqual(backgroundColor, [1, 1, 1]) : false; + align, + side, +}: WindowLevelActionMenuProps): ReactElement { + return ( + + ); +} +export function WindowLevelActionMenuContent({ + viewportId, + align, + side, +}: { + viewportId: string; + align?: string; + side?: string; +}): ReactElement { const { t } = useTranslation('WindowLevelActionMenu'); + // Use a stable key for the menu to avoid infinite re-renders + const menuKey = useMemo(() => `${viewportId}`, [viewportId]); - const [viewportGrid] = useViewportGrid(); - const { activeViewportId } = viewportGrid; - - const [vpHeight, setVpHeight] = useState(element?.clientHeight); - const [menuKey, setMenuKey] = useState(0); - const [is3DVolume, setIs3DVolume] = useState(false); - - const onSetColorbar = useCallback(() => { - setViewportColorbar(viewportId, commandsManager, servicesManager, { - colormaps, - ticks: { - position: colorbarTickPosition, - }, - width: colorbarWidth, - position: colorbarContainerPosition, - activeColormapName: colorbarInitialColormap, - }); - }, [commandsManager]); - - useEffect(() => { - const newVpHeight = element?.clientHeight; - if (vpHeight !== newVpHeight) { - setVpHeight(newVpHeight); - } - }, [element, vpHeight]); - - useEffect(() => { - if (!colorbarService.hasColorbar(viewportId)) { - return; - } - window.setTimeout(() => { - colorbarService.removeColorbar(viewportId); - onSetColorbar(); - }, 0); - }, [viewportId, displaySets, viewport]); - - useEffect(() => { - setMenuKey(menuKey + 1); - const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - if (viewport instanceof VolumeViewport3D) { - setIs3DVolume(true); - } else { - setIs3DVolume(false); - } - }, [ - displaySets, - viewportId, - presets, - volumeRenderingQualityRange, - volumeRenderingPresets, + const { + is3DVolume, colorbarProperties, - activeViewportId, - viewportGrid, - ]); + windowLevelPresets, + volumeRenderingPresets, + volumeRenderingQualityRange, + } = useViewportRendering(viewportId); return ( - {!is3DVolume && ( - ds.supportsWindowLevel === true)} - colorbarProperties={colorbarProperties} - /> - )} + {!is3DVolume && } - {colormaps && !is3DVolume && ( + {colorbarProperties?.colormaps && !is3DVolume && ( - ds.supportsWindowLevel === true)} - /> + )} - {presets && presets.length > 0 && !is3DVolume && ( + {windowLevelPresets?.length > 0 && !is3DVolume && ( - + )} - {volumeRenderingPresets && is3DVolume && ( - - )} + {volumeRenderingPresets && is3DVolume && } {volumeRenderingQualityRange && is3DVolume && ( - + )} diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenuWrapper.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenuWrapper.tsx index 10c306c7c..b1ad429de 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenuWrapper.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenuWrapper.tsx @@ -2,63 +2,75 @@ import React, { ReactNode } from 'react'; import { useSystem } from '@ohif/core'; import { Button, - cn, Icons, Popover, PopoverContent, PopoverTrigger, useViewportGrid, - useViewportActionCorners, - AllInOneMenu, + useIconPresentation, } from '@ohif/ui-next'; import { WindowLevelActionMenu } from './WindowLevelActionMenu'; -import { MENU_IDS } from '../menus/menu-ids'; +import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; +import { useViewportRendering } from '../../hooks'; -export function WindowLevelActionMenuWrapper({ - viewportId, - element, - location, - displaySets, -}: withAppTypes<{ - viewportId: string; - element: HTMLElement; - location: string; - displaySets: Array; -}>): ReactNode { - const [viewportGrid] = useViewportGrid(); - const [actionCornerState, viewportActionCornersAPI] = useViewportActionCorners(); - const isActiveViewport = viewportId === viewportGrid.activeViewportId; +export function WindowLevelActionMenuWrapper( + props: withAppTypes<{ + viewportId: string; + element?: HTMLElement; + location?: number; + isOpen?: boolean; + onOpen?: () => void; + onClose?: () => void; + displaySets?: Array; + disabled?: boolean; + isEmbedded?: boolean; + }> +): ReactNode { + const { + viewportId, + element, + location, + isOpen = false, + onOpen, + onClose, + disabled, + isEmbedded = false, + onInteraction: onInteractionProps, + hasEmbeddedVariantToUse, + ...rest + } = props; + + const [gridState] = useViewportGrid(); + const viewportIdToUse = viewportId || gridState.activeViewportId; + + const { viewportDisplaySets: displaySets } = useViewportDisplaySets(viewportIdToUse); const { servicesManager } = useSystem(); - const { customizationService } = servicesManager.services; - - const presets = customizationService.getCustomization('cornerstone.windowLevelPresets'); - const colorbarProperties = customizationService.getCustomization('cornerstone.colorbar'); - const { volumeRenderingPresets, volumeRenderingQualityRange } = - customizationService.getCustomization('cornerstone.3dVolumeRendering'); - - const isMenuOpen = - actionCornerState.viewports[viewportId]?.[location]?.find( - item => item.id === MENU_IDS.WINDOW_LEVEL_MENU - )?.isOpen ?? false; + const { toolbarService } = servicesManager.services; + const { IconContainer, className: iconClassName, containerProps } = useIconPresentation(); + const { hasColorbar, toggleColorbar } = useViewportRendering(viewportId); const handleOpenChange = (openState: boolean) => { + const shouldToggleColorbar = hasColorbar && !isEmbedded; + + if (isOpen && shouldToggleColorbar && openState) { + toggleColorbar(); + onClose?.(); + return; + } + + if (!isOpen && openState && shouldToggleColorbar) { + toggleColorbar(); + return; + } + if (openState) { - viewportActionCornersAPI.openItem?.(viewportId, MENU_IDS.WINDOW_LEVEL_MENU); + onOpen?.(); } else { - viewportActionCornersAPI.closeItem?.(viewportId, MENU_IDS.WINDOW_LEVEL_MENU); + onClose?.(); } }; - const { align, side, horizontalDirection, verticalDirection } = getMenuDirections( - location, - viewportActionCornersAPI - ); - - const displaySetPresets = displaySets - .filter(displaySet => presets[displaySet.Modality]) - .map(displaySet => { - return { [displaySet.Modality]: presets[displaySet.Modality] }; - }); + const { align, side } = toolbarService.getAlignAndSide(location); const modalities = displaySets.map(displaySet => displaySet.supportsWindowLevel); @@ -66,25 +78,41 @@ export function WindowLevelActionMenuWrapper({ return null; } + const Icon = + hasColorbar && !isEmbedded && hasEmbeddedVariantToUse ? ( + + ) : ( + + ); + 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 deleted file mode 100644 index 7b8897cb9..000000000 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react'; -import { WindowLevelActionMenuWrapper } from './WindowLevelActionMenuWrapper'; - -export function getWindowLevelActionMenu( - props: withAppTypes<{ - viewportId: string; - element: HTMLElement; - location: string; - displaySets: Array; - }> -) { - return ; -} diff --git a/extensions/cornerstone/src/components/index.ts b/extensions/cornerstone/src/components/index.ts index 6c5fceec7..7557a4936 100644 --- a/extensions/cornerstone/src/components/index.ts +++ b/extensions/cornerstone/src/components/index.ts @@ -6,8 +6,7 @@ export * from './MeasurementsMenu'; export * from './SeriesMeasurements'; export * from './StudyMeasurementsActions'; export * from './MeasurementsOrAdditionalFindings'; -export * from './ViewportDataOverlaySettingMenu'; -export * from './ViewportOrientationMenu'; export * from './WindowLevelActionMenu'; +export * from './ModalityLoadBadge'; export { DicomUpload }; diff --git a/extensions/cornerstone/src/components/menus/menu-ids.ts b/extensions/cornerstone/src/components/menus/menu-ids.ts deleted file mode 100644 index 230096bc4..000000000 --- a/extensions/cornerstone/src/components/menus/menu-ids.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const MENU_IDS = { - ORIENTATION_MENU: 'orientationMenu', - DATA_OVERLAY_MENU: 'dataOverlayMenu', - WINDOW_LEVEL_MENU: 'windowLevelActionMenu', -}; diff --git a/extensions/cornerstone/src/customizations/colorbarCustomization.ts b/extensions/cornerstone/src/customizations/colorbarCustomization.ts index d44aa7bd0..6f452cf0a 100644 --- a/extensions/cornerstone/src/customizations/colorbarCustomization.ts +++ b/extensions/cornerstone/src/customizations/colorbarCustomization.ts @@ -10,13 +10,13 @@ import { } from '../types/Colorbar'; import { ColorMapPreset } from '../types/Colormap'; -const defaultPosition: ColorbarPositionType = 'right'; +const defaultPosition: ColorbarPositionType = 'bottom'; const DefaultColormap = 'Grayscale'; const positionStyles: PositionStylesMapType = { - left: { left: '5%', width: '15px' }, - right: { right: '5%', width: '15px' }, - bottom: { bottom: '1%', height: '18px' }, + left: { width: '15px' }, + right: { width: '15px' }, + bottom: { height: '15px' }, }; // Typed position-specific tick styles diff --git a/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts b/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts deleted file mode 100644 index af90eadf9..000000000 --- a/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { getWindowLevelActionMenu } from '../components/WindowLevelActionMenu/getWindowLevelActionMenu'; -import { getViewportDataOverlaySettingsMenu } from '../components/ViewportDataOverlaySettingMenu'; -import { getViewportOrientationMenu } from '../components/ViewportOrientationMenu'; -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 }) => { - return getViewportOrientationMenu({ - viewportId, - element, - location, - }); -}; - -const createDataOverlay = ({ viewportId, element, displaySets, location }) => { - return getViewportDataOverlaySettingsMenu({ - viewportId, - element, - displaySets, - location, - }); -}; - -const createWindowLevelMenu = ({ viewportId, element, displaySets, location }) => { - return getWindowLevelActionMenu({ - viewportId, - element, - displaySets, - location, - }); -}; - -export default { - 'ui.viewportActionCorner': ViewportActionCorners, - 'viewportActionMenu.topLeft': [ - { - id: MENU_IDS.ORIENTATION_MENU, - component: createOrientationMenu, - }, - { - id: MENU_IDS.DATA_OVERLAY_MENU, - component: createDataOverlay, - }, - { - id: MENU_IDS.WINDOW_LEVEL_MENU, - component: createWindowLevelMenu, - }, - ], - 'viewportActionMenu.topRight': [], - 'viewportActionMenu.bottomLeft': [], - 'viewportActionMenu.bottomRight': [], -}; diff --git a/extensions/cornerstone/src/getCustomizationModule.tsx b/extensions/cornerstone/src/getCustomizationModule.tsx index 0f2f2f6c1..b37931289 100644 --- a/extensions/cornerstone/src/getCustomizationModule.tsx +++ b/extensions/cornerstone/src/getCustomizationModule.tsx @@ -11,7 +11,6 @@ import windowLevelPresetsCustomization from './customizations/windowLevelPresets import miscCustomization from './customizations/miscCustomization'; import captureViewportModalCustomization from './customizations/captureViewportModalCustomization'; import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization'; -import viewportActionMenuCustomizations from './customizations/viewportActionMenuCustomizations'; function getCustomizationModule({ commandsManager, servicesManager }) { return [ @@ -31,7 +30,6 @@ function getCustomizationModule({ commandsManager, servicesManager }) { ...miscCustomization, ...captureViewportModalCustomization, ...viewportDownloadWarningCustomization, - ...viewportActionMenuCustomizations, }, }, ]; diff --git a/extensions/cornerstone/src/getToolbarModule.tsx b/extensions/cornerstone/src/getToolbarModule.tsx index 7e5cbc36f..317aac85f 100644 --- a/extensions/cornerstone/src/getToolbarModule.tsx +++ b/extensions/cornerstone/src/getToolbarModule.tsx @@ -1,23 +1,358 @@ import { Enums } from '@cornerstonejs/tools'; import { utils } from '@ohif/ui-next'; +import { ViewportDataOverlayMenuWrapper } from './components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper'; +import { ViewportOrientationMenuWrapper } from './components/ViewportOrientationMenu/ViewportOrientationMenuWrapper'; +import { WindowLevelActionMenuWrapper } from './components/WindowLevelActionMenu/WindowLevelActionMenuWrapper'; +import { VOIManualControlMenuWrapper } from './components/VOIManualControlMenu'; +import { ThresholdMenuWrapper } from './components/ThresholdMenu/ThresholdMenuWrapper'; +import { OpacityMenuWrapper } from './components/OpacityMenu/OpacityMenuWrapper'; +import ModalityLoadBadge from './components/ModalityLoadBadge/ModalityLoadBadge'; +import NavigationComponent from './components/NavigationComponent/NavigationComponent'; +import TrackingStatus from './components/TrackingStatus/TrackingStatus'; +import ViewportColorbarsContainer from './components/ViewportColorbar'; +import AdvancedRenderingControls from './components/AdvancedRenderingControls'; const getDisabledState = (disabledText?: string) => ({ disabled: true, disabledText: disabledText ?? 'Not available on the current viewport', }); -export default function getToolbarModule({ commandsManager, servicesManager }: withAppTypes) { +export default function getToolbarModule({ servicesManager, extensionManager }: withAppTypes) { const { toolGroupService, toolbarService, syncGroupService, cornerstoneViewportService, - hangingProtocolService, + colorbarService, displaySetService, viewportGridService, + segmentationService, } = servicesManager.services; return [ + { + name: 'ohif.advancedRenderingControls', + defaultComponent: AdvancedRenderingControls, + }, + { + name: 'evaluate.advancedRenderingControls', + evaluate: ({ viewportId }) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport) { + return { + disabled: true, + }; + } + + const hasColorbar = colorbarService?.hasColorbar(viewportId) || false; + return { + disabled: !hasColorbar, + }; + }, + }, + { + name: 'ohif.colorbar', + defaultComponent: ViewportColorbarsContainer, + }, + { + name: 'ohif.trackingStatus', + defaultComponent: TrackingStatus, + }, + { + name: 'evaluate.trackingStatus', + evaluate: ({ viewportId }) => { + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + + if (!displaySetUIDs?.length) { + return { + disabled: true, + }; + } + + return { + disabled: false, + }; + }, + }, + // ModalityLoadBadge + { + name: 'ohif.modalityLoadBadge', + defaultComponent: ModalityLoadBadge, + }, + { + name: 'evaluate.modalityLoadBadge', + evaluate: ({ viewportId }) => { + // We can't use useViewportDisplaySets hook here since we're in a non-React context, + // but we'll follow the same pattern by getting only the display sets for this viewport + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + + if (!displaySetUIDs?.length) { + return { + disabled: true, + }; + } + + // Get the display sets that are specifically in this viewport + const viewportDisplaySets = displaySetUIDs.map(uid => + displaySetService.getDisplaySetByUID(uid) + ); + + // Only show status for supported types like SR, SEG, RTSTRUCT + const isSupportedType = viewportDisplaySets.some( + displaySet => + displaySet?.Modality === 'SR' || + displaySet?.Modality === 'SEG' || + displaySet?.Modality === 'RTSTRUCT' + ); + + return { + disabled: !isSupportedType, + }; + }, + }, + // NavigationComponent + { + name: 'ohif.navigationComponent', + defaultComponent: NavigationComponent, + }, + { + name: 'evaluate.navigationComponent', + evaluate: ({ viewportId }) => { + const { trackedMeasurementsService } = servicesManager.services; + // Same logic as statusComponent - only show for SR, SEG, RTSTRUCT + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + + if (!displaySetUIDs?.length) { + return { + disabled: true, + }; + } + + // Get the display sets that are specifically in this viewport + const viewportDisplaySets = displaySetUIDs.map(uid => + displaySetService.getDisplaySetByUID(uid) + ); + + // Check if there's a need for navigation: + // 1. Segmentations are present (for SEG/RTSTRUCT navigation) + // 2. There are tracked measurements in the viewport (for SR navigation) + + // Check for SEG/RTSTRUCT navigation + const hasSegmentation = + segmentationService.getSegmentationRepresentations(viewportId).length > 0; + + if (!trackedMeasurementsService) { + return { + disabled: !hasSegmentation, + }; + } + + // Check if any of the viewport's series are being tracked + const hasTrackedInViewport = viewportDisplaySets.some( + displaySet => + displaySet?.SeriesInstanceUID && + trackedMeasurementsService.isSeriesTracked(displaySet.SeriesInstanceUID) + ); + + const isSRDisplaySet = viewportDisplaySets.some( + displaySet => displaySet?.Modality === 'SR' + ); + + // Enable navigation if: + // - There's a segmentation to navigate (SEG/RTSTRUCT) + // - OR there are tracked measurements in the viewport (SR/etc.) + const needsNavigation = hasSegmentation || hasTrackedInViewport || isSRDisplaySet; + + return { + disabled: !needsNavigation, + }; + }, + }, + { + name: 'ohif.dataOverlayMenu', + defaultComponent: ViewportDataOverlayMenuWrapper, + }, + { + name: 'evaluate.dataOverlayMenu', + evaluate: ({ viewportId }) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport) { + return { + disabled: true, + }; + } + + // Example: Show data overlay menu only for certain modalities + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + if (!displaySetUIDs?.length) { + return { + disabled: true, + }; + } + + return { + disabled: false, + }; + }, + }, + { + name: 'ohif.orientationMenu', + defaultComponent: ViewportOrientationMenuWrapper, + }, + { + name: 'evaluate.orientationMenu', + evaluate: ({ viewportId }) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport) { + return { + disabled: true, + }; + } + + // Only show orientation menu for 3D capable viewports + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID); + const isNotReconstructable = displaySets.some(displaySet => !displaySet?.isReconstructable); + + const disabled = isNotReconstructable; + + return { + disabled, + }; + }, + }, + { + name: 'ohif.windowLevelMenu', + defaultComponent: WindowLevelActionMenuWrapper, + }, + { + name: 'ohif.voiManualControlMenu', + defaultComponent: VOIManualControlMenuWrapper, + }, + { + name: 'ohif.windowLevelMenuEmbedded', + defaultComponent: WindowLevelActionMenuWrapper, + }, + { + name: 'evaluate.windowLevelMenuEmbedded', + evaluate: () => { + return { + isEmbedded: true, + }; + }, + }, + { + name: 'ohif.thresholdMenu', + defaultComponent: ThresholdMenuWrapper, + }, + { + name: 'ohif.opacityMenu', + defaultComponent: OpacityMenuWrapper, + }, + { + name: 'evaluate.windowLevelMenu', + evaluate: ({ viewportId }) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport) { + return { + disabled: true, + }; + } + + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID); + + const supportWindowLevel = displaySets.some(displaySet => displaySet?.supportsWindowLevel); + + const isInAnySection = toolbarService.isInAnySection('windowLevelMenuEmbedded'); + + return { + disabled: !supportWindowLevel, + hasEmbeddedVariantToUse: !!isInAnySection, + }; + }, + }, + { + name: 'evaluate.voiManualControlMenu', + evaluate: ({ viewportId }) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport) { + return { + disabled: true, + }; + } + + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID); + + const supportWindowLevel = displaySets.some(displaySet => displaySet?.supportsWindowLevel); + + return { + disabled: !supportWindowLevel, + }; + }, + }, + { + name: 'evaluate.thresholdMenu', + evaluate: ({ viewportId }) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport) { + return { + disabled: true, + }; + } + + if (viewport.type !== 'orthographic') { + return { + disabled: true, + }; + } + + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + if (!displaySetUIDs.length) { + return { + disabled: true, + }; + } + + return { + disabled: false, + }; + }, + }, + { + name: 'evaluate.opacityMenu', + evaluate: ({ viewportId }) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport || viewport.type !== 'orthographic') { + return { + disabled: true, + }; + } + + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + + if (displaySetUIDs.length <= 1) { + return { + disabled: true, + }; + } + + const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID); + const hasOverlayable = displaySets.some(displaySet => displaySet?.isOverlayDisplaySet); + + return { + disabled: hasOverlayable, + }; + }, + }, // functions/helpers to be used by the toolbar buttons to decide if they should // enabled or not { diff --git a/extensions/cornerstone/src/hooks/index.ts b/extensions/cornerstone/src/hooks/index.ts index ebf70eb49..a982b94d8 100644 --- a/extensions/cornerstone/src/hooks/index.ts +++ b/extensions/cornerstone/src/hooks/index.ts @@ -1,4 +1,8 @@ -export * from './useViewportActionCorners'; export * from './useActiveViewportSegmentationRepresentations'; export * from './useMeasurements'; export * from './useSegmentations'; +export * from './useViewportSegmentations'; +export * from './useViewportHover'; +export * from './useViewportDisplaySets'; +export * from './useMeasurementTracking'; +export * from './useViewportRendering'; diff --git a/extensions/cornerstone/src/hooks/useActiveViewportSegmentationRepresentations.ts b/extensions/cornerstone/src/hooks/useActiveViewportSegmentationRepresentations.ts index 946839312..d81c30eef 100644 --- a/extensions/cornerstone/src/hooks/useActiveViewportSegmentationRepresentations.ts +++ b/extensions/cornerstone/src/hooks/useActiveViewportSegmentationRepresentations.ts @@ -1,216 +1,14 @@ -import { useState, useEffect } from 'react'; -import debounce from 'lodash.debounce'; -import { roundNumber } from '@ohif/core/src/utils'; -import { - SegmentationData, - SegmentationRepresentation, -} from '../services/SegmentationService/SegmentationService'; +import { useViewportGrid } from '@ohif/ui-next'; +import { useViewportSegmentations } from './useViewportSegmentations'; -const excludedModalities = ['SM', 'OT', 'DOC', 'ECG']; +function useActiveViewportSegmentationRepresentations() { + const [viewportGrid] = useViewportGrid(); -function mapSegmentationToDisplay(segmentation, customizationService) { - const { label, segments } = segmentation; + const viewportId = viewportGrid?.activeViewportId; - // Get the readable text mapping once - const readableTextMap = customizationService.getCustomization('panelSegmentation.readableText'); + const segmentations = useViewportSegmentations({ viewportId }); - // Helper function to recursively map cachedStats to readable display text - function mapStatsToDisplay(stats, indent = 0) { - const primary = []; - const indentation = ' '.repeat(indent); - - for (const key in stats) { - if (Object.prototype.hasOwnProperty.call(stats, key)) { - const value = stats[key]; - const readableText = readableTextMap?.[key]; - - if (!readableText) { - continue; - } - - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - // Add empty row before category (except for the first category) - if (primary.length > 0) { - primary.push(''); - } - // Add category title - primary.push(`${indentation}${readableText}`); - // Recursively handle nested objects - primary.push(...mapStatsToDisplay(value, indent + 1)); - } else { - // For non-nested values, don't add empty rows - primary.push(`${indentation}${readableText}: ${roundNumber(value, 2)}`); - } - } - } - - return primary; - } - - // Get customization for display text mapping - const displayTextMapper = segment => { - const defaultDisplay = { - primary: [], - secondary: [], - }; - - // If the segment has cachedStats, map it to readable text - if (segment.cachedStats) { - const primary = mapStatsToDisplay(segment.cachedStats); - defaultDisplay.primary = primary; - } - - return defaultDisplay; - }; - - const updatedSegments = {}; - - Object.entries(segments).forEach(([segmentIndex, segment]) => { - updatedSegments[segmentIndex] = { - ...segment, - displayText: displayTextMapper(segment), - }; - }); - - // Map the segments and apply the display text mapper - return { - ...segmentation, - label, - segments: updatedSegments, - }; + return segmentations; } -/** - * Represents the combination of segmentation data and its representation in a viewport. - */ -type ViewportSegmentationRepresentation = { - segmentationsWithRepresentations: { - representation: SegmentationRepresentation; - segmentation: SegmentationData; - }[]; - disabled: boolean; -}; - -/** - * Custom hook that provides segmentation data and their representations for the active viewport. - * @param options - The options object. - * @param options.servicesManager - The services manager object. - * @param options.subscribeToDataModified - Whether to subscribe to segmentation data modifications. - * @param options.debounceTime - Debounce time in milliseconds for updates. - * @returns An array of segmentation data and their representations for the active viewport. - */ -export function useActiveViewportSegmentationRepresentations({ - servicesManager, - subscribeToDataModified = false, - debounceTime = 0, -}: withAppTypes<{ debounceTime?: number }>): ViewportSegmentationRepresentation { - const { segmentationService, viewportGridService, customizationService, displaySetService } = - servicesManager.services; - - const [segmentationsWithRepresentations, setSegmentationsWithRepresentations] = - useState({ - segmentationsWithRepresentations: [], - disabled: false, - }); - - useEffect(() => { - const update = () => { - const viewportId = viewportGridService.getActiveViewportId(); - const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); - - if (!displaySetUIDs?.length) { - return; - } - - const displaySet = displaySetService.getDisplaySetByUID(displaySetUIDs[0]); - - if (!displaySet) { - return; - } - - if (excludedModalities.includes(displaySet.Modality)) { - setSegmentationsWithRepresentations(prev => ({ - segmentationsWithRepresentations: [], - disabled: true, - })); - return; - } - - const segmentations = segmentationService.getSegmentations(); - - if (!segmentations?.length) { - setSegmentationsWithRepresentations(prev => ({ - segmentationsWithRepresentations: [], - disabled: false, - })); - return; - } - - const representations = segmentationService.getSegmentationRepresentations(viewportId); - - const newSegmentationsWithRepresentations = representations.map(representation => { - const segmentation = segmentationService.getSegmentation(representation.segmentationId); - const mappedSegmentation = mapSegmentationToDisplay(segmentation, customizationService); - return { - representation, - segmentation: mappedSegmentation, - }; - }); - - setSegmentationsWithRepresentations({ - segmentationsWithRepresentations: newSegmentationsWithRepresentations, - disabled: false, - }); - }; - - const debouncedUpdate = - debounceTime > 0 ? debounce(update, debounceTime, { leading: true, trailing: true }) : update; - - update(); - - const subscriptions = [ - segmentationService.subscribe( - segmentationService.EVENTS.SEGMENTATION_MODIFIED, - debouncedUpdate - ), - segmentationService.subscribe( - segmentationService.EVENTS.SEGMENTATION_REMOVED, - debouncedUpdate - ), - segmentationService.subscribe( - segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, - debouncedUpdate - ), - viewportGridService.subscribe( - viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED, - debouncedUpdate - ), - viewportGridService.subscribe(viewportGridService.EVENTS.GRID_STATE_CHANGED, debouncedUpdate), - ]; - - if (subscribeToDataModified) { - subscriptions.push( - segmentationService.subscribe( - segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED, - debouncedUpdate - ) - ); - } - - return () => { - subscriptions.forEach(subscription => subscription.unsubscribe()); - if (debounceTime > 0) { - debouncedUpdate.cancel(); - } - }; - }, [ - segmentationService, - viewportGridService, - customizationService, - displaySetService, - debounceTime, - subscribeToDataModified, - ]); - - return segmentationsWithRepresentations; -} +export { useActiveViewportSegmentationRepresentations }; diff --git a/extensions/cornerstone/src/hooks/useMeasurementTracking.ts b/extensions/cornerstone/src/hooks/useMeasurementTracking.ts new file mode 100644 index 000000000..02558503f --- /dev/null +++ b/extensions/cornerstone/src/hooks/useMeasurementTracking.ts @@ -0,0 +1,132 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useSystem } from '@ohif/core/src'; +import { useViewportDisplaySets } from './useViewportDisplaySets'; +import { BaseVolumeViewport } from '@cornerstonejs/core'; + +/** + * Hook that provides measurement tracking information for a viewport + * + * @param options - The hook options + * @param options.viewportId - The ID of the viewport to track + * @returns An object containing the tracking state, related information, and tracked measurement UIDs + */ +export function useMeasurementTracking({ viewportId }: { viewportId: string }) { + const { servicesManager } = useSystem(); + const { cornerstoneViewportService, trackedMeasurementsService, measurementService } = + servicesManager.services; + + const { backgroundDisplaySet } = useViewportDisplaySets(viewportId); + + // Tracking states + const [isTracked, setIsTracked] = useState(false); + const [isLocked, setIsLocked] = useState(false); + const [trackedMeasurementUIDs, setTrackedMeasurementUIDs] = useState([]); + + const updateTrackedMeasurements = useCallback(() => { + if (!measurementService || !backgroundDisplaySet?.SeriesInstanceUID || !isTracked) { + setTrackedMeasurementUIDs([]); + return; + } + + const seriesInstanceUID = backgroundDisplaySet.SeriesInstanceUID; + + const seriesMeasurements = measurementService.getMeasurements( + measurement => measurement.referenceSeriesUID === seriesInstanceUID + ); + + const uids = seriesMeasurements.map(measurement => measurement.uid); + setTrackedMeasurementUIDs(uids); + }, [measurementService, backgroundDisplaySet, isTracked]); + + const updateIsTracked = useCallback(() => { + if (!trackedMeasurementsService || !backgroundDisplaySet?.SeriesInstanceUID) { + setIsTracked(false); + return; + } + + const trackedSeries = trackedMeasurementsService.getTrackedSeries(); + + if (!trackedSeries?.length) { + setIsTracked(false); + return; + } + + const viewport = cornerstoneViewportService?.getCornerstoneViewport(viewportId); + const SeriesInstanceUID = backgroundDisplaySet.SeriesInstanceUID; + + if (viewport instanceof BaseVolumeViewport) { + const currentImageId = viewport?.getCurrentImageId(); + + if (!currentImageId) { + setIsTracked(false); + return; + } + } + + const seriesIsTracked = trackedSeries.includes(SeriesInstanceUID); + setIsTracked(seriesIsTracked); + }, [viewportId, backgroundDisplaySet, cornerstoneViewportService, trackedMeasurementsService]); + + // Update tracked measurements whenever tracking state changes + useEffect(() => { + updateTrackedMeasurements(); + }, [isTracked, updateTrackedMeasurements]); + + useEffect(() => { + if (!trackedMeasurementsService) { + return; + } + + setIsLocked(trackedMeasurementsService.isTrackingEnabled()); + updateIsTracked(); + + const subscriptions = [ + trackedMeasurementsService.subscribe(trackedMeasurementsService.EVENTS.TRACKING_ENABLED, () => + setIsLocked(true) + ), + trackedMeasurementsService.subscribe( + trackedMeasurementsService.EVENTS.TRACKING_DISABLED, + () => setIsLocked(false) + ), + + trackedMeasurementsService.subscribe( + trackedMeasurementsService.EVENTS.TRACKED_SERIES_CHANGED, + () => updateIsTracked() + ), + trackedMeasurementsService.subscribe(trackedMeasurementsService.EVENTS.SERIES_ADDED, () => + updateIsTracked() + ), + trackedMeasurementsService.subscribe(trackedMeasurementsService.EVENTS.SERIES_REMOVED, () => + updateIsTracked() + ), + ]; + + // Subscribe to measurement service events to update trackedMeasurementUIDs + if (measurementService) { + [ + measurementService.EVENTS.MEASUREMENT_ADDED, + measurementService.EVENTS.RAW_MEASUREMENT_ADDED, + measurementService.EVENTS.MEASUREMENT_UPDATED, + measurementService.EVENTS.MEASUREMENT_REMOVED, + measurementService.EVENTS.MEASUREMENTS_CLEARED, + ].forEach(eventType => { + subscriptions.push( + measurementService.subscribe(eventType, () => updateTrackedMeasurements()) + ); + }); + } + + return () => { + subscriptions.forEach(sub => sub.unsubscribe()); + }; + }, [trackedMeasurementsService, measurementService, updateIsTracked, updateTrackedMeasurements]); + + return { + isTracked, + isLocked, + seriesInstanceUID: backgroundDisplaySet?.SeriesInstanceUID, + trackedMeasurementUIDs, + }; +} + +export default useMeasurementTracking; diff --git a/extensions/cornerstone/src/hooks/useMeasurements.ts b/extensions/cornerstone/src/hooks/useMeasurements.ts index e76445375..5e4abbcba 100644 --- a/extensions/cornerstone/src/hooks/useMeasurements.ts +++ b/extensions/cornerstone/src/hooks/useMeasurements.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import debounce from 'lodash.debounce'; - +import { useSystem } from '@ohif/core'; function mapMeasurementToDisplay(measurement, displaySetService) { const { referenceSeriesUID } = measurement; @@ -56,13 +56,14 @@ function mapMeasurementToDisplay(measurement, displaySetService) { * @param {Object} options.valueTypes - The value types for mapping measurements. * @returns {Array} An array of mapped and filtered measurements. */ -export function useMeasurements(servicesManager, { measurementFilter }) { +export function useMeasurements({ measurementFilter }) { + const { servicesManager } = useSystem(); const { measurementService, displaySetService } = servicesManager.services; const [displayMeasurements, setDisplayMeasurements] = useState([]); useEffect(() => { const updateDisplayMeasurements = () => { - let measurements = measurementService.getMeasurements(measurementFilter); + const measurements = measurementService.getMeasurements(measurementFilter); const mappedMeasurements = measurements.map(m => mapMeasurementToDisplay(m, displaySetService) ); diff --git a/extensions/cornerstone/src/hooks/useViewportDisplaySets.ts b/extensions/cornerstone/src/hooks/useViewportDisplaySets.ts index cca79a109..fef6901ca 100644 --- a/extensions/cornerstone/src/hooks/useViewportDisplaySets.ts +++ b/extensions/cornerstone/src/hooks/useViewportDisplaySets.ts @@ -48,30 +48,38 @@ export type UseViewportDisplaySetsOptions = { * Return type for useViewportDisplaySets */ export type ViewportDisplaySets = { + /** + * All display sets for the viewport + */ + allDisplaySets: AppTypes.DisplaySet[]; + /** + * The viewport display sets for the viewport + */ + viewportDisplaySets: AppTypes.DisplaySet[]; /** * The primary display set for the viewport (base image) */ - backgroundDisplaySet?: any; + backgroundDisplaySet?: AppTypes.DisplaySet; /** * Display sets currently shown with background (non-overlay layers) */ - foregroundDisplaySets?: any[]; + foregroundDisplaySets?: AppTypes.DisplaySet[]; /** * Segmentation display sets currently applied as overlays */ - overlayDisplaySets?: any[]; + overlayDisplaySets?: AppTypes.DisplaySet[]; /** * Display sets that could be toggled on as overlays (derived modalities) */ - potentialOverlayDisplaySets?: any[]; + potentialOverlayDisplaySets?: AppTypes.DisplaySet[]; /** * Display sets that could be added as foreground layers */ - potentialForegroundDisplaySets?: any[]; + potentialForegroundDisplaySets?: AppTypes.DisplaySet[]; /** * Display sets that could replace the current background */ - potentialBackgroundDisplaySets?: any[]; + potentialBackgroundDisplaySets?: AppTypes.DisplaySet[]; }; /** @@ -82,7 +90,7 @@ export type ViewportDisplaySets = { * @returns Object containing requested display set collections based on options */ export function useViewportDisplaySets( - viewportId: string, + viewportId?: string, options?: UseViewportDisplaySetsOptions ): ViewportDisplaySets { const { servicesManager } = useSystem(); @@ -91,7 +99,9 @@ export function useViewportDisplaySets( // Note: this is very important we should use the useViewportGrid hook here, // since if the viewport displaySet is changed we should re-run this hook // to get the latest displaySets - const [_, viewportGridService] = useViewportGrid(); + const [viewportGridState, viewportGridService] = useViewportGrid(); + + const viewportIdToUse = viewportId || viewportGridState.activeViewportId; // Apply defaults - include everything if no options specified const { @@ -114,8 +124,8 @@ export function useViewportDisplaySets( const needsSegmentations = includeOverlay; const segmentationRepresentations = useMemo( () => - needsSegmentations ? segmentationService.getSegmentationRepresentations(viewportId) : [], - [segmentationService, viewportId, needsSegmentations] + needsSegmentations ? segmentationService.getSegmentationRepresentations(viewportIdToUse) : [], + [segmentationService, viewportIdToUse, needsSegmentations] ); const overlayDisplaySets = useMemo(() => { @@ -142,10 +152,10 @@ export function useViewportDisplaySets( return { viewportDisplaySets: [], enhancedDisplaySets: [] }; } return getEnhancedDisplaySets({ - viewportId, + viewportId: viewportIdToUse, services: { displaySetService, viewportGridService }, }); - }, [viewportId, displaySetService, viewportGridService, needsEnhancedDisplaySets]); + }, [viewportIdToUse, displaySetService, viewportGridService, needsEnhancedDisplaySets]); const backgroundDisplaySet = useMemo( () => (includeBackground ? viewportDisplaySets[0] : undefined), @@ -217,7 +227,7 @@ export function useViewportDisplaySets( foregroundDisplaySetUIDs, ]); - const result: ViewportDisplaySets = {}; + const result: ViewportDisplaySets = { allDisplaySets, viewportDisplaySets }; if (includeBackground) { result.backgroundDisplaySet = backgroundDisplaySet; diff --git a/extensions/cornerstone/src/hooks/useViewportHover.ts b/extensions/cornerstone/src/hooks/useViewportHover.ts new file mode 100644 index 000000000..d26121a49 --- /dev/null +++ b/extensions/cornerstone/src/hooks/useViewportHover.ts @@ -0,0 +1,77 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useViewportGrid } from '@ohif/ui-next'; + +/** + * Hook to track whether the mouse is hovering over a specific viewport + * and whether the viewport is active + * + * @param viewportId - The ID of the viewport to track + * @returns { isHovered, isActive } - Whether the viewport is hovered and active + */ +export function useViewportHover(viewportId: string): { isHovered: boolean; isActive: boolean } { + const [isHovered, setIsHovered] = useState(false); + const [viewportGrid] = useViewportGrid(); + const { activeViewportId } = viewportGrid; + + const isActive = activeViewportId === viewportId; + + const setupListeners = useCallback(() => { + const viewportElement = document.querySelector(`[data-viewportId="${viewportId}"]`); + const element = viewportElement?.closest('.viewport-wrapper') || viewportElement; + + if (!element) { + return null; + } + + let elementRect = (element as HTMLElement).getBoundingClientRect(); + let lastIsInside = false; + + // Update rectangle when window is resized + const updateRect = () => { + elementRect = (element as HTMLElement).getBoundingClientRect(); + }; + + const isPointInViewport = (x, y) => { + return ( + x >= elementRect.left && + x <= elementRect.right && + y >= elementRect.top && + y <= elementRect.bottom + ); + }; + + const handleMouseMove = event => { + const isInside = isPointInViewport(event.clientX, event.clientY); + + if (isInside !== lastIsInside) { + lastIsInside = isInside; + setIsHovered(isInside); + } + }; + + let resizeTimeout; + const handleResize = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(updateRect, 10); + }; + + window.addEventListener('resize', handleResize); + document.addEventListener('mousemove', handleMouseMove); + + updateRect(); + + return () => { + window.removeEventListener('resize', handleResize); + document.removeEventListener('mousemove', handleMouseMove); + clearTimeout(resizeTimeout); + }; + }, [viewportId]); + + useEffect(() => { + const cleanup = setupListeners(); + return cleanup; + }, [setupListeners]); + + // Memoize the return value to prevent unnecessary re-renders + return useMemo(() => ({ isHovered, isActive }), [isHovered, isActive]); +} diff --git a/extensions/cornerstone/src/hooks/useViewportRendering.tsx b/extensions/cornerstone/src/hooks/useViewportRendering.tsx new file mode 100644 index 000000000..6a7027b8e --- /dev/null +++ b/extensions/cornerstone/src/hooks/useViewportRendering.tsx @@ -0,0 +1,882 @@ +import React, { useCallback, useState, useEffect, useMemo } from 'react'; +import { useSystem } from '@ohif/core'; +import { useViewportDisplaySets } from './useViewportDisplaySets'; +import { + StackViewport, + Types, + VolumeViewport3D, + utilities, + Enums, + BaseVolumeViewport, + cache, +} from '@cornerstonejs/core'; +import { WindowLevelPreset } from '../types/WindowLevel'; +import { ColorbarPositionType, ColorbarOptions, ColorbarProperties } from '../types/Colorbar'; +import { VolumeRenderingConfig } from '../types/VolumeRenderingConfig'; +import { VolumeLightingParams } from '../types'; +import { ButtonLocation } from '@ohif/core/src/services/ToolBarService/ToolbarService'; + +interface ViewportRenderingOptions { + location?: number; + displaySetInstanceUID?: string; +} + +interface PixelValueRange { + min: number; + max: number; +} + +interface WindowLevelHook { + // Viewport information + is3DVolume: boolean; + isViewportBackgroundLight: boolean; + viewportDisplaySets: AppTypes.DisplaySet[] | undefined; + voiRange: { lower: number; upper: number } | undefined; + windowLevel: { windowWidth: number; windowCenter: number } | undefined; + + // Window level functions + setWindowLevel: (preset: { + windowWidth: number; + windowCenter: number; + immediate?: boolean; + }) => void; + setVOIRange: (params: { lower: number; upper: number }) => void; + windowLevelPresets: WindowLevelPreset[]; + allWindowLevelPresets: Array<{ + displaySetInstanceUID: string; + modality: string; + presets: WindowLevelPreset[]; + }>; + + // Colorbar functions + hasColorbar: boolean; + toggleColorbar: (options?: Partial) => void; + colorbarProperties: ColorbarProperties; + colorbarPosition: ColorbarPositionType; + setColorbarPosition: React.Dispatch>; + + // Colormap functions + setColormap: (params: { colormap: any; opacity?: number; immediate?: boolean }) => void; + colormap: any; + + // Opacity functions + opacity: number | undefined; + setOpacity: (opacity: number) => void; + opacityLinear: number | undefined; + setOpacityLinear: (opacityLinear: number) => void; + + // Threshold functions + threshold: number | undefined; + setThreshold: (threshold: number) => void; + pixelValueRange: PixelValueRange; + + // 3D volume rendering functions + setVolumeRenderingPreset: (preset: any) => void; + setVolumeRenderingQuality: (quality: number) => void; + setVolumeLighting: (params: { ambient: number; diffuse: number; specular: number }) => void; + setVolumeShading: (enabled: boolean) => void; + volumeRenderingPresets: Array; + volumeRenderingQualityRange: { min: number; max: number; step: number }; +} + +const getPosition = (location: number): ColorbarPositionType => { + switch (location) { + case ButtonLocation.LeftMiddle: + return 'left'; + case ButtonLocation.RightMiddle: + return 'right'; + case ButtonLocation.BottomMiddle: + return 'bottom'; + case ButtonLocation.TopMiddle: + return 'top'; + default: + return 'bottom'; // Default to bottom if location doesn't match a middle position + } +}; + +const GAMMA = 1 / 5; + +const linearToOpacity = (linearValue: number): number => { + return Math.pow(linearValue, GAMMA); +}; + +const opacityToLinear = (opacityValue: number): number => { + return Math.pow(opacityValue, 1.0 / GAMMA); +}; + +/** + * Hook to access window level functionality for a specific viewport + * + * @param viewportId - The ID of the viewport to get window level functionality for + * @param options - Options for the hook, including location and displaySetInstanceUID + * @returns Window level API for the specified viewport + */ +export function useViewportRendering( + viewportId?: string, + options?: ViewportRenderingOptions +): WindowLevelHook { + const { servicesManager, commandsManager } = useSystem(); + const { cornerstoneViewportService, colorbarService, customizationService } = + servicesManager.services; + + const [is3DVolume, setIs3DVolume] = useState(false); + const [hasColorbar, setHasColorbar] = useState(colorbarService.hasColorbar(viewportId)); + const [colorbarPosition, setColorbarPosition] = useState( + options?.location ? getPosition(options.location) : 'bottom' + ); + const [voiRange, setVoiRange] = useState<{ lower: number; upper: number } | undefined>(); + const voiRangeRef = React.useRef<{ lower: number; upper: number } | undefined>(); + const [opacity, setOpacityState] = useState(); + const [opacityLinear, setOpacityLinearState] = useState(); + const [threshold, setThresholdState] = useState(); + const [pixelValueRange, setPixelValueRange] = useState({ min: 0, max: 255 }); + + const { viewportDisplaySets } = useViewportDisplaySets(viewportId); + const { displaySetService } = servicesManager.services; + + // Determine the active display set instance UID (internal only, not exposed) + const activeDisplaySetInstanceUID = useMemo(() => { + if (options?.displaySetInstanceUID) { + return options.displaySetInstanceUID; + } + + if (viewportDisplaySets && viewportDisplaySets.length > 0) { + return viewportDisplaySets[0].displaySetInstanceUID; + } + + return undefined; + }, [options?.displaySetInstanceUID, viewportDisplaySets]); + + const viewportInfo = viewportId ? cornerstoneViewportService.getViewportInfo(viewportId) : null; + + const { presets, colorbarProperties, volumeRenderingPresets, volumeRenderingQualityRange } = + getCustomizationData(customizationService); + + const backgroundColor = viewportInfo?.getViewportOptions().background; + const isViewportBackgroundLight = backgroundColor + ? utilities.isEqual(backgroundColor, [1, 1, 1]) + : false; + + const allWindowLevelPresets = useMemo(() => { + return ( + viewportDisplaySets + ?.filter(displaySet => presets?.[displaySet.Modality as string]) + .map(displaySet => { + return { + displaySetInstanceUID: displaySet.displaySetInstanceUID, + modality: displaySet.Modality as string, + presets: presets[displaySet.Modality as string], + }; + }) || [] + ); + }, [viewportDisplaySets, presets]); + + // Calculate pixel value range for the active display set + useEffect(() => { + if (!activeDisplaySetInstanceUID) { + return; + } + + const selectedDisplaySet = displaySetService.getDisplaySetByUID(activeDisplaySetInstanceUID); + if (!selectedDisplaySet?.imageIds?.length) { + return; + } + + const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!csViewport) { + return; + } + + if (!(csViewport instanceof BaseVolumeViewport)) { + return; + } + + const volumeIds = csViewport.getAllVolumeIds(); + const volumeId = volumeIds.find(id => id.includes(activeDisplaySetInstanceUID)); + + if (!volumeId) { + return; + } + + // only handle volume viewports for now + const imageData = csViewport.getImageData(volumeId); + + if (!imageData) { + return; + } + + const imageDataVtk = imageData.imageData; + + const { voxelManager } = imageDataVtk.get('voxelManager'); + + const range = voxelManager.getRange(); + + setPixelValueRange({ min: range[0], max: range[1] }); + }, [activeDisplaySetInstanceUID, displaySetService, cornerstoneViewportService, viewportId]); + + // Get the presets specifically for the active display set + const activeDisplaySetPresets = useMemo(() => { + if (!activeDisplaySetInstanceUID || allWindowLevelPresets.length === 0) { + return []; + } + + const activePresetData = allWindowLevelPresets.find( + preset => preset.displaySetInstanceUID === activeDisplaySetInstanceUID + ); + + if (!activePresetData) { + return []; + } + + return activePresetData.presets; + }, [allWindowLevelPresets, activeDisplaySetInstanceUID]); + + useEffect(() => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + setIs3DVolume(viewport instanceof VolumeViewport3D); + + // Initialize the VOI range from the viewport + if (viewport && activeDisplaySetInstanceUID) { + try { + let properties; + + if (viewport instanceof StackViewport) { + properties = viewport.getProperties(); + if (properties.voiRange) { + setVoiRange(properties.voiRange); + voiRangeRef.current = properties.voiRange; + } + } else if (viewport instanceof BaseVolumeViewport) { + // For volume viewports, find the actor for the active display set + const volumeIds = viewport.getAllVolumeIds(); + const volumeId = volumeIds.find(id => id.includes(activeDisplaySetInstanceUID)); + + if (volumeId) { + properties = viewport.getProperties(volumeId); + if (properties.voiRange) { + setVoiRange(properties.voiRange); + voiRangeRef.current = properties.voiRange; + } + + // Get opacity from colormap if available + if (properties.colormap && properties.colormap.opacity !== undefined) { + const isArray = Array.isArray(properties.colormap.opacity); + const opacity = isArray + ? properties.colormap.opacity.reduce((max, current) => Math.max(max, current), 0) + : properties.colormap.opacity; + + setOpacityState(opacity); + setOpacityLinearState(opacityToLinear(opacity)); + } + + // Get threshold from colormap if available + if (properties.colormap && properties.colormap.threshold !== undefined) { + setThresholdState(properties.colormap.threshold); + } + } + } + } catch (error) { + console.error('Error initializing VOI range:', error); + } + } + }, [cornerstoneViewportService, viewportId, activeDisplaySetInstanceUID]); + + useEffect(() => { + if (!viewportId) { + return; + } + + const updateColorbarState = () => { + const hasColorbarValue = colorbarService.hasColorbar(viewportId); + setHasColorbar(hasColorbarValue); + }; + + updateColorbarState(); + + const { unsubscribe } = colorbarService.subscribe( + colorbarService.EVENTS.STATE_CHANGED, + updateColorbarState + ); + + return () => { + unsubscribe(); + }; + }, [colorbarService, viewportId]); + + useEffect(() => { + if (!viewportId || !activeDisplaySetInstanceUID) { + return; + } + + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (!viewport) { + return; + } + + const element = viewport.element; + if (!element) { + return; + } + + const updateVOI = eventDetail => { + const { range } = eventDetail.detail; + + if (!range) { + return; + } + + // Check if this update is coming from our own setVOIRange or setWindowLevel call + // If so, we already updated our state and don't need to do it again + const isInternalUpdate = voiRangeRef.current && areVoiRangesClose(voiRangeRef.current, range); + + if (!isInternalUpdate) { + voiRangeRef.current = range; + setVoiRange(range); + } + }; + + const updateColormap = eventDetail => { + const { colormap } = eventDetail.detail; + + if (!colormap) { + return; + } + + // Extract threshold from colormap in the event detail + if (colormap.threshold !== undefined) { + setThresholdState(colormap.threshold); + } + + if (colormap.opacity !== undefined) { + setOpacityState(colormap.opacity); + setOpacityLinearState(opacityToLinear(colormap.opacity)); + } + }; + + element.addEventListener(Enums.Events.VOI_MODIFIED, updateVOI); + element.addEventListener(Enums.Events.COLORMAP_MODIFIED, updateColormap); + + return () => { + element.removeEventListener(Enums.Events.VOI_MODIFIED, updateVOI); + element.removeEventListener(Enums.Events.COLORMAP_MODIFIED, updateColormap); + }; + }, [viewportId, activeDisplaySetInstanceUID, cornerstoneViewportService, opacityToLinear]); + + const validateActiveDisplaySet = useCallback(() => { + if (!activeDisplaySetInstanceUID) { + throw new Error('No active display set instance UID is available'); + } + + if (viewportDisplaySets?.length === 0) { + throw new Error('No display sets are available for this viewport'); + } + + // Verify the active display set is in the viewport + const isDisplaySetInViewport = viewportDisplaySets?.some( + ds => ds.displaySetInstanceUID === activeDisplaySetInstanceUID + ); + + if (!isDisplaySetInViewport) { + throw new Error( + `Display set with UID ${activeDisplaySetInstanceUID} is not present in the viewport` + ); + } + + return activeDisplaySetInstanceUID; + }, [activeDisplaySetInstanceUID, viewportDisplaySets]); + + const setWindowLevel = useCallback( + (preset: { windowWidth: number; windowCenter: number; immediate?: boolean }) => { + if (!viewportId) { + return; + } + + const displaySetInstanceUID = validateActiveDisplaySet(); + + // Update voiRange as well, to ensure immediate UI updates + const { lower, upper } = utilities.windowLevel.toLowHighRange( + preset.windowWidth, + preset.windowCenter + ); + + const newVoiRange = { lower, upper }; + + if (!voiRangeRef.current || !areVoiRangesClose(voiRangeRef.current, newVoiRange)) { + voiRangeRef.current = newVoiRange; + setVoiRange(newVoiRange); + + commandsManager.run({ + commandName: 'setViewportWindowLevel', + commandOptions: { + ...preset, + viewportId, + displaySetInstanceUID, + }, + context: 'CORNERSTONE', + }); + } + }, + [commandsManager, viewportId, validateActiveDisplaySet] + ); + + const setVOIRange = useCallback( + (params: { lower: number; upper: number }) => { + if (!viewportId) { + return; + } + + // Only update if VOI values have actually changed + if (!voiRangeRef.current || !areVoiRangesClose(voiRangeRef.current, params)) { + // Update the ref and state immediately to avoid race conditions with the event listener + voiRangeRef.current = params; + setVoiRange(params); + + const windowLevel = utilities.windowLevel.toWindowLevel(params.lower, params.upper); + + // Set window level using the command manager directly to avoid circular calls + const displaySetInstanceUID = validateActiveDisplaySet(); + commandsManager.run({ + commandName: 'setViewportWindowLevel', + commandOptions: { + ...windowLevel, + viewportId, + displaySetInstanceUID, + }, + context: 'CORNERSTONE', + }); + } + }, + [viewportId, commandsManager, validateActiveDisplaySet] + ); + + const toggleColorbar = useCallback( + (options?: Partial) => { + if (!viewportId) { + return; + } + + if (viewportDisplaySets?.length === 0) { + return; + } + + if (!colorbarProperties) { + return; + } + + const { + width: colorbarWidth, + colorbarTickPosition, + colorbarInitialColormap, + } = colorbarProperties as ColorbarProperties; + + let appropriateTickPosition = colorbarTickPosition || 'top'; + + if (colorbarPosition === 'left' || colorbarPosition === 'right') { + appropriateTickPosition = colorbarPosition === 'left' ? 'right' : 'left'; + } else { + appropriateTickPosition = colorbarPosition === 'top' ? 'bottom' : 'top'; + } + + const colorbarOptions = { + viewportId, + colormaps: colorbarProperties.colormaps || {}, + ticks: { + position: appropriateTickPosition, + }, + width: colorbarWidth, + position: colorbarPosition, + activeColormapName: colorbarInitialColormap || 'Grayscale', + ...options, + }; + + // If light background, adjust tick style but keep the appropriate position + if (isViewportBackgroundLight) { + colorbarOptions.ticks = { + position: appropriateTickPosition, + style: { + font: '13px Inter', + color: '#000000', + maxNumTicks: 8, + tickSize: 5, + tickWidth: 1, + labelMargin: 3, + }, + }; + } + + const displaySetInstanceUIDs = viewportDisplaySets.map(ds => ds.displaySetInstanceUID); + + try { + commandsManager.run('toggleViewportColorbar', { + viewportId, + options: colorbarOptions, + displaySetInstanceUIDs, + }); + } catch (error) { + console.error('Error toggling colorbar:', error); + } + }, + [ + commandsManager, + viewportId, + colorbarProperties, + isViewportBackgroundLight, + viewportDisplaySets, + colorbarPosition, + ] + ); + + const setColormap = useCallback( + ({ colormap, opacity = 1, immediate = false }) => { + if (!viewportId) { + return; + } + + const displaySetInstanceUID = validateActiveDisplaySet(); + + // Update local opacity state + if (opacity !== undefined) { + setOpacityState(opacity); + } + + commandsManager.run({ + commandName: 'setViewportColormap', + commandOptions: { + viewportId, + colormap, + displaySetInstanceUID, + opacity, + immediate, + }, + context: 'CORNERSTONE', + }); + }, + [commandsManager, viewportId, validateActiveDisplaySet] + ); + + const setOpacity = useCallback( + (opacityValue: number) => { + if (!viewportId) { + return; + } + + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (!viewport || !(viewport instanceof BaseVolumeViewport)) { + return; + } + + // Apply the actual opacity value + setOpacityState(opacityValue); + // Update the linear value for UI + setOpacityLinearState(opacityToLinear(opacityValue)); + + const displaySetInstanceUID = validateActiveDisplaySet(); + const volumeIds = viewport.getAllVolumeIds(); + const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID)); + + if (!volumeId) { + return; + } + + // Get current properties including colormap + const properties = viewport.getProperties(volumeId); + const currentColormap = properties.colormap || {}; + + // Update colormap with new opacity + const updatedColormap = { + ...currentColormap, + opacity: opacityValue, + }; + + // Apply updated colormap + viewport.setProperties( + { + colormap: updatedColormap, + }, + volumeId + ); + + viewport.render(); + }, + [cornerstoneViewportService, viewportId, validateActiveDisplaySet, opacityToLinear] + ); + + const setOpacityLinear = useCallback( + (linearValue: number) => { + // Convert linear UI value to actual opacity value and apply it + const actualOpacity = linearToOpacity(linearValue); + setOpacity(actualOpacity); + }, + [linearToOpacity, setOpacity] + ); + + const setThreshold = useCallback( + (thresholdValue: number) => { + if (!viewportId) { + return; + } + + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (!viewport || !(viewport instanceof BaseVolumeViewport)) { + return; + } + + setThresholdState(thresholdValue); + + const displaySetInstanceUID = validateActiveDisplaySet(); + const volumeIds = viewport.getAllVolumeIds(); + const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID)); + + if (!volumeId) { + return; + } + + console.debug('🚀 ~ thresholdValue:', thresholdValue); + + viewport.setProperties( + { + colormap: { + threshold: thresholdValue, + }, + }, + volumeId + ); + + viewport.render(); + }, + [cornerstoneViewportService, viewportId, validateActiveDisplaySet] + ); + + // Get the current colormap for the active display set + const colormap = useMemo(() => { + if (!viewportId || !activeDisplaySetInstanceUID || !viewportDisplaySets?.length) { + return null; + } + + try { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + + if (!viewport) { + return null; + } + + if (viewport instanceof StackViewport) { + const { colormap } = viewport.getProperties(); + if (!colormap) { + return ( + colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || + colorbarProperties?.colormaps?.[0] + ); + } + return colormap; + } + + const actorEntries = viewport.getActors(); + const actorEntry = actorEntries?.find(entry => + entry.referencedId.includes(activeDisplaySetInstanceUID) + ); + + if (!actorEntry) { + return ( + colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || + colorbarProperties?.colormaps?.[0] + ); + } + + const { colormap } = (viewport as Types.IVolumeViewport).getProperties( + actorEntry.referencedId + ); + + if (!colormap) { + return ( + colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || + colorbarProperties?.colormaps?.[0] + ); + } + + return colormap; + } catch (error) { + console.error('Error getting viewport colormap:', error); + return ( + colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || + colorbarProperties?.colormaps?.[0] + ); + } + }, [ + cornerstoneViewportService, + viewportId, + activeDisplaySetInstanceUID, + viewportDisplaySets, + colorbarProperties?.colormaps, + ]); + + // 3D volume rendering functions + const setVolumeRenderingPreset = useCallback( + (preset: any) => { + if (!viewportId) { + return; + } + + const displaySetInstanceUID = validateActiveDisplaySet(); + + commandsManager.run({ + commandName: 'setVolumesPreset', + commandOptions: { + preset, + viewportId, + displaySetInstanceUID, + }, + }); + }, + [commandsManager, viewportId, validateActiveDisplaySet] + ); + + const setVolumeRenderingQuality = useCallback( + (quality: number) => { + if (!viewportId) { + return; + } + + commandsManager.run({ + commandName: 'setVolumeRenderingQuality', + commandOptions: { + quality, + viewportId, + }, + }); + }, + [commandsManager, viewportId] + ); + + const setVolumeLighting = useCallback( + ({ ambient, diffuse, specular }: VolumeLightingParams) => { + if (!viewportId) { + return; + } + + commandsManager.run({ + commandName: 'setVolumeLighting', + commandOptions: { + ambient, + diffuse, + specular, + viewportId, + }, + }); + }, + [commandsManager, viewportId] + ); + + const setVolumeShading = useCallback( + (enabled: boolean) => { + if (!viewportId) { + return; + } + + commandsManager.run({ + commandName: 'setVolumeShading', + commandOptions: { + enabled, + viewportId, + }, + }); + }, + [commandsManager, viewportId] + ); + + return { + is3DVolume, + isViewportBackgroundLight, + + // Window level functions + setWindowLevel, + setVOIRange, + voiRange, + windowLevel: utilities.windowLevel.toWindowLevel(voiRange?.lower, voiRange?.upper), + + // Colorbar functions + hasColorbar, + toggleColorbar, + + // Colormap functions + setColormap, + colormap, + + // Opacity functions + opacity, + setOpacity, + opacityLinear, + setOpacityLinear, + + // Threshold functions + threshold, + setThreshold, + pixelValueRange, + + // 3D volume rendering functions + setVolumeRenderingPreset, + setVolumeRenderingQuality, + setVolumeLighting, + setVolumeShading, + + // Display sets + viewportDisplaySets, + + // Colorbar properties + colorbarProperties, + colorbarPosition, + setColorbarPosition, + + // Presets + windowLevelPresets: activeDisplaySetPresets, + allWindowLevelPresets, + volumeRenderingPresets, + volumeRenderingQualityRange, + }; +} + +export default useViewportRendering; + +/** + * Helper function to compare two VOI ranges with a small tolerance. + * @param rangeA - The first VOI range { lower, upper }. + * @param rangeB - The second VOI range { lower, upper }. + * @param epsilon - The tolerance for comparison. + * @returns True if the ranges are considered close, false otherwise. + */ +function areVoiRangesClose( + rangeA: { lower: number; upper: number }, + rangeB: { lower: number; upper: number }, + epsilon = 0.001 +): boolean { + if (!rangeA || !rangeB) { + return false; + } + return ( + Math.abs(rangeA.lower - rangeB.lower) < epsilon && + Math.abs(rangeA.upper - rangeB.upper) < epsilon + ); +} + +function getCustomizationData(customizationService) { + const presets = customizationService.getCustomization('cornerstone.windowLevelPresets'); + const colorbarProperties = customizationService.getCustomization( + 'cornerstone.colorbar' + ) as ColorbarProperties; + + const { + volumeRenderingPresets = [], + volumeRenderingQualityRange = { min: 0, max: 1, step: 0.1 }, + } = + (customizationService.getCustomization( + 'cornerstone.3dVolumeRendering' + ) as VolumeRenderingConfig) || {}; + + return { + presets, + colorbarProperties, + volumeRenderingPresets, + volumeRenderingQualityRange, + }; +} diff --git a/extensions/cornerstone/src/hooks/useViewportSegmentations.ts b/extensions/cornerstone/src/hooks/useViewportSegmentations.ts new file mode 100644 index 000000000..03b5619af --- /dev/null +++ b/extensions/cornerstone/src/hooks/useViewportSegmentations.ts @@ -0,0 +1,221 @@ +import { useState, useEffect } from 'react'; +import debounce from 'lodash.debounce'; +import { roundNumber } from '@ohif/core/src/utils'; +import { + SegmentationData, + SegmentationRepresentation, +} from '../services/SegmentationService/SegmentationService'; +import { useSystem } from '@ohif/core'; +const excludedModalities = ['SM', 'OT', 'DOC', 'ECG']; + +function mapSegmentationToDisplay(segmentation, customizationService) { + const { label, segments } = segmentation; + + // Get the readable text mapping once + const readableTextMap = customizationService.getCustomization('panelSegmentation.readableText'); + + // Helper function to recursively map cachedStats to readable display text + function mapStatsToDisplay(stats, indent = 0) { + const primary = []; + const indentation = ' '.repeat(indent); + + for (const key in stats) { + if (Object.prototype.hasOwnProperty.call(stats, key)) { + const value = stats[key]; + const readableText = readableTextMap?.[key]; + + if (!readableText) { + continue; + } + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // Add empty row before category (except for the first category) + if (primary.length > 0) { + primary.push(''); + } + // Add category title + primary.push(`${indentation}${readableText}`); + // Recursively handle nested objects + primary.push(...mapStatsToDisplay(value, indent + 1)); + } else { + // For non-nested values, don't add empty rows + primary.push(`${indentation}${readableText}: ${roundNumber(value, 2)}`); + } + } + } + + return primary; + } + + // Get customization for display text mapping + const displayTextMapper = segment => { + const defaultDisplay = { + primary: [], + secondary: [], + }; + + // If the segment has cachedStats, map it to readable text + if (segment.cachedStats) { + const primary = mapStatsToDisplay(segment.cachedStats); + defaultDisplay.primary = primary; + } + + return defaultDisplay; + }; + + const updatedSegments = {}; + + Object.entries(segments).forEach(([segmentIndex, segment]) => { + updatedSegments[segmentIndex] = { + ...segment, + displayText: displayTextMapper(segment), + }; + }); + + // Map the segments and apply the display text mapper + return { + ...segmentation, + label, + segments: updatedSegments, + }; +} + +/** + * Represents the combination of segmentation data and its representation in a viewport. + */ +type ViewportSegmentationRepresentation = { + segmentationsWithRepresentations: { + representation: SegmentationRepresentation; + segmentation: SegmentationData; + }[]; + disabled: boolean; +}; + +/** + * Custom hook that provides segmentation data and their representations for the active viewport. + * @param options - The options object. + * @param options.servicesManager - The services manager object. + * @param options.subscribeToDataModified - Whether to subscribe to segmentation data modifications. + * @param options.debounceTime - Debounce time in milliseconds for updates. + * @returns An array of segmentation data and their representations for the active viewport. + */ +export function useViewportSegmentations({ + viewportId, + subscribeToDataModified = false, + debounceTime = 0, +}: { + viewportId: string; + subscribeToDataModified?: boolean; + debounceTime?: number; +}): ViewportSegmentationRepresentation { + const { servicesManager } = useSystem(); + const { segmentationService, viewportGridService, customizationService, displaySetService } = + servicesManager.services; + + const [segmentationsWithRepresentations, setSegmentationsWithRepresentations] = + useState({ + segmentationsWithRepresentations: [], + disabled: false, + }); + + useEffect(() => { + const update = () => { + const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); + + if (!displaySetUIDs?.length) { + return; + } + + const displaySet = displaySetService.getDisplaySetByUID(displaySetUIDs[0]); + + if (!displaySet) { + return; + } + + if (excludedModalities.includes(displaySet.Modality)) { + setSegmentationsWithRepresentations(prev => ({ + segmentationsWithRepresentations: [], + disabled: true, + })); + return; + } + + const segmentations = segmentationService.getSegmentations(); + + if (!segmentations?.length) { + setSegmentationsWithRepresentations(prev => ({ + segmentationsWithRepresentations: [], + disabled: false, + })); + return; + } + + const representations = segmentationService.getSegmentationRepresentations(viewportId); + + const newSegmentationsWithRepresentations = representations.map(representation => { + const segmentation = segmentationService.getSegmentation(representation.segmentationId); + const mappedSegmentation = mapSegmentationToDisplay(segmentation, customizationService); + return { + representation, + segmentation: mappedSegmentation, + }; + }); + + setSegmentationsWithRepresentations({ + segmentationsWithRepresentations: newSegmentationsWithRepresentations, + disabled: false, + }); + }; + + const debouncedUpdate = + debounceTime > 0 ? debounce(update, debounceTime, { leading: true, trailing: true }) : update; + + update(); + + const subscriptions = [ + segmentationService.subscribe( + segmentationService.EVENTS.SEGMENTATION_MODIFIED, + debouncedUpdate + ), + segmentationService.subscribe( + segmentationService.EVENTS.SEGMENTATION_REMOVED, + debouncedUpdate + ), + segmentationService.subscribe( + segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, + debouncedUpdate + ), + viewportGridService.subscribe( + viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED, + debouncedUpdate + ), + viewportGridService.subscribe(viewportGridService.EVENTS.GRID_STATE_CHANGED, debouncedUpdate), + ]; + + if (subscribeToDataModified) { + subscriptions.push( + segmentationService.subscribe( + segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED, + debouncedUpdate + ) + ); + } + + return () => { + subscriptions.forEach(subscription => subscription.unsubscribe()); + if (debounceTime > 0) { + debouncedUpdate.cancel(); + } + }; + }, [ + segmentationService, + viewportGridService, + customizationService, + displaySetService, + debounceTime, + subscribeToDataModified, + viewportId, + ]); + + return segmentationsWithRepresentations; +} diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index f2f54db70..9f3ccc943 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -34,7 +34,6 @@ import PlanarFreehandROI from './utils/measurementServiceMappings/PlanarFreehand 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 getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; import { findNearbyToolData } from './utils/findNearbyToolData'; import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer'; @@ -56,6 +55,7 @@ import { useSegmentations } from './hooks/useSegmentations'; import { StudySummaryFromMetadata } from './components/StudySummaryFromMetadata'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import utils from './utils'; +import { useMeasurementTracking } from './hooks/useMeasurementTracking'; export * from './components'; const { imageRetrieveMetadataProvider } = cornerstone.utilities; @@ -158,7 +158,6 @@ const cornerstoneExtension: Types.Extensions.Extension = { servicesManager.registerService(SyncGroupService.REGISTRATION); servicesManager.registerService(SegmentationService.REGISTRATION); servicesManager.registerService(CornerstoneCacheService.REGISTRATION); - servicesManager.registerService(ViewportActionCornersService.REGISTRATION); servicesManager.registerService(ColorbarService.REGISTRATION); const { syncGroupService } = servicesManager.services; @@ -262,6 +261,8 @@ export { StudySummaryFromMetadata, CornerstoneViewportDownloadForm, utils, + OHIFCornerstoneViewport, + useMeasurementTracking, }; // Export constants diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 562443797..a3b12ee74 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -98,8 +98,13 @@ export default async function init({ measurementService, colorbarService, displaySetService, + toolbarService, } = servicesManager.services; + toolbarService.registerEventForToolbarUpdate(colorbarService, [ + colorbarService.EVENTS.STATE_CHANGED, + ]); + window.services = servicesManager.services; window.extensionManager = extensionManager; window.commandsManager = commandsManager; diff --git a/extensions/cornerstone/src/panels/PanelMeasurement.tsx b/extensions/cornerstone/src/panels/PanelMeasurement.tsx index 2f7179fce..0cd861bd8 100644 --- a/extensions/cornerstone/src/panels/PanelMeasurement.tsx +++ b/extensions/cornerstone/src/panels/PanelMeasurement.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useSystem } from '@ohif/core'; import { useMeasurements } from '../hooks/useMeasurements'; import StudyMeasurements from '../components/StudyMeasurements'; @@ -12,7 +11,7 @@ import StudyMeasurements from '../components/StudyMeasurements'; * * Creates a default study measurements panel with default children: * ``` - * + * * * * ``` @@ -60,10 +59,7 @@ import StudyMeasurements from '../components/StudyMeasurements'; export default function PanelMeasurement(props): React.ReactNode { const { measurementFilter, emptyComponent: EmptyComponent, children } = props; - const system = useSystem(); - const displayMeasurements = useMeasurements(system.servicesManager, { - measurementFilter, - }); + const displayMeasurements = useMeasurements({ measurementFilter }); if (!displayMeasurements.length) { return EmptyComponent ? ( diff --git a/extensions/cornerstone/src/panels/PanelSegmentation.tsx b/extensions/cornerstone/src/panels/PanelSegmentation.tsx index 91ce81fe4..bd531e7c1 100644 --- a/extensions/cornerstone/src/panels/PanelSegmentation.tsx +++ b/extensions/cornerstone/src/panels/PanelSegmentation.tsx @@ -9,9 +9,7 @@ export default function PanelSegmentation({ children }: withAppTypes) { const { customizationService, displaySetService } = servicesManager.services; const { segmentationsWithRepresentations, disabled } = - useActiveViewportSegmentationRepresentations({ - servicesManager, - }); + useActiveViewportSegmentationRepresentations(); // Extract customization options const segmentationTableMode = customizationService.getCustomization( diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index f3368bfb0..3430c15ca 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -291,7 +291,7 @@ class SegmentationService extends PubSubService { const { isVolumeViewport, isVolumeSegmentation } = this.determineViewportAndSegmentationType( csViewport, segmentation - ); + ) || { isVolumeViewport: false, isVolumeSegmentation: false }; ({ representationTypeToUse, isConverted } = await this.handleViewportConversion( isVolumeViewport, @@ -677,7 +677,6 @@ class SegmentationService extends PubSubService { // Mark the RT display set as loaded rtDisplaySet.isLoaded = true; - // Add or update the segmentation in the state this.addOrUpdateSegmentation(segmentation); diff --git a/extensions/cornerstone/src/services/ViewportActionCornersService/ViewportActionCornersService.ts b/extensions/cornerstone/src/services/ViewportActionCornersService/ViewportActionCornersService.ts deleted file mode 100644 index 6c6ca1e5a..000000000 --- a/extensions/cornerstone/src/services/ViewportActionCornersService/ViewportActionCornersService.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { PubSubService } from '@ohif/core'; -import { ViewportActionCornersLocations, Types } from '@ohif/ui-next'; - -class ViewportActionCornersService extends PubSubService { - public static readonly EVENTS = {}; - public static readonly LOCATIONS = ViewportActionCornersLocations; - - public static REGISTRATION = { - name: 'viewportActionCornersService', - altName: 'ViewportActionCornersService', - create: ({ configuration = {} }) => { - return new ViewportActionCornersService(); - }, - }; - - serviceImplementation = {}; - - public LOCATIONS = ViewportActionCornersService.LOCATIONS; - - constructor() { - super(ViewportActionCornersService.EVENTS); - this.serviceImplementation = {}; - } - - public setServiceImplementation({ - getState: getStateImplementation, - addComponent: addComponentImplementation, - addComponents: addComponentsImplementation, - 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 { - 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: Types.ActionComponentInfo) { - this.serviceImplementation._addComponent(component); - } - - public addComponents(components: Array) { - this.serviceImplementation._addComponents(components); - } - - public clear(viewportId: string): void { - this.serviceImplementation._clear(viewportId); - } - - /* 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' }; - case ViewportActionCornersLocations.topRight: - return { align: 'end', side: 'bottom' }; - case ViewportActionCornersLocations.bottomLeft: - return { align: 'start', side: 'top' }; - case ViewportActionCornersLocations.bottomRight: - return { align: 'end', side: 'top' }; - default: - console.debug('Unknown location, defaulting to bottom-start'); - return { align: 'start', side: 'bottom' }; - } - } -} - -export default ViewportActionCornersService; diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index b454502c3..04824161f 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -16,7 +16,11 @@ import { import { utilities as csToolsUtils, Enums as csToolsEnums } from '@cornerstonejs/tools'; import { IViewportService } from './IViewportService'; import { RENDERING_ENGINE_ID } from './constants'; -import ViewportInfo, { DisplaySetOptions, PublicViewportOptions } from './Viewport'; +import ViewportInfo, { + DisplaySetOptions, + PublicViewportOptions, + ViewportOptions, +} from './Viewport'; import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService'; import { LutPresentation, @@ -479,6 +483,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi }); } + public getViewportOptions(viewportId: string): ViewportOptions { + return this.viewportsById.get(viewportId).getViewportOptions(); + } + /** * Retrieves the Cornerstone viewport with the specified ID. * diff --git a/extensions/cornerstone/src/types/AppTypes.ts b/extensions/cornerstone/src/types/AppTypes.ts index 36cb848b2..0942f9e23 100644 --- a/extensions/cornerstone/src/types/AppTypes.ts +++ b/extensions/cornerstone/src/types/AppTypes.ts @@ -4,7 +4,6 @@ import CornerstoneViewportServiceType from '../services/ViewportService/Cornerst import SegmentationServiceType from '../services/SegmentationService'; import SyncGroupServiceType from '../services/SyncGroupService'; import ToolGroupServiceType from '../services/ToolGroupService'; -import ViewportActionCornersServiceType from '../services/ViewportActionCornersService/ViewportActionCornersService'; import ColorbarServiceType from '../services/ColorbarService'; import * as cornerstone from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -15,7 +14,7 @@ import type { SegmentationRepresentation as SegRep, SegmentationInfo as SegInfo, } from '../services/SegmentationService/SegmentationService'; - +import type { ViewportOptions as ViewportOpts } from '../services/ViewportService/Viewport'; declare global { namespace AppTypes { export type CornerstoneCacheService = CornerstoneCacheServiceType; @@ -23,7 +22,6 @@ declare global { export type SegmentationService = SegmentationServiceType; export type SyncGroupService = SyncGroupServiceType; export type ToolGroupService = ToolGroupServiceType; - export type ViewportActionCornersService = ViewportActionCornersServiceType; export type ColorbarService = ColorbarServiceType; export interface Services { @@ -32,7 +30,6 @@ declare global { syncGroupService?: SyncGroupServiceType; segmentationService?: SegmentationServiceType; cornerstoneCacheService?: CornerstoneCacheServiceType; - viewportActionCornersService?: ViewportActionCornersServiceType; colorbarService?: ColorbarServiceType; } @@ -54,5 +51,7 @@ declare global { cornerstone?: typeof cornerstone; cornerstoneTools?: typeof cornerstoneTools; } + + export type ViewportOptions = ViewportOpts; } } diff --git a/extensions/cornerstone/src/types/CornerstoneServices.ts b/extensions/cornerstone/src/types/CornerstoneServices.ts index b11dc97ee..ffb4be406 100644 --- a/extensions/cornerstone/src/types/CornerstoneServices.ts +++ b/extensions/cornerstone/src/types/CornerstoneServices.ts @@ -4,7 +4,6 @@ import SyncGroupService from '../services/SyncGroupService'; import SegmentationService from '../services/SegmentationService'; import CornerstoneCacheService from '../services/CornerstoneCacheService'; import CornerstoneViewportService from '../services/ViewportService/CornerstoneViewportService'; -import ViewportActionCornersService from '../services/ViewportActionCornersService/ViewportActionCornersService'; import ColorbarService from '../services/ColorbarService'; interface CornerstoneServices extends Types.Services { @@ -13,7 +12,6 @@ interface CornerstoneServices extends Types.Services { syncGroupService: SyncGroupService; segmentationService: SegmentationService; cornerstoneCacheService: CornerstoneCacheService; - viewportActionCornersService: ViewportActionCornersService; colorbarService: ColorbarService; } diff --git a/extensions/cornerstone/src/types/VolumeLighting.ts b/extensions/cornerstone/src/types/VolumeLighting.ts new file mode 100644 index 000000000..eef7f66eb --- /dev/null +++ b/extensions/cornerstone/src/types/VolumeLighting.ts @@ -0,0 +1,7 @@ +interface VolumeLightingParams { + ambient: number; + diffuse: number; + specular: number; +} + +export type { VolumeLightingParams }; diff --git a/extensions/cornerstone/src/types/VolumeRenderingConfig.ts b/extensions/cornerstone/src/types/VolumeRenderingConfig.ts new file mode 100644 index 000000000..6bc9ad8b4 --- /dev/null +++ b/extensions/cornerstone/src/types/VolumeRenderingConfig.ts @@ -0,0 +1,8 @@ +export interface VolumeRenderingConfig { + volumeRenderingPresets?: Array; + volumeRenderingQualityRange?: { + min: number; + max: number; + step: number; + }; +} diff --git a/extensions/cornerstone/src/types/index.ts b/extensions/cornerstone/src/types/index.ts index 4ae9d5cd1..94c4340d9 100644 --- a/extensions/cornerstone/src/types/index.ts +++ b/extensions/cornerstone/src/types/index.ts @@ -1,10 +1,10 @@ import * as CornerstoneCacheService from './CornerstoneCacheService'; import type CornerstoneServices from './CornerstoneServices'; export { type SyncGroup } from '../services/SyncGroupService'; -export { - type ViewportActionCornerLocation, - type ViewportActionCornerService, - type CustomizationService, -} from '../hooks/useViewportActionCorners'; - +export { type HydrationCallback } from '../utils/promptHydrationDialog'; export type { CornerstoneCacheService, CornerstoneServices }; +export * from './Presentation'; +export * from './ViewportPresets'; +export * from './WindowLevel'; +export * from './VolumeRenderingConfig'; +export * from './VolumeLighting'; diff --git a/extensions/cornerstone/src/utils/index.ts b/extensions/cornerstone/src/utils/index.ts index 3717385f3..a05c5509c 100644 --- a/extensions/cornerstone/src/utils/index.ts +++ b/extensions/cornerstone/src/utils/index.ts @@ -4,12 +4,20 @@ import { setupSegmentationDataModifiedHandler, setupSegmentationModifiedHandler, } from './segmentationHandlers'; +import promptHydrationDialog, { + HydrationDialogProps, + HydrationCallback, + HydrationSRResult, +} from './promptHydrationDialog'; const utils = { handleSegmentChange, isReferenceViewable, setupSegmentationDataModifiedHandler, setupSegmentationModifiedHandler, + promptHydrationDialog, }; +export type { HydrationDialogProps, HydrationCallback, HydrationSRResult }; + export default utils; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts index 0b0c1c6e8..e193febbf 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.ts @@ -26,7 +26,7 @@ const Length = { const isLocked = getIsLocked(annotationUID); const isVisible = getIsVisible(annotationUID); - const colorString = config.style.getStyleProperty('color', { annotationUID }); + // const colorString = config.style.getStyleProperty('color', { annotationUID }); // color string is like 'rgb(255, 255, 255)' we need them to be in RGBA array [255, 255, 255, 255] // Todo: this should be in a utility diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/LivewireContour.ts b/extensions/cornerstone/src/utils/measurementServiceMappings/LivewireContour.ts index a9a3d03b3..af00932d5 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/LivewireContour.ts +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/LivewireContour.ts @@ -21,7 +21,7 @@ const LivewireContour = { */ toMeasurement: ( csToolsEventDetail, - DisplaySetService, + displaySetService, CornerstoneViewportService, getValueTypeFromToolType, customizationService @@ -43,16 +43,16 @@ const LivewireContour = { } const { SOPInstanceUID, SeriesInstanceUID, frameNumber, StudyInstanceUID } = - getSOPInstanceAttributes(referencedImageId); + getSOPInstanceAttributes(referencedImageId, displaySetService, annotation); let displaySet; if (SOPInstanceUID) { - displaySet = DisplaySetService.getDisplaySetForSOPInstanceUID( + displaySet = displaySetService.getDisplaySetForSOPInstanceUID( SOPInstanceUID, SeriesInstanceUID ); } else { - displaySet = DisplaySetService.getDisplaySetsForSeries(SeriesInstanceUID); + displaySet = displaySetService.getDisplaySetsForSeries(SeriesInstanceUID); } return { @@ -64,13 +64,14 @@ const LivewireContour = { metadata, frameNumber, referenceSeriesUID: SeriesInstanceUID, + referencedImageId, referenceStudyUID: StudyInstanceUID, toolName: metadata.toolName, displaySetInstanceUID: displaySet.displaySetInstanceUID, label: data.label, isLocked, isVisible, - displayText: getDisplayText(annotation, displaySet), + displayText: getDisplayText(annotation, displaySet, displaySetService), data: data.cachedStats, type: getValueTypeFromToolType(toolName), getReport: () => getColumnValueReport(annotation, customizationService), @@ -122,20 +123,27 @@ function getColumnValueReport(annotation, customizationService) { * * @param {Object} annotation - The annotation object. * @param {Object} displaySet - The display set object. - * @returns {string[]} - An array of display text. + * @returns {object} - An object with primary and secondary text arrays. */ -function getDisplayText(annotation, displaySet) { +function getDisplayText(annotation, displaySet, displaySetService) { const { metadata, data } = annotation; + const displayText = { + primary: [], + secondary: [], + }; + if (!data.cachedStats || !data.cachedStats[`imageId:${metadata.referencedImageId}`]) { - return []; + return displayText; } const { area, areaUnit } = data.cachedStats[`imageId:${metadata.referencedImageId}`]; - const { SOPInstanceUID, frameNumber } = getSOPInstanceAttributes(metadata.referencedImageId); - - const displayText = []; + const { SOPInstanceUID, frameNumber } = getSOPInstanceAttributes( + metadata.referencedImageId, + displaySetService, + annotation + ); const instance = displaySet.instances.find(image => image.SOPInstanceUID === SOPInstanceUID); let InstanceNumber; @@ -152,21 +160,15 @@ function getDisplayText(annotation, displaySet) { seriesText = `S: ${SeriesNumber}${instanceText}${frameText}`; } - const texts = []; if (area) { const roundedArea = utils.roundNumber(area || 0, 2); - texts.push(`${roundedArea} ${getDisplayUnit(areaUnit)}`); + displayText.primary.push(`${roundedArea} ${getDisplayUnit(areaUnit)}`); } if (seriesText) { - texts.push(seriesText); + displayText.secondary.push(seriesText); } - displayText.push({ - text: texts, - series: seriesText, - }); - return displayText; } diff --git a/extensions/cornerstone/src/utils/promptHydrationDialog.ts b/extensions/cornerstone/src/utils/promptHydrationDialog.ts new file mode 100644 index 000000000..d743f8005 --- /dev/null +++ b/extensions/cornerstone/src/utils/promptHydrationDialog.ts @@ -0,0 +1,189 @@ +export type HydrationCallback = (params: any) => Promise; + +export const HydrationType = { + SEG: 'SEG', + SR: 'SR', + RTSTRUCT: 'RTSTRUCT', +} as const; + +export interface HydrationDialogProps { + servicesManager: AppTypes.ServicesManager; + viewportId: string; + displaySet: AppTypes.DisplaySet; + preHydrateCallbacks?: HydrationCallback[]; + hydrateCallback: HydrationCallback; + type: string; +} + +export interface HydrationSRResult { + userResponse: number; + displaySetInstanceUID: string; + srSeriesInstanceUID: string; + viewportId: string; + StudyInstanceUID?: string; + SeriesInstanceUIDs?: string[]; +} + +const RESPONSE = { + NO_NEVER: -1, + CANCEL: 0, + CREATE_REPORT: 1, + ADD_SERIES: 2, + SET_STUDY_AND_SERIES: 3, + NO_NOT_FOR_SERIES: 4, + HYDRATE: 5, +}; + +function getCustomizationMessageKey(type: string): string { + switch (type) { + case HydrationType.RTSTRUCT: + return 'viewportNotification.hydrateRTMessage'; + case HydrationType.SEG: + return 'viewportNotification.hydrateSEGMessage'; + case HydrationType.SR: + return 'viewportNotification.hydrateSRMessage'; + default: + return 'viewportNotification.hydrateMessage'; + } +} + +function getDialogId(type: string): string { + switch (type) { + case HydrationType.RTSS: + return 'promptHydrateRT'; + case HydrationType.SEG: + return 'promptHydrateSEG'; + case HydrationType.SR: + return 'promptHydrateSR'; + default: + return 'promptHydrate'; + } +} + +function promptHydrationDialog({ + servicesManager, + viewportId, + displaySet, + preHydrateCallbacks = [], + hydrateCallback, + type, +}: HydrationDialogProps): Promise { + const { uiViewportDialogService, customizationService } = servicesManager.services; + const extensionManager = servicesManager._extensionManager; + const appConfig = extensionManager._appConfig; + + // Todo: make this use enum from the extension, we should move the enum + const standardMode = appConfig?.measurementTrackingMode === 'standard'; + + return new Promise(async function (resolve, reject) { + // For RT and SEG, we check disableConfirmationPrompts + // For SR, we check if standardMode is true + const shouldPrompt = + type === HydrationType.SR ? standardMode : !appConfig?.disableConfirmationPrompts; + + const promptResult = shouldPrompt + ? await _askHydrate(uiViewportDialogService, customizationService, viewportId, type) + : RESPONSE.HYDRATE; + + if (promptResult === RESPONSE.HYDRATE) { + // Execute preHydrate callbacks + preHydrateCallbacks?.forEach(callback => { + callback(); + }); + + if (type === HydrationType.SEG) { + // SEG needs setTimeout + window.setTimeout(async () => { + const isHydrated = await hydrateCallback({ + segDisplaySet: displaySet, + viewportId, + }); + + resolve(isHydrated); + }, 0); + } else if (type === HydrationType.RTSTRUCT) { + // RT hydration + const isHydrated = await hydrateCallback({ + rtDisplaySet: displaySet, + viewportId, + servicesManager, + }); + + resolve(isHydrated); + } else if (type === HydrationType.SR) { + // SR has a different result structure + const hydrationResult = await hydrateCallback(displaySet); + + resolve({ + userResponse: promptResult, + displaySetInstanceUID: displaySet.displaySetInstanceUID, + srSeriesInstanceUID: displaySet.SeriesInstanceUID, + viewportId, + StudyInstanceUID: hydrationResult?.StudyInstanceUID, + SeriesInstanceUIDs: hydrationResult?.SeriesInstanceUIDs, + }); + } + } else { + if (type === HydrationType.SR) { + resolve({ + userResponse: promptResult, + displaySetInstanceUID: displaySet.displaySetInstanceUID, + srSeriesInstanceUID: displaySet.SeriesInstanceUID, + viewportId, + }); + } else { + resolve(false); + } + } + }); +} + +function _askHydrate( + uiViewportDialogService: AppTypes.UIViewportDialogService, + customizationService: AppTypes.CustomizationService, + viewportId: string, + type: string +) { + return new Promise(function (resolve, reject) { + const messageKey = getCustomizationMessageKey(type); + const message = customizationService.getCustomization(messageKey); + const actions = [ + { + id: 'no-hydrate', + type: 'secondary', + text: 'No', + value: RESPONSE.CANCEL, + }, + { + id: 'yes-hydrate', + type: 'primary', + text: 'Yes', + value: RESPONSE.HYDRATE, + }, + ]; + const onSubmit = result => { + uiViewportDialogService.hide(); + resolve(result); + }; + + uiViewportDialogService.show({ + id: getDialogId(type), + viewportId, + type: 'info', + message, + actions, + onSubmit, + onOutsideClick: () => { + uiViewportDialogService.hide(); + resolve(RESPONSE.CANCEL); + }, + onKeyPress: event => { + if (event.key === 'Enter') { + onSubmit(RESPONSE.HYDRATE); + } + }, + }); + }); +} + +export default promptHydrationDialog; diff --git a/extensions/cornerstone/src/utils/segmentUtils.ts b/extensions/cornerstone/src/utils/segmentUtils.ts index dd43734f1..61fed2069 100644 --- a/extensions/cornerstone/src/utils/segmentUtils.ts +++ b/extensions/cornerstone/src/utils/segmentUtils.ts @@ -2,18 +2,17 @@ import SegmentationServiceType from '../services/SegmentationService'; export const handleSegmentChange = ({ direction, - segDisplaySet, + segmentationId, viewportId, selectedSegmentObjectIndex, segmentationService, }: { direction: number; - segDisplaySet: AppTypes.DisplaySet; + segmentationId: string; viewportId: string; selectedSegmentObjectIndex: number; segmentationService: SegmentationServiceType; }) => { - const segmentationId = segDisplaySet.displaySetInstanceUID; const segmentation = segmentationService.getSegmentation(segmentationId); const { segments } = segmentation; diff --git a/extensions/default/src/Components/ProgressDropdownWithService.tsx b/extensions/default/src/Components/ProgressDropdownWithService.tsx index aeed5b3f5..68eb7f73d 100644 --- a/extensions/default/src/Components/ProgressDropdownWithService.tsx +++ b/extensions/default/src/Components/ProgressDropdownWithService.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, useCallback, ReactElement } from 'react'; import { ProgressDropdown } from '@ohif/ui-next'; +import { useSystem } from '@ohif/core'; const workflowStepsToDropdownOptions = (steps = []) => steps.map(step => ({ @@ -10,7 +11,8 @@ const workflowStepsToDropdownOptions = (steps = []) => completed: false, })); -export function ProgressDropdownWithService({ servicesManager }: withAppTypes): ReactElement { +export function ProgressDropdownWithService(): ReactElement { + const { servicesManager } = useSystem(); const { workflowStepsService } = servicesManager.services; const [activeStepId, setActiveStepId] = useState(workflowStepsService.activeWorkflowStep?.id); diff --git a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx index 2ba136673..fe2d744b7 100644 --- a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useImageViewer } from '@ohif/ui-next'; import { useSystem, utils } from '@ohif/core'; import { useNavigate } from 'react-router-dom'; @@ -31,6 +31,7 @@ function PanelStudyBrowser({ const internalImageViewer = useImageViewer(); const StudyInstanceUIDs = internalImageViewer.StudyInstanceUIDs; + const fetchedStudiesRef = useRef(new Set()); const [{ activeViewportId, viewports, isHangingProtocolLayout }] = useViewportGrid(); const [activeTabName, setActiveTabName] = useState(studyMode); @@ -105,6 +106,13 @@ function PanelStudyBrowser({ useEffect(() => { // Fetch all studies for the patient in each primary study async function fetchStudiesForPatient(StudyInstanceUID) { + // Skip fetching if we've already fetched this study + if (fetchedStudiesRef.current.has(StudyInstanceUID)) { + return; + } + + fetchedStudiesRef.current.add(StudyInstanceUID); + // current study qido const qidoForStudyUID = await dataSource.query.studies.search({ studyInstanceUid: StudyInstanceUID, diff --git a/extensions/default/src/Toolbar/ToolBoxWrapper.tsx b/extensions/default/src/Toolbar/ToolBoxWrapper.tsx index 7d7f5cdb9..93376277e 100644 --- a/extensions/default/src/Toolbar/ToolBoxWrapper.tsx +++ b/extensions/default/src/Toolbar/ToolBoxWrapper.tsx @@ -8,12 +8,12 @@ import { useToolbar } from '@ohif/core/src/hooks/useToolbar'; * @param props - Component props * @returns Component */ -export function ToolBoxButtonGroupWrapper({ groupId, buttonSection, ...props }) { +export function ToolBoxButtonGroupWrapper({ buttonSection, id }) { const { onInteraction, toolbarButtons } = useToolbar({ buttonSection, }); - if (!groupId) { + if (!buttonSection) { return null; } @@ -30,7 +30,7 @@ export function ToolBoxButtonGroupWrapper({ groupId, buttonSection, ...props }) onInteraction={event => { onInteraction?.({ event, - groupId, + id, commands: item.commands, itemId: item.id, item, diff --git a/extensions/default/src/Toolbar/ToolButtonListWrapper.tsx b/extensions/default/src/Toolbar/ToolButtonListWrapper.tsx index fb995846f..def3a2cd5 100644 --- a/extensions/default/src/Toolbar/ToolButtonListWrapper.tsx +++ b/extensions/default/src/Toolbar/ToolButtonListWrapper.tsx @@ -10,13 +10,9 @@ import { import { useToolbar } from '@ohif/core/src'; interface ToolButtonListWrapperProps { - groupId: string; buttonSection: string; - onInteraction?: (details: { - groupId: string; - itemId: string; - commands?: Record; - }) => void; + onInteraction?: (details: { itemId: string; commands?: Record }) => void; + id: string; } /** @@ -25,10 +21,7 @@ interface ToolButtonListWrapperProps { * @returns Component * // test */ -export default function ToolButtonListWrapper({ - groupId, - buttonSection, -}: ToolButtonListWrapperProps) { +export default function ToolButtonListWrapper({ buttonSection, id }: ToolButtonListWrapperProps) { const { onInteraction, toolbarButtons } = useToolbar({ buttonSection, }); @@ -47,21 +40,21 @@ export default function ToolButtonListWrapper({
- onInteraction?.({ groupId, itemId, commands: primary.commands }) + onInteraction?.({ id, itemId, commands: primary.commands }) } className={primary.className} />
-
+
{items.map(item => { return ( @@ -71,9 +64,7 @@ export default function ToolButtonListWrapper({ data-cy={item.id} data-tool={item.id} data-active={item.isActive} - onSelect={() => - onInteraction?.({ groupId, itemId: item.id, commands: item.commands }) - } + onSelect={() => onInteraction?.({ id, itemId: item.id, commands: item.commands })} > {item.label || item.tooltip || item.id} diff --git a/extensions/default/src/Toolbar/ToolButtonWrapper.tsx b/extensions/default/src/Toolbar/ToolButtonWrapper.tsx new file mode 100644 index 000000000..120112471 --- /dev/null +++ b/extensions/default/src/Toolbar/ToolButtonWrapper.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useIconPresentation, Icons, Button } from '@ohif/ui-next'; + +export default function ToolButtonWrapper(props) { + const { IconContainer, containerProps } = useIconPresentation(); + + const Icon = ; + + return ( +
+ {IconContainer ? ( + + {Icon} + + ) : ( + + )} +
+ ); +} + +export { ToolButtonWrapper }; diff --git a/extensions/default/src/Toolbar/ToolRowWrapper.tsx b/extensions/default/src/Toolbar/ToolRowWrapper.tsx new file mode 100644 index 000000000..237504981 --- /dev/null +++ b/extensions/default/src/Toolbar/ToolRowWrapper.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useToolbar, ButtonLocation } from '@ohif/core'; + +interface ToolRowWrapperProps { + buttonSection: string; + className?: string; + show?: boolean; +} + +function ToolRowWrapper({ buttonSection, className = '', show = true }: ToolRowWrapperProps) { + const { onInteraction, toolbarButtons } = useToolbar({ + buttonSection, + }); + + // No need for debugger statement + if (!toolbarButtons?.length) { + return null; + } + + return ( +
+ {toolbarButtons.map((button, index) => { + const { id, Component, componentProps } = button; + return ( +
+ +
+ ); + })} +
+ ); +} + +export default ToolRowWrapper; diff --git a/extensions/default/src/Toolbar/Toolbar.tsx b/extensions/default/src/Toolbar/Toolbar.tsx index 967baf9b8..3cf27b840 100644 --- a/extensions/default/src/Toolbar/Toolbar.tsx +++ b/extensions/default/src/Toolbar/Toolbar.tsx @@ -1,9 +1,22 @@ import React from 'react'; import { useToolbar } from '@ohif/core'; -export function Toolbar({ servicesManager, buttonSection = 'primary' }) { - const { toolbarButtons, onInteraction } = useToolbar({ - servicesManager, +interface ToolbarProps { + buttonSection?: string; + viewportId?: string; + location?: number; +} + +export function Toolbar({ buttonSection = 'primary', viewportId, location }: ToolbarProps) { + const { + toolbarButtons, + onInteraction, + isItemOpen, + isItemLocked, + openItem, + closeItem, + toggleLock, + } = useToolbar({ buttonSection, }); @@ -19,13 +32,31 @@ export function Toolbar({ servicesManager, buttonSection = 'primary' }) { } const { id, Component, componentProps } = toolDef; + + // Enhanced props with state and actions - respecting viewport specificity + const enhancedProps = { + ...componentProps, + isOpen: isItemOpen(id, viewportId), + isLocked: isItemLocked(id, viewportId), + onOpen: () => openItem(id, viewportId), + onClose: () => closeItem(id, viewportId), + onToggleLock: () => toggleLock(id, viewportId), + viewportId, + }; + const tool = ( { + onInteraction({ + ...args, + itemId: id, + viewportId, + }); + }} + {...enhancedProps} /> ); diff --git a/extensions/default/src/Toolbar/ToolbarButtonGroupWithServices.tsx b/extensions/default/src/Toolbar/ToolbarButtonGroupWithServices.tsx deleted file mode 100644 index 4b0a79478..000000000 --- a/extensions/default/src/Toolbar/ToolbarButtonGroupWithServices.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useCallback } from 'react'; -import { ToolbarButton, ButtonGroup } from '@ohif/ui'; - -function ToolbarButtonGroupWithServices({ groupId, items, onInteraction, size }) { - const getSplitButtonItems = useCallback( - items => - items.map((item, index) => ( - { - onInteraction({ - groupId, - itemId: item.id, - commands: item.commands, - item, - }); - }} - // Note: this is necessary since tooltip will add - // default styles to the tooltip container which - // we don't want for groups - toolTipClassName="" - /> - )), - [onInteraction, groupId] - ); - - return {getSplitButtonItems(items)}; -} - -export default ToolbarButtonGroupWithServices; diff --git a/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx b/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx deleted file mode 100644 index 392df4a27..000000000 --- a/extensions/default/src/Toolbar/ToolbarSplitButtonWithServices.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { SplitButton, ToolbarButton } from '@ohif/ui'; -import React, { useCallback } from 'react'; -import PropTypes from 'prop-types'; - -function ToolbarSplitButtonWithServices({ - groupId, - primary, - secondary, - items, - renderer, - onInteraction, - servicesManager, -}: withAppTypes) { - const { toolbarService } = servicesManager?.services; - - /* Bubbles up individual item clicks */ - const getSplitButtonItems = useCallback( - items => - items.map((item, index) => ({ - ...item, - index, - onClick: () => { - onInteraction({ - groupId, - itemId: item.id, - item, - }); - }, - })), - [groupId, onInteraction] - ); - - const PrimaryButtonComponent = - toolbarService?.getButtonComponentForUIType(primary.uiType) ?? ToolbarButton; - - const listItemRenderer = renderer; - - return ( - ( - - )} - /> - ); -} - -ToolbarSplitButtonWithServices.propTypes = { - groupId: PropTypes.string, - primary: PropTypes.shape({ - id: PropTypes.string.isRequired, - uiType: PropTypes.string, - }), - secondary: PropTypes.shape({ - id: PropTypes.string, - icon: PropTypes.string.isRequired, - label: PropTypes.string, - tooltip: PropTypes.string.isRequired, - disabled: PropTypes.bool, - className: PropTypes.string, - }), - items: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - icon: PropTypes.string, - label: PropTypes.string, - tooltip: PropTypes.string, - disabled: PropTypes.bool, - className: PropTypes.string, - }) - ), - renderer: PropTypes.func, - onInteraction: PropTypes.func.isRequired, - servicesManager: PropTypes.shape({ - services: PropTypes.shape({ - toolbarService: PropTypes.object, - }), - }), -}; - -export default ToolbarSplitButtonWithServices; diff --git a/extensions/default/src/Toolbar/index.ts b/extensions/default/src/Toolbar/index.ts new file mode 100644 index 000000000..b53d478dc --- /dev/null +++ b/extensions/default/src/Toolbar/index.ts @@ -0,0 +1,7 @@ +export * from './Toolbar'; +export * from './ToolButtonWrapper'; +export * from './ToolButtonListWrapper'; +export * from './ToolRowWrapper'; +export * from './ToolBoxWrapper'; +export * from './ToolbarDivider'; +export * from './ToolbarLayoutSelector'; diff --git a/extensions/default/src/ViewerLayout/ViewerHeader.tsx b/extensions/default/src/ViewerLayout/ViewerHeader.tsx index cf9c501cc..65df514f4 100644 --- a/extensions/default/src/ViewerLayout/ViewerHeader.tsx +++ b/extensions/default/src/ViewerLayout/ViewerHeader.tsx @@ -87,12 +87,7 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config } isReturnEnabled={!!appConfig.showStudyList} onClickReturnButton={onClickReturnButton} WhiteLabeling={appConfig.whiteLabeling} - Secondary={ - - } + Secondary={} PatientInfo={ appConfig.showPatientInfo !== PatientInfoVisibility.DISABLED && (
- +
); diff --git a/extensions/default/src/getToolbarModule.tsx b/extensions/default/src/getToolbarModule.tsx index 9704acc08..6e9b32d4a 100644 --- a/extensions/default/src/getToolbarModule.tsx +++ b/extensions/default/src/getToolbarModule.tsx @@ -1,16 +1,15 @@ -import { ToolButton, utils } from '@ohif/ui-next'; +import { utils } from '@ohif/ui-next'; import ToolbarLayoutSelectorWithServices from './Toolbar/ToolbarLayoutSelector'; // legacy -import ToolbarDividerLegacy from './Toolbar/ToolbarDivider'; -import ToolbarSplitButtonWithServicesLegacy from './Toolbar/ToolbarSplitButtonWithServices'; -import ToolbarButtonGroupWithServicesLegacy from './Toolbar/ToolbarButtonGroupWithServices'; import { ProgressDropdownWithService } from './Components/ProgressDropdownWithService'; // new import ToolButtonListWrapper from './Toolbar/ToolButtonListWrapper'; +import ToolRowWrapper from './Toolbar/ToolRowWrapper'; import { ToolBoxButtonGroupWrapper, ToolBoxButtonWrapper } from './Toolbar/ToolBoxWrapper'; +import { ToolButtonWrapper } from './Toolbar/ToolButtonWrapper'; export default function getToolbarModule({ commandsManager, servicesManager }: withAppTypes) { const { cineService } = servicesManager.services; @@ -18,12 +17,16 @@ export default function getToolbarModule({ commandsManager, servicesManager }: w // new { name: 'ohif.toolButton', - defaultComponent: ToolButton, + defaultComponent: ToolButtonWrapper, }, { name: 'ohif.toolButtonList', defaultComponent: ToolButtonListWrapper, }, + { + name: 'ohif.row', + defaultComponent: ToolRowWrapper, + }, { name: 'ohif.toolBoxButtonGroup', defaultComponent: ToolBoxButtonGroupWrapper, diff --git a/extensions/default/src/hooks/usePatientInfo.tsx b/extensions/default/src/hooks/usePatientInfo.tsx index a28d94dfc..ba7ad4156 100644 --- a/extensions/default/src/hooks/usePatientInfo.tsx +++ b/extensions/default/src/hooks/usePatientInfo.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; -import { utils } from '@ohif/core'; +import { utils, useSystem } from '@ohif/core'; const { formatPN, formatDate } = utils; -function usePatientInfo(servicesManager: AppTypes.ServicesManager) { +function usePatientInfo() { + const { servicesManager } = useSystem(); const { displaySetService } = servicesManager.services; const [patientInfo, setPatientInfo] = useState({ diff --git a/extensions/default/src/init.ts b/extensions/default/src/init.ts index 19bff11b2..6c449264d 100644 --- a/extensions/default/src/init.ts +++ b/extensions/default/src/init.ts @@ -11,16 +11,13 @@ const metadataProvider = classes.MetadataProvider; * @param {Object} servicesManager * @param {Object} configuration */ -export default function init({ - servicesManager, - configuration = {}, - commandsManager, -}: withAppTypes): void { +export default function init({ servicesManager, commandsManager }: withAppTypes): void { const { toolbarService, cineService, viewportGridService } = servicesManager.services; toolbarService.registerEventForToolbarUpdate(cineService, [ cineService.EVENTS.CINE_STATE_CHANGED, ]); + // Add DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.INSTANCES_ADDED, handleScalingModules); @@ -52,10 +49,10 @@ export default function init({ toolbarService.subscribe(toolbarService.EVENTS.TOOL_BAR_MODIFIED, state => { const { buttons } = state; for (const [id, button] of Object.entries(buttons)) { - const { groupId, items, listeners } = button.props || {}; + const { buttonSection, items, listeners } = button.props || {}; // Handle group items' listeners - if (groupId && items) { + if (buttonSection && items) { items.forEach(item => { if (item.listeners) { subscribeToEvents(item.listeners); diff --git a/extensions/default/src/utils/Toolbox.tsx b/extensions/default/src/utils/Toolbox.tsx index 96b9ca48c..42781fd8e 100644 --- a/extensions/default/src/utils/Toolbox.tsx +++ b/extensions/default/src/utils/Toolbox.tsx @@ -27,7 +27,6 @@ export function Toolbox({ buttonSectionId, title }: { buttonSectionId: string; t const [showConfig, setShowConfig] = useState(false); const { toolbarButtons: toolboxSections, onInteraction } = useToolbar({ - servicesManager, buttonSection: buttonSectionId, }); diff --git a/extensions/dicom-pdf/src/viewports/OHIFCornerstonePdfViewport.tsx b/extensions/dicom-pdf/src/viewports/OHIFCornerstonePdfViewport.tsx index e0d62d782..49b46a460 100644 --- a/extensions/dicom-pdf/src/viewports/OHIFCornerstonePdfViewport.tsx +++ b/extensions/dicom-pdf/src/viewports/OHIFCornerstonePdfViewport.tsx @@ -1,14 +1,18 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import PropTypes from 'prop-types'; +import { useViewportRef } from '@ohif/core'; import './OHIFCornerstonePdfViewport.css'; -function OHIFCornerstonePdfViewport({ displaySets }) { +function OHIFCornerstonePdfViewport({ displaySets, viewportId = 'pdf-viewport' }) { const [url, setUrl] = useState(null); + const viewportElementRef = useRef(null); + const viewportRef = useViewportRef(viewportId); useEffect(() => { document.body.addEventListener('drag', makePdfDropTarget); return function cleanup() { document.body.removeEventListener('drag', makePdfDropTarget); + viewportRef.unregister(); }; }, []); @@ -42,6 +46,11 @@ function OHIFCornerstonePdfViewport({ displaySets }) {
{ + viewportElementRef.current = el; + if (el) viewportRef.register(el); + }} + data-viewport-id={viewportId} > { + if (trackedMeasurements?.context?.trackedSeries && trackedMeasurementsService) { + trackedMeasurementsService.updateTrackedSeries(trackedMeasurements.context.trackedSeries); + } + }, [trackedMeasurements?.context?.trackedSeries, trackedMeasurementsService]); + useEffect(() => { // Update the state machine with the active viewport ID sendTrackedMeasurementsEvent('UPDATE_ACTIVE_VIEWPORT_ID', { diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js deleted file mode 100644 index 6503eae1a..000000000 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js +++ /dev/null @@ -1,97 +0,0 @@ -import { hydrateStructuredReport } from '@ohif/extension-cornerstone-dicom-sr'; -import { measurementTrackingMode } from './promptBeginTracking'; - -const RESPONSE = { - NO_NEVER: -1, - CANCEL: 0, - CREATE_REPORT: 1, - ADD_SERIES: 2, - SET_STUDY_AND_SERIES: 3, - NO_NOT_FOR_SERIES: 4, - HYDRATE_REPORT: 5, -}; - -function promptHydrateStructuredReport( - { servicesManager, extensionManager, commandsManager, appConfig }, - ctx, - evt -) { - const { uiViewportDialogService, displaySetService, customizationService } = - servicesManager.services; - const { viewportId, displaySetInstanceUID } = evt; - const srDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); - return new Promise(async function (resolve, reject) { - const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD; - - const promptResult = standardMode - ? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) - : RESPONSE.HYDRATE_REPORT; - - // Need to do action here... So we can set state... - let StudyInstanceUID, SeriesInstanceUIDs; - - if (promptResult === RESPONSE.HYDRATE_REPORT) { - console.warn('!! HYDRATING STRUCTURED REPORT'); - const hydrationResult = hydrateStructuredReport( - { servicesManager, extensionManager, commandsManager, appConfig }, - displaySetInstanceUID - ); - - StudyInstanceUID = hydrationResult.StudyInstanceUID; - SeriesInstanceUIDs = hydrationResult.SeriesInstanceUIDs; - } - - resolve({ - userResponse: promptResult, - displaySetInstanceUID: evt.displaySetInstanceUID, - srSeriesInstanceUID: srDisplaySet.SeriesInstanceUID, - viewportId, - StudyInstanceUID, - SeriesInstanceUIDs, - }); - }); -} - -function _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) { - return new Promise(function (resolve, reject) { - const message = customizationService.getCustomization('viewportNotification.hydrateSRMessage'); - const actions = [ - { - id: 'no-hydrate', - type: 'secondary', - text: 'No', - value: RESPONSE.CANCEL, - }, - { - id: 'yes-hydrate', - type: 'primary', - text: 'Yes', - value: RESPONSE.HYDRATE_REPORT, - }, - ]; - const onSubmit = result => { - uiViewportDialogService.hide(); - resolve(result); - }; - - uiViewportDialogService.show({ - viewportId, - type: 'info', - message, - actions, - onSubmit, - onOutsideClick: () => { - uiViewportDialogService.hide(); - resolve(RESPONSE.CANCEL); - }, - onKeyPress: event => { - if (event.key === 'Enter') { - const action = actions.find(action => action.value === RESPONSE.HYDRATE_REPORT); - onSubmit(action.value); - } - }, - }); - }); -} - -export default promptHydrateStructuredReport; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.ts b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.ts new file mode 100644 index 000000000..442fb2bae --- /dev/null +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.ts @@ -0,0 +1,30 @@ +import { utils } from '@ohif/extension-cornerstone'; + +function promptHydrateStructuredReport({ servicesManager, commandsManager }, ctx, evt) { + const { displaySetService } = servicesManager.services; + const { viewportId, displaySetInstanceUID } = evt; + const srDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); + + const hydrateCallback = async () => { + return commandsManager.runCommand('hydrateSecondaryDisplaySet', { + displaySet: srDisplaySet, + viewportId, + }); + }; + + // For SR we need to use the whole context + const enhancedSrDisplaySet = { + ...srDisplaySet, + displaySetInstanceUID, + }; + + return utils.promptHydrationDialog({ + servicesManager, + viewportId, + displaySet: enhancedSrDisplaySet, + hydrateCallback, + type: 'SR', + }); +} + +export default promptHydrateStructuredReport; diff --git a/extensions/measurement-tracking/src/index.tsx b/extensions/measurement-tracking/src/index.tsx index 0992e71a8..cc669e695 100644 --- a/extensions/measurement-tracking/src/index.tsx +++ b/extensions/measurement-tracking/src/index.tsx @@ -1,17 +1,16 @@ -import React from 'react'; - import getContextModule from './getContextModule'; import getPanelModule from './getPanelModule'; import getViewportModule from './getViewportModule'; import { id } from './id.js'; -import { ViewportActionButton } from '@ohif/ui-next'; -import i18n from '@ohif/i18n'; import { measurementTrackingMode } from './contexts/TrackedMeasurementsContext/promptBeginTracking'; import getCustomizationModule from './getCustomizationModule'; import { onDoubleClickHandler, customOnDropHandlerCallback, } from './customizations/studyBrowserCustomization'; +import { TrackedMeasurementsService } from './services'; +// Import types to ensure they're included in the build +import './types'; const measurementTrackingExtension = { /** @@ -23,8 +22,25 @@ const measurementTrackingExtension = { getPanelModule, getViewportModule, + /** + * Service configuration + */ + preRegistration({ servicesManager }) { + servicesManager.registerService(TrackedMeasurementsService.REGISTRATION); + }, + onModeEnter({ servicesManager }) { - const { toolbarService, customizationService } = servicesManager.services; + const { customizationService, toolbarService, trackedMeasurementsService } = + servicesManager.services; + + toolbarService.registerEventForToolbarUpdate(trackedMeasurementsService, [ + trackedMeasurementsService.EVENTS.TRACKED_SERIES_CHANGED, + trackedMeasurementsService.EVENTS.SERIES_ADDED, + trackedMeasurementsService.EVENTS.SERIES_REMOVED, + trackedMeasurementsService.EVENTS.TRACKING_ENABLED, + trackedMeasurementsService.EVENTS.TRACKING_DISABLED, + ]); + customizationService.setCustomizations({ 'studyBrowser.thumbnailDoubleClickCallback': { $set: onDoubleClickHandler, @@ -33,23 +49,6 @@ const measurementTrackingExtension = { $set: customOnDropHandlerCallback, }, }); - toolbarService.addButtons( - [ - { - // A button for loading tracked, SR measurements. - // Note that the command run is registered in TrackedMeasurementsContext - // because it must be bound to a React context's data. - id: 'loadSRMeasurements', - component: props => ( - {i18n.t('Common:LOAD')} - ), - props: { - commands: ['loadTrackedSRMeasurements'], - }, - }, - ], - true // replace the button if it is already defined - ); }, getCustomizationModule, }; diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking.tsx b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking.tsx index 7b3b2094c..9612ba1c2 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking.tsx @@ -1,12 +1,12 @@ import React from 'react'; -import { utils } from '@ohif/core'; +import { useSystem, utils } from '@ohif/core'; import { AccordionTrigger, MeasurementTable, ScrollArea, useViewportGrid } from '@ohif/ui-next'; import { PanelMeasurement, StudyMeasurements, - StudyMeasurementsActions, StudySummaryFromMetadata, AccordionGroup, + StudyMeasurementsActions, MeasurementsOrAdditionalFindings, } from '@ohif/extension-cornerstone'; @@ -18,7 +18,9 @@ const { filterAnd, filterPlanarMeasurement, filterMeasurementsBySeriesUID } = function PanelMeasurementTableTracking(props) { const [viewportGrid] = useViewportGrid(); - const { measurementService, uiModalService } = props.servicesManager.services; + const { servicesManager } = useSystem(); + const { measurementService, uiModalService } = servicesManager.services; + const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements(); const { trackedStudy, trackedSeries } = trackedMeasurements.context; const measurementFilter = trackedStudy diff --git a/extensions/measurement-tracking/src/services/TrackedMeasurementsService/TrackedMeasurementsService.ts b/extensions/measurement-tracking/src/services/TrackedMeasurementsService/TrackedMeasurementsService.ts new file mode 100644 index 000000000..d657404af --- /dev/null +++ b/extensions/measurement-tracking/src/services/TrackedMeasurementsService/TrackedMeasurementsService.ts @@ -0,0 +1,168 @@ +import { PubSubService } from '@ohif/core'; + +const EVENTS = { + TRACKED_SERIES_CHANGED: 'event::trackedmeasurements:trackedserieschanged', + SERIES_ADDED: 'event::trackedmeasurements:seriesadded', + SERIES_REMOVED: 'event::trackedmeasurements:seriesremoved', + TRACKING_ENABLED: 'event::trackedmeasurements:trackingenabled', + TRACKING_DISABLED: 'event::trackedmeasurements:trackingdisabled', +}; + +/** + * Service class for accessing tracked measurements data. + * This service provides a robust way to access tracked series information + * from anywhere in the application, including outside of React components. + */ +export class TrackedMeasurementsService extends PubSubService { + public static readonly REGISTRATION = { + name: 'trackedMeasurementsService', + altName: 'TrackedMeasurementsService', + create: ({ configuration = {} }) => { + return new TrackedMeasurementsService(); + }, + }; + + private _trackedSeries: string[] = []; + + constructor() { + super(EVENTS); + } + + /** + * Updates the tracked series and notifies subscribers + * @param trackedSeries Array of series UIDs being tracked + */ + public updateTrackedSeries(trackedSeries: string[]): void { + if (!trackedSeries) { + trackedSeries = []; + } + + const hasChanged = + this._trackedSeries.length !== trackedSeries.length || + this._trackedSeries.some((seriesUID, index) => seriesUID !== trackedSeries[index]); + + if (hasChanged) { + const oldSeries = [...this._trackedSeries]; + this._trackedSeries = [...trackedSeries]; + + const wasEmpty = oldSeries.length === 0; + const isEmpty = trackedSeries.length === 0; + + if (wasEmpty && !isEmpty) { + this._broadcastEvent(EVENTS.TRACKING_ENABLED, { + trackedSeries: this.getTrackedSeries(), + }); + } else if (!wasEmpty && isEmpty) { + this._broadcastEvent(EVENTS.TRACKING_DISABLED, { + trackedSeries: this.getTrackedSeries(), + }); + } + + this._broadcastEvent(EVENTS.TRACKED_SERIES_CHANGED, { + trackedSeries: this.getTrackedSeries(), + }); + } + } + + /** + * Adds a single series to tracking + * @param seriesInstanceUID Series instance UID to add to tracking + */ + public addTrackedSeries(seriesInstanceUID: string): void { + if (!seriesInstanceUID || this.isSeriesTracked(seriesInstanceUID)) { + return; + } + + const wasEmpty = this._trackedSeries.length === 0; + this._trackedSeries = [...this._trackedSeries, seriesInstanceUID]; + + this._broadcastEvent(EVENTS.SERIES_ADDED, { + seriesInstanceUID, + trackedSeries: this.getTrackedSeries(), + }); + + if (wasEmpty) { + this._broadcastEvent(EVENTS.TRACKING_ENABLED, { + trackedSeries: this.getTrackedSeries(), + }); + } + + this._broadcastEvent(EVENTS.TRACKED_SERIES_CHANGED, { + trackedSeries: this.getTrackedSeries(), + }); + } + + /** + * Removes a single series from tracking + * @param seriesInstanceUID Series instance UID to remove from tracking + */ + public removeTrackedSeries(seriesInstanceUID: string): void { + if (!seriesInstanceUID || !this.isSeriesTracked(seriesInstanceUID)) { + return; + } + + this._trackedSeries = this._trackedSeries.filter(uid => uid !== seriesInstanceUID); + + this._broadcastEvent(EVENTS.SERIES_REMOVED, { + seriesInstanceUID, + trackedSeries: this.getTrackedSeries(), + }); + + if (this._trackedSeries.length === 0) { + this._broadcastEvent(EVENTS.TRACKING_DISABLED, { + trackedSeries: this.getTrackedSeries(), + }); + } + + this._broadcastEvent(EVENTS.TRACKED_SERIES_CHANGED, { + trackedSeries: this.getTrackedSeries(), + }); + } + + /** + * Retrieves the currently tracked series + * @returns Array of series UIDs being tracked + */ + public getTrackedSeries(): string[] { + return [...this._trackedSeries]; + } + + /** + * Checks if a specific series is being tracked + * @param seriesInstanceUID Series instance UID to check + * @returns boolean indicating if series is tracked + */ + public isSeriesTracked(seriesInstanceUID: string): boolean { + return this._trackedSeries.includes(seriesInstanceUID); + } + + /** + * Resets the service state + */ + public reset(): void { + const wasTracking = this._trackedSeries.length > 0; + this._trackedSeries = []; + + if (wasTracking) { + this._broadcastEvent(EVENTS.TRACKING_DISABLED, { + trackedSeries: [], + }); + + this._broadcastEvent(EVENTS.TRACKED_SERIES_CHANGED, { + trackedSeries: [], + }); + } + + super.reset(); + } + + /** + * Checks if any series are being tracked + * @returns boolean indicating if tracking is active + */ + public isTrackingEnabled(): boolean { + return this._trackedSeries.length > 0; + } +} + +export default TrackedMeasurementsService; diff --git a/extensions/measurement-tracking/src/services/TrackedMeasurementsService/index.ts b/extensions/measurement-tracking/src/services/TrackedMeasurementsService/index.ts new file mode 100644 index 000000000..458114479 --- /dev/null +++ b/extensions/measurement-tracking/src/services/TrackedMeasurementsService/index.ts @@ -0,0 +1 @@ +export * from './TrackedMeasurementsService'; diff --git a/extensions/measurement-tracking/src/services/index.ts b/extensions/measurement-tracking/src/services/index.ts new file mode 100644 index 000000000..458114479 --- /dev/null +++ b/extensions/measurement-tracking/src/services/index.ts @@ -0,0 +1 @@ +export * from './TrackedMeasurementsService'; diff --git a/extensions/measurement-tracking/src/types/AppTypes.ts b/extensions/measurement-tracking/src/types/AppTypes.ts new file mode 100644 index 000000000..24552f61d --- /dev/null +++ b/extensions/measurement-tracking/src/types/AppTypes.ts @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import { TrackedMeasurementsService } from '../services/TrackedMeasurementsService'; + +declare global { + namespace AppTypes { + export type TrackedMeasurementsServiceType = TrackedMeasurementsService; + export interface Services { + trackedMeasurementsService?: TrackedMeasurementsService; + } + } +} diff --git a/extensions/measurement-tracking/src/types/index.ts b/extensions/measurement-tracking/src/types/index.ts new file mode 100644 index 000000000..03f81d63a --- /dev/null +++ b/extensions/measurement-tracking/src/types/index.ts @@ -0,0 +1 @@ +export * from './AppTypes'; diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx index ed99a7238..74ebb313a 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx @@ -2,32 +2,28 @@ import React, { useState, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import { ViewportActionArrows } from '@ohif/ui-next'; -import { useViewportGrid, Icons, Tooltip, TooltipTrigger, TooltipContent } from '@ohif/ui-next'; +import { OHIFCornerstoneViewport } from '@ohif/extension-cornerstone'; import { annotation } from '@cornerstonejs/tools'; import { useTrackedMeasurements } from './../getContextModule'; import { BaseVolumeViewport, Enums } from '@cornerstonejs/core'; -import { useTranslation } from 'react-i18next'; +import { useSystem } from '@ohif/core'; function TrackedCornerstoneViewport( props: withAppTypes<{ viewportId: string; displaySets: AppTypes.DisplaySet[] }> ) { - const { displaySets, viewportId, servicesManager, extensionManager } = props; + const { servicesManager } = useSystem(); + const { displaySets, viewportId } = props as { + displaySets: AppTypes.DisplaySet[]; + viewportId: string; + servicesManager: AppTypes.Services; + }; - const { - measurementService, - cornerstoneViewportService, - viewportGridService, - viewportActionCornersService, - } = servicesManager.services; + const { measurementService, cornerstoneViewportService, viewportGridService, toolbarService } = + servicesManager.services; // Todo: handling more than one displaySet on the same viewport const displaySet = displaySets[0]; - const { t } = useTranslation('Common'); - - const [viewportGrid] = useViewportGrid(); - const { activeViewportId } = viewportGrid; - const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements(); const [isTracked, setIsTracked] = useState(false); @@ -193,39 +189,9 @@ function TrackedCornerstoneViewport( [measurementService, servicesManager, trackedMeasurementUID, trackedMeasurements, viewportId] ); - useEffect(() => { - const statusComponent = _getStatusComponent(isTracked, t); - const arrowsComponent = _getArrowsComponent( - isTracked, - switchMeasurement, - viewportId === activeViewportId - ); - - viewportActionCornersService.addComponents([ - { - viewportId, - id: 'viewportStatusComponent', - component: statusComponent, - indexPriority: -100, - location: viewportActionCornersService.LOCATIONS.topRight, - }, - { - viewportId, - id: 'viewportActionArrowsComponent', - component: arrowsComponent, - indexPriority: 0, - location: viewportActionCornersService.LOCATIONS.topRight, - }, - ]); - }, [activeViewportId, isTracked, switchMeasurement, viewportActionCornersService, viewportId]); - const getCornerstoneViewport = () => { - const { component: Component } = extensionManager.getModuleEntry( - '@ohif/extension-cornerstone.viewportModule.cornerstone' - ); - return ( - { props.onElementEnabled?.(evt); @@ -316,30 +282,4 @@ const _getArrowsComponent = (isTracked, switchMeasurement, isActiveViewport) => ); }; -function _getStatusComponent(isTracked, t) { - if (!isTracked) { - return null; - } - - return ( - - - - - - - - {isTracked ? ( - <>{t('Series is tracked and can be viewed in the measurement panel')} - ) : ( - <>{t('Measurements for untracked series will not be shown in the measurements panel')} - )} - - - ); -} - export default TrackedCornerstoneViewport; diff --git a/extensions/tmtv/src/Panels/PanelPetSUV.tsx b/extensions/tmtv/src/Panels/PanelPetSUV.tsx index 89faeedae..f74941f3e 100644 --- a/extensions/tmtv/src/Panels/PanelPetSUV.tsx +++ b/extensions/tmtv/src/Panels/PanelPetSUV.tsx @@ -58,11 +58,10 @@ InputRow.Input = ({ className, ...props }) => ( InputRow.Label.displayName = 'InputRow.Label'; InputRow.Input.displayName = 'InputRow.Input'; -export default function PanelPetSUV({ servicesManager }: withAppTypes) { - const { commandsManager } = useSystem(); +export default function PanelPetSUV() { + const { commandsManager, servicesManager } = useSystem(); const { t } = useTranslation('PanelSUV'); - const { displaySetService, toolGroupService, toolbarService, hangingProtocolService } = - servicesManager.services; + const { displaySetService, hangingProtocolService } = servicesManager.services; const [metadata, setMetadata] = useState(DEFAULT_MEATADATA); const [ptDisplaySet, setPtDisplaySet] = useState(null); diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdExport.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdExport.tsx index 1a3b95098..cf5e0fd6b 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdExport.tsx +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdExport.tsx @@ -9,7 +9,7 @@ export default function PanelRoiThresholdSegmentation() { const { commandsManager, servicesManager } = useSystem(); const { segmentationService } = servicesManager.services; const { segmentationsWithRepresentations: segmentationsInfo } = - useActiveViewportSegmentationRepresentations({ servicesManager }); + useActiveViewportSegmentationRepresentations(); const segmentationIds = segmentationsInfo?.map(info => info.segmentation.segmentationId) || []; const segmentations = segmentationsInfo?.map(info => info.segmentation) || []; diff --git a/modes/basic-dev-mode/src/index.ts b/modes/basic-dev-mode/src/index.ts index d0036a544..c3f4ed8a8 100644 --- a/modes/basic-dev-mode/src/index.ts +++ b/modes/basic-dev-mode/src/index.ts @@ -94,8 +94,8 @@ function modeFactory({ modeConfiguration }) { toolGroupService.createToolGroupAndAddTools('default', tools); - toolbarService.addButtons(toolbarButtons); - toolbarService.createButtonSection('primary', [ + toolbarService.register(toolbarButtons); + toolbarService.updateSection('primary', [ 'measurementSection', 'Zoom', 'WindowLevel', diff --git a/modes/basic-dev-mode/src/toolbarButtons.ts b/modes/basic-dev-mode/src/toolbarButtons.ts index 4b4d7ad88..c9f05ba8c 100644 --- a/modes/basic-dev-mode/src/toolbarButtons.ts +++ b/modes/basic-dev-mode/src/toolbarButtons.ts @@ -15,7 +15,6 @@ const toolbarButtons: Button[] = [ uiType: 'ohif.toolButtonList', props: { buttonSection: 'measurementSection', - groupId: 'measurementSection', }, }, { @@ -23,7 +22,6 @@ const toolbarButtons: Button[] = [ uiType: 'ohif.toolButtonList', props: { buttonSection: 'moreToolsSection', - groupId: 'MoreTools', }, }, diff --git a/modes/basic-test-mode/src/index.ts b/modes/basic-test-mode/src/index.ts index 5390da1e7..2fe74aade 100644 --- a/modes/basic-test-mode/src/index.ts +++ b/modes/basic-test-mode/src/index.ts @@ -89,9 +89,9 @@ function modeFactory() { '@ohif/extension-test.customizationModule.custom-context-menu', ]); - toolbarService.addButtons(toolbarButtons); + toolbarService.register(toolbarButtons); console.debug('toolbarButtons', toolbarButtons); - toolbarService.createButtonSection('primary', [ + toolbarService.updateSection(toolbarService.sections.primary, [ 'MeasurementTools', 'Zoom', 'WindowLevelGroup', @@ -103,7 +103,7 @@ function modeFactory() { 'MoreTools', ]); - toolbarService.createButtonSection('windowLevelSection', [ + toolbarService.updateSection('windowLevelSection', [ 'WindowLevel', 'Soft tissue', 'Lung', @@ -112,7 +112,42 @@ function modeFactory() { 'Brain', ]); - toolbarService.createButtonSection('measurementSection', [ + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [ + 'orientationMenu', + 'dataOverlayMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [ + 'advancedRenderingControls', + ]); + + toolbarService.updateSection('advancedRenderingControls', [ + 'voiManualControlMenu', + 'Colorbar', + 'opacityMenu', + 'thresholdMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [ + 'modalityLoadBadge', + 'trackingStatus', + 'navigationComponent', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [ + 'windowLevelMenu', + ]); + + toolbarService.updateSection('windowLevelSection', [ + 'WindowLevel', + 'Soft tissue', + 'Lung', + 'Liver', + 'Bone', + 'Brain', + ]); + + toolbarService.updateSection(toolbarService.sections.measurementSection, [ 'Length', 'Bidirectional', 'ArrowAnnotate', @@ -123,7 +158,7 @@ function modeFactory() { 'LivewireContour', ]); - toolbarService.createButtonSection('moreToolsSection', [ + toolbarService.updateSection(toolbarService.sections.moreToolsSection, [ 'Reset', 'rotate-right', 'flipHorizontal', diff --git a/modes/basic-test-mode/src/toolbarButtons.ts b/modes/basic-test-mode/src/toolbarButtons.ts index 846c8f5aa..5491a8684 100644 --- a/modes/basic-test-mode/src/toolbarButtons.ts +++ b/modes/basic-test-mode/src/toolbarButtons.ts @@ -40,11 +40,11 @@ export const setToolActiveToolbar = { }, }; -const ReferenceLinesListeners = [ +const callbacks = (toolName: string) => [ { commandName: 'setViewportForToolConfiguration', commandOptions: { - toolName: 'ReferenceLines', + toolName, }, }, ]; @@ -55,7 +55,6 @@ const toolbarButtons: Button[] = [ uiType: 'ohif.toolButtonList', props: { buttonSection: 'measurementSection', - groupId: 'MeasurementTools', }, }, { @@ -63,19 +62,143 @@ const toolbarButtons: Button[] = [ uiType: 'ohif.toolButtonList', props: { buttonSection: 'moreToolsSection', - groupId: 'MoreTools', }, }, { id: 'WindowLevelGroup', uiType: 'ohif.toolButtonList', props: { - groupId: 'WindowLevelGroup', buttonSection: 'windowLevelSection', }, }, // tool defs + { + id: 'advancedRenderingControls', + uiType: 'ohif.advancedRenderingControls', + props: { + evaluate: { + name: 'evaluate.advancedRenderingControls', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'modalityLoadBadge', + uiType: 'ohif.modalityLoadBadge', + props: { + icon: 'Status', + label: 'Status', + tooltip: 'Status', + evaluate: { + name: 'evaluate.modalityLoadBadge', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'navigationComponent', + uiType: 'ohif.navigationComponent', + props: { + icon: 'Navigation', + label: 'Navigation', + tooltip: 'Navigate between segments/measurements and manage their visibility', + evaluate: { + name: 'evaluate.navigationComponent', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'trackingStatus', + uiType: 'ohif.trackingStatus', + props: { + icon: 'TrackingStatus', + label: 'Tracking Status', + tooltip: 'View and manage tracking status of measurements and annotations', + evaluate: { + name: 'evaluate.trackingStatus', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'dataOverlayMenu', + uiType: 'ohif.dataOverlayMenu', + props: { + icon: 'ViewportViews', + label: 'Data Overlay', + tooltip: 'Configure data overlay options and manage foreground/background display sets', + evaluate: 'evaluate.dataOverlayMenu', + }, + }, + { + id: 'orientationMenu', + uiType: 'ohif.orientationMenu', + props: { + icon: 'OrientationSwitch', + label: 'Orientation', + tooltip: + 'Change viewport orientation between axial, sagittal, coronal and acquisition planes', + evaluate: { + name: 'evaluate.orientationMenu', + }, + }, + }, + { + id: 'windowLevelMenu', + uiType: 'ohif.windowLevelMenu', + props: { + icon: 'WindowLevel', + label: 'Window Level', + tooltip: 'Adjust window/level presets and customize image contrast settings', + evaluate: 'evaluate.windowLevelMenu', + }, + }, + { + id: 'voiManualControlMenu', + uiType: 'ohif.voiManualControlMenu', + props: { + icon: 'WindowLevelAdvanced', + label: 'Advanced Window Level', + tooltip: 'Advanced window/level settings with manual controls and presets', + evaluate: 'evaluate.voiManualControlMenu', + }, + }, + { + id: 'thresholdMenu', + uiType: 'ohif.thresholdMenu', + props: { + icon: 'Threshold', + label: 'Threshold', + tooltip: 'Image threshold settings', + evaluate: { + name: 'evaluate.thresholdMenu', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'opacityMenu', + uiType: 'ohif.opacityMenu', + props: { + icon: 'Opacity', + label: 'Opacity', + tooltip: 'Image opacity settings', + evaluate: { + name: 'evaluate.opacityMenu', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'Colorbar', + uiType: 'ohif.colorbar', + props: { + type: 'tool', + label: 'Colorbar', + }, + }, _createWwwcPreset(1, 'Soft tissue', '400 / 40'), _createWwwcPreset(2, 'Lung', '1500 / -600'), _createWwwcPreset(3, 'Liver', '150 / 90'), @@ -338,10 +461,6 @@ const toolbarButtons: Button[] = [ label: 'Reference Lines', tooltip: 'Show Reference Lines', commands: 'toggleEnabledDisabledToolbar', - listeners: { - [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners, - [ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners, - }, evaluate: 'evaluate.cornerstoneTool.toggle', }, }, diff --git a/modes/longitudinal/src/index.ts b/modes/longitudinal/src/index.ts index b24e1f250..2004eaf2b 100644 --- a/modes/longitudinal/src/index.ts +++ b/modes/longitudinal/src/index.ts @@ -90,8 +90,8 @@ function modeFactory({ modeConfiguration }) { // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager); - toolbarService.addButtons(toolbarButtons); - toolbarService.createButtonSection('primary', [ + toolbarService.register(toolbarButtons); + toolbarService.updateSection(toolbarService.sections.primary, [ 'MeasurementTools', 'Zoom', 'Pan', @@ -103,7 +103,34 @@ function modeFactory({ modeConfiguration }) { 'MoreTools', ]); - toolbarService.createButtonSection('measurementSection', [ + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [ + 'orientationMenu', + 'dataOverlayMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [ + 'AdvancedRenderingControls', + ]); + + toolbarService.updateSection(toolbarService.sections.advancedRenderingControlsSection, [ + 'windowLevelMenuEmbedded', + 'voiManualControlMenu', + 'Colorbar', + 'opacityMenu', + 'thresholdMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [ + 'modalityLoadBadge', + 'trackingStatus', + 'navigationComponent', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [ + 'windowLevelMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.measurementSection, [ 'Length', 'Bidirectional', 'ArrowAnnotate', @@ -115,7 +142,7 @@ function modeFactory({ modeConfiguration }) { 'LivewireContour', ]); - toolbarService.createButtonSection('moreToolsSection', [ + toolbarService.updateSection(toolbarService.sections.moreToolsSection, [ 'Reset', 'rotate-right', 'flipHorizontal', diff --git a/modes/longitudinal/src/toolbarButtons.ts b/modes/longitudinal/src/toolbarButtons.ts index 14c1e9c10..0f80e0a0e 100644 --- a/modes/longitudinal/src/toolbarButtons.ts +++ b/modes/longitudinal/src/toolbarButtons.ts @@ -26,7 +26,6 @@ const toolbarButtons: Button[] = [ uiType: 'ohif.toolButtonList', props: { buttonSection: 'measurementSection', - groupId: 'MeasurementTools', }, }, { @@ -34,10 +33,148 @@ const toolbarButtons: Button[] = [ uiType: 'ohif.toolButtonList', props: { buttonSection: 'moreToolsSection', - groupId: 'MoreTools', + }, + }, + { + id: 'AdvancedRenderingControls', + uiType: 'ohif.advancedRenderingControls', + props: { + buttonSection: 'advancedRenderingControlsSection', }, }, // tool defs + { + id: 'modalityLoadBadge', + uiType: 'ohif.modalityLoadBadge', + props: { + icon: 'Status', + label: 'Status', + tooltip: 'Status', + evaluate: { + name: 'evaluate.modalityLoadBadge', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'navigationComponent', + uiType: 'ohif.navigationComponent', + props: { + icon: 'Navigation', + label: 'Navigation', + tooltip: 'Navigate between segments/measurements and manage their visibility', + evaluate: { + name: 'evaluate.navigationComponent', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'trackingStatus', + uiType: 'ohif.trackingStatus', + props: { + icon: 'TrackingStatus', + label: 'Tracking Status', + tooltip: 'View and manage tracking status of measurements and annotations', + evaluate: { + name: 'evaluate.trackingStatus', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'dataOverlayMenu', + uiType: 'ohif.dataOverlayMenu', + props: { + icon: 'ViewportViews', + label: 'Data Overlay', + tooltip: 'Configure data overlay options and manage foreground/background display sets', + evaluate: 'evaluate.dataOverlayMenu', + }, + }, + { + id: 'orientationMenu', + uiType: 'ohif.orientationMenu', + props: { + icon: 'OrientationSwitch', + label: 'Orientation', + tooltip: + 'Change viewport orientation between axial, sagittal, coronal and acquisition planes', + evaluate: { + name: 'evaluate.orientationMenu', + // hideWhenDisabled: true, + }, + }, + }, + { + id: 'windowLevelMenuEmbedded', + uiType: 'ohif.windowLevelMenuEmbedded', + props: { + icon: 'WindowLevel', + label: 'Window Level', + tooltip: 'Adjust window/level presets and customize image contrast settings', + evaluate: { + name: 'evaluate.windowLevelMenuEmbedded', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'windowLevelMenu', + uiType: 'ohif.windowLevelMenu', + props: { + icon: 'WindowLevel', + label: 'Window Level', + tooltip: 'Adjust window/level presets and customize image contrast settings', + evaluate: { + name: 'evaluate.windowLevelMenu', + }, + }, + }, + { + id: 'voiManualControlMenu', + uiType: 'ohif.voiManualControlMenu', + props: { + icon: 'WindowLevelAdvanced', + label: 'Advanced Window Level', + tooltip: 'Advanced window/level settings with manual controls and presets', + evaluate: 'evaluate.voiManualControlMenu', + }, + }, + { + id: 'thresholdMenu', + uiType: 'ohif.thresholdMenu', + props: { + icon: 'Threshold', + label: 'Threshold', + tooltip: 'Image threshold settings', + evaluate: { + name: 'evaluate.thresholdMenu', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'opacityMenu', + uiType: 'ohif.opacityMenu', + props: { + icon: 'Opacity', + label: 'Opacity', + tooltip: 'Image opacity settings', + evaluate: { + name: 'evaluate.opacityMenu', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'Colorbar', + uiType: 'ohif.colorbar', + props: { + type: 'tool', + label: 'Colorbar', + }, + }, { id: 'Reset', uiType: 'ohif.toolButton', diff --git a/modes/microscopy/src/index.tsx b/modes/microscopy/src/index.tsx index dd49efcdc..7deabc564 100644 --- a/modes/microscopy/src/index.tsx +++ b/modes/microscopy/src/index.tsx @@ -47,10 +47,10 @@ function modeFactory({ modeConfiguration }) { onModeEnter: ({ servicesManager }: withAppTypes) => { const { toolbarService } = servicesManager.services; - toolbarService.addButtons(toolbarButtons); - toolbarService.createButtonSection('primary', ['MeasurementTools', 'dragPan', 'TagBrowser']); + toolbarService.register(toolbarButtons); + toolbarService.updateSection('primary', ['MeasurementTools', 'dragPan', 'TagBrowser']); - toolbarService.createButtonSection('measurementSection', [ + toolbarService.updateSection('measurementSection', [ 'line', 'point', 'polygon', diff --git a/modes/microscopy/src/toolbarButtons.ts b/modes/microscopy/src/toolbarButtons.ts index ff4990b8f..1b3a6ab4e 100644 --- a/modes/microscopy/src/toolbarButtons.ts +++ b/modes/microscopy/src/toolbarButtons.ts @@ -14,7 +14,6 @@ const toolbarButtons: Button[] = [ uiType: 'ohif.toolButtonList', props: { buttonSection: 'measurementSection', - groupId: 'MeasurementTools', }, }, { diff --git a/modes/preclinical-4d/src/index.tsx b/modes/preclinical-4d/src/index.tsx index 4649e8ded..0080b7e72 100644 --- a/modes/preclinical-4d/src/index.tsx +++ b/modes/preclinical-4d/src/index.tsx @@ -54,9 +54,41 @@ function modeFactory({ modeConfiguration }) { measurementService.clearMeasurements(); initToolGroups({ toolNames, Enums, toolGroupService, commandsManager, servicesManager }); - toolbarService.addButtons(toolbarButtons); + toolbarService.register(toolbarButtons); - toolbarService.createButtonSection('secondary', ['ProgressDropdown']); + toolbarService.updateSection('secondary', ['ProgressDropdown']); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [ + 'orientationMenu', + 'dataOverlayMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [ + 'orientationMenu', + 'dataOverlayMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [ + 'AdvancedRenderingControls', + ]); + + toolbarService.updateSection(toolbarService.sections.advancedRenderingControlsSection, [ + 'windowLevelMenuEmbedded', + 'voiManualControlMenu', + 'Colorbar', + 'opacityMenu', + 'thresholdMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [ + 'modalityLoadBadge', + 'trackingStatus', + 'navigationComponent', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [ + 'windowLevelMenu', + ]); // the primary button section is created in the workflow steps // specific to the step diff --git a/modes/preclinical-4d/src/toolbarButtons.tsx b/modes/preclinical-4d/src/toolbarButtons.tsx index fa9eb12df..6721b9615 100644 --- a/modes/preclinical-4d/src/toolbarButtons.tsx +++ b/modes/preclinical-4d/src/toolbarButtons.tsx @@ -1,4 +1,5 @@ import { toolGroupIds } from './initToolGroups'; +import { ViewportGridService } from '@ohif/core'; const setToolActiveToolbar = { commandName: 'setToolActiveToolbar', @@ -7,12 +8,20 @@ const setToolActiveToolbar = { }, }; +const callbacks = (toolName: string) => [ + { + commandName: 'setViewportForToolConfiguration', + commandOptions: { + toolName, + }, + }, +]; + const toolbarButtons = [ { id: 'MeasurementTools', uiType: 'ohif.toolButtonList', props: { - groupId: 'MeasurementTools', buttonSection: 'measurementSection', }, }, @@ -20,7 +29,6 @@ const toolbarButtons = [ id: 'BrushTools', uiType: 'ohif.toolBoxButtonGroup', props: { - groupId: 'BrushTools', buttonSection: 'brushToolsSection', }, }, @@ -28,10 +36,68 @@ const toolbarButtons = [ id: 'SegmentationTools', uiType: 'ohif.toolBoxButton', props: { - groupId: 'SegmentationTools', buttonSection: 'segmentationToolboxToolsSection', }, }, + { + id: 'AdvancedRenderingControls', + uiType: 'ohif.advancedRenderingControls', + props: { + buttonSection: 'advancedRenderingControlsSection', + }, + }, + { + id: 'modalityLoadBadge', + uiType: 'ohif.modalityLoadBadge', + props: { + icon: 'Status', + label: 'Status', + tooltip: 'Status', + evaluate: { + name: 'evaluate.modalityLoadBadge', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'navigationComponent', + uiType: 'ohif.navigationComponent', + props: { + icon: 'Navigation', + label: 'Navigation', + tooltip: 'Navigate between segments/measurements and manage their visibility', + evaluate: { + name: 'evaluate.navigationComponent', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'windowLevelMenuEmbedded', + uiType: 'ohif.windowLevelMenuEmbedded', + props: { + icon: 'WindowLevel', + label: 'Window Level', + tooltip: 'Adjust window/level presets and customize image contrast settings', + evaluate: { + name: 'evaluate.windowLevelMenuEmbedded', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'trackingStatus', + uiType: 'ohif.trackingStatus', + props: { + icon: 'TrackingStatus', + label: 'Tracking Status', + tooltip: 'View and manage tracking status of measurements and annotations', + evaluate: { + name: 'evaluate.trackingStatus', + hideWhenDisabled: true, + }, + }, + }, { id: 'Length', uiType: 'ohif.toolButton', @@ -330,6 +396,84 @@ const toolbarButtons = [ ], }, }, + { + id: 'dataOverlayMenu', + uiType: 'ohif.dataOverlayMenu', + props: { + icon: 'ViewportViews', + label: 'Data Overlay', + tooltip: 'Configure data overlay options and manage foreground/background display sets', + evaluate: 'evaluate.dataOverlayMenu', + }, + }, + { + id: 'orientationMenu', + uiType: 'ohif.orientationMenu', + props: { + icon: 'OrientationSwitch', + label: 'Orientation', + tooltip: + 'Change viewport orientation between axial, sagittal, coronal and acquisition planes', + evaluate: { + name: 'evaluate.orientationMenu', + // hideWhenDisabled: true, + }, + }, + }, + { + id: 'windowLevelMenu', + uiType: 'ohif.windowLevelMenu', + props: { + icon: 'WindowLevel', + label: 'Window Level', + tooltip: 'Adjust window/level presets and customize image contrast settings', + evaluate: 'evaluate.windowLevelMenu', + }, + }, + { + id: 'voiManualControlMenu', + uiType: 'ohif.voiManualControlMenu', + props: { + icon: 'WindowLevelAdvanced', + label: 'Advanced Window Level', + tooltip: 'Advanced window/level settings with manual controls and presets', + evaluate: 'evaluate.voiManualControlMenu', + }, + }, + { + id: 'thresholdMenu', + uiType: 'ohif.thresholdMenu', + props: { + icon: 'Threshold', + label: 'Threshold', + tooltip: 'Image threshold settings', + evaluate: { + name: 'evaluate.thresholdMenu', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'opacityMenu', + uiType: 'ohif.opacityMenu', + props: { + icon: 'Opacity', + label: 'Opacity', + tooltip: 'Image opacity settings', + evaluate: { + name: 'evaluate.opacityMenu', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'Colorbar', + uiType: 'ohif.colorbar', + props: { + type: 'tool', + label: 'Colorbar', + }, + }, ]; export default toolbarButtons; diff --git a/modes/segmentation/src/index.tsx b/modes/segmentation/src/index.tsx index c0a4ada98..bc6d702c2 100644 --- a/modes/segmentation/src/index.tsx +++ b/modes/segmentation/src/index.tsx @@ -61,9 +61,9 @@ function modeFactory({ modeConfiguration }) { // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager); - toolbarService.addButtons(toolbarButtons); + toolbarService.register(toolbarButtons); - toolbarService.createButtonSection('primary', [ + toolbarService.updateSection(toolbarService.sections.primary, [ 'WindowLevel', 'Pan', 'Zoom', @@ -74,7 +74,34 @@ function modeFactory({ modeConfiguration }) { 'MoreTools', ]); - toolbarService.createButtonSection('moreToolsSection', [ + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [ + 'orientationMenu', + 'dataOverlayMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [ + 'AdvancedRenderingControls', + ]); + + toolbarService.updateSection(toolbarService.sections.advancedRenderingControlsSection, [ + 'windowLevelMenuEmbedded', + 'voiManualControlMenu', + 'Colorbar', + 'opacityMenu', + 'thresholdMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [ + 'modalityLoadBadge', + 'trackingStatus', + 'navigationComponent', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [ + 'windowLevelMenu', + ]); + + toolbarService.updateSection('moreToolsSection', [ 'Reset', 'rotate-right', 'flipHorizontal', @@ -87,22 +114,22 @@ function modeFactory({ modeConfiguration }) { 'TagBrowser', ]); - toolbarService.createButtonSection('segmentationToolbox', [ + toolbarService.updateSection('segmentationToolbox', [ 'SegmentationUtilities', 'SegmentationTools', ]); - toolbarService.createButtonSection('segmentationToolboxUtilitySection', [ + toolbarService.updateSection('segmentationToolboxUtilitySection', [ 'LabelmapSlicePropagation', 'InterpolateLabelmap', 'SegmentBidirectional', ]); - toolbarService.createButtonSection('segmentationToolboxToolsSection', [ + toolbarService.updateSection('segmentationToolboxToolsSection', [ 'BrushTools', 'MarkerLabelmap', 'RegionSegmentPlus', 'Shapes', ]); - toolbarService.createButtonSection('brushToolsSection', ['Brush', 'Eraser', 'Threshold']); + toolbarService.updateSection('brushToolsSection', ['Brush', 'Eraser', 'Threshold']); }, onModeExit: ({ servicesManager }: withAppTypes) => { const { @@ -165,7 +192,7 @@ function modeFactory({ modeConfiguration }) { props: { leftPanels: [ohif.leftPanel], leftPanelResizable: true, - rightPanels: [cornerstone.panelTool, cornerstone.measurements], + rightPanels: [cornerstone.panelTool], rightPanelResizable: true, // leftPanelClosed: true, viewports: [ diff --git a/modes/segmentation/src/toolbarButtons.ts b/modes/segmentation/src/toolbarButtons.ts index 54238ae9f..af4b91ac8 100644 --- a/modes/segmentation/src/toolbarButtons.ts +++ b/modes/segmentation/src/toolbarButtons.ts @@ -18,20 +18,155 @@ const callbacks = (toolName: string) => [ ]; const toolbarButtons: Button[] = [ + { + id: 'AdvancedRenderingControls', + uiType: 'ohif.advancedRenderingControls', + props: { + buttonSection: 'advancedRenderingControlsSection', + }, + }, + { + id: 'modalityLoadBadge', + uiType: 'ohif.modalityLoadBadge', + props: { + icon: 'Status', + label: 'Status', + tooltip: 'Status', + evaluate: { + name: 'evaluate.modalityLoadBadge', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'navigationComponent', + uiType: 'ohif.navigationComponent', + props: { + icon: 'Navigation', + label: 'Navigation', + tooltip: 'Navigate between segments/measurements and manage their visibility', + evaluate: { + name: 'evaluate.navigationComponent', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'trackingStatus', + uiType: 'ohif.trackingStatus', + props: { + icon: 'TrackingStatus', + label: 'Tracking Status', + tooltip: 'View and manage tracking status of measurements and annotations', + evaluate: { + name: 'evaluate.trackingStatus', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'dataOverlayMenu', + uiType: 'ohif.dataOverlayMenu', + props: { + icon: 'ViewportViews', + label: 'Data Overlay', + tooltip: 'Configure data overlay options and manage foreground/background display sets', + evaluate: 'evaluate.dataOverlayMenu', + }, + }, + { + id: 'orientationMenu', + uiType: 'ohif.orientationMenu', + props: { + icon: 'OrientationSwitch', + label: 'Orientation', + tooltip: + 'Change viewport orientation between axial, sagittal, coronal and acquisition planes', + evaluate: { + name: 'evaluate.orientationMenu', + // hideWhenDisabled: true, + }, + }, + }, + { + id: 'windowLevelMenuEmbedded', + uiType: 'ohif.windowLevelMenuEmbedded', + props: { + icon: 'WindowLevel', + label: 'Window Level', + tooltip: 'Adjust window/level presets and customize image contrast settings', + evaluate: { + name: 'evaluate.windowLevelMenuEmbedded', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'windowLevelMenu', + uiType: 'ohif.windowLevelMenu', + props: { + icon: 'WindowLevel', + label: 'Window Level', + tooltip: 'Adjust window/level presets and customize image contrast settings', + evaluate: 'evaluate.windowLevelMenu', + }, + }, + { + id: 'voiManualControlMenu', + uiType: 'ohif.voiManualControlMenu', + props: { + icon: 'WindowLevelAdvanced', + label: 'Advanced Window Level', + tooltip: 'Advanced window/level settings with manual controls and presets', + evaluate: 'evaluate.voiManualControlMenu', + }, + }, + { + id: 'thresholdMenu', + uiType: 'ohif.thresholdMenu', + props: { + icon: 'Threshold', + label: 'Threshold', + tooltip: 'Image threshold settings', + evaluate: { + name: 'evaluate.thresholdMenu', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'opacityMenu', + uiType: 'ohif.opacityMenu', + props: { + icon: 'Opacity', + label: 'Opacity', + tooltip: 'Image opacity settings', + evaluate: { + name: 'evaluate.opacityMenu', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'Colorbar', + uiType: 'ohif.colorbar', + props: { + type: 'tool', + label: 'Colorbar', + }, + }, // sections { id: 'MoreTools', uiType: 'ohif.toolButtonList', props: { buttonSection: 'moreToolsSection', - groupId: 'MoreTools', }, }, { id: 'BrushTools', uiType: 'ohif.toolBoxButtonGroup', props: { - groupId: 'BrushTools', buttonSection: 'brushToolsSection', }, }, @@ -40,7 +175,6 @@ const toolbarButtons: Button[] = [ id: 'SegmentationUtilities', uiType: 'ohif.toolBoxButton', props: { - groupId: 'SegmentationUtilities', buttonSection: 'segmentationToolboxUtilitySection', }, }, @@ -48,7 +182,6 @@ const toolbarButtons: Button[] = [ id: 'SegmentationTools', uiType: 'ohif.toolBoxButton', props: { - groupId: 'SegmentationTools', buttonSection: 'segmentationToolboxToolsSection', }, }, diff --git a/modes/tmtv/src/index.ts b/modes/tmtv/src/index.ts index 0671846c3..c0262876f 100644 --- a/modes/tmtv/src/index.ts +++ b/modes/tmtv/src/index.ts @@ -88,28 +88,56 @@ function modeFactory({ modeConfiguration }) { ); unsubscriptions.push(unsubscribe); - toolbarService.addButtons(toolbarButtons); - toolbarService.createButtonSection('primary', [ + toolbarService.register(toolbarButtons); + toolbarService.updateSection(toolbarService.sections.primary, [ 'MeasurementTools', 'Zoom', + 'Pan', 'WindowLevel', 'Crosshairs', - 'Pan', ]); - toolbarService.createButtonSection('measurementSection', [ + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [ + 'orientationMenu', + 'dataOverlayMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [ + 'AdvancedRenderingControls', + ]); + + toolbarService.updateSection(toolbarService.sections.advancedRenderingControlsSection, [ + 'windowLevelMenuEmbedded', + 'voiManualControlMenu', + 'Colorbar', + 'opacityMenu', + 'thresholdMenu', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [ + 'modalityLoadBadge', + 'trackingStatus', + 'navigationComponent', + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [ + 'windowLevelMenu', + ]); + + toolbarService.updateSection('measurementSection', [ 'Length', 'Bidirectional', 'ArrowAnnotate', 'EllipticalROI', ]); - toolbarService.createButtonSection('ROIThresholdToolbox', ['SegmentationTools']); - toolbarService.createButtonSection('segmentationToolboxToolsSection', [ + toolbarService.updateSection('ROIThresholdToolbox', ['SegmentationTools']); + toolbarService.updateSection('segmentationToolboxToolsSection', [ 'RectangleROIStartEndThreshold', 'BrushTools', ]); - toolbarService.createButtonSection('brushToolsSection', ['Brush', 'Eraser', 'Threshold']); + toolbarService.updateSection('brushToolsSection', ['Brush', 'Eraser', 'Threshold']); customizationService.setCustomizations({ 'panelSegmentation.tableMode': { diff --git a/modes/tmtv/src/toolbarButtons.ts b/modes/tmtv/src/toolbarButtons.ts index b76d13bd4..39e0eabba 100644 --- a/modes/tmtv/src/toolbarButtons.ts +++ b/modes/tmtv/src/toolbarButtons.ts @@ -13,14 +13,12 @@ const toolbarButtons = [ uiType: 'ohif.toolButtonList', props: { buttonSection: 'measurementSection', - groupId: 'MeasurementTools', }, }, { id: 'SegmentationTools', uiType: 'ohif.toolBoxButton', props: { - groupId: 'SegmentationTools', buttonSection: 'segmentationToolboxToolsSection', }, }, @@ -29,7 +27,73 @@ const toolbarButtons = [ uiType: 'ohif.toolBoxButtonGroup', props: { buttonSection: 'brushToolsSection', - groupId: 'BrushTools', + }, + }, + { + id: 'AdvancedRenderingControls', + uiType: 'ohif.advancedRenderingControls', + props: { + buttonSection: 'advancedRenderingControlsSection', + }, + }, + { + id: 'modalityLoadBadge', + uiType: 'ohif.modalityLoadBadge', + props: { + icon: 'Status', + label: 'Status', + tooltip: 'Status', + evaluate: { + name: 'evaluate.modalityLoadBadge', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'Colorbar', + uiType: 'ohif.colorbar', + props: { + type: 'tool', + label: 'Colorbar', + }, + }, + { + id: 'navigationComponent', + uiType: 'ohif.navigationComponent', + props: { + icon: 'Navigation', + label: 'Navigation', + tooltip: 'Navigate between segments/measurements and manage their visibility', + evaluate: { + name: 'evaluate.navigationComponent', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'windowLevelMenuEmbedded', + uiType: 'ohif.windowLevelMenuEmbedded', + props: { + icon: 'WindowLevel', + label: 'Window Level', + tooltip: 'Adjust window/level presets and customize image contrast settings', + evaluate: { + name: 'evaluate.windowLevelMenuEmbedded', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'trackingStatus', + uiType: 'ohif.trackingStatus', + props: { + icon: 'TrackingStatus', + label: 'Tracking Status', + tooltip: 'View and manage tracking status of measurements and annotations', + evaluate: { + name: 'evaluate.trackingStatus', + hideWhenDisabled: true, + }, }, }, { @@ -295,6 +359,76 @@ const toolbarButtons = [ ], }, }, + { + id: 'dataOverlayMenu', + uiType: 'ohif.dataOverlayMenu', + props: { + icon: 'ViewportViews', + label: 'Data Overlay', + tooltip: 'Configure data overlay options and manage foreground/background display sets', + evaluate: 'evaluate.dataOverlayMenu', + }, + }, + { + id: 'orientationMenu', + uiType: 'ohif.orientationMenu', + props: { + icon: 'OrientationSwitch', + label: 'Orientation', + tooltip: + 'Change viewport orientation between axial, sagittal, coronal and acquisition planes', + evaluate: { + name: 'evaluate.orientationMenu', + // hideWhenDisabled: true, + }, + }, + }, + { + id: 'windowLevelMenu', + uiType: 'ohif.windowLevelMenu', + props: { + icon: 'WindowLevel', + label: 'Window Level', + tooltip: 'Adjust window/level presets and customize image contrast settings', + evaluate: 'evaluate.windowLevelMenu', + }, + }, + { + id: 'voiManualControlMenu', + uiType: 'ohif.voiManualControlMenu', + props: { + icon: 'WindowLevelAdvanced', + label: 'Advanced Window Level', + tooltip: 'Advanced window/level settings with manual controls and presets', + evaluate: 'evaluate.voiManualControlMenu', + }, + }, + { + id: 'thresholdMenu', + uiType: 'ohif.thresholdMenu', + props: { + icon: 'Threshold', + label: 'Threshold', + tooltip: 'Image threshold settings', + evaluate: { + name: 'evaluate.thresholdMenu', + hideWhenDisabled: true, + }, + }, + }, + { + id: 'opacityMenu', + uiType: 'ohif.opacityMenu', + props: { + icon: 'Opacity', + label: 'Opacity', + tooltip: 'Image opacity settings', + evaluate: { + name: 'evaluate.opacityMenu', + hideWhenDisabled: true, + }, + }, + }, ]; export default toolbarButtons; diff --git a/package.json b/package.json index 735bbbf5c..7269c9eb6 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "optionalDependencies": { "@percy/cypress": "^3.1.1", "@playwright/test": "^1.48.0", - "cypress": "^14.1.0", + "cypress": "14.3.1", "cypress-file-upload": "^5.0.8" }, "devDependencies": { diff --git a/platform/app/package.json b/platform/app/package.json index 06ec22058..b9ca5e12d 100644 --- a/platform/app/package.json +++ b/platform/app/package.json @@ -53,7 +53,7 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", - "@cornerstonejs/dicom-image-loader": "^3.12.2", + "@cornerstonejs/dicom-image-loader": "^3.15.1", "@emotion/serialize": "^1.1.3", "@ohif/core": "3.11.0-beta.32", "@ohif/extension-cornerstone": "3.11.0-beta.32", diff --git a/platform/app/src/App.tsx b/platform/app/src/App.tsx index 4963b494b..5e1be45fa 100644 --- a/platform/app/src/App.tsx +++ b/platform/app/src/App.tsx @@ -13,6 +13,7 @@ import { HotkeysManager, ServiceProvidersManager, SystemContextProvider, + ViewportRefsProvider, } from '@ohif/core'; import { ThemeWrapper as ThemeWrapperNext, @@ -26,7 +27,6 @@ import { ModalProvider, ViewportDialogProvider, UserAuthenticationProvider, - ViewportActionCornersProvider, } from '@ohif/ui-next'; // Viewer Project // TODO: Should this influence study list? @@ -105,7 +105,6 @@ function App({ userAuthenticationService, uiNotificationService, customizationService, - viewportActionCornersService, } = servicesManager.services; const providers = [ @@ -114,6 +113,7 @@ function App({ [I18nextProvider, { i18n }], [ThemeWrapperNext], [SystemContextProvider, { commandsManager, extensionManager, hotkeysManager, servicesManager }], + [ViewportRefsProvider], [ViewportGridProvider, { service: viewportGridService }], [ViewportDialogProvider, { service: uiViewportDialogService }], [CineProvider, { service: cineService }], @@ -121,7 +121,6 @@ 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 7687d5c4b..67563ed5e 100644 --- a/platform/app/src/components/ViewportGrid.tsx +++ b/platform/app/src/components/ViewportGrid.tsx @@ -4,13 +4,11 @@ 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; @@ -419,12 +417,6 @@ function ViewerViewportGrid(props: withAppTypes) { isHangingProtocolLayout={isHangingProtocolLayout} 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); - } }} /> @@ -433,7 +425,7 @@ function ViewerViewportGrid(props: withAppTypes) { } return viewportPanes; - }, [viewports, activeViewportId, viewportComponents, dataSource, initializeViewportCorners]); + }, [viewports, activeViewportId, viewportComponents, dataSource]); /** * 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 44163c188..29c94cee1 100644 --- a/platform/app/src/hooks/index.js +++ b/platform/app/src/hooks/index.js @@ -1,5 +1,4 @@ import useDebounce from './useDebounce'; import useSearchParams from './useSearchParams'; -import useViewportActionCornersWithGrid from './useViewportActionCornersWithGrid'; -export { useDebounce, useSearchParams, useViewportActionCornersWithGrid }; +export { useDebounce, useSearchParams }; diff --git a/platform/app/src/hooks/index.ts b/platform/app/src/hooks/index.ts index 2eb9011e0..29c94cee1 100644 --- a/platform/app/src/hooks/index.ts +++ b/platform/app/src/hooks/index.ts @@ -1,5 +1,4 @@ -import useViewportActionCornersWithGrid from './useViewportActionCornersWithGrid'; import useDebounce from './useDebounce'; import useSearchParams from './useSearchParams'; -export { useViewportActionCornersWithGrid, useDebounce, useSearchParams }; +export { useDebounce, useSearchParams }; diff --git a/platform/app/src/hooks/useViewportActionCornersWithGrid.ts b/platform/app/src/hooks/useViewportActionCornersWithGrid.ts deleted file mode 100644 index d0f75092c..000000000 --- a/platform/app/src/hooks/useViewportActionCornersWithGrid.ts +++ /dev/null @@ -1,112 +0,0 @@ -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 94f1a1e34..328887d5b 100644 --- a/platform/app/src/routes/Mode/Mode.tsx +++ b/platform/app/src/routes/Mode/Mode.tsx @@ -83,10 +83,10 @@ export default function ModeRoute({ extensionManager.setActiveDataSource(dataSourceName); } - const dataSource = extensionManager.getActiveDataSource()[0]; + const dataSource = extensionManager.getActiveDataSourceOrNull(); // Only handling one route per mode for now - const route = mode.routes[0]; + const route = mode.routes?.[0] ?? null; useEffect(() => { const loadExtensions = async () => { diff --git a/platform/cli/templates/mode/src/index.tsx b/platform/cli/templates/mode/src/index.tsx index c3cd36805..bcb637b45 100644 --- a/platform/cli/templates/mode/src/index.tsx +++ b/platform/cli/templates/mode/src/index.tsx @@ -48,8 +48,8 @@ function modeFactory({ modeConfiguration }) { // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager); - toolbarService.addButtons([...toolbarButtons, ...moreTools]); - toolbarService.createButtonSection('primary', [ + toolbarService.register([...toolbarButtons, ...moreTools]); + toolbarService.updateSection('primary', [ 'measurementSection', 'Zoom', 'Pan', @@ -61,7 +61,7 @@ function modeFactory({ modeConfiguration }) { 'moreToolsSection', ]); - toolbarService.createButtonSection('measurementSection', [ + toolbarService.updateSection('measurementSection', [ 'Length', 'Bidirectional', 'ArrowAnnotate', @@ -73,7 +73,7 @@ function modeFactory({ modeConfiguration }) { 'LivewireContour', ]); - toolbarService.createButtonSection('moreToolsSection', [ + toolbarService.updateSection('moreToolsSection', [ 'Reset', 'rotate-right', 'flipHorizontal', diff --git a/platform/core/package.json b/platform/core/package.json index 3e62fe183..db02662b0 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -37,8 +37,8 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", - "@cornerstonejs/core": "^3.12.2", - "@cornerstonejs/dicom-image-loader": "^3.12.2", + "@cornerstonejs/core": "^3.15.1", + "@cornerstonejs/dicom-image-loader": "^3.15.1", "@ohif/ui": "3.11.0-beta.32", "cornerstone-math": "0.1.9", "dicom-parser": "^1.8.21" diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index 20e67f90a..677f8fe68 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -388,6 +388,10 @@ export default class ExtensionManager extends PubSubService { return this.dataSourceMap[this._activeDataSourceName]; }; + getActiveDataSourceOrNull = () => { + return this.dataSourceMap[this._activeDataSourceName]?.[0] ?? null; + }; + /** * Gets the data source definition for the given data source name. * If no data source name is provided, the active data source definition is diff --git a/platform/core/src/hooks/index.ts b/platform/core/src/hooks/index.ts new file mode 100644 index 000000000..eb47ffed4 --- /dev/null +++ b/platform/core/src/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './useToolbar'; +export * from './types'; +export * from './useViewportRef'; +export * from './useViewportSize'; +export * from './useViewportMousePosition'; diff --git a/platform/core/src/hooks/types.ts b/platform/core/src/hooks/types.ts new file mode 100644 index 000000000..6c93fc58e --- /dev/null +++ b/platform/core/src/hooks/types.ts @@ -0,0 +1,27 @@ +export interface ToolbarButtonActions { + // Lock/Unlock actions + lockItem: (itemId: string, viewportId?: string) => void; + unlockItem: (itemId: string, viewportId?: string) => void; + toggleLock: (itemId: string, viewportId?: string) => void; + isItemLocked: (itemId: string, viewportId?: string) => boolean; + + // Visibility actions + showItem: (itemId: string, viewportId?: string) => void; + hideItem: (itemId: string, viewportId?: string) => void; + toggleVisibility: (itemId: string, viewportId?: string) => void; + isItemVisible: (itemId: string, viewportId?: string) => boolean; + + // Open/Close actions (for menus) + openItem: (itemId: string, viewportId?: string) => void; + closeItem: (itemId: string, viewportId?: string) => void; + closeAllItems: (viewportId?: string) => void; + isItemOpen: (itemId: string, viewportId?: string) => boolean; + + // Evaluation + evaluateButtonForViewport: (itemId: string, viewportId?: string) => any; +} + +export interface ToolbarHookReturn extends ToolbarButtonActions { + toolbarButtons: any[]; // The display representation of toolbar buttons + onInteraction: (args: any) => void; +} diff --git a/platform/core/src/hooks/useActiveViewportDisplaySets.ts b/platform/core/src/hooks/useActiveViewportDisplaySets.ts index 895e0aedc..1314fff21 100644 --- a/platform/core/src/hooks/useActiveViewportDisplaySets.ts +++ b/platform/core/src/hooks/useActiveViewportDisplaySets.ts @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from 'react'; import { DisplaySet } from '../types'; - +import { useSystem } from '../'; /** * Hook that listens for changes in the active viewport and its display sets. * It returns the display sets associated with the active viewport. @@ -8,7 +8,8 @@ import { DisplaySet } from '../types'; * @param servicesManager - Services manager instance * @returns Array of display sets for the active viewport */ -const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => { +const useActiveViewportDisplaySets = (): DisplaySet[] => { + const { servicesManager } = useSystem(); const { displaySetService, viewportGridService } = servicesManager.services; // Move this function outside useEffect and memoize it const getDisplaySetsForViewport = useCallback( diff --git a/platform/core/src/hooks/useToolbar.tsx b/platform/core/src/hooks/useToolbar.tsx index 4ca4e8fa4..33c2fe011 100644 --- a/platform/core/src/hooks/useToolbar.tsx +++ b/platform/core/src/hooks/useToolbar.tsx @@ -1,20 +1,26 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useMemo } from 'react'; import { useSystem } from '../contextProviders/SystemProvider'; +import { ToolbarHookReturn } from './types'; -export function useToolbar({ buttonSection = 'primary' }: withAppTypes) { +export function useToolbar({ buttonSection = 'primary' }: withAppTypes): ToolbarHookReturn { const { commandsManager, servicesManager } = useSystem(); const { toolbarService, viewportGridService } = servicesManager.services; const { EVENTS } = toolbarService; + // Store all buttons returned by the toolbar service const [toolbarButtons, setToolbarButtons] = useState( - toolbarService.getButtonSection(buttonSection as string) + toolbarService.getButtonSection(buttonSection as string).filter(Boolean) ); + // Store state of open/closed menu items + // Note: We keep this in local state to avoid re-evaluating the toolbar on every interaction + const [openItemIds, setOpenItemIds] = useState>({}); + // Callback function for handling toolbar interactions const onInteraction = useCallback( args => { args.event?.stopPropagation?.(); - const viewportId = viewportGridService.getActiveViewportId(); + const viewportId = args.viewportId || viewportGridService.getActiveViewportId(); const refreshProps = { viewportId }; const buttonProps = toolbarService.getButtonProps(args.itemId); @@ -58,15 +64,17 @@ export function useToolbar({ buttonSection = 'primary' }: withAppTypes) { buttonProps.commands = allCommands; } + toolbarService.recordInteraction({ ...args, ...buttonProps }, { refreshProps }); }, - [toolbarService, viewportGridService, toolbarButtons] + [toolbarService, viewportGridService] ); // Effect to handle toolbar modification events useEffect(() => { const handleToolbarModified = () => { - setToolbarButtons(toolbarService.getButtonSection(buttonSection as string)?.filter(Boolean)); + const buttons = toolbarService.getButtonSection(buttonSection as string)?.filter(Boolean); + setToolbarButtons(buttons); }; const subs = [EVENTS.TOOL_BAR_MODIFIED, EVENTS.TOOL_BAR_STATE_MODIFIED].map(event => { @@ -96,5 +104,163 @@ export function useToolbar({ buttonSection = 'primary' }: withAppTypes) { return () => subscriptions.forEach(sub => sub.unsubscribe()); }, [viewportGridService, toolbarService]); - return { toolbarButtons, onInteraction }; + // Action API for toolbar buttons + const actions = useMemo(() => { + return { + // Lock/Unlock actions + lockItem: (itemId: string, viewportId?: string) => { + const targetViewportId = viewportId || viewportGridService.getActiveViewportId(); + const button = toolbarService.getButton(itemId); + + if (button) { + // Set isLocked flag in button metadata + button.props = { + ...button.props, + isLocked: true, + }; + + // Re-evaluate the button + toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId }); + } + }, + + unlockItem: (itemId: string, viewportId?: string) => { + const targetViewportId = viewportId || viewportGridService.getActiveViewportId(); + const button = toolbarService.getButton(itemId); + + if (button) { + // Set isLocked flag in button metadata + button.props = { + ...button.props, + isLocked: false, + }; + + // Re-evaluate the button + toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId }); + } + }, + + toggleLock: (itemId: string, viewportId?: string) => { + const targetViewportId = viewportId || viewportGridService.getActiveViewportId(); + const button = toolbarService.getButton(itemId); + + if (button) { + // Toggle isLocked flag in button metadata + button.props = { + ...button.props, + isLocked: !button.props.isLocked, + }; + + // Re-evaluate the button + toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId }); + } + }, + + isItemLocked: (itemId: string, viewportId?: string): boolean => { + const button = toolbarService.getButton(itemId); + return button?.props?.isLocked === true; + }, + + // Visibility actions - controlled by evaluator functions in toolbar items + showItem: (itemId: string, viewportId?: string) => { + const targetViewportId = viewportId || viewportGridService.getActiveViewportId(); + const button = toolbarService.getButton(itemId); + + if (button) { + // Set isVisible flag in button metadata + button.props = { + ...button.props, + isVisible: true, + }; + + // Re-evaluate the button + toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId }); + } + }, + + hideItem: (itemId: string, viewportId?: string) => { + const targetViewportId = viewportId || viewportGridService.getActiveViewportId(); + const button = toolbarService.getButton(itemId); + + if (button) { + // Set isVisible flag in button metadata + button.props = { + ...button.props, + isVisible: false, + }; + + // Re-evaluate the button + toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId }); + } + }, + + toggleVisibility: (itemId: string, viewportId?: string) => { + const targetViewportId = viewportId || viewportGridService.getActiveViewportId(); + const button = toolbarService.getButton(itemId); + + if (button) { + // Toggle isVisible flag in button metadata + button.props = { + ...button.props, + isVisible: button.props.isVisible === false ? true : false, + }; + + // Re-evaluate the button + toolbarService.refreshToolbarState({ viewportId: targetViewportId, itemId }); + } + }, + + isItemVisible: (itemId: string, viewportId?: string): boolean => { + const button = toolbarService.getButton(itemId); + // If isVisible is explicitly false, return false; otherwise return true + return button?.props?.isVisible !== false; + }, + + // Open/Close actions - managed in local state for performance + openItem: (itemId: string, viewportId?: string) => { + setOpenItemIds(prev => { + // Close all other items + const updated = {}; + // Then set the current item to open + updated[itemId] = true; + return updated; + }); + }, + + closeItem: (itemId: string, viewportId?: string) => { + setOpenItemIds(prev => ({ + ...prev, + [itemId]: false, + })); + }, + + closeAllItems: (viewportId?: string) => { + setOpenItemIds({}); + }, + + isItemOpen: (itemId: string, viewportId?: string): boolean => { + return openItemIds[itemId] === true; + }, + }; + }, [viewportGridService, toolbarService, openItemIds]); + + if (!toolbarButtons?.length) { + return { + toolbarButtons: [], + onInteraction, + ...actions, + }; + } + + // filter out buttons that are disabled and have hideWhenDisabled set to true + const filteredToolbarButtons = toolbarButtons.filter(button => { + const props = button.componentProps; + return props.visible !== false; + }); + + return { + toolbarButtons: filteredToolbarButtons, + onInteraction, + ...actions, + }; } diff --git a/platform/core/src/hooks/useViewportMousePosition.ts b/platform/core/src/hooks/useViewportMousePosition.ts new file mode 100644 index 000000000..60fa12c80 --- /dev/null +++ b/platform/core/src/hooks/useViewportMousePosition.ts @@ -0,0 +1,67 @@ +import { useState, useEffect } from 'react'; +import { useViewportRef } from './useViewportRef'; +import { useViewportSize } from './useViewportSize'; + +interface MousePosition { + x: number; + y: number; + isInViewport: boolean; + relativeY: number; // Position as percentage from top (0-1) + isInBottomPercentage: (percentage: number) => boolean; +} + +function useViewportMousePosition(viewportId: string): MousePosition { + const viewportRef = useViewportRef(viewportId); + const { height, clientRect } = useViewportSize(viewportId); + + const [mousePosition, setMousePosition] = useState({ + x: 0, + y: 0, + isInViewport: false, + relativeY: 0, + isInBottomPercentage: (percentage: number) => false, + }); + + useEffect(() => { + if (!viewportRef.current) { + return; + } + + const handleMouseMove = (event: MouseEvent) => { + if (!clientRect) { + return; + } + + // Get mouse position relative to viewport + const x = event.clientX - clientRect.left; + const y = event.clientY - clientRect.top; + + const isInViewport = x >= 0 && x <= clientRect.width && y >= 0 && y <= clientRect.height; + + const relativeY = Math.max(0, Math.min(1, y / height)); + + const isInBottomPercentage = (percentage: number) => { + return relativeY >= 1 - percentage / 100; + }; + + setMousePosition({ + x, + y, + isInViewport, + relativeY, + isInBottomPercentage, + }); + }; + + document.addEventListener('mousemove', handleMouseMove); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + }; + }, [viewportRef, height, clientRect]); + + return mousePosition; +} + +export default useViewportMousePosition; +export { useViewportMousePosition }; diff --git a/platform/core/src/hooks/useViewportRef.ts b/platform/core/src/hooks/useViewportRef.ts new file mode 100644 index 000000000..d6fd4eaba --- /dev/null +++ b/platform/core/src/hooks/useViewportRef.ts @@ -0,0 +1,58 @@ +import React, { createContext, useContext, useRef } from 'react'; + +type ViewportRefsContextType = { + registerViewport: (viewportId: string, element: HTMLElement) => void; + unregisterViewport: (viewportId: string) => void; + getViewportElement: (viewportId: string) => HTMLElement | null; + viewportRefs: Map; +}; + +// comment +const ViewportRefsContext = createContext(undefined); + +export const ViewportRefsProvider = ({ children }: { children: React.ReactNode }) => { + const viewportRefsRef = useRef>(new Map()); + + const registerViewport = (viewportId: string, element: HTMLElement) => { + viewportRefsRef.current.set(viewportId, element); + }; + + const unregisterViewport = (viewportId: string) => { + viewportRefsRef.current.delete(viewportId); + }; + + const getViewportElement = (viewportId: string): HTMLElement | null => { + return viewportRefsRef.current.get(viewportId) || null; + }; + + const contextValue: ViewportRefsContextType = { + registerViewport, + unregisterViewport, + getViewportElement, + viewportRefs: viewportRefsRef.current, + }; + + return React.createElement(ViewportRefsContext.Provider, { value: contextValue }, children); +}; + +export const useViewportRefs = () => { + const context = useContext(ViewportRefsContext); + + if (context === undefined) { + throw new Error('useViewportRefs must be used within a ViewportRefsProvider'); + } + + return context; +}; + +export const useViewportRef = (viewportId: string) => { + const { registerViewport, unregisterViewport, getViewportElement } = useViewportRefs(); + + const ref = { + current: getViewportElement(viewportId), + register: (element: HTMLElement) => registerViewport(viewportId, element), + unregister: () => unregisterViewport(viewportId), + }; + + return ref; +}; diff --git a/platform/core/src/hooks/useViewportSize.ts b/platform/core/src/hooks/useViewportSize.ts new file mode 100644 index 000000000..6ffe60bd7 --- /dev/null +++ b/platform/core/src/hooks/useViewportSize.ts @@ -0,0 +1,97 @@ +import { useEffect, useState, useCallback, useMemo } from 'react'; +import { useViewportRef } from './'; + +interface ViewportSize { + width: number; + height: number; + offsetLeft: number; + offsetTop: number; + clientRect: DOMRect | null; + isVisible: boolean; +} + +/** + * Hook that provides viewport size dimensions and monitors for changes + * @param viewportId - The ID of the viewport to monitor + * @returns ViewportSize object containing width, height, and visibility info + */ +function useViewportSize(viewportId: string): ViewportSize { + const viewportElementRef = useViewportRef(viewportId); + + const [size, setSize] = useState({ + width: 0, + height: 0, + offsetLeft: 0, + offsetTop: 0, + clientRect: null, + isVisible: false, + }); + + // Update viewport dimensions + const updateViewportSize = useCallback(() => { + if (!viewportElementRef?.current) { + return; + } + + const element = viewportElementRef.current; + const clientRect = element.getBoundingClientRect(); + const newWidth = clientRect.width; + const newHeight = clientRect.height; + const newOffsetLeft = element.offsetLeft; + const newOffsetTop = element.offsetTop; + const newIsVisible = newWidth > 0 && newHeight > 0; + + setSize(prevSize => { + if ( + prevSize.width === newWidth && + prevSize.height === newHeight && + prevSize.offsetLeft === newOffsetLeft && + prevSize.offsetTop === newOffsetTop && + prevSize.isVisible === newIsVisible + ) { + return prevSize; + } + + return { + width: newWidth, + height: newHeight, + offsetLeft: newOffsetLeft, + offsetTop: newOffsetTop, + clientRect, + isVisible: newIsVisible, + }; + }); + }, [viewportElementRef]); + + useEffect(() => { + if (!viewportId || !viewportElementRef?.current) { + return; + } + + updateViewportSize(); + + const resizeObserver = new ResizeObserver(() => { + updateViewportSize(); + }); + + resizeObserver.observe(viewportElementRef.current); + + window.addEventListener('resize', updateViewportSize); + + return () => { + window.removeEventListener('resize', updateViewportSize); + + if (viewportElementRef.current) { + resizeObserver.unobserve(viewportElementRef.current); + } + resizeObserver.disconnect(); + }; + }, [viewportId, viewportElementRef, updateViewportSize]); + + const memoizedSize = useMemo(() => size, [size]); + + return memoizedSize; +} + +export default useViewportSize; +export { useViewportSize }; diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index b9d8719f5..134f471d2 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -2,6 +2,7 @@ import { ExtensionManager, MODULE_TYPES } from './extensions'; import { ServiceProvidersManager, ServicesManager } from './services'; import classes, { CommandsManager, HotkeysManager } from './classes'; import { SystemContextProvider, useSystem } from './contextProviders/SystemProvider'; +import { ViewportRefsProvider } from './hooks/useViewportRef'; import DICOMWeb from './DICOMWeb'; import errorHandler from './errorHandler.js'; @@ -13,7 +14,6 @@ import utils from './utils'; import defaults from './defaults'; import * as Types from './types'; import * as Enums from './enums'; -import { useToolbar } from './hooks/useToolbar'; import { CineService, UIDialogService, @@ -42,6 +42,8 @@ import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetS import IWebApiDataSource from './DataSources/IWebApiDataSource'; import useActiveViewportDisplaySets from './hooks/useActiveViewportDisplaySets'; +export * from './hooks'; + const hotkeys = { ...utils.hotkeys, defaults: { hotkeyBindings: defaults.hotkeyBindings }, @@ -86,7 +88,6 @@ const OHIF = { pubSubServiceInterface, PubSubService, PanelService, - useToolbar, useActiveViewportDisplaySets, WorkflowStepsService, StudyPrefetcherService, @@ -101,6 +102,7 @@ export { ServicesManager, ServiceProvidersManager, SystemContextProvider, + ViewportRefsProvider, // defaults, utils, @@ -137,7 +139,6 @@ export { WorkflowStepsService, StudyPrefetcherService, useSystem, - useToolbar, useActiveViewportDisplaySets, }; diff --git a/platform/core/src/services/ToolBarService/ToolbarService.ts b/platform/core/src/services/ToolBarService/ToolbarService.ts index 7edf04bba..99252b29e 100644 --- a/platform/core/src/services/ToolBarService/ToolbarService.ts +++ b/platform/core/src/services/ToolBarService/ToolbarService.ts @@ -2,13 +2,68 @@ import { CommandsManager } from '../../classes'; import { ExtensionManager } from '../../extensions'; import { PubSubService } from '../_shared/pubSubServiceInterface'; import type { RunCommand } from '../../types/Command'; -import { Button, ButtonProps, EvaluateFunction, EvaluatePublic, NestedButtonProps } from './types'; +import { Button, ButtonProps, EvaluateFunction, EvaluatePublic } from './types'; const EVENTS = { TOOL_BAR_MODIFIED: 'event::toolBarService:toolBarModified', TOOL_BAR_STATE_MODIFIED: 'event::toolBarService:toolBarStateModified', }; +/** + * Predefined toolbar sections used throughout the application + */ +export const TOOLBAR_SECTIONS = { + /** + * Main toolbar + */ + primary: 'primary', + + /** + * Secondary toolbar + */ + secondary: 'secondary', + + /** + * Viewport action menu sections + */ + viewportActionMenu: { + topLeft: 'viewportActionMenu.topLeft', + topRight: 'viewportActionMenu.topRight', + bottomLeft: 'viewportActionMenu.bottomLeft', + bottomRight: 'viewportActionMenu.bottomRight', + topMiddle: 'viewportActionMenu.topMiddle', + bottomMiddle: 'viewportActionMenu.bottomMiddle', + leftMiddle: 'viewportActionMenu.leftMiddle', + rightMiddle: 'viewportActionMenu.rightMiddle', + }, + + /** + * Measurement tools section + */ + measurementSection: 'measurementSection', + + /** + * More tools section + */ + moreToolsSection: 'moreToolsSection', + + /** + * Advanced rendering controls section + */ + advancedRenderingControlsSection: 'advancedRenderingControlsSection', +}; + +export enum ButtonLocation { + TopLeft = 0, + TopMiddle = 1, + TopRight = 2, + LeftMiddle = 3, + RightMiddle = 4, + BottomLeft = 5, + BottomMiddle = 6, + BottomRight = 7, +} + export default class ToolbarService extends PubSubService { public static REGISTRATION = { name: 'toolbarService', @@ -18,6 +73,13 @@ export default class ToolbarService extends PubSubService { }, }; + /** + * Access to predefined toolbar sections for autocomplete support + */ + public get sections() { + return TOOLBAR_SECTIONS; + } + public static createButton(options: { id: string; label: string; @@ -130,7 +192,7 @@ export default class ToolbarService extends PubSubService { * @param buttons - The buttons to be added. * @param replace - Flag indicating if any existing button with the same id as one being added should be replaced */ - public addButtons(buttons: Button[], replace: boolean = false): void { + public register(buttons: Button[], replace: boolean = false): void { buttons.forEach(button => { if (replace || !this.state.buttons[button.id]) { if (!button.props) { @@ -231,7 +293,8 @@ export default class ToolbarService extends PubSubService { * which buttons to evaluate based on the props */ public refreshToolbarState(refreshProps) { - const buttons = this.state.buttons; + const originalButtons = this.state.buttons; + const updatedButtons = { ...originalButtons }; const evaluationResults = new Map(); const evaluateButtonProps = (button, props, refreshProps) => { @@ -239,6 +302,7 @@ export default class ToolbarService extends PubSubService { const { disabled, disabledText, className, isActive } = evaluationResults.get(button.id); return { ...props, disabled, disabledText, className, isActive }; } else { + const evaluateProps = props.evaluateProps; const evaluated = typeof props.evaluate === 'function' ? props.evaluate({ ...refreshProps, button }) @@ -247,6 +311,7 @@ export default class ToolbarService extends PubSubService { ...props, ...evaluated, disabled: evaluated?.disabled || false, + visible: evaluateProps?.hideWhenDisabled && evaluated?.disabled ? false : true, className: evaluated?.className || '', isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop }; @@ -255,7 +320,14 @@ export default class ToolbarService extends PubSubService { } }; - Object.values(buttons).forEach(button => { + const updatedIds = new Set(); + Object.values(originalButtons).forEach(button => { + // Note: do not re-evaluate buttons that have already been evaluated + // this will result in inconsistencies in the toolbar state + if (updatedIds.has(button.id)) { + return; + } + const hasSection = (button.props as NestedButtonProps)?.buttonSection; if (!hasSection) { @@ -263,10 +335,12 @@ export default class ToolbarService extends PubSubService { const buttonProps = button.props as ButtonProps; const updatedProps = evaluateButtonProps(button, buttonProps, refreshProps); - buttons[button.id] = { + updatedButtons[button.id] = { ...button, props: updatedProps, }; + + updatedIds.add(button.id); } else { let buttonProps = button.props as NestedButtonProps; const { evaluate: groupEvaluate } = buttonProps; @@ -288,17 +362,27 @@ export default class ToolbarService extends PubSubService { } toolButtonIds.forEach(buttonId => { - const button = buttons[buttonId]; + const button = originalButtons[buttonId]; + if (!button) { + return; + } + + if (updatedIds.has(buttonId)) { + return; + } + const updatedProps = evaluateButtonProps(button, button.props, refreshProps); - buttons[buttonId] = { + updatedButtons[buttonId] = { ...button, props: updatedProps, }; + + updatedIds.add(buttonId); }); } }); - this.setButtons(buttons); + this.setButtons(updatedButtons); return this.state; } @@ -373,7 +457,7 @@ export default class ToolbarService extends PubSubService { * @param {string} key - The key of the button section. * @param {Array} buttons - The buttons to be added to the section. */ - createButtonSection(key, buttons) { + updateSection(key, buttons) { if (this.state.buttonSections[key]) { this.state.buttonSections[key].push( ...buttons.filter( @@ -443,7 +527,7 @@ export default class ToolbarService extends PubSubService { } const { id, uiType } = btn; - const { groupId } = btn.props as NestedButtonProps; + const { buttonSection } = btn.props; const buttonTypes = this._getButtonUITypes(); @@ -457,7 +541,7 @@ export default class ToolbarService extends PubSubService { return; } - !groupId ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props); + !buttonSection ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props); const { id: buttonId, props: componentProps } = btn; @@ -587,6 +671,7 @@ export default class ToolbarService extends PubSubService { return evaluateFunction; }); + const evaluateProps = props.evaluate; props.evaluate = args => { const results = evaluators.map(evaluator => evaluator(args)).filter(Boolean); @@ -607,6 +692,8 @@ export default class ToolbarService extends PubSubService { return mergedResult; }; + props.evaluateProps = evaluateProps; + return; } @@ -627,7 +714,9 @@ export default class ToolbarService extends PubSubService { const { name, ...options } = evaluate; const evaluateFunction = this._evaluateFunction[name]; if (evaluateFunction) { + const evaluateProps = props.evaluate; props.evaluate = args => evaluateFunction({ ...args, ...options }); + props.evaluateProps = evaluateProps; return; } @@ -645,4 +734,56 @@ export default class ToolbarService extends PubSubService { this.state.buttonSections[buttonSection] = []; this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state }); } + + /** + * Checks if a button exists in any toolbar section. + * + * @param buttonId - The button ID to check for + * @returns True if the button exists in any section, false otherwise + */ + isInAnySection(buttonId: string): boolean { + if (!buttonId) { + return false; + } + + // Check all sections to see if the button ID exists in any of them + return Object.values(this.state.buttonSections).some( + section => Array.isArray(section) && section.includes(buttonId) + ); + } + + /** + * Returns the alignment and side for a specific viewport corner location. + * Used for menu positioning based on the corner location. + * + * @param location - The viewport corner location + * @returns An object with align and side properties + */ + public getAlignAndSide(location: ButtonLocation | string): { + align: 'start' | 'end' | 'center'; + side: 'top' | 'bottom' | 'left' | 'right'; + } { + const locationNumber = Number(location); + switch (locationNumber) { + case ButtonLocation.TopLeft: // Enum 0, Original 0 (topLeft) + return { align: 'start', side: 'bottom' }; + case ButtonLocation.TopMiddle: // Enum 1, Original 4 (topMiddle) + return { align: 'center', side: 'bottom' }; + case ButtonLocation.TopRight: // Enum 2, Original 1 (topRight) + return { align: 'end', side: 'bottom' }; + case ButtonLocation.LeftMiddle: // Enum 3, Original 6 (leftMiddle) + return { align: 'start', side: 'right' }; + case ButtonLocation.RightMiddle: // Enum 4, Original 7 (rightMiddle) + return { align: 'end', side: 'left' }; + case ButtonLocation.BottomLeft: // Enum 5, Original 2 (bottomLeft) + return { align: 'start', side: 'top' }; + case ButtonLocation.BottomMiddle: // Enum 6, Original 5 (bottomMiddle) + return { align: 'center', side: 'top' }; + case ButtonLocation.BottomRight: // Enum 7, Original 3 (bottomRight) + return { align: 'end', side: 'top' }; + default: + // Default to TopLeft behavior if an unexpected value is passed. + return { align: 'start', side: 'bottom' }; + } + } } diff --git a/platform/core/src/services/ToolBarService/index.ts b/platform/core/src/services/ToolBarService/index.ts index 0147896b4..0ff1702a7 100644 --- a/platform/core/src/services/ToolBarService/index.ts +++ b/platform/core/src/services/ToolBarService/index.ts @@ -1,3 +1,3 @@ -import ToolbarService from './ToolbarService'; - +import ToolbarService, { TOOLBAR_SECTIONS } from './ToolbarService'; export default ToolbarService; +export { TOOLBAR_SECTIONS }; diff --git a/platform/core/src/services/ToolBarService/types.ts b/platform/core/src/services/ToolBarService/types.ts index ed424a2ea..02ed1afe6 100644 --- a/platform/core/src/services/ToolBarService/types.ts +++ b/platform/core/src/services/ToolBarService/types.ts @@ -1,6 +1,22 @@ import type { RunCommand } from '../../types/Command'; import React from 'react'; +/** + * Type definitions for toolbar sections + */ +export type ToolbarSections = { + primary: string; + secondary: string; + viewportActionMenu: { + topLeft: string; + topRight: string; + bottomLeft: string; + bottomRight: string; + }; + measurementSection: string; + moreToolsSection: string; +}; + export type EvaluatePublic = | string | EvaluateFunction diff --git a/platform/core/src/services/WorkflowStepsService/WorkflowStepsService.ts b/platform/core/src/services/WorkflowStepsService/WorkflowStepsService.ts index 33a9b5449..ee2a0c6ab 100644 --- a/platform/core/src/services/WorkflowStepsService/WorkflowStepsService.ts +++ b/platform/core/src/services/WorkflowStepsService/WorkflowStepsService.ts @@ -137,7 +137,7 @@ class WorkflowStepsService extends PubSubService { toUse.forEach(({ buttonSection, buttons }) => { toolbarService.clearButtonSection(buttonSection); - toolbarService.createButtonSection(buttonSection, buttons); + toolbarService.updateSection(buttonSection, buttons); }); } diff --git a/platform/core/src/types/DisplaySet.ts b/platform/core/src/types/DisplaySet.ts index 2c410d8ab..c2061c41c 100644 --- a/platform/core/src/types/DisplaySet.ts +++ b/platform/core/src/types/DisplaySet.ts @@ -35,9 +35,19 @@ export type DisplaySet = { */ renderedUrl?: string; + /** + * The instance UID of the display set that this display set references. + * This is used to determine if the display set is a referenced display set. + * It usually is for SEG, RTSTRUCT, etc. + */ + referencedDisplaySetInstanceUID?: string; + SeriesDate?: string; SeriesTime?: string; instance?: InstanceMetadata; + + isHydrated?: boolean; + isRehydratable?: boolean; }; export type DisplaySetSeriesMetadataInvalidatedEvent = { diff --git a/platform/docs/docs/migration-guide/3p10-to-3p11/commands.md b/platform/docs/docs/migration-guide/3p10-to-3p11/commands.md new file mode 100644 index 000000000..d38b52f52 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p10-to-3p11/commands.md @@ -0,0 +1,72 @@ +--- +sidebar_position: 4 +sidebar_label: Commands +summary: Migration guide for OHIF 3.11's different commands +--- + + + +## updateStoredPositionPresentation + +now uses displaySetInstanceUIDs instead of displaySetInstanceUID as a parameter. + + +## `loadSRMeasurements` Command + +**Key Changes:** + +* The `loadSRMeasurements` command, previously part of the `@ohif/extension-cornerstone-dicom-sr` extension, has been **removed**. +* Its functionality of hydrating a Structured Report (SR) and displaying its referenced series in a viewport is now primarily handled by the new `hydrateSecondaryDisplaySet` command available in the `@ohif/extension-cornerstone` extension. +* The `hydrateStructuredReport` command (from `@ohif/extension-cornerstone-dicom-sr`) now solely focuses on hydrating the SR and returning its data, without directly manipulating viewports. + +**Migration Steps:** + +If you were previously using the `loadSRMeasurements` command to load and display SR measurements, you should update your code to use the `hydrateSecondaryDisplaySet` command. + +1. **Identify `loadSRMeasurements` Usage:** + Locate where your code calls `commandsManager.runCommand('loadSRMeasurements', ...)`. + +2. **Update to `hydrateSecondaryDisplaySet`:** + Replace the call to `loadSRMeasurements` with `hydrateSecondaryDisplaySet`. You will need to pass the full `displaySet` object for the SR and the target `viewportId`. + + ```diff + - // Old way: using loadSRMeasurements + - commandsManager.runCommand('loadSRMeasurements', { + - displaySetInstanceUID: srDisplaySetInstanceUID, + - // viewportId was implicitly the active one or not directly specifiable here + - }); + - + + // New way: using hydrateSecondaryDisplaySet + + const { displaySetService, viewportGridService } = servicesManager.services; + + + + // 1. Get the SR displaySet object + + const srDisplaySet = displaySetService.getDisplaySetByUID(srDisplaySetInstanceUID); + + + + // 2. Determine the target viewportId (e.g., active viewport) + + const viewportId = viewportGridService.getActiveViewportId(); // Or your specific viewportId + + + + if (srDisplaySet && viewportId) { + + commandsManager.runCommand('hydrateSecondaryDisplaySet', { + + displaySet: srDisplaySet, + + viewportId: viewportId, + + }); + + } else { + + console.warn('SR DisplaySet or ViewportId not found, cannot hydrate.'); + + } + ``` + +**Explanation:** + +* The `loadSRMeasurements` command was responsible for both hydrating the SR (getting its measurement data and referenced series UIDs) and then updating the viewport to show the referenced series. +* The new `hydrateSecondaryDisplaySet` command, when given an SR `displaySet` (`displaySet.Modality === 'SR'`), will: + 1. Internally call the `hydrateStructuredReport` command to parse the SR and get its details (including `SeriesInstanceUIDs` of referenced images). + 2. Then, it will automatically find the corresponding image display sets for those `SeriesInstanceUIDs`. + 3. Finally, it will update the specified `viewportId` to display the primary referenced image series. +* This change centralizes the logic for hydrating secondary display sets (like SR, SEG, RTSTRUCT) and updating viewports into the `hydrateSecondaryDisplaySet` command within the core Cornerstone extension. + +**Note on UI/Button Changes:** +The UI button typically associated with "Load SR" (often seen in viewport corners or specific contexts) has also been refactored. The hydration of SRs is now often triggered by: +* The `TrackedMeasurementsContext` if the `@ohif/extension-measurement-tracking` is in use. +* The new `ModalityLoadBadge` component, which can appear in viewports containing SR, SEG, or RTSTRUCT display sets, offering a "LOAD" action that calls `hydrateSecondaryDisplaySet`. + +If you had custom UI invoking `loadSRMeasurements`, you'll need to adapt it to call `hydrateSecondaryDisplaySet` as described above. diff --git a/platform/docs/docs/migration-guide/3p10-to-3p11/hydration.md b/platform/docs/docs/migration-guide/3p10-to-3p11/hydration.md new file mode 100644 index 000000000..0f82524f4 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p10-to-3p11/hydration.md @@ -0,0 +1,85 @@ +--- +title: Hydration +sidebar_position: 3 +sidebar_label: Hydration +summary: Migration guide for OHIF 3.11's hydration system changes, including the transition to a centralized hydration dialog and command-based hydration for secondary display sets. +--- + +## Update Hydration Logic: + * The `promptHydrateSEG` and `promptHydrateRT` functions have been updated to use the generic `utils.promptHydrationDialog` from `@ohif/extension-cornerstone`. + * The actual hydration is now often triggered by the `hydrateSecondaryDisplaySet` command. + + *Example: `promptHydrateRT` change* + + ```diff + // extensions/cornerstone-dicom-rt/src/utils/promptHydrateRT.ts + - const RESPONSE = { + - NO_NEVER: -1, + - CANCEL: 0, + - HYDRATE_SEG: 5, + - }; + + import { utils, Types } from '@ohif/extension-cornerstone'; + + function promptHydrateRT({ + servicesManager, + rtDisplaySet, + viewportId, + preHydrateCallbacks, + hydrateRTDisplaySet, + -}: withAppTypes) { + - const { uiViewportDialogService, customizationService } = servicesManager.services; + - // ... lots of old promise and dialog logic + - return new Promise(async function (resolve, reject) { + - // ... + - }); + -} + - + -function _askHydrate( + - // ... + -) { + - // ... + -} + +}: { + + servicesManager: AppTypes.ServicesManager; + + rtDisplaySet: AppTypes.DisplaySet; + + viewportId: string; + + preHydrateCallbacks?: Types.HydrationCallback[]; + + hydrateRTDisplaySet: Types.HydrationCallback; + +}) { + + return utils.promptHydrationDialog({ + + servicesManager, + + viewportId, + + displaySet: rtDisplaySet, + + preHydrateCallbacks, + + hydrateCallback: hydrateRTDisplaySet, + + type: 'RTSTRUCT', + + }); + } + ``` + The `hydrateRTDisplaySet` callback passed to this function would now typically involve the `hydrateSecondaryDisplaySet` command. + ```diff + // extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx + useEffect(() => { + if (rtIsLoading) { + return; + } + promptHydrateRT({ + servicesManager, + viewportId, + rtDisplaySet, + - preHydrateCallbacks: [storePresentationState], + - hydrateRTDisplaySet, + - }).then(isHydrated => { + - if (isHydrated) { + - setIsHydrated(true); + - } + + hydrateRTDisplaySet: async () => { + + return commandsManager.runCommand('hydrateSecondaryDisplaySet', { + + displaySet: rtDisplaySet, + + viewportId, + + }); + + }, + }); + - }, [servicesManager, viewportId, rtDisplaySet, rtIsLoading]); + + }, [servicesManager, viewportId, rtDisplaySet, rtIsLoading, commandsManager]); + ``` diff --git a/platform/docs/docs/migration-guide/3p10-to-3p11/index.md b/platform/docs/docs/migration-guide/3p10-to-3p11/index.md index bee159b84..d60fdfd76 100644 --- a/platform/docs/docs/migration-guide/3p10-to-3p11/index.md +++ b/platform/docs/docs/migration-guide/3p10-to-3p11/index.md @@ -6,17 +6,3 @@ sidebar_label: 3.10 -> 3.11 beta # Migration Guide This guide provides information about migrating from OHIF version 3.10 to version 3.11. - -## General - -`viewportActionMenu.segmentationOverlay` is renamed to `viewportActionMenu.dataOverlay` -as it handles now both segmentation and data overlay. - -## Viewport Action Menu Customization - -The structure for defining viewport action menu customizations has changed. See the [Viewport Action Menu](./viewport-action-menu.md) migration guide for details. - - -## updateStoredPositionPresentation - -now uses displaySetInstanceUIDs instead of displaySetInstanceUID. diff --git a/platform/docs/docs/migration-guide/3p10-to-3p11/toolbarService.md b/platform/docs/docs/migration-guide/3p10-to-3p11/toolbarService.md new file mode 100644 index 000000000..510ce538b --- /dev/null +++ b/platform/docs/docs/migration-guide/3p10-to-3p11/toolbarService.md @@ -0,0 +1,153 @@ +--- +sidebar_position: 2 +sidebar_label: Toolbar Service +summary: Migration guide for OHIF 3.11's toolbar service changes, including the transition from `ViewportActionCornersService` to `ToolbarService` +--- + + +**Key Changes:** + +* **`ViewportActionCornersService` Removed:** The `ViewportActionCornersService` and its associated provider (`ViewportActionCornersProvider`) and hook (`useViewportActionCorners`) have been removed. Functionality for viewport corner items is now integrated into the `ToolbarService` and standard toolbar components. +* **Viewport Corner Items as Toolbar Buttons:** Items previously managed by `ViewportActionCornersService` are now configured as regular toolbar buttons. They are assigned to specific toolbar sections (e.g., `viewportActionMenu.topLeft`) and rendered using `Toolbar` components within the viewport corners. +* **`ToolbarService` API Updates:** + * `ToolbarService.addButtons()` has been renamed to `ToolbarService.register()` to better reflect its purpose of registering button definitions rather than just adding them. + * `ToolbarService.createButtonSection()` has been renamed to `ToolbarService.updateSection()` to better reflect that it is not about creating new sections but updating existing ones. + * `ToolbarService` now has a `sections` property (e.g., `toolbarService.sections.viewportActionMenu.topLeft`) providing predefined section names. +* **Enhanced `useToolbar` Hook:** + * The `useToolbar` hook now returns additional state management functions for toolbar items: + * `openItem`, `closeItem`, `isItemOpen` (for managing dropdown/popover states). + * `lockItem`, `unlockItem`, `toggleLock`, `isItemLocked`. + * `showItem`, `hideItem`, `toggleVisibility`, `isItemVisible`. + * The `onInteraction` callback now receives `itemId` and `viewportId`. +* **Toolbar Button Configuration:** + * The `groupId` prop in button configurations (e.g., for `ohif.toolButtonList`, `ohif.toolBoxButtonGroup`) is generally replaced by directly using `buttonSection` to define the set of buttons. + * Button `evaluate` functions can now leverage `evaluateProps.hideWhenDisabled` to automatically hide a button when it's disabled. +* **New UI Components & Hooks for Viewport Corners:** + * Specialized components like `ModalityLoadBadge`, `NavigationComponent`, `TrackingStatus`, `ViewportDataOverlayMenuWrapper`, `ViewportOrientationMenuWrapper`, `WindowLevelActionMenuWrapper` are now used as toolbar buttons, typically in viewport action menu sections. + * `useViewportHover` hook can be used to determine if a viewport is hovered or active, controlling the visibility of corner toolbars. +* **`IconPresentationProvider`:** A new `IconPresentationProvider` and `useIconPresentation` hook have been introduced to standardize icon sizing and styling within toolbars and related components. +* **Legacy Toolbar Components Removed:** `ToolbarSplitButtonWithServicesLegacy` and `ToolbarButtonGroupWithServicesLegacy` have been removed. + +**Migration Steps:** + +1. **Update `ToolbarService` Method Calls:** + * Replace all instances of `toolbarService.addButtons(...)` with `toolbarService.register(...)`. + * Replace all instances of `toolbarService.createButtonSection(...)` with `toolbarService.updateSection(...)`. + + ```diff + // Before + - toolbarService.addButtons(toolbarButtons); + - toolbarService.createButtonSection('primary', ['Zoom', 'Pan']); + + // After + + toolbarService.register(toolbarButtons); + + toolbarService.updateSection('primary', ['Zoom', 'Pan']); + ``` + +2. **Migrate Viewport Action Corner Items:** + * Remove any direct usage of the old `ViewportActionCornersService`, `useViewportActionCorners`, or `ViewportActionCornersProvider`. + * Define your viewport corner items (like orientation menu, W/L menu, data overlay menu) as standard toolbar buttons using `toolbarService.register()`. + * Assign these buttons to the new dedicated viewport action menu sections. You can access these section names via `toolbarService.sections.viewportActionMenu.`, e.g., `toolbarService.sections.viewportActionMenu.topLeft`. + + ```diff + // Before: Customization in viewportActionMenuCustomizations.ts (now deleted) + // or direct use of ViewportActionCornersService.addComponent + - // Example: viewportActionCornersService.addComponent({ viewportId, id: 'orientationMenu', component: MyOrientationMenu, location: 'topLeft' }); + + // After: In your mode's onModeEnter or similar setup + + const myViewportCornerButtons = [ + + { + + id: 'orientationMenu', + + uiType: 'ohif.orientationMenu', // Or your custom component registered as a UI type + + props: { /* ... props for your component ... */ } + + }, + + // ... other corner buttons + + ]; + + toolbarService.register(myViewportCornerButtons); + + toolbarService.updateSection( + + toolbarService.sections.viewportActionMenu.topLeft, + + ['orientationMenu', /* other button IDs */] + + ); + ``` + * The `OHIFViewportActionCorners.tsx` component now internally uses `Toolbar` components for each corner, which are populated by these sections. + * For custom components that act as menus (e.g., popovers), use the `onOpen`, `onClose`, `isOpen` props passed down by the `Toolbar` component (which get these from `useToolbar`). + + ```diff + // Before: Custom component might have managed its own open state + - // const [isMenuOpen, setIsMenuOpen] = useState(false); + - // const handleOpenChange = (open) => setIsMenuOpen(open); + + // After: Custom component receives isOpen, onOpen, onClose from Toolbar + + function MyCustomMenuButton({ isOpen, onOpen, onClose, ...rest }) { + + const handleOpenChange = (openState: boolean) => { + + if (openState) { + + onOpen?.(); + + } else { + + onClose?.(); + + } + + }; + + + + return ( + + + + {/* ... PopoverTrigger and PopoverContent ... */} + + + + ); + + } + ``` + +3. **Adapt Toolbar Button and Component Configurations:** + * For `ohif.toolButtonList` or `ohif.toolBoxButtonGroup` (and their wrappers), the `groupId` prop is no longer the primary way to define the set of buttons. Instead, ensure the `buttonSection` prop correctly points to the section name containing the desired buttons. The `id` prop on these wrapper components should be unique for the component instance. + + ```diff + // Before + - { + - id: 'MeasurementTools', + - uiType: 'ohif.toolButtonList', + - props: { + - buttonSection: 'measurementSection', + - groupId: 'MeasurementTools', // groupId often matched buttonSection + - }, + - }, + + // After + + { + + id: 'MeasurementTools', // This is the ID of the ToolButtonList/ToolBox component itself + + uiType: 'ohif.toolButtonList', + + props: { + + // This section contains the actual tool buttons (e.g., Length, Bidirectional) + + buttonSection: 'measurementSection', + + }, + + }, + ``` + * Update wrappers like `ToolBoxButtonGroupWrapper` and `ToolButtonListWrapper`: + * The `groupId` prop is replaced by `id` (which is the ID of the wrapper button itself). + * The `onInteraction` callback in these wrappers now provides `id` (the wrapper's ID) instead of `groupId`. + * If you have custom `evaluate` functions, you can now use `evaluateProps: { hideWhenDisabled: true }` in your button definition to automatically hide the button if it evaluates to disabled. + + +5. **Adopt `IconPresentationProvider` (Optional but Recommended):** + * For consistent icon styling across your application's toolbars, wrap a high-level component (like your main `Header` or layout component) with ``. + * Custom tool button components can then use the `useIconPresentation` hook to get appropriate class names for icons or a pre-styled `IconContainer`. + + ```diff + // In your main App.tsx or Header.tsx + + import { IconPresentationProvider, ToolButton } from '@ohif/ui-next'; + // ... + + + {/* Your Header content including Toolbars */} + + + + // In a custom tool button using icons + + import { useIconPresentation, Icons } from '@ohif/ui-next'; + + function MyCustomToolButton({ iconName }) { + + const { className: iconClassName } = useIconPresentation(); + + return ; + + } + ``` + +6. **Remove Legacy Component Usage:** + * Replace any usage of `ToolbarSplitButtonWithServicesLegacy` and `ToolbarButtonGroupWithServicesLegacy` with the newer patterns, typically by configuring individual buttons and using `ToolButtonList` or `ButtonGroup` from `@ohif/ui-next` directly, driven by `useToolbar`. diff --git a/platform/docs/docs/migration-guide/3p10-to-3p11/ui.md b/platform/docs/docs/migration-guide/3p10-to-3p11/ui.md new file mode 100644 index 000000000..074790e5d --- /dev/null +++ b/platform/docs/docs/migration-guide/3p10-to-3p11/ui.md @@ -0,0 +1,60 @@ +--- +sidebar_position: 5 +sidebar_label: UI +summary: Migration guide for OHIF 3.11's UI changes, including the transition from `ViewportActionCornersService` to `ToolbarService` and the introduction of `useToolbar` hook. +--- + + +## New useIconPresentation hook + +This section details the introduction of the `IconPresentationProvider` and `useIconPresentation` hook, offering an optional way to manage icon size and container styling within the UI. + +### Key Changes: + +* **New Context and Hook:** Introduction of `IconPresentationProvider` and `useIconPresentation` to provide a standardized way to control the size and potentially the container component/props for icons and related interactive elements (like `ToolButton`). + +### Migration Steps: + +Using the `IconPresentationProvider` is **entirely optional**. If you do not wrap your components with this provider, components like `ToolButton` will continue to use their explicit `size` prop and default styling. + +However, if you wish to centrally manage the presentation of icons within a specific part of your application's component tree, follow these steps: + +1. **Identify the component subtree:** Determine which section of your UI you want to apply consistent icon styling to. + +2. **Wrap with `IconPresentationProvider`:** Wrap the root of that component subtree with the `IconPresentationProvider`. Pass the desired `size` prop. You can also optionally provide a custom `IconContainer` component and `containerProps` if you want to change the wrapper around the icon itself (e.g., switching from a `Button` to a `ToolButton` or applying specific styling). + + ```jsx + import { IconPresentationProvider, ToolButton } from '@ohif/ui-next'; + + function MyComponentTree() { + return ( + // Icons and ToolButtons within this provider will inherit 'large' size + + {/* Any components inside that consume the context */} + + {/* ... other components ... */} + + ); + } + ``` + +3. **Consume the context in components (if building custom components):** If you are building a custom component that renders an icon and want it to respect the provider's settings, use the `useIconPresentation` hook within that component. This hook provides the configured size, a calculated CSS class name (`className`), the specified `IconContainer` component, and its `containerProps`. + + ```jsx + import React from 'react'; + import { useIconPresentation, Icons } from '@ohif/ui-next'; + + function MyCustomIconButton({ iconName, ...rest }) { + // This hook reads the nearest IconPresentationProvider context + const { className, IconContainer, containerProps } = useIconPresentation(); + + // Use the provided IconContainer and its props + return ( + + {/* Use the calculated className for the icon */} + + + ); + } + ``` + *Note: Built-in components like `ToolButton` in `@ohif/ui-next` have been updated internally to consume this context automatically if a provider is available.* diff --git a/platform/docs/docs/migration-guide/3p10-to-3p11/viewport-action-menu.md b/platform/docs/docs/migration-guide/3p10-to-3p11/viewport-action-menu.md index 2b7f36d98..b2b1fd307 100644 --- a/platform/docs/docs/migration-guide/3p10-to-3p11/viewport-action-menu.md +++ b/platform/docs/docs/migration-guide/3p10-to-3p11/viewport-action-menu.md @@ -1,102 +1,124 @@ --- -sidebar_position: 2 -sidebar_label: Viewport Action Menu -summary: Migration guide for OHIF 3.11's viewport action menu customization changes, including the transition from individual item configurations to location-based arrays and the removal of index priorities. +sidebar_position: 1 +sidebar_label: Viewport Corners +summary: Migration guide for OHIF 3.11's viewport corners customization changes, including the transition from individual item configurations to location-based arrays and the removal of index priorities. --- -# Viewport Action Menu Customization -In OHIF 3.11, we've redesigned how viewport action menu customizations are defined to make them more intuitive and organized by location. +Okay, here's a migration guide based on the provided diff, focusing on the introduction of `TrackingStatus`, `ModalityLoadBadge`, and `NavigationComponent`. -## Changes +**Key Changes:** -Previously, viewport action menu customizations were defined with individual item configurations that specified location and priority: +* **Deprecated `ViewportActionCornersService`**: The `ViewportActionCornersService` and its associated provider (`ViewportActionCornersProvider`) have been removed. UI elements previously managed by this service are now typically handled by dedicated components integrated via the `ToolbarService`. +* **New Centralized UI Components**: + * `ModalityLoadBadge`: A new component in `@ohif/extension-cornerstone` that displays the status (e.g., SEG/RT/SR loaded or requiring hydration) and a "LOAD" button for secondary display sets (SEG, RTSTRUCT, SR). This replaces the inline status and load logic within individual viewport components like `OHIFCornerstoneSEGViewport` and `OHIFCornerstoneRTViewport`. + * `TrackingStatus`: A new component in `@ohif/extension-cornerstone` to indicate if measurements in a viewport are being tracked. This replaces inline tracking status indicators previously in `OHIFCornerstoneSRMeasurementViewport` and `TrackedCornerstoneViewport`. + * `NavigationComponent`: A new component in `@ohif/extension-cornerstone` that provides navigation arrows (e.g., for segments in SEG/RT or measurements in SR/tracked series). This replaces the `ViewportActionArrows` previously instantiated directly within viewport components. +* **Viewport Simplification**: Viewport components like `OHIFCornerstoneSEGViewport`, `OHIFCornerstoneRTViewport`, and `OHIFCornerstoneSRMeasurementViewport` have been simplified. They no longer manage their own status indicators, load buttons, or navigation arrows. They now primarily delegate rendering to `OHIFCornerstoneViewport`. +* **Refactored Hydration Prompts**: Utility functions like `promptHydrateSEG` and `promptHydrateRT` now use a centralized `utils.promptHydrationDialog` from `@ohif/extension-cornerstone`. +* **Centralized Hydration Command**: A new command `hydrateSecondaryDisplaySet` has been added to `@ohif/extension-cornerstone` to handle the hydration logic for SEG, RTSTRUCT, and SR display sets. +* **New Hooks**: Several new hooks have been introduced in `@ohif/extension-cornerstone` (e.g., `useViewportDisplaySets`, `useMeasurementTracking`, `useViewportSegmentations`, `useViewportHover`) to provide data and state for these new UI components. -```ts -export default { - 'viewportActionMenu.orientationMenu': { - enabled: true, - location: viewportActionCornersService.LOCATIONS.topLeft, - indexPriority: 1, - }, - 'viewportActionMenu.dataOverlay': { - enabled: true, - location: viewportActionCornersService.LOCATIONS.topLeft, - indexPriority: 2, - }, - // ... -}; -``` +**Migration Steps:** -Now, viewport action menu customizations are organized by location, with each location having its own customization ID: +1. **Remove `ViewportActionCornersService` Usage**: + * If you were using `ViewportActionCornersService` to add custom components to viewport corners, you will need to refactor this. The recommended approach is to define these components as toolbar buttons and place them in designated viewport action menu sections (e.g., `viewportActionMenu.topLeft`) using the `ToolbarService`. + * The internal status components (`_getStatusComponent`) and `ViewportActionArrows` within specific viewports (SEG, RT, SR) have been removed. Their functionality is now provided by `ModalityLoadBadge`, `TrackingStatus`, and `NavigationComponent`. -```ts -export default { - 'viewportActionMenu.topLeft': [ - { - id: 'orientationMenu', - enabled: true, - }, - { - id: 'dataOverlay', - enabled: true, - }, - { - id: 'windowLevelActionMenu', - enabled: true, - }, - ], - 'viewportActionMenu.topRight': [], - 'viewportActionMenu.bottomLeft': [], - 'viewportActionMenu.bottomRight': [], -}; -``` -## Migration Steps +3. **Integrate New UI Components via `ToolbarService`**: + * The `ModalityLoadBadge`, `TrackingStatus`, and `NavigationComponent` are now registered with the `ToolbarService` within the `@ohif/extension-cornerstone`'s `getToolbarModule`. + * Modes (e.g., `longitudinal`) should define toolbar sections for viewport corners and add these components to those sections. -1. Reorganize your viewport action menu customizations by using location-based customization IDs (`viewportActionMenu.topLeft`, `viewportActionMenu.topRight`, etc.). -2. For each component, move it into the appropriate location array. -3. Replace the component key with an `id` property that doesn't include the prefix (just use `orientationMenu` instead of `viewportActionMenu.orientationMenu`). -4. Remove the `location` property (since it's now implied by the customization ID). -5. Remove the `indexPriority` property (order in the array now determines display order). -6. For each component, provide a `component` function that returns the component instance. + *Example: Adding components to viewport corners in `longitudinal` mode* + ```diff + // modes/longitudinal/src/index.ts + function modeFactory({ modeConfiguration }) { + return { + // ... + onModeEnter: ({ servicesManager, extensionManager, commandsManager }: withAppTypes) => { + // ... + toolbarService.addButtons(toolbarButtons); + toolbarService.createButtonSection('primary', [ + // ... primary tools + ]); -## Component Rendering + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [ + + 'orientationMenu', + + 'dataOverlayMenu', + + 'windowLevelMenu', + + ]); + + toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [ + + 'modalityLoadBadge', + + 'trackingStatus', + + 'navigationComponent', + + ]); + // ... + }, + ``` + And ensure these buttons are defined in your mode's `toolbarButtons.ts`: + ```diff + // modes/longitudinal/src/toolbarButtons.ts + + { + + id: 'modalityLoadBadge', + + uiType: 'ohif.modalityLoadBadge', + + props: { + + // ... props like icon, label, tooltip, evaluate + + evaluate: { + + name: 'evaluate.modalityLoadBadge', + + hideWhenDisabled: true, + + }, + + }, + + }, + + { + + id: 'navigationComponent', + + uiType: 'ohif.navigationComponent', + + props: { + + // ... props + + evaluate: { + + name: 'evaluate.navigationComponent', + + hideWhenDisabled: true, + + }, + + }, + + }, + + { + + id: 'trackingStatus', + + uiType: 'ohif.trackingStatus', + + props: { + + // ... props + + evaluate: { + + name: 'evaluate.trackingStatus', + + hideWhenDisabled: true, + + }, + + }, + + }, + ``` -Component rendering logic is now included directly in the item configuration via a `component` function: -```ts -const createOrientationMenu = ({ viewportId, element, location }) => { - return getViewportOrientationMenu({ - viewportId, - element, - location, - }); -}; +5. **Direct Import of `OHIFCornerstoneViewport`**: + * Extensions that were previously getting the cornerstone viewport component dynamically via `extensionManager.getModuleEntry('@ohif/extension-cornerstone.viewportModule.cornerstone')` should now import `OHIFCornerstoneViewport` directly from `@ohif/extension-cornerstone`. -const createDataOverlay = ({ viewportId, element, displaySets, location }) => { - return getViewportDataOverlaySettingsMenu({ - viewportId, - element, - displaySets, - location, - }); -}; + ```diff + // extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx + import PropTypes from 'prop-types'; + import React, { useCallback, useEffect, useRef, useState } from 'react'; + import { useViewportGrid } from '@ohif/ui-next'; + +import { OHIFCornerstoneViewport } from '@ohif/extension-cornerstone'; -export default { - 'viewportActionMenu.topLeft': [ - { - id: 'orientationMenu', - enabled: true, - component: createOrientationMenu, - }, - { - id: 'dataOverlay', - enabled: true, - component: createDataOverlay, - }, - // other components... - ], - // other locations... -}; -``` + function OHIFCornerstonePMAPViewport(props: withAppTypes) { + // ... + const getCornerstoneViewport = useCallback(() => { + - const { component: Component } = extensionManager.getModuleEntry( + - '@ohif/extension-cornerstone.viewportModule.cornerstone' + - ); + // ... + return ( + - + + /> + ); + // ... + ``` diff --git a/platform/docs/docs/platform/hooks/index.md b/platform/docs/docs/platform/hooks/index.md new file mode 100644 index 000000000..52cc5ee47 --- /dev/null +++ b/platform/docs/docs/platform/hooks/index.md @@ -0,0 +1,33 @@ +--- +title: Hooks +summary: List of React hooks available in the platform, these are custom hooks that are used to access the state of the platform +--- + +# Hooks + +## [useMeasurements](./useMeasurements.md) +A React hook that provides mapped measurements from the measurement service with automatic updates when measurements change. + +## [useViewportSegmentations](./useViewportSegmentations.md) +A React hook that provides segmentation data and representations for the active viewport with automatic updates when segmentations change. + +## [useMeasurementTracking](./useMeasurementTracking.md) +A React hook that provides measurement tracking information for a specific viewport, including tracking state and tracked measurement UIDs. + +## [useViewportDisplaySets](./useViewportDisplaySets.md) +A React hook that provides access to display sets associated with a viewport, including background, foreground, overlay, and potential display sets. + +## [useViewportHover](./useViewportHover.md) +A React hook that tracks mouse hover state and active status for a specific viewport. + +## [usePatientInfo](./usePatientInfo.md) +A React hook that provides patient information from the active display sets and detects when multiple patients are loaded. + +## [useSearchParams](./useSearchParams.md) +A React hook that provides access to URL search parameters from both the query string and hash fragment. + +## [useDynamicMaxHeight](./useDynamicMaxHeight.md) +A React hook that calculates the maximum height for an element based on its position in the viewport, with automatic recalculation on window resize or data changes. + +## [useSessionStorage](./useSessionStorage.md) +A React hook that provides sessionStorage access with automatic JSON parsing/stringifying and an option to clear data when the page unloads. \ No newline at end of file diff --git a/platform/docs/docs/platform/hooks/useDynamicMaxHeight.md b/platform/docs/docs/platform/hooks/useDynamicMaxHeight.md new file mode 100644 index 000000000..95854c173 --- /dev/null +++ b/platform/docs/docs/platform/hooks/useDynamicMaxHeight.md @@ -0,0 +1,65 @@ +--- +title: useDynamicMaxHeight +summary: A React hook that calculates the maximum height for an element based on its position in the viewport, with automatic recalculation on window resize or data changes. +--- + +# useDynamicMaxHeight + +The `useDynamicMaxHeight` hook calculates the maximum height an element can have based on its position relative to the bottom of the viewport, ensuring it doesn't overflow or get cut off by the viewport edge. + +## Overview + +This hook is useful for creating responsive UI elements that need to fit within the visible area of the screen without causing scrolling or content overflow. It automatically recalculates the maximum height when the window is resized or when specified data changes, ensuring the element always fits properly. + +## Import + +```js +import { useDynamicMaxHeight } from '@ohif/ui-next'; +``` + +## Usage + +```jsx +function DynamicHeightPanel({ data, children }) { + const { ref, maxHeight } = useDynamicMaxHeight(data, 30, 200); + + return ( +
+ {children} +
+ ); +} +``` + +## Parameters + +- `data` (required): Any data that, when changed, should trigger a recalculation of the maximum height. This can be an array, object, or primitive value. +- `buffer` (optional): Additional space (in pixels) to leave below the element. Defaults to 20px. +- `minHeight` (optional): Minimum height (in pixels) for the element. Defaults to 100px. + +## Returns + +An object containing: + +- `ref`: A React ref object that must be attached to the target DOM element +- `maxHeight`: A CSS-compatible string value for the calculated maximum height (e.g., "500px") + +## Implementation Details + +- The hook uses `window.innerHeight` and the element's position to calculate the available space between the element's top edge and the bottom of the viewport. +- It subtracts the specified buffer from the available height to ensure there's space below the element. +- The calculated height is constrained by the specified minimum height to prevent the element from becoming too small. +- The hook uses `requestAnimationFrame` to ensure the initial calculation happens after the component has been rendered and positioned in the DOM. +- It automatically recalculates the maximum height when: + - The window is resized + - The `data`, `buffer`, or `minHeight` dependencies change +- The hook properly cleans up event listeners and animation frame requests when the component unmounts. + +This hook is particularly useful for panels, menus, and content areas that need to dynamically adjust their height based on their position in the viewport, ensuring a good user experience without unexpected scrolling or content clipping. \ No newline at end of file diff --git a/platform/docs/docs/platform/hooks/useMeasurementTracking.md b/platform/docs/docs/platform/hooks/useMeasurementTracking.md new file mode 100644 index 000000000..b488e86d0 --- /dev/null +++ b/platform/docs/docs/platform/hooks/useMeasurementTracking.md @@ -0,0 +1,79 @@ +--- +title: useMeasurementTracking +summary: A React hook that provides measurement tracking information for a specific viewport, including tracking state and tracked measurement UIDs. +--- + +# useMeasurementTracking + +The `useMeasurementTracking` hook provides measurement tracking information for a specific viewport, including the tracking state and UIDs of tracked measurements associated with the viewport's series. + +## Overview + +This hook gives components access to tracking information for measurements in a viewport. It monitors the tracked measurements service and measurement service to provide up-to-date tracking states and the list of measurement UIDs associated with the series displayed in the viewport. + +## Import + +```js +import { useMeasurementTracking } from '@ohif/extension-cornerstone'; +``` + +## Usage + +```jsx +function MeasurementTrackingInfo({ viewportId }) { + const { + isTracked, + isLocked, + seriesInstanceUID, + trackedMeasurementUIDs + } = useMeasurementTracking({ + viewportId, + }); + + return ( +
+
Series UID: {seriesInstanceUID}
+
Tracking Status: {isTracked ? 'Tracked' : 'Not Tracked'}
+
Locked: {isLocked ? 'Yes' : 'No'}
+
Tracked Measurements: {trackedMeasurementUIDs.length}
+
    + {trackedMeasurementUIDs.map(uid => ( +
  • {uid}
  • + ))} +
+
+ ); +} +``` + +## Parameters + +- `options` - Configuration options: + - `viewportId` (required): The ID of the viewport to track + +## Returns + +An object containing the following properties: + +- `isTracked`: Boolean indicating if the series in the viewport is currently tracked +- `isLocked`: Boolean indicating if tracking is enabled (locked) globally +- `seriesInstanceUID`: The Series Instance UID of the background display set in the viewport +- `trackedMeasurementUIDs`: Array of measurement UIDs that are associated with the tracked series in the viewport + +## Events + +The hook automatically updates when any of these events occur: + +From the tracked measurements service: +- `TRACKING_ENABLED` +- `TRACKING_DISABLED` +- `TRACKED_SERIES_CHANGED` +- `SERIES_ADDED` +- `SERIES_REMOVED` + +From the measurement service: +- `MEASUREMENT_ADDED` +- `RAW_MEASUREMENT_ADDED` +- `MEASUREMENT_UPDATED` +- `MEASUREMENT_REMOVED` +- `MEASUREMENTS_CLEARED` diff --git a/platform/docs/docs/platform/hooks/useMeasurements.md b/platform/docs/docs/platform/hooks/useMeasurements.md new file mode 100644 index 000000000..d1336d0c7 --- /dev/null +++ b/platform/docs/docs/platform/hooks/useMeasurements.md @@ -0,0 +1,68 @@ +--- +title: useMeasurements +summary: A React hook that provides mapped measurements from the measurement service with automatic updates when measurements change. +--- + +# useMeasurements + +The `useMeasurements` hook provides access to measurements from the measurement service, with automatic updates when measurements are added, updated, or removed. + +## Overview + +This hook retrieves measurements from the measurement service and maps them to a display-friendly format. It monitors various measurement service events and updates the returned measurements automatically when changes occur. + +## Import + +```js +import { useMeasurements } from '@ohif/extension-cornerstone'; +``` + +## Usage + +```jsx +function MeasurementPanel() { + const measurementFilter = measurements => measurements.someFilter; + + const measurements = useMeasurements({ + measurementFilter, + }); + + return ( +
+ {measurements.map(measurement => ( +
+ {measurement.label} +
+ {measurement.displayText.primary.map((text, i) => ( +

{text}

+ ))} +
+
+ ))} +
+ ); +} +``` + +## Parameters + +- `options` - Configuration options: + - `measurementFilter` - Optional function to filter measurements returned by the measurement service. + +## Returns + +An array of mapped measurements with the following structure: + + +## Events + +The hook automatically updates when any of these measurement service events occur: +- `MEASUREMENT_ADDED` +- `RAW_MEASUREMENT_ADDED` +- `MEASUREMENT_UPDATED` +- `MEASUREMENT_REMOVED` +- `MEASUREMENTS_CLEARED` + +## Implementation Details + +The hook uses debouncing to prevent excessive re-renders when multiple measurement events occur in rapid succession. It also performs a deep comparison of the measurements to avoid unnecessary state updates when the data hasn't actually changed. diff --git a/platform/docs/docs/platform/hooks/usePatientInfo.md b/platform/docs/docs/platform/hooks/usePatientInfo.md new file mode 100644 index 000000000..62bc9b9ae --- /dev/null +++ b/platform/docs/docs/platform/hooks/usePatientInfo.md @@ -0,0 +1,68 @@ +--- +title: usePatientInfo +summary: A React hook that provides patient information from the active display sets and detects when multiple patients are loaded. +--- + +# usePatientInfo + +The `usePatientInfo` hook provides access to basic patient demographic information from the active display sets and also detects when display sets from multiple patients are loaded simultaneously. + +## Overview + +This hook retrieves patient information from the first instance of the first display set added to the viewer. It monitors when new display sets are added and updates the patient information accordingly. It also checks if any of the active display sets are from different patients and provides this information through the `isMixedPatients` flag. + +## Import + +```js +import { usePatientInfo } from '@ohif/extension-default'; +``` + +## Usage + +```jsx +function PatientBanner() { + const { patientInfo, isMixedPatients } = usePatientInfo(); + + return ( +
+ {isMixedPatients && ( +
Multiple patients loaded
+ )} +
{patientInfo.PatientName}
+
+ ID: {patientInfo.PatientID} + Sex: {patientInfo.PatientSex} + DOB: {patientInfo.PatientDOB} +
+
+ ); +} +``` + +## Parameters + +This hook doesn't take any parameters. + +## Returns + +An object containing: + +- `patientInfo`: Object with the following properties: + - `PatientName`: Formatted patient name + - `PatientID`: Patient identifier + - `PatientSex`: Patient sex + - `PatientDOB`: Formatted patient date of birth +- `isMixedPatients`: Boolean indicating whether multiple patients are loaded in the viewer + +## Events + +The hook subscribes to the following display set service events: + +- `DISPLAY_SETS_ADDED`: Updates patient information when new display sets are added to the viewer + +## Implementation Details + +- Patient name and date of birth are formatted using OHIF utility functions. +- The hook checks all active display sets to determine if they belong to different patients. +- Patient information is initialized with empty strings and updated when display sets are added. +- When no instances are available in a display set, the hook attempts to get information from the `instance` property as a fallback. \ No newline at end of file diff --git a/platform/docs/docs/platform/hooks/useSearchParams.md b/platform/docs/docs/platform/hooks/useSearchParams.md new file mode 100644 index 000000000..909c1b0bc --- /dev/null +++ b/platform/docs/docs/platform/hooks/useSearchParams.md @@ -0,0 +1,69 @@ +--- +title: useSearchParams +summary: A React hook that provides access to URL search parameters from both the query string and hash fragment. +--- + +# useSearchParams + +The `useSearchParams` hook provides access to URL search parameters, combining both query string parameters and hash fragment parameters into a single URLSearchParams object. + +## Overview + +This hook extends the standard React Router `useLocation` functionality by merging search parameters from both the query string and the hash fragment of the URL. It also provides an option to normalize parameter keys to lowercase for case-insensitive parameter handling. + +## Import + +```js +import { useSearchParams } from '@ohif/app'; +``` + +## Usage + +```jsx +function RouteParameterReader() { + const searchParams = useSearchParams(); + // Or with lowercase keys option + const lowerCaseParams = useSearchParams({ lowerCaseKeys: true }); + + const studyInstanceUID = searchParams.get('StudyInstanceUID'); + // With lowerCaseKeys: true + const sameStudyUID = lowerCaseParams.get('studyinstanceuid'); + + return ( +
+
Study UID: {studyInstanceUID}
+
All Parameters:
+
    + {Array.from(searchParams.entries()).map(([key, value]) => ( +
  • + {key}: {value} +
  • + ))} +
+
+ ); +} +``` + +## Parameters + +- `options` (optional): Configuration options + - `lowerCaseKeys` - Boolean indicating whether to convert all parameter keys to lowercase (default: false) + +## Returns + +A `URLSearchParams` object containing all parameters from both: +- The query string (e.g., `?param1=value1¶m2=value2`) +- The hash fragment (e.g., `#param3=value3¶m4=value4`) + +If parameters with the same key exist in both the query string and hash fragment, the hash fragment values take precedence. + +## Implementation Details + +- The hook uses React Router's `useLocation` to access the current URL. +- It first creates a URLSearchParams object from the location's search property. +- It then creates another URLSearchParams object from the location's hash property (excluding the leading '#'). +- The parameters from the hash are added to the search parameters, overriding any duplicate keys. +- If the `lowerCaseKeys` option is enabled, it creates a new URLSearchParams object with all keys converted to lowercase. + +This is particularly useful in OHIF where parameters may be specified in either the query string or hash fragment, and where case-sensitivity of parameter names might vary across different systems. diff --git a/platform/docs/docs/platform/hooks/useSessionStorage.md b/platform/docs/docs/platform/hooks/useSessionStorage.md new file mode 100644 index 000000000..8eed706d5 --- /dev/null +++ b/platform/docs/docs/platform/hooks/useSessionStorage.md @@ -0,0 +1,84 @@ +--- +title: useSessionStorage +summary: A React hook that provides sessionStorage access with automatic JSON parsing/stringifying and an option to clear data when the page unloads. +--- + +# useSessionStorage + +The `useSessionStorage` hook provides a convenient way to store and retrieve data from the browser's sessionStorage with automatic JSON serialization and deserialization. It also offers the option to automatically clear the stored data when a page unloads. + +## Overview + +This hook wraps the browser's sessionStorage API to provide a more React-friendly interface. It handles JSON serialization/deserialization automatically and maintains the stored values in local state for reactive updates. The hook also includes a unique feature to clear specific items from sessionStorage when the page unloads, which is useful for temporary session data that shouldn't persist. + +## Import + +```js +import { useSessionStorage } from '@ohif/ui-next'; +``` + +## Usage + +```jsx +function UserPreferencesPanel() { + const [preferences, setPreferences] = useSessionStorage({ + key: 'viewer-preferences', + defaultValue: { theme: 'dark', fontSize: 'medium' }, + clearOnUnload: false, + }); + + const updateTheme = (theme) => { + setPreferences({ ...preferences, theme }); + }; + + return ( +
+

User Preferences

+
Current Theme: {preferences.theme}
+ + +
+ ); +} + +function TemporaryWorkspace() { + const [workspace, setWorkspace] = useSessionStorage({ + key: 'temp-workspace', + defaultValue: { annotations: [] }, + clearOnUnload: true, // This data will be cleared when the page unloads + }); + + return ( +
+

Temporary Workspace

+

This workspace will be cleared when you leave the page

+ {/* Workspace UI components */} +
+ ); +} +``` + +## Parameters + +An options object with the following properties: + +- `key` (required): The sessionStorage key under which to store the data +- `defaultValue` (optional): The default value to use if no data exists in sessionStorage for the given key. Defaults to an empty object (`{}`) +- `clearOnUnload` (optional): Whether to clear this item from sessionStorage when the page unloads. Defaults to `false` + +## Returns + +An array containing: + +1. The current value from sessionStorage (parsed from JSON) +2. A function to update the value in both state and sessionStorage + +The update function automatically handles JSON stringification of the data. + +## Implementation Details + +- The hook uses a global Map (`sessionItemsToClearOnUnload`) to track items that should be cleared when the page unloads. +- It utilizes the browser's `visibilitychange` event to implement the clearOnUnload feature. When the page becomes hidden (which happens both when switching tabs and when unloading), items marked for clearing are removed from sessionStorage. +- If the page later becomes visible again (e.g., when switching back to the tab), the items are restored from the Map. +- The hook's update function merges the new value with the current state using the spread operator, maintaining a similar behavior to React's `setState`. +- All data is automatically serialized to JSON before storing in sessionStorage and deserialized when retrieving. diff --git a/platform/docs/docs/platform/hooks/useViewportDisplaySets.md b/platform/docs/docs/platform/hooks/useViewportDisplaySets.md new file mode 100644 index 000000000..7c3a99f5e --- /dev/null +++ b/platform/docs/docs/platform/hooks/useViewportDisplaySets.md @@ -0,0 +1,98 @@ +--- +title: useViewportDisplaySets +summary: A React hook that provides access to display sets associated with a viewport, including background, foreground, overlay, and potential display sets. +--- + +# useViewportDisplaySets + +The `useViewportDisplaySets` hook provides access to display sets associated with a viewport, organized into categories based on their role and potential usage. + +## Overview + +This hook retrieves all display sets associated with a specific viewport and categorizes them into background, foreground, overlays, and potential display sets that could be added to the viewport in different roles. It allows components to efficiently access and manage viewport display sets for various UI interactions like layer menus and display set selectors. + +## Import + +```js +import { useViewportDisplaySets } from '@ohif/extension-cornerstone'; +``` + +## Usage + +```jsx +function ViewportLayerControls({ viewportId }) { + const { + backgroundDisplaySet, + foregroundDisplaySets, + overlayDisplaySets, + potentialOverlayDisplaySets, + potentialForegroundDisplaySets, + potentialBackgroundDisplaySets, + } = useViewportDisplaySets(viewportId); + + return ( +
+
+

Background

+
{backgroundDisplaySet?.SeriesDescription}
+
+ +
+

Foreground ({foregroundDisplaySets.length})

+ {foregroundDisplaySets.map(ds => ( +
{ds.SeriesDescription}
+ ))} +
+ +
+

Overlays ({overlayDisplaySets.length})

+ {overlayDisplaySets.map(ds => ( +
{ds.SeriesDescription}
+ ))} +
+ +
+

Available Overlays ({potentialOverlayDisplaySets.length})

+ {potentialOverlayDisplaySets.map(ds => ( +
{ds.SeriesDescription}
+ ))} +
+
+ ); +} +``` + +## Parameters + +- `viewportId` (optional): The ID of the viewport to get display sets for. If not provided, uses the active viewport. +- `options` (optional): Configuration options to control which display sets to include: + - `includeBackground`: Whether to include the background display set (default: true) + - `includeForeground`: Whether to include foreground display sets (default: true) + - `includeOverlay`: Whether to include overlay display sets (default: true) + - `includePotentialOverlay`: Whether to include potential overlay display sets (default: true) + - `includePotentialForeground`: Whether to include potential foreground display sets (default: true) + - `includePotentialBackground`: Whether to include potential background display sets (default: true) + +## Returns + +An object containing requested display set collections based on options: + +- `allDisplaySets`: All display sets in the viewer (only if requested) +- `viewportDisplaySets`: The display sets currently in the viewport +- `backgroundDisplaySet`: The primary display set for the viewport (base image) +- `foregroundDisplaySets`: Display sets currently shown with background (non-overlay layers) +- `overlayDisplaySets`: Segmentation display sets currently applied as overlays +- `potentialOverlayDisplaySets`: Display sets that could be toggled on as overlays (derived modalities) +- `potentialForegroundDisplaySets`: Display sets that could be added as foreground layers +- `potentialBackgroundDisplaySets`: Display sets that could replace the current background + +Each property is only included if the corresponding option is true. + +## Implementation Details + +- The hook automatically adapts to viewport changes through the `useViewportGrid` hook. +- It only fetches and processes display sets that are needed based on the provided options. +- Display sets are categorized based on their modality and properties. +- Potential display sets are sorted by priority to present the most relevant options first. +- Derived overlay modalities (like SEG, SR) are treated differently than other display sets. +- The hook uses memoization extensively to optimize performance and prevent unnecessary recalculations. diff --git a/platform/docs/docs/platform/hooks/useViewportHover.md b/platform/docs/docs/platform/hooks/useViewportHover.md new file mode 100644 index 000000000..61c45a1de --- /dev/null +++ b/platform/docs/docs/platform/hooks/useViewportHover.md @@ -0,0 +1,57 @@ +--- +title: useViewportHover +summary: A React hook that tracks mouse hover state and active status for a specific viewport. +--- + +# useViewportHover + +The `useViewportHover` hook provides information about whether the mouse is currently hovering over a specific viewport and whether that viewport is active. + +## Overview + +This hook monitors mouse movement to track hover state over a viewport element by its ID. It also checks whether the viewport is currently active (selected) in the viewer. This is useful for implementing conditional UI elements or behaviors that depend on user interaction with viewports. + +## Import + +```js +import { useViewportHover } from '@ohif/extension-cornerstone'; +``` + +## Usage + +```jsx +function ViewportOverlay({ viewportId }) { + const { isHovered, isActive } = useViewportHover(viewportId); + + return ( +
+ {isHovered && !isActive && ( + + )} + {isActive && ( +
Active viewport controls
+ )} +
+ ); +} +``` + +## Parameters + +- `viewportId` (required): The ID of the viewport to track hover state for + +## Returns + +An object containing the following properties: + +- `isHovered`: Boolean indicating if the mouse is currently hovering over the viewport +- `isActive`: Boolean indicating if the viewport is currently the active viewport in the grid + +## Implementation Details + +- The hook uses the DOM to find the viewport element by its `data-viewportId` attribute. +- It calculates and maintains the viewport's bounding rectangle to efficiently determine if the mouse is within the viewport's bounds. +- The viewport element's rectangle is updated when the window is resized. +- Global mouse movement is tracked to determine hover state, rather than relying on traditional mouseenter/mouseleave events. +- The hook automatically cleans up event listeners when the component unmounts or the viewport ID changes. +- Active viewport state is derived from the viewport grid state using the `useViewportGrid` hook. \ No newline at end of file diff --git a/platform/docs/docs/platform/hooks/useViewportSegmentations.md b/platform/docs/docs/platform/hooks/useViewportSegmentations.md new file mode 100644 index 000000000..26d59e931 --- /dev/null +++ b/platform/docs/docs/platform/hooks/useViewportSegmentations.md @@ -0,0 +1,95 @@ +--- +title: useViewportSegmentations +summary: A React hook that provides segmentation data and representations for the active viewport with automatic updates when segmentations change. +--- + +# useViewportSegmentations + +The `useViewportSegmentations` hook provides access to segmentation data and their representations for a specific viewport, with automatic updates when segmentations are modified, removed, or representations change. + +## Overview + +This hook retrieves all segmentations and their representations for a given viewport from the segmentation service. It maps the segmentation data to a display-friendly format, including readable text for statistics. The hook monitors various segmentation and viewport events to update automatically when changes occur. + +## Import + +```js +import { useViewportSegmentations } from '@ohif/extension-cornerstone'; +``` + +## Usage + +```jsx +function SegmentationPanel({ viewportId }) { + const { segmentationsWithRepresentations, disabled } = useViewportSegmentations({ + viewportId, + subscribeToDataModified: true, + debounceTime: 100, + }); + + if (disabled) { + return
Segmentations not available for this modality
; + } + + if (!segmentationsWithRepresentations.length) { + return
No segmentations available
; + } + + return ( +
+ {segmentationsWithRepresentations.map(({ segmentation, representation }) => ( +
+

{segmentation.label}

+ {Object.entries(segmentation.segments).map(([segmentIndex, segment]) => ( +
+

{segment.label}

+ {segment.displayText.primary.map((text, i) => ( +

{text}

+ ))} +
+ ))} +
+ ))} +
+ ); +} +``` + +## Parameters + +- `options` - Configuration options: + - `viewportId` (required): The ID of the viewport to get segmentations for + - `subscribeToDataModified` (optional): Whether to subscribe to segmentation data modifications (default: false) + - `debounceTime` (optional): Debounce time in milliseconds for updates (default: 0) + +## Returns + +An object with the following properties: + +- `segmentationsWithRepresentations`: An array of objects with each containing: + - `representation`: The segmentation representation in the viewport + - `segmentation`: The mapped segmentation data with display-friendly properties: + - `label`: The segmentation label + - `segments`: Object mapping segment indices to segment data: + - `label`: Segment label + - `color`: Segment color + - `displayText`: Organized text for display with `primary` and `secondary` arrays + +- `disabled`: Boolean indicating if segmentations are disabled for the current modality + +## Events + +The hook automatically updates when any of these events occur: +- `SEGMENTATION_MODIFIED` +- `SEGMENTATION_REMOVED` +- `SEGMENTATION_REPRESENTATION_MODIFIED` +- `ACTIVE_VIEWPORT_ID_CHANGED` +- `GRID_STATE_CHANGED` +- `SEGMENTATION_DATA_MODIFIED` (only if `subscribeToDataModified` is true) + +## Implementation Details + +- The hook excludes certain modalities from segmentation display: 'SM', 'OT', 'DOC', 'ECG'. +- It uses debouncing to prevent excessive re-renders when multiple segmentation events occur in rapid succession. +- Segmentation statistics are automatically mapped to readable text using the customization service. +- Nested statistics are displayed with indentation to maintain hierarchy. diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx index 1bb0b3d10..3763f3ae8 100644 --- a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx +++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx @@ -23,7 +23,6 @@ import segDisplayEditingTrue from '../../../assets/img/segDisplayEditingTrue.png import segDisplayEditingFalse from '../../../assets/img/segDisplayEditingFalse.png'; import thumbnailMenuItemsImage from '../../../assets/img/thumbnailMenuItemsImage.png'; import studyMenuItemsImage from '../../../assets/img/studyMenuItemsImage.png'; -import windowLevelActionMenu from '../../../assets/img/windowLevelActionMenu.png'; import viewPortNotificationImage from '../../../assets/img/viewport-notification.png'; import captureViewportModal from '../../../assets/img/captureViewportModal.png'; import aboutModal from '../../../assets/img/aboutModal.png'; @@ -201,31 +200,6 @@ 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.', @@ -936,56 +910,6 @@ 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: 'viewportActionMenu.dataOverlayMenu', - description: 'Configures the display and location of the data overlay in the viewport.', - image: segmentationOverlay, - default: null, - configuration: ` - window.config = { - // rest of window config - customizationService: [ - { - 'viewportActionMenu.dataOverlayMenu': { - $merge: { - enabled: true, - location: 1, // Set the location of the overlay in the viewport. - // 0: topLeft - // 1: topRight - // 2: bottomLeft - // 3: bottomRight - } - }, - }, - ], - }; - `, - }, { id: 'viewportNotification.beginTrackingMessage', description: 'Define the content to be displayed in begin tracking prompt', diff --git a/platform/ui-next/src/components/AllInOneMenu/Menu.tsx b/platform/ui-next/src/components/AllInOneMenu/Menu.tsx index 0fe900e55..d769ddb5b 100644 --- a/platform/ui-next/src/components/AllInOneMenu/Menu.tsx +++ b/platform/ui-next/src/components/AllInOneMenu/Menu.tsx @@ -50,7 +50,9 @@ export interface MenuProps { showHeaderDivider?: boolean; activePanelIndex?: number; onVisibilityChange?: (isVisible: boolean) => void; - horizontalDirection?: HorizontalDirection; + // New props that can be used as alternatives to horizontalDirection and verticalDirection + align?: 'start' | 'end' | 'center'; + side?: 'top' | 'bottom' | 'left' | 'right'; children: ReactNode; } type MenuContextProps = { @@ -58,6 +60,7 @@ type MenuContextProps = { hideMenu: () => void; addItemPanel: (index: number, label: string) => void; horizontalDirection: HorizontalDirection; + verticalDirection?: VerticalDirection; activePanelIndex: number; }; @@ -76,9 +79,24 @@ const Menu = (props: MenuProps) => { preventHideMenu, menuClassName, menuStyle, - horizontalDirection = HorizontalDirection.LeftToRight, + align, + side, } = props; + // Derive horizontalDirection and verticalDirection from align and side if provided + let horizontalDirection = HorizontalDirection.LeftToRight; + let verticalDirection = VerticalDirection.BottomToTop; + + if (align !== undefined) { + horizontalDirection = + align === 'start' ? HorizontalDirection.LeftToRight : HorizontalDirection.RightToLeft; + } + + if (side !== undefined) { + verticalDirection = + side === 'bottom' ? VerticalDirection.TopToBottom : VerticalDirection.BottomToTop; + } + const [isMenuVisible, setIsMenuVisible] = useState(isVisible); // The menuPath is an array consisting of this top Menu and every SubMenu @@ -151,6 +169,7 @@ const Menu = (props: MenuProps) => { addItemPanel, activePanelIndex: currentMenuActivePanelIndex, horizontalDirection, + verticalDirection, }} > {isMenuVisible && ( diff --git a/platform/ui-next/src/components/CinePlayer/CinePlayer.tsx b/platform/ui-next/src/components/CinePlayer/CinePlayer.tsx index 74275c9ba..e10b21b95 100644 --- a/platform/ui-next/src/components/CinePlayer/CinePlayer.tsx +++ b/platform/ui-next/src/components/CinePlayer/CinePlayer.tsx @@ -70,20 +70,7 @@ const CinePlayer: React.FC = ({ ); return ( -
- {isDynamic && dynamicInfo && ( - handleDimensionGroupNumberChange(val as number)} - className="mb-3 w-full" - > - - - )} +
= ({
+ + {isDynamic && dynamicInfo && ( + handleDimensionGroupNumberChange(val as number)} + className="mt-3 w-full" + > + + + )}
); }; diff --git a/platform/ui-next/src/components/DoubleSlider/DoubleSlider.tsx b/platform/ui-next/src/components/DoubleSlider/DoubleSlider.tsx index 50b9b3e1f..20a5a1484 100644 --- a/platform/ui-next/src/components/DoubleSlider/DoubleSlider.tsx +++ b/platform/ui-next/src/components/DoubleSlider/DoubleSlider.tsx @@ -12,7 +12,6 @@ interface DoubleSliderProps { defaultValue?: [number, number]; onValueChange?: (value: [number, number]) => void; showNumberInputs?: boolean; - lockMode?: boolean; } const DoubleSlider = React.forwardRef( @@ -25,19 +24,10 @@ const DoubleSlider = React.forwardRef( step = 1, defaultValue = [min, max], showNumberInputs = false, - lockMode = false, }, ref ) => { const [value, setValue] = React.useState<[number, number]>(defaultValue); - const trackRef = React.useRef(null); - const dragStateRef = React.useRef<{ - startX: number; - startValue: [number, number]; - rangeWidth: number; - trackLeft: number; - trackWidth: number; - } | null>(null); const prevDefaultValueRef = React.useRef<[number, number] | null>(null); @@ -60,172 +50,8 @@ const DoubleSlider = React.forwardRef( return Math.round(num * inverse) / inverse; }; - const handleRangePointerDown = (event: React.PointerEvent) => { - if (!lockMode) { - return; - } - event.preventDefault(); - const rect = trackRef.current!.getBoundingClientRect(); - dragStateRef.current = { - startX: event.clientX, - startValue: [...value] as [number, number], - rangeWidth: value[1] - value[0], - trackLeft: rect.left, - trackWidth: rect.width, - }; - window.addEventListener('pointermove', handleRangePointerMove); - window.addEventListener('pointerup', handleRangePointerUp, { once: true }); - }; - - const handleRangePointerMove = (event: PointerEvent) => { - const state = dragStateRef.current; - if (!state) { - return; - } - const { startX, startValue, rangeWidth, trackWidth } = state; - const dxPx = event.clientX - startX; - const pct = dxPx / trackWidth; - const delta = pct * (max - min); - - let newLeft = startValue[0] + delta; - let newRight = newLeft + rangeWidth; - - // clamp to bounds - if (newLeft < min) { - newLeft = min; - newRight = min + rangeWidth; - } else if (newRight > max) { - newRight = max; - newLeft = max - rangeWidth; - } - - const clampedLeft = roundToStep(newLeft); - const clampedRight = roundToStep(newRight); - setValue([clampedLeft, clampedRight]); - onValueChange?.([clampedLeft, clampedRight]); - }; - - const handleRangePointerUp = () => { - dragStateRef.current = null; - window.removeEventListener('pointermove', handleRangePointerMove); - }; - const handleSliderChange = React.useCallback( (newValue: number[]) => { - // Default behavior (no lock mode) - if (!lockMode) { - const clampedValue: [number, number] = [ - roundToStep(Math.max(min, Math.min(newValue[0], max))), - roundToStep(Math.min(max, Math.max(newValue[1], min))), - ]; - setValue(clampedValue); - onValueChange?.(clampedValue); - return; - } - - // Lock mode behavior - // First, determine if this is a symmetric expansion/contraction or a range shift - // by checking which thumb(s) moved - const isLeftThumbMoved = newValue[0] !== value[0]; - const isRightThumbMoved = newValue[1] !== value[1]; - - // Case 1: Left thumb moved, right thumb needs to move symmetrically (opposite) - if (isLeftThumbMoved && !isRightThumbMoved) { - const delta = newValue[0] - value[0]; - const centerPoint = (value[0] + value[1]) / 2; - - // Symmetric movement: move right thumb in opposite direction - const newRight = value[1] - delta; - - // Ensure values stay within bounds - const clampedLeft = roundToStep(Math.max(min, Math.min(newValue[0], max))); - const clampedRight = roundToStep(Math.min(max, Math.max(newRight, min))); - - // Special case: If right would go out of bounds, adjust left too to maintain center - if (newRight !== clampedRight) { - // Calculate new delta given the clamped right value - const adjustedDelta = value[1] - clampedRight; - // Apply same delta to left but in opposite direction - const adjustedLeft = value[0] + adjustedDelta; - const finalClampedLeft = roundToStep(Math.max(min, Math.min(adjustedLeft, max))); - - const finalValues: [number, number] = [finalClampedLeft, clampedRight]; - setValue(finalValues); - onValueChange?.(finalValues); - return; - } - - const finalValues: [number, number] = [clampedLeft, clampedRight]; - setValue(finalValues); - onValueChange?.(finalValues); - return; - } - - // Case 2: Right thumb moved, left thumb needs to move symmetrically (opposite) - if (!isLeftThumbMoved && isRightThumbMoved) { - const delta = newValue[1] - value[1]; - const centerPoint = (value[0] + value[1]) / 2; - - // Symmetric movement: move left thumb in opposite direction - const newLeft = value[0] - delta; - - // Ensure values stay within bounds - const clampedRight = roundToStep(Math.min(max, Math.max(newValue[1], min))); - const clampedLeft = roundToStep(Math.max(min, Math.min(newLeft, max))); - - // Special case: If left would go out of bounds, adjust right too to maintain center - if (newLeft !== clampedLeft) { - // Calculate new delta given the clamped left value - const adjustedDelta = value[0] - clampedLeft; - // Apply same delta to right but in opposite direction - const adjustedRight = value[1] + adjustedDelta; - const finalClampedRight = roundToStep(Math.min(max, Math.max(adjustedRight, min))); - - const finalValues: [number, number] = [clampedLeft, finalClampedRight]; - setValue(finalValues); - onValueChange?.(finalValues); - return; - } - - const finalValues: [number, number] = [clampedLeft, clampedRight]; - setValue(finalValues); - onValueChange?.(finalValues); - return; - } - - // Case 3: Both thumbs moved (range shift) - maintain the same distance between thumbs - if (isLeftThumbMoved && isRightThumbMoved) { - const rangeWidth = value[1] - value[0]; - - // Calculate how much the left thumb moved - const leftDelta = newValue[0] - value[0]; - - // New values maintaining the same range width - let newLeft = newValue[0]; - let newRight = newLeft + rangeWidth; - - // Handle bounds checking - if (newRight > max) { - newRight = max; - newLeft = newRight - rangeWidth; - } - - if (newLeft < min) { - newLeft = min; - newRight = newLeft + rangeWidth; - } - - const clampedValues: [number, number] = [ - roundToStep(Math.max(min, Math.min(newLeft, max))), - roundToStep(Math.min(max, Math.max(newRight, min))), - ]; - - setValue(clampedValues); - onValueChange?.(clampedValues); - return; - } - - // Fallback to default behavior const clampedValue: [number, number] = [ roundToStep(Math.max(min, Math.min(newValue[0], max))), roundToStep(Math.min(max, Math.max(newValue[1], min))), @@ -233,86 +59,25 @@ const DoubleSlider = React.forwardRef( setValue(clampedValue); onValueChange?.(clampedValue); }, - [min, max, onValueChange, step, value, lockMode] + [min, max, onValueChange, step] ); const handleInputChange = React.useCallback( (index: 0 | 1, inputValue: string) => { const newValue = parseFloat(inputValue); if (!isNaN(newValue)) { - // Default behavior (no lock mode) - if (!lockMode) { - const clampedValue: [number, number] = [...value]; - clampedValue[index] = roundToStep(Math.min(Math.max(newValue, min), max)); - if (index === 0 && clampedValue[0] > clampedValue[1]) { - clampedValue[1] = clampedValue[0]; - } else if (index === 1 && clampedValue[1] < clampedValue[0]) { - clampedValue[0] = clampedValue[1]; - } - setValue(clampedValue); - onValueChange?.(clampedValue); - return; - } - - // Lock mode behavior - const centerPoint = (value[0] + value[1]) / 2; - const rangeWidth = value[1] - value[0]; const clampedValue: [number, number] = [...value]; - - // Calculate new value with constraints - const boundedNewValue = roundToStep(Math.min(Math.max(newValue, min), max)); - - if (index === 0) { - // Left thumb changed - const delta = boundedNewValue - value[0]; - - // Symmetric change: move right thumb in opposite direction - const newRight = value[1] - delta; - const boundedNewRight = roundToStep(Math.min(Math.max(newRight, min), max)); - - // If right would go out of bounds, adjust left too - if (newRight !== boundedNewRight) { - // Adjust to maintain the same width but respect bounds - if (boundedNewRight === max) { - clampedValue[0] = roundToStep(Math.max(max - rangeWidth, min)); - clampedValue[1] = max; - } else if (boundedNewRight === min) { - clampedValue[0] = min; - clampedValue[1] = roundToStep(Math.min(min + rangeWidth, max)); - } - } else { - clampedValue[0] = boundedNewValue; - clampedValue[1] = boundedNewRight; - } - } else { - // Right thumb changed - const delta = boundedNewValue - value[1]; - - // Symmetric change: move left thumb in opposite direction - const newLeft = value[0] - delta; - const boundedNewLeft = roundToStep(Math.min(Math.max(newLeft, min), max)); - - // If left would go out of bounds, adjust right too - if (newLeft !== boundedNewLeft) { - // Adjust to maintain the same width but respect bounds - if (boundedNewLeft === min) { - clampedValue[0] = min; - clampedValue[1] = roundToStep(Math.min(min + rangeWidth, max)); - } else if (boundedNewLeft === max) { - clampedValue[0] = roundToStep(Math.max(max - rangeWidth, min)); - clampedValue[1] = max; - } - } else { - clampedValue[0] = boundedNewLeft; - clampedValue[1] = boundedNewValue; - } + clampedValue[index] = roundToStep(Math.min(Math.max(newValue, min), max)); + if (index === 0 && clampedValue[0] > clampedValue[1]) { + clampedValue[1] = clampedValue[0]; + } else if (index === 1 && clampedValue[1] < clampedValue[0]) { + clampedValue[0] = clampedValue[1]; } - setValue(clampedValue); onValueChange?.(clampedValue); } }, - [value, min, max, onValueChange, step, lockMode] + [value, min, max, onValueChange, step] ); const formatValue = (val: number) => { @@ -344,14 +109,8 @@ const DoubleSlider = React.forwardRef( value={value} onValueChange={handleSliderChange} > - - + + diff --git a/platform/ui-next/src/components/Header/Header.tsx b/platform/ui-next/src/components/Header/Header.tsx index 97b3ffcec..480b45b28 100644 --- a/platform/ui-next/src/components/Header/Header.tsx +++ b/platform/ui-next/src/components/Header/Header.tsx @@ -1,5 +1,4 @@ import React, { ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; import classNames from 'classnames'; import { DropdownMenu, @@ -8,7 +7,9 @@ import { DropdownMenuItem, Icons, Button, + ToolButton, } from '../'; +import { IconPresentationProvider } from '@ohif/ui-next'; import NavBar from '../NavBar'; @@ -51,72 +52,77 @@ function Header({ }; return ( - -
-
-
- {isReturnEnabled && } -
- {WhiteLabeling?.createLogoComponentFn?.(React, props) || } + +
+
+
+ {isReturnEnabled && } +
+ {WhiteLabeling?.createLogoComponentFn?.(React, props) || } +
+
+
+
{Secondary}
+
+
{children}
+
+
+ {UndoRedo} +
+ {PatientInfo} +
+
+ + + + + + {menuOptions.map((option, index) => { + const IconComponent = option.icon + ? Icons[option.icon as keyof typeof Icons] + : null; + return ( + + {IconComponent && ( + + + + )} + {option.title} + + ); + })} + +
-
{Secondary}
-
-
{children}
-
-
- {UndoRedo} -
- {PatientInfo} -
-
- - - - - - {menuOptions.map((option, index) => { - const IconComponent = option.icon - ? Icons[option.icon as keyof typeof Icons] - : null; - return ( - - {IconComponent && ( - - - - )} - {option.title} - - ); - })} - - -
-
-
- + + ); } diff --git a/platform/ui-next/src/components/Icons/Icons.tsx b/platform/ui-next/src/components/Icons/Icons.tsx index 953161288..f3daa6443 100644 --- a/platform/ui-next/src/components/Icons/Icons.tsx +++ b/platform/ui-next/src/components/Icons/Icons.tsx @@ -79,6 +79,9 @@ import OrientationSwitch from './Sources/OrientationSwitch'; import LayerBackground from './Sources/LayerBackground'; import LayerForeground from './Sources/LayerForeground'; import LayerSegmentation from './Sources/LayerSegmentation'; +import WindowLevelAdvanced from './Sources/WindowLevelAdvanced'; +import Opacity from './Sources/Opacity'; +import Threshold from './Sources/Threshold'; import { Tool3DRotate, ToolAngle, @@ -583,6 +586,9 @@ export const Icons = { OHIFLogoColorDarkBackground, Magnifier, Pencil, + WindowLevelAdvanced, + Opacity, + Threshold, // // // diff --git a/platform/ui-next/src/components/Icons/Sources/Close.tsx b/platform/ui-next/src/components/Icons/Sources/Close.tsx index 308317d4d..a96308558 100644 --- a/platform/ui-next/src/components/Icons/Sources/Close.tsx +++ b/platform/ui-next/src/components/Icons/Sources/Close.tsx @@ -3,16 +3,24 @@ import type { IconProps } from '../types'; export const Close = (props: IconProps) => ( + ); diff --git a/platform/ui-next/src/components/Icons/Sources/Opacity.tsx b/platform/ui-next/src/components/Icons/Sources/Opacity.tsx new file mode 100644 index 000000000..ea1902a2c --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/Opacity.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const Opacity = (props: IconProps) => ( + + + + + + + + + + + + + + + +); + +export default Opacity; diff --git a/platform/ui-next/src/components/Icons/Sources/Threshold.tsx b/platform/ui-next/src/components/Icons/Sources/Threshold.tsx new file mode 100644 index 000000000..d3bd401aa --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/Threshold.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const Threshold = (props: IconProps) => ( + + + + + + +); + +export default Threshold; diff --git a/platform/ui-next/src/components/Icons/Sources/WindowLevelAdvanced.tsx b/platform/ui-next/src/components/Icons/Sources/WindowLevelAdvanced.tsx new file mode 100644 index 000000000..e4759b1ff --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/WindowLevelAdvanced.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const WindowLevelAdvanced = (props: IconProps) => ( + + + + + +); + +export default WindowLevelAdvanced; diff --git a/platform/ui-next/src/components/Numeric/Numeric.tsx b/platform/ui-next/src/components/Numeric/Numeric.tsx index eca111654..40badb61f 100644 --- a/platform/ui-next/src/components/Numeric/Numeric.tsx +++ b/platform/ui-next/src/components/Numeric/Numeric.tsx @@ -29,7 +29,6 @@ interface NumericMetaContextValue { min: number; max: number; step: number; - lockMode?: boolean; } const NumericMetaContext = createContext(null); @@ -48,7 +47,6 @@ interface NumericMetaContainerProps { max?: number; step?: number; className?: string; - lockMode?: boolean; // Add lock mode prop for doubleRange } function NumericMetaContainer({ @@ -62,7 +60,6 @@ function NumericMetaContainer({ max = 100, step = 1, className, - lockMode = false, children, }: PropsWithChildren) { // Calculate default values based on min and max @@ -118,7 +115,6 @@ function NumericMetaContainer({ min, max, step, - lockMode, }} >
{children}
@@ -240,7 +236,7 @@ function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) { throw new Error('DoubleRange must be used inside .'); } - const { mode, doubleValue, setDoubleValue, min, max, step, lockMode } = ctx; + const { mode, doubleValue, setDoubleValue, min, max, step } = ctx; const handleSliderChange = useCallback( (values: [number, number]) => { @@ -262,7 +258,6 @@ function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) { defaultValue={doubleValue} onValueChange={handleSliderChange} showNumberInputs={showNumberInputs} - lockMode={lockMode} />
); diff --git a/platform/ui-next/src/components/Select/Select.tsx b/platform/ui-next/src/components/Select/Select.tsx index 441a3abce..116a14438 100644 --- a/platform/ui-next/src/components/Select/Select.tsx +++ b/platform/ui-next/src/components/Select/Select.tsx @@ -119,7 +119,13 @@ const SelectItem = React.forwardRef< - {children} + {typeof children === 'string' ? ( + + {children} + + ) : ( + children + )} )); SelectItem.displayName = SelectPrimitive.Item.displayName; diff --git a/platform/ui-next/src/components/ToolButton/ToolButton.tsx b/platform/ui-next/src/components/ToolButton/ToolButton.tsx index 6e93779da..fa221c9de 100644 --- a/platform/ui-next/src/components/ToolButton/ToolButton.tsx +++ b/platform/ui-next/src/components/ToolButton/ToolButton.tsx @@ -3,6 +3,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip'; import { Icons } from '../Icons'; import { Button } from '../Button'; import { cn } from '../../lib/utils'; +import { useIconPresentation } from '../../contextProviders/IconPresentationProvider'; const baseClasses = '!rounded-lg inline-flex items-center justify-center'; const defaultClasses = 'bg-transparent text-foreground/80 hover:bg-background hover:text-highlight'; @@ -19,6 +20,10 @@ const sizeClasses = { buttonSizeClass: 'w-8 h-8', iconSizeClass: 'h-6 w-6', }, + tiny: { + buttonSizeClass: 'w-6 h-6', + iconSizeClass: 'h-4 w-4', + }, }; interface ToolButtonProps { @@ -33,6 +38,7 @@ interface ToolButtonProps { commands?: Record; onInteraction?: (details: { itemId: string; commands?: Record }) => void; className?: string; + children?: React.ReactNode; } function ToolButton(props: ToolButtonProps) { @@ -48,8 +54,10 @@ function ToolButton(props: ToolButtonProps) { commands, onInteraction, className, + children, } = props; + const { className: iconClassName } = useIconPresentation(); const { buttonSizeClass, iconSizeClass } = sizeClasses[size] || sizeClasses.default; const buttonClasses = cn( @@ -90,10 +98,12 @@ function ToolButton(props: ToolButtonProps) { aria-label={defaultTooltip} disabled={disabled} > - + {children || ( + + )} diff --git a/platform/ui-next/src/components/ToolButton/ToolButtonList.tsx b/platform/ui-next/src/components/ToolButton/ToolButtonList.tsx index e2374a470..062807c40 100644 --- a/platform/ui-next/src/components/ToolButton/ToolButtonList.tsx +++ b/platform/ui-next/src/components/ToolButton/ToolButtonList.tsx @@ -141,7 +141,6 @@ const ToolButtonListItem = React.forwardRef< ToolButtonListItemProps >(({ className, children, icon, disabledText, tooltip, disabled, ...props }, ref) => { const defaultTooltip = tooltip || (typeof children === 'string' ? children : undefined); - const hasTooltip = defaultTooltip || disabledText; const menuItem = ( {children}; } +function TopMiddle({ children }: { children: ReactNode }) { + return {children}; +} + +function BottomMiddle({ children }: { children: ReactNode }) { + return {children}; +} + +function LeftMiddle({ children }: { children: ReactNode }) { + return {children}; +} + +function RightMiddle({ children }: { children: ReactNode }) { + return {children}; +} + export const ViewportActionCorners = { Container, TopLeft, TopRight, BottomLeft, BottomRight, + TopMiddle, + BottomMiddle, + LeftMiddle, + RightMiddle, }; diff --git a/platform/ui-next/src/components/Viewport/ViewportOverlay.css b/platform/ui-next/src/components/Viewport/ViewportOverlay.css index 5b17f7ad8..40a59a7b4 100644 --- a/platform/ui-next/src/components/Viewport/ViewportOverlay.css +++ b/platform/ui-next/src/components/Viewport/ViewportOverlay.css @@ -14,7 +14,7 @@ top: 2.15rem; } .overlay-bottom { - bottom: 1rem; + bottom: 2.15rem; } .overlay-text { diff --git a/platform/ui-next/src/contextProviders/IconPresentationProvider.tsx b/platform/ui-next/src/contextProviders/IconPresentationProvider.tsx new file mode 100644 index 000000000..d951a98ab --- /dev/null +++ b/platform/ui-next/src/contextProviders/IconPresentationProvider.tsx @@ -0,0 +1,88 @@ +import React, { createContext, useContext, ReactNode, ComponentType } from 'react'; +import { cn } from '../utils'; +import { Button } from '../components/Button'; + +export type IconSizeType = 'tiny' | 'small' | 'medium' | 'large' | number; + +interface IconSizeContextType { + size: IconSizeType; + getSizeValue: (size?: IconSizeType) => number | string; + getSizeClassName: (size?: IconSizeType, additionalClasses?: string) => string; + IconContainer: ComponentType; + containerProps: { + variant: string; + size: string; + [key: string]: any; + }; + className: string; +} + +const sizeMap = { + tiny: 16, + small: 20, + medium: 24, + large: 28, +}; + +export const getSizeValue = (size: IconSizeType = 'medium'): number | string => { + if (typeof size === 'number') { + return size; + } + return sizeMap[size] || sizeMap.medium; +}; + +export const getSizeClassName = (size: IconSizeType = 'medium', additionalClasses = ''): string => { + const sizeValue = getSizeValue(size); + return cn(`h-[${sizeValue}px] w-[${sizeValue}px]`, additionalClasses); +}; + +const defaultContext: IconSizeContextType = { + size: 'medium', + getSizeValue, + getSizeClassName, + IconContainer: Button, + containerProps: { + variant: 'ghost', + size: 'icon', + }, + className: '', +}; + +export const IconSizeContext = createContext(defaultContext); + +interface IconPresentationProviderProps { + size: IconSizeType; + children: ReactNode; + IconContainer?: ComponentType; + containerProps?: { + variant?: string; + size?: string; + [key: string]: any; + }; +} + +export const IconPresentationProvider = ({ + size, + children, + IconContainer = Button, + containerProps = {}, +}: IconPresentationProviderProps) => { + const className = getSizeClassName(size); + const mergedProps = { + variant: 'ghost', + size: 'icon', + ...containerProps, + }; + + const contextValue = { + size, + getSizeValue, + getSizeClassName, + IconContainer, + containerProps: mergedProps, + className, + }; + return {children}; +}; + +export const useIconPresentation = () => useContext(IconSizeContext); diff --git a/platform/ui-next/src/contextProviders/ViewportActionCornersProvider.tsx b/platform/ui-next/src/contextProviders/ViewportActionCornersProvider.tsx deleted file mode 100644 index 55fe7dc2d..000000000 --- a/platform/ui-next/src/contextProviders/ViewportActionCornersProvider.tsx +++ /dev/null @@ -1,550 +0,0 @@ -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 72a4726c3..c37857c11 100644 --- a/platform/ui-next/src/contextProviders/index.ts +++ b/platform/ui-next/src/contextProviders/index.ts @@ -9,10 +9,6 @@ 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 }; @@ -24,4 +20,4 @@ export { UserAuthenticationProvider, useUserAuthentication }; export { ImageViewerContext, ImageViewerProvider, useImageViewer }; export { DragAndDropProvider }; export { CineProvider, useCine }; -export { ViewportActionCornersProvider, useViewportActionCorners }; +export { IconPresentationProvider, useIconPresentation } from './IconPresentationProvider'; diff --git a/tests/SRHydration.spec.ts b/tests/SRHydration.spec.ts index 7af167141..e9640b020 100644 --- a/tests/SRHydration.spec.ts +++ b/tests/SRHydration.spec.ts @@ -11,6 +11,7 @@ test('should hydrate SR reports correctly', async ({ page }) => { await page.getByTestId('side-panel-header-right').click(); await page.getByTestId('trackedMeasurements-btn').click(); await page.getByTestId('study-browser-thumbnail-no-image').dblclick(); + await page.waitForTimeout(2000); await checkForScreenshot(page, page, screenShotPaths.srHydration.srPreHydration); await page.evaluate(() => { @@ -33,6 +34,7 @@ test('should hydrate SR reports correctly', async ({ page }) => { }); await page.getByTestId('yes-hydrate-btn').click(); + await page.waitForTimeout(2000); await checkForScreenshot(page, page, screenShotPaths.srHydration.srPostHydration); await page.evaluate(() => { @@ -55,5 +57,6 @@ test('should hydrate SR reports correctly', async ({ page }) => { }); await page.getByTestId('data-row').first().click(); + await page.waitForTimeout(2000); await checkForScreenshot(page, page, screenShotPaths.srHydration.srJumpToMeasurement); }); diff --git a/tests/screenshots/chromium/3DFourUp.spec.ts/threeDFourUpDisplayedCorrectly.png b/tests/screenshots/chromium/3DFourUp.spec.ts/threeDFourUpDisplayedCorrectly.png index 436c4a89cbdb8beb6177be229b5905b52423ca35..c88dcafbd4dcd752c30873acee8b5c481a809b2f 100644 GIT binary patch literal 331204 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