diff --git a/extensions/cornerstone-dicom-seg/src/commandsModule.ts b/extensions/cornerstone-dicom-seg/src/commandsModule.ts index d346ce207..7e1b456db 100644 --- a/extensions/cornerstone-dicom-seg/src/commandsModule.ts +++ b/extensions/cornerstone-dicom-seg/src/commandsModule.ts @@ -1,5 +1,4 @@ import dcmjs from 'dcmjs'; -import { createReportDialogPrompt } from '@ohif/extension-default'; import { Types } from '@ohif/core'; import { cache, metaData } from '@cornerstonejs/core'; import { @@ -8,11 +7,13 @@ import { utilities, } from '@cornerstonejs/tools'; import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters'; +import { createReportDialogPrompt } from '@ohif/extension-default'; import { classes, DicomMetadataStore } from '@ohif/core'; import vtkImageMarchingSquares from '@kitware/vtk.js/Filters/General/ImageMarchingSquares'; import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES'; const { segmentation: segmentationUtils } = utilities; @@ -51,6 +52,7 @@ const commandsModule = ({ displaySetService, viewportGridService, toolGroupService, + customizationService, } = servicesManager.services as AppTypes.Services; const actions = { @@ -226,14 +228,6 @@ const commandsModule = ({ * otherwise throws an error. */ storeSegmentation: async ({ segmentationId, dataSource }) => { - const promptResult = await createReportDialogPrompt(uiDialogService, { - extensionManager, - }); - - if (promptResult.action !== 1 && !promptResult.value) { - return; - } - const segmentation = segmentationService.getSegmentation(segmentationId); if (!segmentation) { @@ -241,33 +235,50 @@ const commandsModule = ({ } const { label } = segmentation; - const SeriesDescription = promptResult.value || label || 'Research Derived Series'; + const defaultDataSource = dataSource ?? extensionManager.getActiveDataSource(); - const generatedData = actions.generateSegmentation({ - segmentationId, - options: { - SeriesDescription, - }, + const { + value: reportName, + dataSourceName: selectedDataSource, + action, + } = await createReportDialogPrompt({ + servicesManager, + extensionManager, + title: 'Store Segmentation', }); - if (!generatedData || !generatedData.dataset) { - throw new Error('Error during segmentation generation'); + if (action === PROMPT_RESPONSES.CREATE_REPORT) { + try { + const selectedDataSourceConfig = selectedDataSource + ? extensionManager.getDataSources(selectedDataSource)[0] + : defaultDataSource; + + const generatedData = actions.generateSegmentation({ + segmentationId, + options: { + SeriesDescription: reportName || label || 'Research Derived Series', + }, + }); + + if (!generatedData || !generatedData.dataset) { + throw new Error('Error during segmentation generation'); + } + + const { dataset: naturalizedReport } = generatedData; + + await selectedDataSourceConfig.store.dicom(naturalizedReport); + + // add the information for where we stored it to the instance as well + naturalizedReport.wadoRoot = selectedDataSourceConfig.getConfig().wadoRoot; + + DicomMetadataStore.addInstances([naturalizedReport], true); + + return naturalizedReport; + } catch (error) { + console.debug('Error storing segmentation:', error); + throw error; + } } - - const { dataset: naturalizedReport } = generatedData; - - await dataSource.store.dicom(naturalizedReport); - - // The "Mode" route listens for DicomMetadataStore changes - // When a new instance is added, it listens and - // automatically calls makeDisplaySets - - // add the information for where we stored it to the instance as well - naturalizedReport.wadoRoot = dataSource.getConfig().wadoRoot; - - DicomMetadataStore.addInstances([naturalizedReport], true); - - return naturalizedReport; }, /** * Converts segmentations into RTSS for download. diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index b98cd320e..d7570200d 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -146,6 +146,11 @@ const OHIFCornerstoneViewport = React.memo( const { viewportId, element } = evt.detail; const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId); + + if (!viewportInfo) { + return; + } + setEnabledElement(viewportId, element); setEnabledVPElement(element); @@ -323,10 +328,10 @@ const OHIFCornerstoneViewport = React.memo( // Set up the window level action menu in the viewport action corners. useEffect(() => { const windowLevelActionMenu = customizationService.getCustomization( - 'viewportActionMenu.windowLevelActionMenu' + 'viewportActionMenu.windowLevelActionMenu' ); const segmentationOverlay = customizationService.getCustomization( - 'viewportActionMenu.segmentationOverlay' + 'viewportActionMenu.segmentationOverlay' ); if (windowLevelActionMenu?.enabled) { @@ -362,13 +367,7 @@ const OHIFCornerstoneViewport = React.memo( location: segmentationOverlay.location, }); } - }, [ - displaySets, - viewportId, - viewportActionCornersService, - servicesManager, - commandsManager, - ]); + }, [displaySets, viewportId, viewportActionCornersService, servicesManager, commandsManager]); const { ref: resizeRef } = useResizeDetector({ onResize, diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index adbab06f9..24778509d 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -18,21 +18,19 @@ import * as cornerstoneTools from '@cornerstonejs/tools'; import { Types as OhifTypes, utils } from '@ohif/core'; import i18n from '@ohif/i18n'; import { - callLabelAutocompleteDialog, - showLabelAnnotationPopup, + callInputDialogAutoComplete, createReportAsync, - callInputDialog, colorPickerDialog, + callInputDialog, } from '@ohif/extension-default'; import { vec3, mat4 } from 'gl-matrix'; -import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import toggleImageSliceSync from './utils/imageSliceSync/toggleImageSliceSync'; import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; import toggleVOISliceSync from './utils/toggleVOISliceSync'; import { usePositionPresentationStore, useSegmentationPresentationStore } from './stores'; import { toolNames } from './initCornerstoneTools'; - +import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; const { DefaultHistoryMemo } = csUtils.HistoryMemo; const toggleSyncFunctions = { imageSlice: toggleImageSliceSync, @@ -200,26 +198,55 @@ function commandsModule({ } }, /** - * Show the measurement labelling input dialog and update the label - * on the measurement with a response if not cancelled. + * Common logic for handling measurement label updates through dialog + * @param uid - measurement uid + * @returns Promise that resolves when the label is updated */ - setMeasurementLabel: ({ uid }) => { + _handleMeasurementLabelDialog: async uid => { const labelConfig = customizationService.getCustomization('measurementLabels'); const renderContent = customizationService.getCustomization('ui.labellingComponent'); const measurement = measurementService.getMeasurement(uid); - showLabelAnnotationPopup(measurement, uiDialogService, labelConfig, renderContent).then( - (val: Map) => { - measurementService.update( - uid, - { - ...val, - }, - true - ); - } - ); - }, + if (!measurement) { + console.debug('No measurement found for label editing'); + return; + } + + if (!labelConfig) { + const label = await callInputDialog({ + uiDialogService, + title: 'Edit Measurement Label', + placeholder: measurement.label || 'Enter new label', + defaultValue: measurement.label, + }); + + if (label !== undefined && label !== null) { + measurementService.update(uid, { ...measurement, label }, true); + } + return; + } + + const val = await callInputDialogAutoComplete({ + measurement, + uiDialogService, + labelConfig, + renderContent, + }); + + if (val !== undefined && val !== null) { + measurementService.update(uid, { ...val }, true); + } + }, + /** + * Show the measurement labelling input dialog and update the label + * on the measurement with a response if not cancelled. + */ + setMeasurementLabel: async ({ uid }) => { + await actions._handleMeasurementLabelDialog(uid); + }, + renameMeasurement: async ({ uid }) => { + await actions._handleMeasurementLabelDialog(uid); + }, /** * * @param props - containing the updates to apply @@ -321,23 +348,6 @@ function commandsModule({ measurementService.remove(uid); }, - renameMeasurement: ({ uid }) => { - const labelConfig = customizationService.getCustomization('measurementLabels'); - const renderContent = customizationService.getCustomization('ui.labellingComponent'); - const measurement = measurementService.getMeasurement(uid); - showLabelAnnotationPopup(measurement, uiDialogService, labelConfig, renderContent).then( - val => { - measurementService.update( - uid, - { - ...val, - }, - true - ); - } - ); - }, - toggleLockMeasurement: ({ uid }) => { measurementService.toggleLockMeasurement(uid); }, @@ -375,10 +385,10 @@ function commandsModule({ viewportGridService.setActiveViewportId(viewportId); }, - arrowTextCallback: ({ callback, data, uid }) => { + arrowTextCallback: ({ callback }) => { const labelConfig = customizationService.getCustomization('measurementLabels'); const renderContent = customizationService.getCustomization('ui.labellingComponent'); - callLabelAutocompleteDialog(uiDialogService, callback, {}, labelConfig, renderContent); + callInputDialogAutoComplete(uiDialogService, callback, {}, labelConfig, renderContent); }, toggleCine: () => { const { viewports } = viewportGridService.getState(); @@ -568,6 +578,7 @@ function commandsModule({ ], }); }, + // capture viewport showDownloadViewportModal: () => { const { activeViewportId } = viewportGridService.getState(); @@ -589,10 +600,9 @@ function commandsModule({ title: 'Download High Quality Image', contentProps: { activeViewportId, - onClose: uiModalService.hide, cornerstoneViewportService, }, - containerDimensions: 'w-[70%] max-w-[900px]', + containerClassName: 'max-w-4xl p-4', }); } }, @@ -1138,14 +1148,12 @@ function commandsModule({ */ storeSegmentationCommand: async ({ segmentationId }) => { const { segmentationService, viewportGridService } = servicesManager.services; - const datasources = extensionManager.getActiveDataSource(); const displaySetInstanceUIDs = await createReportAsync({ servicesManager, getReport: () => commandsManager.runCommand('storeSegmentation', { segmentationId, - dataSource: datasources[0], }), reportType: 'Segmentation', }); @@ -1271,7 +1279,7 @@ function commandsModule({ segmentationService.setStyle({ type }, { fillAlphaInactive: value }); }, - editSegmentLabel: ({ segmentationId, segmentIndex }) => { + editSegmentLabel: async ({ segmentationId, segmentIndex }) => { const { segmentationService, uiDialogService } = servicesManager.services; const segmentation = segmentationService.getSegmentation(segmentationId); @@ -1280,19 +1288,14 @@ function commandsModule({ } const segment = segmentation.segments[segmentIndex]; - const { label } = segment; - - const callback = (label, actionId) => { - if (label === '') { - return; - } + callInputDialog({ + uiDialogService, + title: 'Edit Segment Label', + placeholder: 'Enter new label', + defaultValue: segment.label, + }).then(label => { segmentationService.setSegmentLabel(segmentationId, segmentIndex, label); - }; - - callInputDialog(uiDialogService, label, callback, false, { - dialogTitle: 'Edit Segment Label', - inputLabel: 'Enter new label', }); }, @@ -1306,17 +1309,13 @@ function commandsModule({ const { label } = segmentation; - const callback = (label, actionId) => { - if (label === '') { - return; - } - + callInputDialog({ + uiDialogService, + title: 'Edit Segmentation Label', + placeholder: 'Enter new label', + defaultValue: label, + }).then(label => { segmentationService.addOrUpdateSegmentation({ segmentationId, label }); - }; - - callInputDialog(uiDialogService, label, callback, false, { - dialogTitle: 'Edit Segmentation Label', - inputLabel: 'Enter new label', }); }, @@ -1333,13 +1332,16 @@ function commandsModule({ a: color[3] / 255.0, }; - colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => { - if (actionId === 'cancel') { - return; - } - - const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0]; - segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color); + uiDialogService.show({ + content: colorPickerDialog, + title: 'Segment Color', + contentProps: { + value: rgbaColor, + onSave: newRgbaColor => { + const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0]; + segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color); + }, + }, }); }, @@ -1395,6 +1397,9 @@ function commandsModule({ setMeasurementLabel: { commandFn: actions.setMeasurementLabel, }, + renameMeasurement: { + commandFn: actions.renameMeasurement, + }, updateMeasurement: { commandFn: actions.updateMeasurement, }, @@ -1407,9 +1412,6 @@ function commandsModule({ removeMeasurement: { commandFn: actions.removeMeasurement, }, - renameMeasurement: { - commandFn: actions.renameMeasurement, - }, toggleLockMeasurement: { commandFn: actions.toggleLockMeasurement, }, diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx index 0949cac87..877fc42c8 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx @@ -10,21 +10,19 @@ export function VolumeRenderingPresets({ commandsManager, volumeRenderingPresets, }: VolumeRenderingPresetsProps): ReactElement { - const { uiModalService } = servicesManager.services; + const { uiDialogService } = servicesManager.services; const onClickPresets = () => { - uiModalService.show({ + uiDialogService.show({ + id: 'volume-rendering-presets', content: VolumeRenderingPresetsContent, title: 'Rendering Presets', - movable: true, + isDraggable: true, contentProps: { - onClose: uiModalService.hide, presets: volumeRenderingPresets, viewportId, commandsManager, }, - containerDimensions: 'h-[543px] w-[460px]', - contentDimensions: 'h-[493px] w-[460px] pl-[12px] pr-[12px]', }); }; diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresetsContent.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresetsContent.tsx index 77853b424..c1275ea65 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresetsContent.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresetsContent.tsx @@ -1,29 +1,24 @@ -import { ButtonEnums } from '@ohif/ui'; -import { Icons } from '@ohif/ui-next'; +import { Icons, FooterAction } from '@ohif/ui-next'; import React, { ReactElement, useState, useCallback } from 'react'; -import { Button, InputFilterText } from '@ohif/ui'; +import { PresetDialog } from '@ohif/ui-next'; import { ViewportPreset, VolumeRenderingPresetsContentProps } from '../../types/ViewportPresets'; +interface Props extends VolumeRenderingPresetsContentProps { + hide: () => void; +} + export function VolumeRenderingPresetsContent({ presets, viewportId, commandsManager, - onClose, -}: VolumeRenderingPresetsContentProps): ReactElement { - const [filteredPresets, setFilteredPresets] = useState(presets); + hide, +}: Props): ReactElement { const [searchValue, setSearchValue] = useState(''); const [selectedPreset, setSelectedPreset] = useState(null); - const handleSearchChange = useCallback( - (value: string) => { - setSearchValue(value); - const filtered = value - ? presets.filter(preset => preset.name.toLowerCase().includes(value.toLowerCase())) - : presets; - setFilteredPresets(filtered); - }, - [presets] - ); + const handleSearchChange = useCallback((event: React.ChangeEvent) => { + setSearchValue(event.target.value); + }, []); const handleApply = useCallback( props => { @@ -34,62 +29,54 @@ export function VolumeRenderingPresetsContent({ [commandsManager] ); + const filteredPresets = searchValue + ? presets.filter(preset => preset.name.toLowerCase().includes(searchValue.toLowerCase())) + : presets; + const formatLabel = (label: string, maxChars: number) => { return label.length > maxChars ? `${label.slice(0, maxChars)}...` : label; }; return ( -
-
-
-
- -
-
-
-
- {filteredPresets.map((preset, index) => ( -
{ - setSelectedPreset(preset); - handleApply({ preset: preset.name, viewportId }); - }} - > - - -
- ))} -
-
-
-
-
- -
-
-
+ + + + + + + {filteredPresets.map((preset, index) => ( +
{ + setSelectedPreset(preset); + handleApply({ preset: preset.name, viewportId }); + }} + > + + +
+ ))} +
+
+ + + Cancel + + +
); } diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx index ebd35e896..9758f25df 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/getWindowLevelActionMenu.tsx @@ -21,8 +21,9 @@ export function getWindowLevelActionMenu({ const { volumeRenderingPresets, volumeRenderingQualityRange } = customizationService.getCustomization('cornerstone.3dVolumeRendering'); const WindowLevelActionMenu = customizationService.getCustomization( - 'cornerstone.windowLevelActionMenu' + 'viewportActionMenu.windowLevelActionMenu' ); + console.debug('šŸš€ ~ WindowLevelActionMenu:', WindowLevelActionMenu); const displaySetPresets = displaySets .filter(displaySet => presets[displaySet.Modality]) .map(displaySet => { @@ -37,8 +38,10 @@ export function getWindowLevelActionMenu({ return null; } + const WindowLevelActionMenuComponent = WindowLevelActionMenu?.component; + return ( - void; + defaultSize: number; + fileTypeOptions: Array<{ value: string; label: string }>; + viewportId: string; + showAnnotations: boolean; + onAnnotationsChange: (show: boolean) => void; + dimensions: { width: number; height: number }; + onDimensionsChange: (dimensions: { width: number; height: number }) => void; + onEnableViewport: (element: HTMLElement) => void; + onDisableViewport: () => void; + onDownload: (filename: string, fileType: string) => void; + warningState: { enabled: boolean; value: string }; +} + +function ViewportDownloadFormNew({ + onClose, + defaultSize, + fileTypeOptions, + viewportId, + showAnnotations, + onAnnotationsChange, + dimensions, + warningState, + onDimensionsChange, + onEnableViewport, + onDisableViewport, + onDownload, +}: ViewportDownloadFormNewProps) { + const [viewportElement, setViewportElement] = useState(null); + const [showWarningMessage, setShowWarningMessage] = useState(true); + const [filename, setFilename] = useState(DEFAULT_FILENAME); + const [fileType, setFileType] = useState('jpg'); + + useEffect(() => { + if (!viewportElement) { + return; + } + + onEnableViewport(viewportElement); + + return () => { + onDisableViewport(); + }; + }, [onDisableViewport, onEnableViewport, viewportElement]); + + return ( + + + +
+ {warningState.enabled && showWarningMessage && ( +
+ {warningState.value} +
+ )} +
+
+ + +
+ setFilename(e.target.value)} + > + File name + + +
+ + { + onDimensionsChange({ + ...dimensions, + width: parseInt(e.target.value) || defaultSize, + }); + }} + onHeightChange={e => { + onDimensionsChange({ + ...dimensions, + height: parseInt(e.target.value) || defaultSize, + }); + }} + maxWidth={MAX_TEXTURE_SIZE.toString()} + maxHeight={MAX_TEXTURE_SIZE.toString()} + > + Image size px + + + + Include annotations + + {warningState.enabled && ( + + Include warning message + + )} + + + Cancel + { + onDownload(filename || DEFAULT_FILENAME, fileType); + onClose(); + }} + > + Save + + + +
+
+
+ ); +} + +export default { + 'ohif.captureViewportModal': ViewportDownloadFormNew, +}; diff --git a/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts b/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts index 0605c15be..8bff4eea4 100644 --- a/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts +++ b/extensions/cornerstone/src/customizations/viewportActionMenuCustomizations.ts @@ -1,9 +1,11 @@ import viewportActionCornersService from '../services/ViewportActionCornersService/ViewportActionCornersService'; +import { WindowLevelActionMenu } from '../components/WindowLevelActionMenu/WindowLevelActionMenu'; export default { 'viewportActionMenu.windowLevelActionMenu': { enabled: true, location: viewportActionCornersService.LOCATIONS.topRight, + component: WindowLevelActionMenu, }, 'viewportActionMenu.segmentationOverlay': { enabled: true, diff --git a/extensions/cornerstone/src/customizations/viewportDownloadWarningCustomization.tsx b/extensions/cornerstone/src/customizations/viewportDownloadWarningCustomization.tsx new file mode 100644 index 000000000..99b198706 --- /dev/null +++ b/extensions/cornerstone/src/customizations/viewportDownloadWarningCustomization.tsx @@ -0,0 +1,6 @@ +export default { + 'viewportDownload.warningMessage': { + enabled: true, + value: 'Not For Diagnostic Use', + }, +}; diff --git a/extensions/cornerstone/src/customizations/windowLevelActionMenuCustomization.ts b/extensions/cornerstone/src/customizations/windowLevelActionMenuCustomization.ts deleted file mode 100644 index ccbcca53b..000000000 --- a/extensions/cornerstone/src/customizations/windowLevelActionMenuCustomization.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { WindowLevelActionMenu } from '../components/WindowLevelActionMenu/WindowLevelActionMenu'; - -export default { - 'cornerstone.windowLevelActionMenu': WindowLevelActionMenu, -}; diff --git a/extensions/cornerstone/src/getCustomizationModule.tsx b/extensions/cornerstone/src/getCustomizationModule.tsx index b37c98902..8a17c0fe5 100644 --- a/extensions/cornerstone/src/getCustomizationModule.tsx +++ b/extensions/cornerstone/src/getCustomizationModule.tsx @@ -8,7 +8,8 @@ import volumeRenderingCustomization from './customizations/volumeRenderingCustom import colorbarCustomization from './customizations/colorbarCustomization'; import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization'; import miscCustomization from './customizations/miscCustomization'; -import windowLevelActionMenuCustomization from './customizations/windowLevelActionMenuCustomization'; +import captureViewportModalCustomization from './customizations/captureViewportModalCustomization'; +import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization'; import viewportActionMenuCustomizations from './customizations/viewportActionMenuCustomizations'; function getCustomizationModule({ commandsManager, servicesManager }) { @@ -26,7 +27,8 @@ function getCustomizationModule({ commandsManager, servicesManager }) { ...colorbarCustomization, ...windowLevelPresetsCustomization, ...miscCustomization, - ...windowLevelActionMenuCustomization, + ...captureViewportModalCustomization, + ...viewportDownloadWarningCustomization, ...viewportActionMenuCustomizations, }, }, diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 3af9177d1..730a82798 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -54,6 +54,7 @@ import PanelMeasurement from './panels/PanelMeasurement'; import DicomUpload from './components/DicomUpload/DicomUpload'; import { useSegmentations } from './hooks/useSegmentations'; import { StudySummaryFromMetadata } from './components/StudySummaryFromMetadata'; +import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import utils from './utils'; const { imageRetrieveMetadataProvider } = cornerstone.utilities; @@ -255,6 +256,7 @@ export { PanelMeasurement, DicomUpload, StudySummaryFromMetadata, + CornerstoneViewportDownloadForm, utils, }; export default cornerstoneExtension; diff --git a/extensions/cornerstone/src/tools/CalibrationLineTool.ts b/extensions/cornerstone/src/tools/CalibrationLineTool.ts index 2c4f46d27..8f85ec35f 100644 --- a/extensions/cornerstone/src/tools/CalibrationLineTool.ts +++ b/extensions/cornerstone/src/tools/CalibrationLineTool.ts @@ -84,35 +84,14 @@ export function onCompletedCalibrationLine( return; } - callInputDialog( + callInputDialog({ uiDialogService, - { - text: '', - label: `${length}`, - }, - (value, id) => { - if (id === 'save') { - adjustCalibration(Number.parseFloat(value)); - resolve(true); - } else { - reject('cancel'); - } - }, - false, - { - dialogTitle: 'Calibration', - inputLabel: 'Actual Physical distance (mm)', - - // the input value must be a number - validateFunc: val => { - try { - const v = Number.parseFloat(val); - return !isNaN(v) && v !== 0.0; - } catch { - return false; - } - }, - } - ); + title: 'Calibration', + placeholder: 'Actual Physical distance (mm)', + defaultValue: `${length}`, + }).then(newValue => { + adjustCalibration(Number.parseFloat(newValue)); + resolve(true); + }); }); } diff --git a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx index 5e72d37ab..c343b85c9 100644 --- a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx +++ b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx @@ -1,53 +1,66 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import html2canvas from 'html2canvas'; -import { - Enums, - getEnabledElement, - getOrCreateCanvas, - StackViewport, - BaseVolumeViewport, -} from '@cornerstonejs/core'; +import { getEnabledElement, StackViewport, BaseVolumeViewport } from '@cornerstonejs/core'; import { ToolGroupManager } from '@cornerstonejs/tools'; -import { ViewportDownloadForm } from '@ohif/ui'; - import { getEnabledElement as OHIFgetEnabledElement } from '../state'; +import { useSystem } from '@ohif/core/src'; -const MINIMUM_SIZE = 100; const DEFAULT_SIZE = 512; const MAX_TEXTURE_SIZE = 10000; const VIEWPORT_ID = 'cornerstone-viewport-download-form'; +const FILE_TYPE_OPTIONS = [ + { + value: 'jpg', + label: 'JPG', + }, + { + value: 'png', + label: 'PNG', + }, +]; + +type ViewportDownloadFormProps = { + hide: () => void; + activeViewportId: string; +}; + const CornerstoneViewportDownloadForm = ({ - onClose, + hide, activeViewportId: activeViewportIdProp, - cornerstoneViewportService, -}: withAppTypes) => { - const enabledElement = OHIFgetEnabledElement(activeViewportIdProp); - const activeViewportElement = enabledElement?.element; - const activeViewportEnabledElement = getEnabledElement(activeViewportElement); +}: ViewportDownloadFormProps) => { + const { servicesManager } = useSystem(); + const { customizationService, cornerstoneViewportService } = servicesManager.services; + const [showAnnotations, setShowAnnotations] = useState(true); + const [viewportDimensions, setViewportDimensions] = useState({ + width: DEFAULT_SIZE, + height: DEFAULT_SIZE, + }); - const { - viewportId: activeViewportId, - renderingEngineId, - viewport: activeViewport, - } = activeViewportEnabledElement; + const warningState = customizationService.getCustomization('viewportDownload.warningMessage') as { + enabled: boolean; + value: string; + }; + const refViewportEnabledElementOHIF = OHIFgetEnabledElement(activeViewportIdProp); + const activeViewportElement = refViewportEnabledElementOHIF?.element; + const { viewportId: activeViewportId, renderingEngineId } = + getEnabledElement(activeViewportElement); + + const renderingEngine = cornerstoneViewportService.getRenderingEngine(); const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId); - const toolModeAndBindings = Object.keys(toolGroup.toolOptions).reduce((acc, toolName) => { - const tool = toolGroup.toolOptions[toolName]; - const { mode, bindings } = tool; - - return { - ...acc, - [toolName]: { - mode, - bindings, - }, - }; - }, {}); - useEffect(() => { + const toolModeAndBindings = Object.keys(toolGroup.toolOptions).reduce((acc, toolName) => { + const tool = toolGroup.toolOptions[toolName]; + const { mode, bindings } = tool; + + return { + ...acc, + [toolName]: { mode, bindings }, + }; + }, {}); + return () => { Object.keys(toolModeAndBindings).forEach(toolName => { const { mode, bindings } = toolModeAndBindings[toolName]; @@ -56,157 +69,92 @@ const CornerstoneViewportDownloadForm = ({ }; }, []); - const enableViewport = viewportElement => { - if (viewportElement) { - const { renderingEngine, viewport } = getEnabledElement(activeViewportElement); - - const viewportInput = { - viewportId: VIEWPORT_ID, - element: viewportElement, - type: viewport.type, - defaultOptions: { - background: viewport.defaultOptions.background, - orientation: viewport.defaultOptions.orientation, - }, - }; - - renderingEngine.enableElement(viewportInput); - } - }; - - const disableViewport = viewportElement => { - if (viewportElement) { - const { renderingEngine } = getEnabledElement(viewportElement); - return new Promise(resolve => { - renderingEngine.disableElement(VIEWPORT_ID); - }); - } - }; - - const updateViewportPreview = (downloadViewportElement, internalCanvas, fileType) => - new Promise(resolve => { - const enabledElement = getEnabledElement(downloadViewportElement); - - const { viewport: downloadViewport, renderingEngine } = enabledElement; - - // Note: Since any trigger of dimensions will update the viewport, - // we need to resize the offScreenCanvas to accommodate for the new - // dimensions, this is due to the reason that we are using the GPU offScreenCanvas - // to render the viewport for the downloadViewport. - renderingEngine.resize(); - - // Trigger the render on the viewport to update the on screen - // downloadViewport.resetCamera(); - downloadViewport.render(); - - downloadViewportElement.addEventListener( - Enums.Events.IMAGE_RENDERED, - function updateViewport(event) { - const enabledElement = getEnabledElement(event.target); - const { viewport } = enabledElement; - const { element } = viewport; - - const downloadCanvas = getOrCreateCanvas(element); - - const type = 'image/' + fileType; - const dataUrl = downloadCanvas.toDataURL(type, 1); - - let newWidth = element.offsetHeight; - let newHeight = element.offsetWidth; - - if (newWidth > DEFAULT_SIZE || newHeight > DEFAULT_SIZE) { - const multiplier = DEFAULT_SIZE / Math.max(newWidth, newHeight); - newHeight *= multiplier; - newWidth *= multiplier; - } - - resolve({ dataUrl, width: newWidth, height: newHeight }); - - downloadViewportElement.removeEventListener(Enums.Events.IMAGE_RENDERED, updateViewport); - - // for some reason we need a reset camera here, and I don't know why - downloadViewport.resetCamera(); - const presentation = activeViewport.getViewPresentation(); - if (downloadViewport.setView) { - downloadViewport.setView(activeViewport.getViewReference(), presentation); - } - downloadViewport.render(); - } - ); - }); - - const loadImage = (activeViewportElement, viewportElement, width, height) => - new Promise(resolve => { - if (activeViewportElement && viewportElement) { - const activeViewportEnabledElement = getEnabledElement(activeViewportElement); - - if (!activeViewportEnabledElement) { - return; - } - - const { viewport } = activeViewportEnabledElement; - - const renderingEngine = cornerstoneViewportService.getRenderingEngine(); - const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID); - - if (downloadViewport instanceof StackViewport) { - const imageId = viewport.getCurrentImageId(); - const properties = viewport.getProperties(); - - downloadViewport.setStack([imageId]).then(() => { - try { - downloadViewport.setProperties(properties); - const newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE); - const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE); - - resolve({ width: newWidth, height: newHeight }); - } catch (e) { - // Happens on clicking the cancel button - console.warn('Unable to set properties', e); - } - }); - } else if (downloadViewport instanceof BaseVolumeViewport) { - const actors = viewport.getActors(); - // downloadViewport.setActors(actors); - actors.forEach(actor => { - downloadViewport.addActor(actor); - }); - - downloadViewport.render(); - - const newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE); - const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE); - - resolve({ width: newWidth, height: newHeight }); - } - } - }); - - const toggleAnnotations = (toggle, viewportElement, activeViewportElement) => { - const activeViewportEnabledElement = getEnabledElement(activeViewportElement); - - const downloadViewportElement = getEnabledElement(viewportElement); - - const { viewportId: activeViewportId, renderingEngineId } = activeViewportEnabledElement; - const { viewportId: downloadViewportId } = downloadViewportElement; - - if (!activeViewportEnabledElement || !downloadViewportElement) { + const handleEnableViewport = (viewportElement: HTMLElement) => { + if (!viewportElement) { return; } - const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId); + const { viewport } = getEnabledElement(activeViewportElement); - // add the viewport to the toolGroup + const viewportInput = { + viewportId: VIEWPORT_ID, + element: viewportElement, + type: viewport.type, + defaultOptions: { + background: viewport.defaultOptions.background, + orientation: viewport.defaultOptions.orientation, + }, + }; + + renderingEngine.enableElement(viewportInput); + }; + + const handleDisableViewport = async () => { + renderingEngine.disableElement(VIEWPORT_ID); + }; + + const handleLoadImage = async (width: number, height: number) => { + if (!activeViewportElement) { + return; + } + + const activeViewportEnabledElement = getEnabledElement(activeViewportElement); + if (!activeViewportEnabledElement) { + return; + } + + const { viewport } = activeViewportEnabledElement; + const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID); + + try { + if (downloadViewport instanceof StackViewport) { + const imageId = viewport.getCurrentImageId(); + const properties = viewport.getProperties(); + + await downloadViewport.setStack([imageId]); + downloadViewport.setProperties(properties); + + return { + width: Math.min(width || DEFAULT_SIZE, MAX_TEXTURE_SIZE), + height: Math.min(height || DEFAULT_SIZE, MAX_TEXTURE_SIZE), + }; + } else if (downloadViewport instanceof BaseVolumeViewport) { + const volumeIds = viewport.getAllVolumeIds(); + downloadViewport.setVolumes([{ volumeId: volumeIds[0] }]); + + return { + width: Math.min(width || DEFAULT_SIZE, MAX_TEXTURE_SIZE), + height: Math.min(height || DEFAULT_SIZE, MAX_TEXTURE_SIZE), + }; + } + } catch (error) { + console.error('Error loading image:', error); + } + }; + + const handleToggleAnnotations = (show: boolean) => { + const activeViewportEnabledElement = getEnabledElement(activeViewportElement); + if (!activeViewportEnabledElement) { + return; + } + + const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID); + if (!downloadViewport) { + return; + } + + const { viewportId: activeViewportId, renderingEngineId } = activeViewportEnabledElement; + const { id: downloadViewportId } = downloadViewport; + + const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId); toolGroup.addViewport(downloadViewportId, renderingEngineId); Object.keys(toolGroup.getToolInstances()).forEach(toolName => { - // make all tools Enabled so that they can not be interacted with - // in the download viewport - if (toggle && toolName !== 'Crosshairs') { + if (show && toolName !== 'Crosshairs') { try { toolGroup.setToolEnabled(toolName); - } catch (e) { - console.log(e); + } catch (error) { + console.debug('Error enabling tool:', error); } } else { toolGroup.setToolDisabled(toolName); @@ -214,33 +162,50 @@ const CornerstoneViewportDownloadForm = ({ }); }; - const downloadBlob = (filename, fileType) => { - const file = `${filename}.${fileType}`; + useEffect(() => { + if (viewportDimensions.width && viewportDimensions.height) { + setTimeout(() => { + handleLoadImage(viewportDimensions.width, viewportDimensions.height); + handleToggleAnnotations(showAnnotations); + }, 100); + } + }, [viewportDimensions, showAnnotations]); + + const handleDownload = async (filename: string, fileType: string) => { const divForDownloadViewport = document.querySelector( `div[data-viewport-uid="${VIEWPORT_ID}"]` ); - html2canvas(divForDownloadViewport).then(canvas => { - const link = document.createElement('a'); - link.download = file; - link.href = canvas.toDataURL(fileType, 1.0); - link.click(); - }); + if (!divForDownloadViewport) { + console.debug('No viewport found for download'); + return; + } + + const canvas = await html2canvas(divForDownloadViewport as HTMLElement); + const link = document.createElement('a'); + link.download = `${filename}.${fileType}`; + link.href = canvas.toDataURL(`image/${fileType}`, 1.0); + link.click(); }; + const ViewportDownloadFormNew = customizationService.getCustomization( + 'ohif.captureViewportModal' + ); + return ( - ); }; diff --git a/extensions/default/src/Actions/createReportAsync.tsx b/extensions/default/src/Actions/createReportAsync.tsx index 44d7c7af2..969fdcc83 100644 --- a/extensions/default/src/Actions/createReportAsync.tsx +++ b/extensions/default/src/Actions/createReportAsync.tsx @@ -11,17 +11,13 @@ async function createReportAsync({ reportType = 'measurement', }: withAppTypes) { const { displaySetService, uiNotificationService, uiDialogService } = servicesManager.services; - const loadingDialogId = uiDialogService.create({ - showOverlay: true, - isDraggable: false, - centralize: true, - content: Loading, - }); try { const naturalizedReport = await getReport(); - if (!naturalizedReport) return; + if (!naturalizedReport) { + return; + } // The "Mode" route listens for DicomMetadataStore changes // When a new instance is added, it listens and @@ -47,12 +43,8 @@ async function createReportAsync({ }); throw new Error(`Failed to store ${reportType}. Error: ${error.message || 'Unknown error'}`); } finally { - uiDialogService.dismiss({ id: loadingDialogId }); + uiDialogService.hide('loading-dialog'); } } -function Loading() { - return
Loading...
; -} - export default createReportAsync; diff --git a/extensions/default/src/Components/DataSourceConfigurationComponent.tsx b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx index fcee0353a..68201c6e8 100644 --- a/extensions/default/src/Components/DataSourceConfigurationComponent.tsx +++ b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx @@ -1,7 +1,6 @@ import React, { ReactElement, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useModal } from '@ohif/ui'; -import { Icons } from '@ohif/ui-next'; +import { Icons, useModal } from '@ohif/ui-next'; import { Types } from '@ohif/core'; import DataSourceConfigurationModalComponent from './DataSourceConfigurationModalComponent'; diff --git a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx index 50303a4ba..183fd417d 100644 --- a/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx +++ b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx @@ -25,7 +25,7 @@ export default class ContextMenuController { } closeContextMenu() { - this.services.uiDialogService.dismiss({ id: 'context-menu' }); + this.services.uiDialogService.hide('context-menu'); } /** @@ -71,26 +71,24 @@ export default class ContextMenuController { menuId ); + if (!items) { + return; + } + const ContextMenu = this.services.customizationService.getCustomization('ui.contextMenu'); - this.services.uiDialogService.dismiss({ id: 'context-menu' }); - this.services.uiDialogService.create({ + this.services.uiDialogService.hide('context-menu'); + this.services.uiDialogService.show({ id: 'context-menu', - isDraggable: false, - preservePosition: false, - preventCutOf: true, defaultPosition: ContextMenuController._getDefaultPosition( defaultPointsPosition, event?.detail || event, viewportElement ), - event, content: ContextMenu, - - // This naming is part of the uiDialogService convention - // Clicking outside simply closes the dialog box. - onClickOutside: () => this.services.uiDialogService.dismiss({ id: 'context-menu' }), - + shouldCloseOnEsc: true, + shouldCloseOnOverlayClick: true, + unstyled: true, contentProps: { items, selectorProps, @@ -100,7 +98,7 @@ export default class ContextMenuController { eventData: event?.detail || event, onClose: () => { - this.services.uiDialogService.dismiss({ id: 'context-menu' }); + this.services.uiDialogService.hide('context-menu'); }, /** diff --git a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.css b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.css index 2845f2f39..1a2f681c0 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.css +++ b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.css @@ -24,8 +24,6 @@ .dicom-tag-browser-content { overflow: hidden; - width: 100%; - padding-bottom: 50px; /*height: 500px;*/ } diff --git a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx index 8ea351d00..deb82f413 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx @@ -176,6 +176,7 @@ const DicomTagBrowser = ({ diff --git a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx index ecafdb768..797357b83 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx @@ -39,7 +39,7 @@ const RowComponent = ({
{getRowComponent({ rows: visibleRows })} diff --git a/extensions/default/src/Panels/createReportDialogPrompt.tsx b/extensions/default/src/Panels/createReportDialogPrompt.tsx index 4e8dfa304..54adac9e1 100644 --- a/extensions/default/src/Panels/createReportDialogPrompt.tsx +++ b/extensions/default/src/Panels/createReportDialogPrompt.tsx @@ -1,132 +1,40 @@ -import React from 'react'; - -import { ButtonEnums, Dialog, Input, Select } from '@ohif/ui'; import PROMPT_RESPONSES from '../utils/_shared/PROMPT_RESPONSES'; -export default function CreateReportDialogPrompt(uiDialogService, { extensionManager }) { +export default function CreateReportDialogPrompt({ + title = 'Create Report', + extensionManager, + servicesManager, +}): Promise<{ + value: string; + dataSourceName: string; + action: (typeof PROMPT_RESPONSES)[keyof typeof PROMPT_RESPONSES]; +}> { + const { uiDialogService, customizationService } = servicesManager.services; + const dataSources = extensionManager.getDataSourcesForUI(); + const ReportDialog = customizationService.getCustomization('ohif.createReportDialog'); + + const allowMultipleDataSources = window.config.allowMultiSelectExport; + return new Promise(function (resolve, reject) { - let dialogId = undefined; - - const _handleClose = () => { - // Dismiss dialog - uiDialogService.dismiss({ id: dialogId }); - // Notify of cancel action - resolve({ - action: PROMPT_RESPONSES.CANCEL, - value: undefined, - dataSourceName: undefined, - }); - }; - - /** - * - * @param {string} param0.action - value of action performed - * @param {string} param0.value - value from input field - */ - const _handleFormSubmit = ({ action, value }) => { - uiDialogService.dismiss({ id: dialogId }); - switch (action.id) { - case 'save': + uiDialogService.show({ + id: 'report-dialog', + title, + content: ReportDialog, + contentProps: { + dataSources: allowMultipleDataSources ? dataSources : undefined, + onSave: async ({ reportName, dataSource: selectedDataSource }) => { resolve({ + value: reportName, + dataSourceName: selectedDataSource, action: PROMPT_RESPONSES.CREATE_REPORT, - value: value.label, - dataSourceName: value.dataSourceName, }); - break; - case 'cancel': + }, + onCancel: () => { resolve({ action: PROMPT_RESPONSES.CANCEL, value: undefined, dataSourceName: undefined, }); - break; - } - }; - - const dataSourcesOpts = Object.keys(extensionManager.dataSourceMap) - .filter(ds => { - const configuration = extensionManager.dataSourceDefs[ds]?.configuration; - const supportsStow = configuration?.supportsStow ?? configuration?.wadoRoot; - return supportsStow; - }) - .map(ds => { - return { - value: ds, - label: ds, - placeHolder: ds, - }; - }); - - dialogId = uiDialogService.create({ - centralize: true, - isDraggable: false, - content: Dialog, - useLastPosition: false, - showOverlay: true, - contentProps: { - title: 'Create Report', - value: { - label: '', - dataSourceName: extensionManager.activeDataSource, - }, - noCloseButton: true, - onClose: _handleClose, - actions: [ - { id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary }, - { id: 'save', text: 'Save', type: ButtonEnums.type.primary }, - ], - // TODO: Should be on button press... - onSubmit: _handleFormSubmit, - body: ({ value, setValue }) => { - const onChangeHandler = event => { - event.persist(); - setValue(value => ({ ...value, label: event.target.value })); - }; - const onKeyPressHandler = event => { - if (event.key === 'Enter') { - uiDialogService.dismiss({ id: dialogId }); - resolve({ - action: PROMPT_RESPONSES.CREATE_REPORT, - value: value.label, - }); - } - }; - return ( - <> - {dataSourcesOpts.length > 1 && window.config?.allowMultiSelectExport && ( -
- - -
- - ); }, }, }); diff --git a/extensions/default/src/ViewerLayout/ViewerHeader.tsx b/extensions/default/src/ViewerLayout/ViewerHeader.tsx index 2cbc8c1a4..a3c7b9c0d 100644 --- a/extensions/default/src/ViewerLayout/ViewerHeader.tsx +++ b/extensions/default/src/ViewerLayout/ViewerHeader.tsx @@ -2,23 +2,17 @@ import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { UserPreferences, AboutModal, useModal } from '@ohif/ui'; -import { Header } from '@ohif/ui-next'; -import i18n from '@ohif/i18n'; -import { hotkeys } from '@ohif/core'; +import { Header, useModal } from '@ohif/ui-next'; +import { useSystem } from '@ohif/core'; import { Toolbar } from '../Toolbar/Toolbar'; import HeaderPatientInfo from './HeaderPatientInfo'; import { PatientInfoVisibility } from './HeaderPatientInfo/HeaderPatientInfo'; import { preserveQueryParameters, publicUrl } from '@ohif/app'; -const { availableLanguages, defaultLanguage, currentLanguage } = i18n; +function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }>) { + const { servicesManager, extensionManager } = useSystem(); + const { customizationService } = servicesManager.services; -function ViewerHeader({ - hotkeysManager, - extensionManager, - servicesManager, - appConfig, -}: withAppTypes<{ appConfig: AppTypes.Config }>) { const navigate = useNavigate(); const location = useLocation(); @@ -42,10 +36,10 @@ function ViewerHeader({ }; const { t } = useTranslation(); - const { show, hide } = useModal(); - const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager; - const versionNumber = process.env.VERSION_NUMBER; - const commitHash = process.env.COMMIT_HASH; + const { show } = useModal(); + + const AboutModal = customizationService.getCustomization('ohif.aboutModal'); + const UserPreferencesModal = customizationService.getCustomization('ohif.userPreferencesModal'); const menuOptions = [ { @@ -55,8 +49,7 @@ function ViewerHeader({ show({ content: AboutModal, title: t('AboutModal:About OHIF Viewer'), - contentProps: { versionNumber, commitHash }, - containerDimensions: 'max-w-4xl max-h-4xl', + containerClassName: 'max-w-md', }), }, { @@ -64,30 +57,9 @@ function ViewerHeader({ icon: 'settings', onClick: () => show({ + content: UserPreferencesModal, title: t('UserPreferencesModal:User preferences'), - content: UserPreferences, - containerDimensions: 'w-[70%] max-w-[900px]', - contentProps: { - hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults), - hotkeyDefinitions, - currentLanguage: currentLanguage(), - availableLanguages, - defaultLanguage, - onCancel: () => { - hotkeys.stopRecord(); - hotkeys.unpause(); - hide(); - }, - onSubmit: ({ hotkeyDefinitions, language }) => { - if (language.value !== currentLanguage().value) { - i18n.changeLanguage(language.value); - } - hotkeysManager.setHotkeys(hotkeyDefinitions); - hide(); - }, - onReset: () => hotkeysManager.restoreDefaultBindings(), - hotkeysModule: hotkeys, - }, + containerClassName: 'flex max-w-4xl p-6 flex-col', }), }, ]; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 494258cb1..47743910f 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -505,10 +505,9 @@ const commandsModule = ({ contentProps: { displaySets, displaySetInstanceUID: defaultDisplaySetInstanceUID, - onClose: UIModalService.hide, }, - containerDimensions: 'w-[70%] max-w-[900px]', title: 'DICOM Tag Browser', + containerClassName: 'max-w-3xl', }); }, diff --git a/extensions/default/src/customizations/aboutModalCustomization.tsx b/extensions/default/src/customizations/aboutModalCustomization.tsx new file mode 100644 index 000000000..d17fc574e --- /dev/null +++ b/extensions/default/src/customizations/aboutModalCustomization.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { AboutModal } from '@ohif/ui-next'; +import detect from 'browser-detect'; + +function AboutModalDefault() { + const { os, version, name } = detect(); + const browser = `${name[0].toUpperCase()}${name.substr(1)} ${version}`; + const versionNumber = process.env.VERSION_NUMBER; + const commitHash = process.env.COMMIT_HASH; + + const [main, beta] = versionNumber.split('-'); + + return ( + + OHIF Viewer + {main} + {beta && {beta}} + + + + + + + + ); +} + +export default { + 'ohif.aboutModal': AboutModalDefault, +}; diff --git a/extensions/default/src/customizations/reportDialogCustomization.tsx b/extensions/default/src/customizations/reportDialogCustomization.tsx new file mode 100644 index 000000000..df0df017b --- /dev/null +++ b/extensions/default/src/customizations/reportDialogCustomization.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { InputDialog } from '@ohif/ui-next'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ohif/ui-next'; + +type DataSource = { + value: string; + label: string; + placeHolder: string; +}; + +type ReportDialogProps = { + dataSources: DataSource[]; + hide: () => void; + onSave: (data: { reportName: string; dataSource: string | null }) => void; + onCancel: () => void; +}; + +function ReportDialog({ dataSources, hide, onSave, onCancel }: ReportDialogProps) { + const [selectedDataSource, setSelectedDataSource] = useState( + dataSources?.[0]?.value ?? null + ); + + const handleSave = (reportName: string) => { + onSave({ + reportName, + dataSource: selectedDataSource, + }); + hide(); + }; + + const handleCancel = () => { + onCancel(); + hide(); + }; + + const showDataSourceSelect = dataSources?.length > 1; + + return ( +
+
+
+ {showDataSourceSelect && ( +
+ +
+ )} +
+ + + + + +
+
+ +
+ + + + Cancel + + Save + + +
+
+
+ ); +} + +export { ReportDialog }; +export default { + 'ohif.createReportDialog': ReportDialog, +}; diff --git a/extensions/default/src/customizations/userPreferencesCustomization.tsx b/extensions/default/src/customizations/userPreferencesCustomization.tsx new file mode 100644 index 000000000..f6adda45e --- /dev/null +++ b/extensions/default/src/customizations/userPreferencesCustomization.tsx @@ -0,0 +1,140 @@ +import React, { useState } from 'react'; +import { useSystem, hotkeys as hotkeysModule } from '@ohif/core'; +import { UserPreferencesModal, FooterAction } from '@ohif/ui-next'; +import { useTranslation } from 'react-i18next'; +import i18n from '@ohif/i18n'; + +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@ohif/ui-next'; + +const { availableLanguages, defaultLanguage, currentLanguage: currentLanguageFn } = i18n; + +interface HotkeyDefinition { + keys: string; + label: string; +} + +interface HotkeyDefinitions { + [key: string]: HotkeyDefinition; +} + +function UserPreferencesModalDefault({ hide }: { hide: () => void }) { + const { hotkeysManager } = useSystem(); + const { t } = useTranslation('UserPreferencesModal'); + + const { hotkeyDefinitions = {}, hotkeyDefaults = {} } = hotkeysManager; + + const currentLanguage = currentLanguageFn(); + + const [state, setState] = useState({ + hotkeyDefinitions: hotkeyDefinitions as HotkeyDefinitions, + languageValue: currentLanguage.value, + }); + + const onLanguageChangeHandler = (value: string) => { + setState(state => ({ ...state, languageValue: value })); + }; + + const onHotkeyChangeHandler = (id: string, newKeys: string) => { + setState(state => ({ + ...state, + hotkeyDefinitions: { + ...state.hotkeyDefinitions, + [id]: { + ...state.hotkeyDefinitions[id], + keys: newKeys, + }, + }, + })); + }; + + const onResetHandler = () => { + setState(state => ({ + ...state, + languageValue: defaultLanguage.value, + hotkeyDefinitions: hotkeyDefaults as HotkeyDefinitions, + })); + + hotkeysManager.restoreDefaultBindings(); + }; + + return ( + + + {/* Language Section */} +
+ {t('Language')} + +
+ + {t('Hotkeys')} + + {Object.entries(state.hotkeyDefinitions).map(([id, definition]) => ( + onHotkeyChangeHandler(id, newKeys)} + placeholder={definition.keys} + hotkeys={hotkeysModule} + /> + ))} + +
+ + + + {t('Reset to defaults')} + + + + { + hotkeysModule.stopRecord(); + hotkeysModule.unpause(); + hide(); + }} + > + {t('Cancel')} + + { + if (state.languageValue !== currentLanguage.value) { + i18n.changeLanguage(state.languageValue); + } + hotkeysManager.setHotkeys(state.hotkeyDefinitions); + hotkeysModule.stopRecord(); + hotkeysModule.unpause(); + hide(); + }} + > + {t('Save')} + + + +
+ ); +} + +export default { + 'ohif.userPreferencesModal': UserPreferencesModalDefault, +}; diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index b202eceae..a3e581eb9 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -18,6 +18,9 @@ import progressLoadingBarCustomization from './customizations/progressLoadingBar import viewportActionCornersCustomization from './customizations/viewportActionCornersCustomization'; import labellingFlowCustomization from './customizations/labellingFlowCustomization'; import viewportNotificationCustomization from './customizations/notificationCustomization'; +import aboutModalCustomization from './customizations/aboutModalCustomization'; +import userPreferencesCustomization from './customizations/userPreferencesCustomization'; +import reportDialogCustomization from './customizations/reportDialogCustomization'; import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomization'; import onboardingCustomization from './customizations/onboardingCustomization'; /** @@ -63,6 +66,9 @@ export default function getCustomizationModule({ servicesManager, extensionManag ...labellingFlowCustomization, ...contextMenuUICustomization, ...viewportNotificationCustomization, + ...aboutModalCustomization, + ...userPreferencesCustomization, + ...reportDialogCustomization, ...hotkeyBindingsCustomization, ...onboardingCustomization, }, diff --git a/extensions/default/src/index.ts b/extensions/default/src/index.ts index 684783387..91f92eb55 100644 --- a/extensions/default/src/index.ts +++ b/extensions/default/src/index.ts @@ -12,9 +12,10 @@ import getCustomizationModule from './getCustomizationModule'; import getViewportModule from './getViewportModule'; import { id } from './id'; import preRegistration from './init'; +import { createReportDialogPrompt } from './Panels'; + import { ContextMenuController, CustomizableContextMenuTypes } from './CustomizableContextMenu'; import * as dicomWebUtils from './DicomWebDataSource/utils'; -import { createReportDialogPrompt } from './Panels'; import createReportAsync from './Actions/createReportAsync'; import StaticWadoClient from './DicomWebDataSource/utils/StaticWadoClient'; import { cleanDenaturalizedDataset } from './DicomWebDataSource/utils'; @@ -25,11 +26,7 @@ import { useDisplaySetSelectorStore } from './stores/useDisplaySetSelectorStore' import { useHangingProtocolStageIndexStore } from './stores/useHangingProtocolStageIndexStore'; import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocolStore'; import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore'; -import { - callLabelAutocompleteDialog, - showLabelAnnotationPopup, - callInputDialog, -} from './utils/callInputDialog'; +import { callInputDialogAutoComplete, callInputDialog } from './utils/callInputDialog'; import colorPickerDialog from './utils/colorPickerDialog'; import promptSaveReport from './utils/promptSaveReport'; @@ -82,7 +79,6 @@ export { CustomizableContextMenuTypes, getStudiesForPatientByMRN, dicomWebUtils, - createReportDialogPrompt, createReportAsync, StaticWadoClient, cleanDenaturalizedDataset, @@ -94,9 +90,7 @@ export { useUIStateStore, useViewportGridStore, useViewportsByPositionStore, - showLabelAnnotationPopup, - callLabelAutocompleteDialog, - callInputDialog, + callInputDialogAutoComplete, promptSaveReport, promptLabelAnnotation, colorPickerDialog, @@ -105,4 +99,6 @@ export { utils, MoreDropdownMenu, requestDisplaySetCreationForStudy, + callInputDialog, + createReportDialogPrompt, }; diff --git a/extensions/default/src/utils/callInputDialog.tsx b/extensions/default/src/utils/callInputDialog.tsx index dcd893f16..34b4bb177 100644 --- a/extensions/default/src/utils/callInputDialog.tsx +++ b/extensions/default/src/utils/callInputDialog.tsx @@ -1,160 +1,107 @@ import React from 'react'; -import { Input, Dialog, ButtonEnums, LabellingFlow } from '@ohif/ui'; +import { LabellingFlow } from '@ohif/ui'; +import { InputDialog } from '@ohif/ui-next'; + +interface InputDialogDefaultProps { + hide: () => void; + onSave: (value: string) => void; + placeholder: string; + defaultValue: string; + submitOnEnter: boolean; +} + +function InputDialogDefault({ + hide, + onSave, + placeholder = 'Enter value', + defaultValue = '', + submitOnEnter, +}: InputDialogDefaultProps) { + return ( + + + + + + Cancel + { + onSave(value); + hide(); + }} + > + Save + + + + ); +} /** - * - * @param {*} data - * @param {*} data.text - * @param {*} data.label - * @param {*} event - * @param {*} callback - * @param {*} isArrowAnnotateInputDialog - * @param {*} dialogConfig - * @param {string?} dialogConfig.dialogTitle - title of the input dialog - * @param {string?} dialogConfig.inputLabel - show label above the input + * Shows an input dialog for entering text with customizable options + * @param uiDialogService - Service for showing UI dialogs + * @param onSave - Callback function called when save button is clicked with entered value + * @param defaultValue - Initial value to show in input field + * @param title - Title text to show in dialog header + * @param placeholder - Placeholder text for input field + * @param submitOnEnter - Whether to submit dialog when Enter key is pressed */ - -export function callInputDialog( +export async function callInputDialog({ uiDialogService, - data, - callback, - isArrowAnnotateInputDialog = true, - dialogConfig: any = {} -) { + defaultValue = '', + title = 'Annotation', + placeholder = '', + submitOnEnter = true, +}: { + uiDialogService: AppTypes.UIDialogService; +}) { const dialogId = 'dialog-enter-annotation'; - const label = data ? (isArrowAnnotateInputDialog ? data.text : data.label) : ''; - const { - dialogTitle = 'Annotation', - inputLabel = 'Enter your annotation', - validateFunc = value => true, - } = dialogConfig; - const onSubmitHandler = ({ action, value }) => { - switch (action.id) { - case 'save': - if (typeof validateFunc === 'function' && !validateFunc(value.label)) { - return; - } - - callback(value.label, action.id); - break; - case 'cancel': - callback('', action.id); - break; - } - uiDialogService.dismiss({ id: dialogId }); - }; - - if (uiDialogService) { - uiDialogService.create({ + const value = await new Promise(resolve => { + uiDialogService.show({ id: dialogId, - centralize: true, - isDraggable: false, - showOverlay: true, - content: Dialog, + content: InputDialogDefault, + title: title, + shouldCloseOnEsc: true, contentProps: { - title: dialogTitle, - value: { label }, - noCloseButton: true, - onClose: () => uiDialogService.dismiss({ id: dialogId }), - actions: [ - { id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary }, - { id: 'save', text: 'Save', type: ButtonEnums.type.primary }, - ], - onSubmit: onSubmitHandler, - body: ({ value, setValue }) => { - return ( - { - event.persist(); - setValue(value => ({ ...value, label: event.target.value })); - }} - onKeyPress={event => { - if (event.key === 'Enter') { - onSubmitHandler({ value, action: { id: 'save' } }); - } - }} - /> - ); + onSave: value => { + resolve(value); }, + placeholder, + defaultValue, + submitOnEnter, }, }); - } -} - -export function callLabelAutocompleteDialog( - uiDialogService, - callback, - dialogConfig, - labelConfig, - renderContent = LabellingFlow -) { - const exclusive = labelConfig ? labelConfig.exclusive : false; - const dropDownItems = labelConfig ? labelConfig.items : []; - - const { validateFunc = value => true } = dialogConfig; - - const labellingDoneCallback = value => { - if (typeof value === 'string') { - if (typeof validateFunc === 'function' && !validateFunc(value)) { - return; - } - callback(value, 'save'); - } else { - callback('', 'cancel'); - } - uiDialogService.dismiss({ id: 'select-annotation' }); - }; - - uiDialogService.create({ - id: 'select-annotation', - centralize: true, - isDraggable: false, - showOverlay: true, - content: renderContent, - contentProps: { - labellingDoneCallback: labellingDoneCallback, - measurementData: { label: '' }, - componentClassName: {}, - labelData: dropDownItems, - exclusive: exclusive, - }, }); + + return value; } -export function showLabelAnnotationPopup( +export async function callInputDialogAutoComplete({ measurement, uiDialogService, labelConfig, - renderContent = LabellingFlow -) { + renderContent = LabellingFlow, +}) { const exclusive = labelConfig ? labelConfig.exclusive : false; const dropDownItems = labelConfig ? labelConfig.items : []; - return new Promise>((resolve, reject) => { + + const value = await new Promise>((resolve, reject) => { const labellingDoneCallback = value => { - uiDialogService.dismiss({ id: 'select-annotation' }); + uiDialogService.hide('select-annotation'); if (typeof value === 'string') { measurement.label = value; } resolve(measurement); }; - uiDialogService.create({ + uiDialogService.show({ id: 'select-annotation', - isDraggable: false, - showOverlay: true, + title: 'Annotation', content: renderContent, - defaultPosition: { - x: window.innerWidth / 2, - y: window.innerHeight / 2, - }, contentProps: { labellingDoneCallback: labellingDoneCallback, measurementData: measurement, @@ -164,6 +111,8 @@ export function showLabelAnnotationPopup( }, }); }); + + return value; } export default callInputDialog; diff --git a/extensions/default/src/utils/colorPickerDialog.tsx b/extensions/default/src/utils/colorPickerDialog.tsx index 9de59eeed..006a72050 100644 --- a/extensions/default/src/utils/colorPickerDialog.tsx +++ b/extensions/default/src/utils/colorPickerDialog.tsx @@ -1,58 +1,39 @@ -import React from 'react'; -import { Dialog } from '@ohif/ui'; +import React, { useState } from 'react'; import { ChromePicker } from 'react-color'; +import { FooterAction } from '@ohif/ui-next'; import './colorPickerDialog.css'; -function colorPickerDialog(uiDialogService, rgbaColor, callback) { - const dialogId = 'pick-color'; +function ColorPickerDialog({ value, hide, onSave }) { + const [color, setColor] = useState(value); - const onSubmitHandler = ({ action, value }) => { - switch (action.id) { - case 'save': - callback(value.rgbaColor, action.id); - break; - case 'cancel': - callback('', action.id); - break; - } - uiDialogService.dismiss({ id: dialogId }); + const handleChange = color => { + setColor(color.rgb); }; - if (uiDialogService) { - uiDialogService.create({ - id: dialogId, - centralize: true, - isDraggable: false, - showOverlay: true, - content: Dialog, - contentProps: { - title: 'Segment Color', - value: { rgbaColor }, - noCloseButton: true, - onClose: () => uiDialogService.dismiss({ id: dialogId }), - actions: [ - { id: 'cancel', text: 'Cancel', type: 'primary' }, - { id: 'save', text: 'Save', type: 'secondary' }, - ], - onSubmit: onSubmitHandler, - body: ({ value, setValue }) => { - const handleChange = color => { - setValue({ rgbaColor: color.rgb }); - }; - - return ( - - ); - }, - }, - }); - } + return ( +
+ + + + Cancel + { + hide(); + onSave(color); + }} + > + Save + + + +
+ ); } -export default colorPickerDialog; +export default ColorPickerDialog; diff --git a/extensions/default/src/utils/promptLabelAnnotation.js b/extensions/default/src/utils/promptLabelAnnotation.js index eb3cbaa3f..1334a786b 100644 --- a/extensions/default/src/utils/promptLabelAnnotation.js +++ b/extensions/default/src/utils/promptLabelAnnotation.js @@ -1,42 +1,46 @@ -import { showLabelAnnotationPopup } from './callInputDialog'; +import { callInputDialogAutoComplete } from './callInputDialog'; function promptLabelAnnotation({ servicesManager }, ctx, evt) { - const { measurementService, customizationService, toolGroupService } = servicesManager.services; + const { measurementService, customizationService, toolGroupService, uiDialogService } = + servicesManager.services; const { viewportId, StudyInstanceUID, SeriesInstanceUID, measurementId, toolName } = evt; - return new Promise(async function (resolve) { - const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); - const activeToolOptions = toolGroup.getToolConfiguration(toolName); - if(activeToolOptions.getTextCallback) { - resolve({ - StudyInstanceUID, - SeriesInstanceUID, - viewportId, - }) - } else { - const labelConfig = customizationService.getCustomization('measurementLabels'); - const measurement = measurementService.getMeasurement(measurementId); - const renderContent = customizationService.getCustomization('ui.labellingComponent'); - const value = await showLabelAnnotationPopup( - measurement, - servicesManager.services.uiDialogService, - labelConfig, - renderContent - ); + return new Promise(resolve => { + (async () => { + const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); + const activeToolOptions = toolGroup.getToolConfiguration(toolName); + if (activeToolOptions.getTextCallback) { + resolve({ + StudyInstanceUID, + SeriesInstanceUID, + viewportId, + }); + } else { + const labelConfig = customizationService.getCustomization('measurementLabels'); + const measurement = measurementService.getMeasurement(measurementId); + const renderContent = customizationService.getCustomization('ui.labellingComponent'); - measurementService.update( - measurementId, - { - ...value, - }, - true - ); + const value = await callInputDialogAutoComplete({ + measurement, + uiDialogService, + labelConfig, + renderContent, + }); - resolve({ - StudyInstanceUID, - SeriesInstanceUID, - viewportId, - }); - } + measurementService.update( + measurementId, + { + ...value, + }, + true + ); + + resolve({ + StudyInstanceUID, + SeriesInstanceUID, + viewportId, + }); + } + })(); }); } diff --git a/extensions/default/src/utils/promptSaveReport.js b/extensions/default/src/utils/promptSaveReport.js deleted file mode 100644 index ae5c53be6..000000000 --- a/extensions/default/src/utils/promptSaveReport.js +++ /dev/null @@ -1,75 +0,0 @@ -import createReportAsync from '../Actions/createReportAsync'; -import { createReportDialogPrompt } from '../Panels'; -import getNextSRSeriesNumber from './getNextSRSeriesNumber'; -import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES'; - -async function promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt) { - const { uiDialogService, measurementService, displaySetService } = servicesManager.services; - const viewportId = evt.viewportId === undefined ? evt.data.viewportId : evt.viewportId; - const isBackupSave = evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave; - const StudyInstanceUID = evt?.data?.StudyInstanceUID; - const SeriesInstanceUID = evt?.data?.SeriesInstanceUID; - - const { trackedStudy, trackedSeries } = ctx; - let displaySetInstanceUIDs; - - try { - const promptResult = await createReportDialogPrompt(uiDialogService, { - extensionManager, - }); - - if (promptResult.action === PROMPT_RESPONSES.CREATE_REPORT) { - const dataSources = extensionManager.getDataSources(); - const dataSource = dataSources[0]; - const measurements = measurementService.getMeasurements(); - const trackedMeasurements = measurements - .filter( - m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID) - ) - .filter(m => m.referencedImageId != null); - - const SeriesDescription = - // isUndefinedOrEmpty - promptResult.value === undefined || promptResult.value === '' - ? 'Research Derived Series' // default - : promptResult.value; // provided value - - const SeriesNumber = getNextSRSeriesNumber(displaySetService); - - const getReport = async () => { - return commandsManager.runCommand( - 'storeMeasurements', - { - measurementData: trackedMeasurements, - dataSource, - additionalFindingTypes: ['ArrowAnnotate'], - options: { - SeriesDescription, - SeriesNumber, - }, - }, - 'CORNERSTONE_STRUCTURED_REPORT' - ); - }; - displaySetInstanceUIDs = await createReportAsync({ - servicesManager, - getReport, - }); - } else if (promptResult.action === RESPONSE.CANCEL) { - // Do nothing - } - - return { - userResponse: promptResult.action, - createdDisplaySetInstanceUIDs: displaySetInstanceUIDs, - StudyInstanceUID, - SeriesInstanceUID, - viewportId, - isBackupSave, - }; - } catch (error) { - return null; - } -} - -export default promptSaveReport; diff --git a/extensions/default/src/utils/promptSaveReport.tsx b/extensions/default/src/utils/promptSaveReport.tsx new file mode 100644 index 000000000..cca0b01c8 --- /dev/null +++ b/extensions/default/src/utils/promptSaveReport.tsx @@ -0,0 +1,109 @@ +import createReportAsync from '../Actions/createReportAsync'; +import getNextSRSeriesNumber from './getNextSRSeriesNumber'; +import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES'; +import createReportDialogPrompt from '../Panels/createReportDialogPrompt'; + +/** + * Prompts the user to save a report and handles the report creation process + * @param services - Object containing required services and managers + * @param ctx - The current context containing tracked study and series information + * @param evt - The event object containing viewport and save-related data + */ +async function promptSaveReport(services, ctx, evt) { + const { servicesManager, extensionManager, commandsManager } = services; + + const { measurementService, displaySetService } = servicesManager.services; + + const viewportId = evt.viewportId ?? evt.data?.viewportId; + const isBackupSave = evt.isBackupSave ?? evt.data?.isBackupSave; + const { StudyInstanceUID, SeriesInstanceUID } = evt?.data ?? {}; + + const { trackedStudy, trackedSeries } = ctx; + const dataSources = extensionManager.getDataSources(); + + const { + value: reportName, + dataSourceName: dataSource, + action, + } = await createReportDialogPrompt({ + servicesManager, + extensionManager, + }); + let displaySetInstanceUIDs; + + try { + if (action === PROMPT_RESPONSES.CREATE_REPORT) { + const selectedDataSource = dataSource ?? dataSources[0]; + const trackedMeasurements = getTrackedMeasurements( + measurementService, + trackedStudy, + trackedSeries + ); + + const SeriesNumber = getNextSRSeriesNumber(displaySetService); + displaySetInstanceUIDs = await handleReportCreation({ + servicesManager, + commandsManager, + trackedMeasurements, + selectedDataSource, + reportName, + SeriesNumber, + }); + } + + return { + userResponse: action, + createdDisplaySetInstanceUIDs: displaySetInstanceUIDs, + StudyInstanceUID, + SeriesInstanceUID, + viewportId, + isBackupSave, + }; + } catch (error) { + return null; + } +} + +/** + * Gets tracked measurements based on study and series criteria + */ +function getTrackedMeasurements(measurementService, trackedStudy, trackedSeries) { + return measurementService + .getMeasurements() + .filter( + m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID) + ) + .filter(m => m.referencedImageId != null); +} + +/** + * Handles the creation of the report using the measurement service + */ +async function handleReportCreation({ + servicesManager, + commandsManager, + trackedMeasurements, + selectedDataSource, + reportName, + SeriesNumber, +}) { + return createReportAsync({ + servicesManager, + getReport: () => + commandsManager.runCommand( + 'storeMeasurements', + { + measurementData: trackedMeasurements, + dataSource: selectedDataSource, + additionalFindingTypes: ['ArrowAnnotate'], + options: { + SeriesDescription: reportName, + SeriesNumber, + }, + }, + 'CORNERSTONE_STRUCTURED_REPORT' + ), + }); +} + +export default promptSaveReport; diff --git a/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx b/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx index b3200d8b0..390296f4c 100644 --- a/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx +++ b/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx @@ -1,14 +1,13 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; +import { callInputDialog } from '@ohif/extension-default'; import { ExtensionManager, CommandsManager, DicomMetadataStore } from '@ohif/core'; import { MeasurementTable } from '@ohif/ui'; import { withTranslation, WithTranslation } from 'react-i18next'; import { EVENTS as MicroscopyEvents } from '../../services/MicroscopyService'; import dcmjs from 'dcmjs'; -import { callInputDialog } from '@ohif/extension-default'; import constructSR from '../../utils/constructSR'; import { saveByteArray } from '../../utils/saveByteArray'; -import { Separator } from '@ohif/ui-next'; let saving = false; const { datasetToBuffer } = dcmjs.data; @@ -140,12 +139,8 @@ function MicroscopyPanel(props: IMicroscopyPanelProps) { uiDialogService, title: 'Enter description of the Series', defaultValue: '', - callback: (value: string, action: string) => { - switch (action) { - case 'save': { - saveFunction(value); - } - } + onSave: (value: string) => { + saveFunction(value); }, }); }; diff --git a/extensions/dicom-microscopy/src/getCommandsModule.ts b/extensions/dicom-microscopy/src/getCommandsModule.ts index 5ed91a13a..0d4855c69 100644 --- a/extensions/dicom-microscopy/src/getCommandsModule.ts +++ b/extensions/dicom-microscopy/src/getCommandsModule.ts @@ -29,13 +29,9 @@ export default function getCommandsModule({ callInputDialog({ uiDialogService, defaultValue: '', - callback: (value: string, action: string) => { - switch (action) { - case 'save': { - roiAnnotation.setLabel(value); - microscopyService.triggerRelabel(roiAnnotation); - } - } + onSave: (value: string) => { + roiAnnotation.setLabel(value); + microscopyService.triggerRelabel(roiAnnotation); }, }); }, diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index d60e1ccc5..ce0e95a80 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -11,8 +11,14 @@ import { useTrackedMeasurements } from '../../getContextModule'; import { Separator } from '@ohif/ui-next'; import { MoreDropdownMenu, PanelStudyBrowserHeader } from '@ohif/extension-default'; import { defaultActionIcons } from './constants'; +import { UntrackSeriesModal } from './untrackSeriesModal'; const { formatDate, createStudyBrowserTabs } = utils; +const DIALOG_ID = { + UNTRACK_SERIES: 'untrack-series', + REJECT_REPORT: 'ds-reject-sr', +}; + const thumbnailNoImageModalities = [ 'SR', 'SEG', @@ -44,6 +50,7 @@ export default function PanelStudyBrowserTracking({ measurementService, studyPrefetcherService, customizationService, + uiModalService, } = servicesManager.services; const navigate = useNavigate(); const studyMode = customizationService.getCustomization('studyBrowser.studyMode'); @@ -446,47 +453,11 @@ export default function PanelStudyBrowserTracking({ }); }; - uiDialogService.create({ - id: 'untrack-series', - centralize: true, - isDraggable: false, - showOverlay: true, - content: Dialog, + uiModalService.show({ + title: 'Untrack Series', + content: UntrackSeriesModal, contentProps: { - title: 'Untrack Series', - body: () => ( -
-

Are you sure you want to untrack this series?

-

- This action cannot be undone and will delete all your existing measurements. -

-
- ), - actions: [ - { - id: 'cancel', - text: 'Cancel', - type: ButtonEnums.type.secondary, - }, - { - id: 'yes', - text: 'Yes', - type: ButtonEnums.type.primary, - classes: ['untrack-yes-button'], - }, - ], - onClose: () => uiDialogService.dismiss({ id: 'untrack-series' }), - onSubmit: async ({ action }) => { - switch (action.id) { - case 'yes': - onConfirm(); - uiDialogService.dismiss({ id: 'untrack-series' }); - break; - case 'cancel': - uiDialogService.dismiss({ id: 'untrack-series' }); - break; - } - }, + onConfirm, }, }); }; @@ -630,77 +601,6 @@ function _mapDisplaySets( isHydratedForDerivedDisplaySet: ds.isHydrated, }; - if (componentType === 'thumbnailNoImage') { - if (dataSource.reject && dataSource.reject.series) { - thumbnailProps.canReject = !ds?.unsupported; - thumbnailProps.onReject = () => { - uiDialogService.create({ - id: 'ds-reject-sr', - centralize: true, - isDraggable: false, - showOverlay: true, - content: Dialog, - contentProps: { - title: 'Delete Report', - body: () => ( -
-

Are you sure you want to delete this report?

-

This action cannot be undone.

-
- ), - actions: [ - { - id: 'cancel', - text: 'Cancel', - type: ButtonEnums.type.secondary, - }, - { - id: 'yes', - text: 'Yes', - type: ButtonEnums.type.primary, - classes: ['reject-yes-button'], - }, - ], - onClose: () => uiDialogService.dismiss({ id: 'ds-reject-sr' }), - onShow: () => { - const yesButton = document.querySelector('.reject-yes-button'); - - yesButton.focus(); - }, - onSubmit: async ({ action }) => { - switch (action.id) { - case 'yes': - try { - await dataSource.reject.series(ds.StudyInstanceUID, ds.SeriesInstanceUID); - displaySetService.deleteDisplaySet(displaySetInstanceUID); - uiDialogService.dismiss({ id: 'ds-reject-sr' }); - uiNotificationService.show({ - title: 'Delete Report', - message: 'Report deleted successfully', - type: 'success', - }); - } catch (error) { - uiDialogService.dismiss({ id: 'ds-reject-sr' }); - uiNotificationService.show({ - title: 'Delete Report', - message: 'Failed to delete report', - type: 'error', - }); - } - break; - case 'cancel': - uiDialogService.dismiss({ id: 'ds-reject-sr' }); - break; - } - }, - }, - }); - }; - } else { - thumbnailProps.canReject = false; - } - } - array.push(thumbnailProps); }); diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/untrackSeriesModal.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/untrackSeriesModal.tsx new file mode 100644 index 000000000..5b7091c84 --- /dev/null +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/untrackSeriesModal.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { FooterAction } from '@ohif/ui-next'; + +export function UntrackSeriesModal({ hide, onConfirm }) { + return ( +
+
+

Are you sure you want to untrack this series?

+

+ This action cannot be undone and will delete all your existing measurements. +

+
+ + + Cancel + { + onConfirm(); + hide(); + }} + > + Untrack + + + +
+ ); +} diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/segmentationEditHandler.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/segmentationEditHandler.tsx deleted file mode 100644 index 605d17bbb..000000000 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/segmentationEditHandler.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import { Input, Dialog, ButtonEnums } from '@ohif/ui'; - -function segmentationItemEditHandler({ id, servicesManager }: withAppTypes) { - const { segmentationService, uiDialogService } = servicesManager.services; - - const segmentation = segmentationService.getSegmentation(id); - - const onSubmitHandler = ({ action, value }) => { - switch (action.id) { - case 'save': { - segmentationService.addOrUpdateSegmentation({ - ...segmentation, - ...value, - }); - } - } - uiDialogService.dismiss({ id: 'enter-annotation' }); - }; - - uiDialogService.create({ - id: 'enter-annotation', - centralize: true, - isDraggable: false, - showOverlay: true, - content: Dialog, - contentProps: { - title: 'Enter your Segmentation', - noCloseButton: true, - value: { label: segmentation.label || '' }, - body: ({ value, setValue }) => { - const onChangeHandler = event => { - event.persist(); - setValue(value => ({ ...value, label: event.target.value })); - }; - - const onKeyPressHandler = event => { - if (event.key === 'Enter') { - onSubmitHandler({ value, action: { id: 'save' } }); - } - }; - return ( - - ); - }, - actions: [ - { id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary }, - { id: 'save', text: 'Save', type: ButtonEnums.type.primary }, - ], - onSubmit: onSubmitHandler, - }, - }); -} - -export default segmentationItemEditHandler; diff --git a/modes/basic-dev-mode/src/index.ts b/modes/basic-dev-mode/src/index.ts index a1ca916e1..0c550219f 100644 --- a/modes/basic-dev-mode/src/index.ts +++ b/modes/basic-dev-mode/src/index.ts @@ -105,14 +105,8 @@ function modeFactory({ modeConfiguration }) { ]); }, onModeExit: ({ servicesManager }: withAppTypes) => { - const { - toolGroupService, - measurementService, - toolbarService, - uiDialogService, - uiModalService, - } = servicesManager.services; - uiDialogService.dismissAll(); + const { toolGroupService, uiDialogService, uiModalService } = servicesManager.services; + uiDialogService.hideAll(); uiModalService.hide(); toolGroupService.destroy(); }, diff --git a/modes/basic-test-mode/src/index.ts b/modes/basic-test-mode/src/index.ts index 184f6756d..f8852ea10 100644 --- a/modes/basic-test-mode/src/index.ts +++ b/modes/basic-test-mode/src/index.ts @@ -128,7 +128,7 @@ function modeFactory() { uiModalService, } = servicesManager.services; - uiDialogService.dismissAll(); + uiDialogService.hideAll(); uiModalService.hide(); toolGroupService.destroy(); syncGroupService.destroy(); diff --git a/modes/longitudinal/src/index.ts b/modes/longitudinal/src/index.ts index 11f85ebce..729622a34 100644 --- a/modes/longitudinal/src/index.ts +++ b/modes/longitudinal/src/index.ts @@ -147,7 +147,7 @@ function modeFactory({ modeConfiguration }) { _activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe()); _activatePanelTriggersSubscriptions = []; - uiDialogService.dismissAll(); + uiDialogService.hideAll(); uiModalService.hide(); toolGroupService.destroy(); syncGroupService.destroy(); diff --git a/modes/longitudinal/src/initToolGroups.js b/modes/longitudinal/src/initToolGroups.js index 63d1f6122..6384265a6 100644 --- a/modes/longitudinal/src/initToolGroups.js +++ b/modes/longitudinal/src/initToolGroups.js @@ -12,12 +12,7 @@ const colorsByOrientation = { coronal: 'rgb(0, 200, 0)', }; -function initDefaultToolGroup( - extensionManager, - toolGroupService, - commandsManager, - toolGroupId -) { +function initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, toolGroupId) { const utilityModule = extensionManager.getModuleEntry( '@ohif/extension-cornerstone.utilityModule.tools' ); @@ -308,12 +303,7 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) { } function initToolGroups(extensionManager, toolGroupService, commandsManager) { - initDefaultToolGroup( - extensionManager, - toolGroupService, - commandsManager, - 'default' - ); + initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default'); initSRToolGroup(extensionManager, toolGroupService); initMPRToolGroup(extensionManager, toolGroupService, commandsManager); initVolume3DToolGroup(extensionManager, toolGroupService); diff --git a/modes/microscopy/src/index.tsx b/modes/microscopy/src/index.tsx index bdef6ef3f..2e1050474 100644 --- a/modes/microscopy/src/index.tsx +++ b/modes/microscopy/src/index.tsx @@ -57,7 +57,7 @@ function modeFactory({ modeConfiguration }) { onModeExit: ({ servicesManager }: withAppTypes) => { const { toolbarService, uiDialogService, uiModalService } = servicesManager.services; - uiDialogService.dismissAll(); + uiDialogService.hideAll(); uiModalService.hide(); toolbarService.reset(); }, diff --git a/modes/segmentation/src/index.tsx b/modes/segmentation/src/index.tsx index 80e7ce2cd..76fdf68bc 100644 --- a/modes/segmentation/src/index.tsx +++ b/modes/segmentation/src/index.tsx @@ -81,7 +81,7 @@ function modeFactory({ modeConfiguration }) { uiModalService, } = servicesManager.services; - uiDialogService.dismissAll(); + uiDialogService.hideAll(); uiModalService.hide(); toolGroupService.destroy(); syncGroupService.destroy(); diff --git a/modes/tmtv/src/index.ts b/modes/tmtv/src/index.ts index eb070f0de..a74ab7d1d 100644 --- a/modes/tmtv/src/index.ts +++ b/modes/tmtv/src/index.ts @@ -154,7 +154,7 @@ function modeFactory({ modeConfiguration }) { } = servicesManager.services; unsubscriptions.forEach(unsubscribe => unsubscribe()); - uiDialogService.dismissAll(); + uiDialogService.hideAll(); uiModalService.hide(); toolGroupService.destroy(); syncGroupService.destroy(); diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index 81240a424..01374a99d 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -6,7 +6,13 @@ window.config = { // whiteLabeling: {}, extensions: [], modes: [], - customizationService: {}, + customizationService: { + 'viewportActionMenu.windowLevelActionMenu': { + $merge: { + location: 0, + }, + }, + }, showStudyList: true, // some windows systems have issues with more than 3 web workers maxNumberOfWebWorkers: 3, @@ -17,6 +23,7 @@ window.config = { experimentalStudyBrowserSort: false, strictZSpacingForVolumeViewport: true, groupEnabledModesFirst: true, + allowMultiSelectExport: true, maxNumRequests: { interaction: 100, thumbnail: 75, diff --git a/platform/app/src/App.tsx b/platform/app/src/App.tsx index df31b2c21..8ab724e7a 100644 --- a/platform/app/src/App.tsx +++ b/platform/app/src/App.tsx @@ -15,9 +15,6 @@ import { SystemContextProvider, } from '@ohif/core'; import { - DialogProvider, - Modal, - ModalProvider, ThemeWrapper, ViewportDialogProvider, CineProvider, @@ -27,8 +24,12 @@ import { ThemeWrapper as ThemeWrapperNext, NotificationProvider, ViewportGridProvider, + DialogProvider, TooltipProvider, ToolboxProvider, + Modal as ModalNext, + ManagedDialog, + ModalProvider, } from '@ohif/ui-next'; // Viewer Project // TODO: Should this influence study list? @@ -122,8 +123,8 @@ function App({ [CineProvider, { service: cineService }], [NotificationProvider, { service: uiNotificationService }], [TooltipProvider], - [DialogProvider, { service: uiDialogService }], - [ModalProvider, { service: uiModalService, modal: Modal }], + [DialogProvider, { service: uiDialogService, dialog: ManagedDialog }], + [ModalProvider, { service: uiModalService, modal: ModalNext }], [ShepherdJourneyProvider], ]; diff --git a/platform/app/src/routes/DataSourceWrapper.tsx b/platform/app/src/routes/DataSourceWrapper.tsx index d65256263..8290e23ac 100644 --- a/platform/app/src/routes/DataSourceWrapper.tsx +++ b/platform/app/src/routes/DataSourceWrapper.tsx @@ -199,7 +199,6 @@ function DataSourceWrapper(props: withAppTypes) { servicesManager.services.uiModalService.show({ title: 'Data Source Connection Error', - containerDimensions: 'w-1/2', content: () => { return (
diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx index a63a96ec2..685342ac9 100644 --- a/platform/app/src/routes/WorkList/WorkList.tsx +++ b/platform/app/src/routes/WorkList/WorkList.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'; import filtersMeta from './filtersMeta.js'; import { useAppConfig } from '@state'; import { useDebounce, useSearchParams } from '@hooks'; -import { utils, hotkeys } from '@ohif/core'; +import { utils } from '@ohif/core'; import publicUrl from '../../utils/publicUrl'; import { @@ -19,9 +19,6 @@ import { StudyListTable, StudyListPagination, StudyListFilter, - useModal, - AboutModal, - UserPreferences, useSessionStorage, InvestigationalUseDialog, Button, @@ -35,21 +32,19 @@ import { TooltipTrigger, TooltipContent, Clipboard, + useModal, Onboarding, ScrollArea, } from '@ohif/ui-next'; import { Types } from '@ohif/ui'; -import i18n from '@ohif/i18n'; import { preserveQueryParameters, preserveQueryStrings } from '../../utils/preserveQueryParameters'; const PatientInfoVisibility = Types.PatientInfoVisibility; const { sortBySeriesDate } = utils; -const { availableLanguages, defaultLanguage, currentLanguage } = i18n; - const seriesInStudiesMap = new Map(); /** @@ -102,6 +97,7 @@ function WorkList({ const sortModifier = sortDirection === 'descending' ? 1 : -1; const defaultSortValues = shouldUseDefaultSort && canSort ? { sortBy: 'studyDate', sortDirection: 'ascending' } : {}; + const { customizationService } = servicesManager.services; const sortedStudies = useMemo(() => { if (!canSort) { @@ -470,8 +466,9 @@ function WorkList({ }); const hasStudies = numOfStudies > 0; - const versionNumber = process.env.VERSION_NUMBER; - const commitHash = process.env.COMMIT_HASH; + + const AboutModal = customizationService.getCustomization('ohif.aboutModal'); + const UserPreferencesModal = customizationService.getCustomization('ohif.userPreferencesModal'); const menuOptions = [ { @@ -479,10 +476,9 @@ function WorkList({ icon: 'info', onClick: () => show({ - content: AboutModal, + content: AboutModal as React.ComponentType, title: t('AboutModal:About OHIF Viewer'), - contentProps: { versionNumber, commitHash }, - containerDimensions: 'max-w-4xl max-h-4xl', + containerClassName: 'max-w-md ', }), }, { @@ -491,24 +487,8 @@ function WorkList({ onClick: () => show({ title: t('UserPreferencesModal:User preferences'), - content: UserPreferences, - contentProps: { - hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults), - hotkeyDefinitions, - onCancel: hide, - currentLanguage: currentLanguage(), - availableLanguages, - defaultLanguage, - onSubmit: state => { - if (state.language.value !== currentLanguage().value) { - i18n.changeLanguage(state.language.value); - } - hotkeysManager.setHotkeys(state.hotkeyDefinitions); - hide(); - }, - onReset: () => hotkeysManager.restoreDefaultBindings(), - hotkeysModule: hotkeys, - }, + content: UserPreferencesModal as React.ComponentType, + containerClassName: 'flex max-w-4xl flex-col', }), }, ]; @@ -523,7 +503,6 @@ function WorkList({ }); } - const { customizationService } = servicesManager.services; const LoadingIndicatorProgress = customizationService.getCustomization( 'ui.loadingIndicatorProgress' ); diff --git a/platform/cli/templates/mode/src/index.tsx b/platform/cli/templates/mode/src/index.tsx index 4f2a57c6f..2f631af0c 100644 --- a/platform/cli/templates/mode/src/index.tsx +++ b/platform/cli/templates/mode/src/index.tsx @@ -70,7 +70,7 @@ function modeFactory({ modeConfiguration }) { uiModalService, } = servicesManager.services; - uiDialogService.dismissAll(); + uiDialogService.hideAll(); uiModalService.hide(); toolGroupService.destroy(); syncGroupService.destroy(); diff --git a/platform/core/src/defaults/hotkeyBindings.ts b/platform/core/src/defaults/hotkeyBindings.ts index eeea06e97..3bc3d9236 100644 --- a/platform/core/src/defaults/hotkeyBindings.ts +++ b/platform/core/src/defaults/hotkeyBindings.ts @@ -135,7 +135,7 @@ const bindings = [ }, { commandName: 'cancelMeasurement', - label: 'Cancel Cornerstone Measurement', + label: 'Cancel Measurement', keys: ['esc'], }, { diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index af620b535..f1b143d4d 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -382,6 +382,10 @@ export default class ExtensionManager extends PubSubService { return this.dataSourceMap[dataSourceName]; }; + getDataSourceInstance = dataSourceName => { + return this.dataSourceMap[dataSourceName][0]; + }; + getActiveDataSource = () => { return this.dataSourceMap[this.activeDataSource]; }; @@ -409,6 +413,29 @@ export default class ExtensionManager extends PubSubService { return this.getDataSourceDefinition(this.activeDataSource); }; + /** + * Gets a formatted list of data sources suitable for UI display/selection. + * Only returns data sources that support STOW or have a WADO root. + * @returns Array of data source options with value, label, and placeholder + */ + getDataSourcesForUI = () => { + // If multi-select export is not allowed, return empty list + if (this._appConfig?.allowMultiSelectExport === false) { + return []; + } + + return Object.keys(this.dataSourceMap) + .filter(ds => { + const configuration = this.dataSourceDefs[ds]?.configuration; + return configuration?.supportsStow ?? configuration?.wadoRoot; + }) + .map(ds => ({ + value: ds, + label: ds, + placeHolder: ds, + })); + }; + /** * @private * @param {string} moduleType diff --git a/platform/core/src/services/CustomizationService/types.ts b/platform/core/src/services/CustomizationService/types.ts index 94be1cef4..0a183cce3 100644 --- a/platform/core/src/services/CustomizationService/types.ts +++ b/platform/core/src/services/CustomizationService/types.ts @@ -9,7 +9,6 @@ export interface BaseCustomization extends Obj { description?: string; label?: string; commands?: Command[]; - content?: (...props: any) => React.JSX.Element; } export interface LabelCustomization extends BaseCustomization { @@ -29,6 +28,7 @@ export interface ComponentCustomization extends BaseCustomization { } export type Customization = + | React.ComponentType | BaseCustomization | LabelCustomization | CommandCustomization diff --git a/platform/core/src/services/UIDialogService/UIDialogService.ts b/platform/core/src/services/UIDialogService/UIDialogService.ts index dd7acdaa0..2f291d331 100644 --- a/platform/core/src/services/UIDialogService/UIDialogService.ts +++ b/platform/core/src/services/UIDialogService/UIDialogService.ts @@ -1,80 +1,95 @@ -import { PubSubService } from '../_shared/pubSubServiceInterface'; +import type { ManagedDialogProps } from 'platform/ui-next/src/contextProviders/ManagedDialog'; +type DialogOptions = ManagedDialogProps; -class UIDialogService extends PubSubService { - public static readonly EVENTS = {}; +const name = 'uiDialogService'; - public static REGISTRATION = { - name: 'uiDialogService', +const serviceImplementation = { + _show: (options: DialogOptions) => { + console.warn('show() NOT IMPLEMENTED'); + return ''; + }, + _hide: (id: string) => console.warn('hide() NOT IMPLEMENTED'), + _hideAll: () => console.warn('hideAll() NOT IMPLEMENTED'), + _isEmpty: () => { + console.warn('isEmpty() NOT IMPLEMENTED'); + return true; + }, + _customComponent: null, +}; + +class UIDialogService { + static REGISTRATION = { + name, altName: 'UIDialogService', - create: ({ configuration = {} }) => { + create: (): UIDialogService => { return new UIDialogService(); }, }; - serviceImplementation = { - _dismiss: () => console.warn('dismiss() NOT IMPLEMENTED'), - _dismissAll: () => console.warn('dismissAll() NOT IMPLEMENTED'), - _create: () => console.warn('create() NOT IMPLEMENTED'), - }; + readonly name = name; - constructor() { - super(UIDialogService.EVENTS); - this.serviceImplementation = { - ...this.serviceImplementation, - }; + /** + * Show a new UI dialog + * + * @param {DialogOptions} options - The dialog options + * @returns {string} The dialog id + */ + show(options: DialogOptions): string { + return serviceImplementation._show(options); } - public create({ - id, - content, - contentProps, - onStart, - onDrag, - onStop, - centralize = false, - preservePosition = true, - isDraggable = true, - showOverlay = false, - defaultPosition, - onClickOutside, - }) { - return this.serviceImplementation._create({ - id, - content, - contentProps, - onStart, - onDrag, - onStop, - centralize, - preservePosition, - isDraggable, - showOverlay, - defaultPosition, - onClickOutside, - }); + /** + * Hide a specific dialog by id + * + * @param {string} id - The dialog id to hide + */ + hide(id: string): void { + return serviceImplementation._hide(id); } - public dismiss({ id }) { - return this.serviceImplementation._dismiss({ id }); + /** + * Hide all currently shown dialogs + */ + hideAll(): void { + return serviceImplementation._hideAll(); } - public dismissAll() { - return this.serviceImplementation._dismissAll(); + /** + * Check if there are any dialogs currently shown + * + * @returns {boolean} True if no dialogs are shown + */ + isEmpty(): boolean { + return serviceImplementation._isEmpty(); } - public setServiceImplementation({ - dismiss: dismissImplementation, - dismissAll: dismissAllImplementation, - create: createImplementation, - }) { - if (dismissImplementation) { - this.serviceImplementation._dismiss = dismissImplementation; + /** + * This provides flexibility in customizing the Modal's default component + * + * @returns {React.Component} + */ + getCustomComponent() { + return serviceImplementation._customComponent; + } + + /** + * Set the service implementation + */ + setServiceImplementation({ show, hide, hideAll, isEmpty, customComponent }: any): void { + if (show) { + serviceImplementation._show = show; } - if (dismissAllImplementation) { - this.serviceImplementation._dismissAll = dismissAllImplementation; + if (hide) { + serviceImplementation._hide = hide; } - if (createImplementation) { - this.serviceImplementation._create = createImplementation; + if (hideAll) { + serviceImplementation._hideAll = hideAll; + } + if (isEmpty) { + serviceImplementation._isEmpty = isEmpty; + } + if (customComponent) { + serviceImplementation._customComponent = customComponent; } } } diff --git a/platform/core/src/services/UIModalService/index.ts b/platform/core/src/services/UIModalService/index.ts index 73aa5651a..e692f53f8 100644 --- a/platform/core/src/services/UIModalService/index.ts +++ b/platform/core/src/services/UIModalService/index.ts @@ -1,16 +1,3 @@ -/** - * UI Modal - * - * @typedef {Object} ModalProps - * @property {ReactElement|HTMLElement} [content=null] Modal content. - * @property {Object} [contentProps=null] Modal content props. - * @property {boolean} [shouldCloseOnEsc=false] Modal is dismissible via the esc key. - * @property {boolean} [isOpen=true] Make the Modal visible or hidden. - * @property {boolean} [closeButton=true] Should the modal body render the close button. - * @property {string} [title=null] Should the modal render the title independently of the body content. - * @property {string} [customClassName=null] The custom class to style the modal. - */ - const name = 'uiModalService'; const serviceImplementation = { @@ -38,30 +25,20 @@ class UIModalService { show({ content = null, contentProps = null, - shouldCloseOnEsc = true, - isOpen = true, - closeButton = true, title = null, - customClassName = null, - movable = false, - containerDimensions = null, - contentDimensions = null, + className = null, + shouldCloseOnEsc = true, shouldCloseOnOverlayClick = true, - shouldCloseImmediately = false, + containerClassName = null, }) { return serviceImplementation._show({ content, contentProps, shouldCloseOnEsc, - isOpen, - closeButton, title, - customClassName, - movable, - containerDimensions, - contentDimensions, + className, shouldCloseOnOverlayClick, - shouldCloseImmediately, + containerClassName, }); } diff --git a/platform/docs/docs/assets/img/aboutModal.png b/platform/docs/docs/assets/img/aboutModal.png new file mode 100644 index 000000000..4f3552f34 Binary files /dev/null and b/platform/docs/docs/assets/img/aboutModal.png differ diff --git a/platform/docs/docs/assets/img/captureViewportModal.png b/platform/docs/docs/assets/img/captureViewportModal.png new file mode 100644 index 000000000..4cdc10820 Binary files /dev/null and b/platform/docs/docs/assets/img/captureViewportModal.png differ diff --git a/platform/docs/docs/assets/img/viewport-download-warning.png b/platform/docs/docs/assets/img/viewport-download-warning.png new file mode 100644 index 000000000..863596b9d Binary files /dev/null and b/platform/docs/docs/assets/img/viewport-download-warning.png differ diff --git a/platform/docs/docs/migration-guide/3p9-to-3p10/UI/Migration-3p10-DialogService.md b/platform/docs/docs/migration-guide/3p9-to-3p10/UI/Migration-3p10-DialogService.md new file mode 100644 index 000000000..960029bda --- /dev/null +++ b/platform/docs/docs/migration-guide/3p9-to-3p10/UI/Migration-3p10-DialogService.md @@ -0,0 +1,314 @@ +--- +title: uiDialogService +--- + + +## DialogService + +This guide details the migration steps for the `uiDialogService` API changes, based on the provided diff. The most significant change is a shift from methods like `.create()` and `.dismiss()` to `.show()` and `.hide()`, alongside structural changes in how dialogs are defined and rendered. The changes aim for a more streamlined and flexible dialog management, leveraging React context for state management. + +**Key Changes:** + +* **`uiDialogService.create()` and `uiDialogService.dismiss()` are deprecated.** They have been replaced with `uiDialogService.show()` and `uiDialogService.hide()`. This change + makes it consistent with the `uiModalService` and `uiNotificationService` APIs. +* **`content` property now expects a React Component Type**, not an instance. Props for the content component are passed via `contentProps`. +* **The `dialogId` is now consistently passed as `id` within the options** to `uiDialogService.show()`. + + +### Props Kept same as before + +| Prop | Description | +|------|-------------| +| `id` | Still required, but we don't return it from the `show` method anymore. | +| `content` | This is now expected to be a *React component type* (a function or class that returns JSX) | +| `contentProps` | This continues to be the way to pass data *to* your custom dialog component. However, several specific props that *used* to be passed here (like `onClose`, `actions`) are no longer valid. | +| `isDraggable` | Controls whether the dialog can be moved by dragging. | +| `defaultPosition` | Allows you to specify an initial `{ x, y }` position for the dialog. | +| `title` | The title text to display in the dialog header. | +| `showOverlay` | default true - if the dialog is draggable the overlay is not shown by default | + + +### Removed Props: + +| Prop | Description | +|------|-------------| +| `centralize` | Dialogs are now centered by default via CSS if you don't want center you pass defaultPosition | +| `preservePosition` | Work in progress and will be available in future | +| `contentDimensions` | Removed - should be specified directly in dialogs | +| `onStart` | Removed | +| `onDrag` | Removed | +| `onStop` | Removed | +| `onClickOutside` | Removed - if you want to close the dialog on click outside, you can use the `shouldCloseOnOverlayClick` prop | + + +### Renamed Props: + +| Prop | Description | +|------|-------------| +| `containerDimensions` | renamed to `containerClassName` | + + + + +### New Props: + +| Prop | Description | +|------|-------------| +| `unstyled` | A boolean prop to render the dialog without the default styling. It is used for context menu dialogs | +| `shouldCloseOnEsc` | Default off for dialogs - Controls whether pressing the Escape key will close the dialog. | +| `shouldCloseOnOverlayClick` | Default off for dialogs - Controls whether clicking the overlay background will close the dialog. | + + +**Migration Steps:** + +1. **Replace `.create()` with `.show()`:** + + ```diff + - const dialogId = uiDialogService.create({ + - id: 'my-dialog', + - content: MyDialogComponent, + - contentProps: { prop1: 'value1' }, + - // ... other options ... + - }); + + + uiDialogService.show({ + + id: 'my-dialog', + + content: MyDialogComponent, + + contentProps: { prop1: 'value1' }, + + // ... other options ... + + }); + ``` + +2. Rename `containerDimensions` to `containerClassName` + + ```diff + - containerDimensions: 'w-[70%] max-w-[900px]', + + containerClassName: 'w-[70%] max-w-[900px]', + ``` + +3. **Replace `.dismiss({ id: dialogId })` with `.hide(dialogId)`:** + + ```diff + - uiDialogService.dismiss({ id: dialogId }); + + + uiDialogService.hide(dialogId); + ``` + +4. **Replace `dismissAll` with `hideAll`** + ```diff + - uiDialogService.dismissAll(); + + uiDialogService.hideAll(); + ``` +5. **Update Dialog Content:** + + * Ensure your dialog content is defined as a React component (functional or class-based). + * Pass props to the component via `contentProps`. + * You don't need to pass in `onClose` or `hide` as they are now handled passed in automatically. + + ```javascript + // Example: MyDialogComponent.tsx + function MyDialogComponent({ prop1, hide }) { + return ( +
+

Value of prop1: {prop1}

+ +
+ ); + } + ``` + + + +**Example: Updating a Simple Alert Dialog** + +```javascript +// Before (using deprecated API) +let dialogId; +const showAlert = (message) => { + dialogId = uiDialogService.create({ + centralize: true, + isDraggable: false, + content: Dialog, + contentProps: { + title: 'Alert', + body: () =>

{message}

, + onClose: () => uiDialogService.dismiss({ id: dialogId }), + }, + }); +}; + +// After (using new API) +function AlertDialog({ message, hide }) { + return ( +
+

{message}

+ +
+ ); +} + +const showAlert = (message) => { + uiDialogService.show({ + id: 'alert-dialog', + title: 'Alert', + content: AlertDialog, + contentProps: { message }, + }); +}; + +``` + + +--- + +## Components + +### CreateReportDialogPrompt + +**Key Changes:** + +* **Function Signature Update:** The function now accepts an object with `servicesManager`, `extensionManager`, `title`(optional). +* **Return Value Structure:** The function now returns an object containing `value` (the report name), `dataSourceName` (the selected data source, if applicable), and `action` (indicating the user's choice). + +**Migration Steps:** + +1. **Update Function Call:** + + Previously, the function was called with separate arguments. You should now pass an object: + + ```diff + - const promptResult = await createReportDialogPrompt(uiDialogService, { + - extensionManager, + - }); + + + const promptResult = await createReportDialogPrompt({ + + servicesManager, + + extensionManager, + + title: 'Store Segmentation', // Optional title + + }); + + ``` + +### promptSaveReport + +Not changed, just a javascript to typescript migration. + +### callLabelAutocompleteDialog + +`callLabelAutocompleteDialog` is deprecated and has been replaced by `callInputDialogAutoComplete`. This new function simplifies the asynchronous handling of user input by using `uiDialogService.show()` and returning a promise. + + + +### showLabelAnnotationPopup + +`showLabelAnnotationPopup` has been replaced with `callInputDialogAutoComplete`. This update also uses `uiDialogService.show()` and promises, and it removes the callback function. + +- The function now expects an object with `measurement`, `uiDialogService`, `labelConfig`, and `renderContent`. + +```diff +- const value = await showLabelAnnotationPopup( +- measurement, +- servicesManager.services.uiDialogService, +- labelConfig, +- renderContent +- ); ++ const value = await callInputDialogAutoComplete({ ++ measurement, ++ uiDialogService, ++ labelConfig, ++ renderContent, ++ }); +``` + +### callInputDialog + +- expects an objects now and returns the value of the input which you can then use for actions + + +```diff + +- callInputDialog( +- uiDialogService, +- { +- text: '', +- label: `${length}`, +- }, +- (value, id) => { +- if (id === 'save') { +- adjustCalibration(Number.parseFloat(value)); +- resolve(true); +- } else { +- reject('cancel'); +- } +- }, +- false, +- { +- dialogTitle: 'Calibration', +- inputLabel: 'Actual Physical distance (mm)', +- validateFunc: val => { +- const v = Number.parseFloat(val); +- return !isNaN(v) && v !== 0.0; +- }, +- } +- ); ++ callInputDialog({ ++ uiDialogService, ++ title: 'Calibration', ++ placeholder: 'Actual Physical distance (mm)', ++ defaultValue: `${length}`, ++ }).then(newValue => { ++ adjustCalibration(Number.parseFloat(newValue)); ++ resolve(true); ++ }); +``` + +or another one + +```diff +- callInputDialog( +- uiDialogService, +- { text: '', label: 'Enter description' }, +- (value, action) => { +- if (action === 'save') { +- saveFunction(value); +- } +- } +- ); ++ callInputDialog({ ++ uiDialogService, ++ title: 'Enter description of the Series', ++ defaultValue: '', ++ }).then(value => { ++ saveFunction(value); ++ }); +``` + + +### colorPickerDialog + +Instead of calling `colorPickerDialog(uiDialogService, rgbaColor, callback)`, use `uiDialogService.show()` with `ColorPickerDialog` as the content. + + +```diff +- colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => { +- if (actionId === 'cancel') { +- return; +- } +- const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0]; +- segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color); +- }); + +// after + ++ uiDialogService.show({ ++ content: ColorPickerDialog, ++ title: 'Segment Color', ++ contentProps: { ++ value: rgbaColor, ++ onSave: newRgbaColor => { ++ const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0]; ++ segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color); ++ }, ++ }, ++ }); +``` diff --git a/platform/docs/docs/migration-guide/3p9-to-3p10/UI/Migration-3p10-ModalService.md b/platform/docs/docs/migration-guide/3p9-to-3p10/UI/Migration-3p10-ModalService.md new file mode 100644 index 000000000..1cd2d9022 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p9-to-3p10/UI/Migration-3p10-ModalService.md @@ -0,0 +1,104 @@ +--- +title: uiModalService +--- + + +## ModalService + + +### Props Kept same as before + +| Prop | Description | +|------|-------------| +| `content` | This is now expected to be a *React component type* (a function or class that returns JSX) | +| `contentProps` | This continues to be the way to pass data *to* your custom dialog component. However, several specific props that *used* to be passed here (like `onClose`, `actions`) are no longer valid. | +| `title` | The title text to display in the dialog header. | +| `shouldCloseOnEsc` | Allows closing the modal when the escape key is pressed. | +| `shouldCloseOnOverlayClick` | Allows closing the modal when the overlay is clicked. | + +### Renamed Props: + +| Prop | Description | +|------|-------------| +| `containerDimensions` | renamed to `containerClassName` | + + + +### Removed Props: + +| Prop | Description | +|------|-------------| +| `movable` | It's removed because modals shouldn't be movable. If you need to move a dialog, use `uidDialogService` and `dialogs` instead. | +| `isOpen` | always assumed `true` when `show` is called. | +| `contentDimensions` | Removed, it is now component's responsibility to set the size for the content | +| `customClassName` | renamed to `className` | +| `closeButton` | The component now manages modal closing internally. If you need a close button, you can add one, perhaps by checking out the `FooterActions` component. | + + + + +**Migration Steps:** + + +### Rename of `containerDimensions` to `containerClassName` and removal of `contentDimensions` + + +Before + +```js +uiModalService.show({ + title: 'Download High-Quality Image', + content: CornerstoneViewportDownloadForm, + contentProps: { + activeViewportId, + }, + containerDimensions: 'w-[70%] max-w-[900px]', + contentDimensions: 'h-[493px] w-[460px] pl-[12px] pr-[12px]', +}); +``` + +After: the component is responsible for setting the size + +```js +function CornerstoneViewportDownloadForm({ activeViewportId }) { + return ( +
+

Download Image

+

Viewport ID: {activeViewportId}

+ +
+ ); +} + +// Show the modal +uiModalService.show({ + title: 'Download High-Quality Image', + content: CornerstoneViewportDownloadForm, + contentProps: { activeViewportId }, + containerClassName: 'w-[70%] max-w-[900px]', +}); +``` + + + + +### onClose +Previously, you had to pass in the `onClose` as `hide` function automatically added to the component. + +```diff +- uiModalService.show({ +- title: 'Untrack Series', +- content: UntrackSeriesModal, +- contentProps: { onConfirm }, +- onClose: () => uiModalService.hide(), +- }); + ++ uiModalService.show({ ++ title: 'Untrack Series', ++ content: UntrackSeriesModal, ++ contentProps: { ++ onConfirm, ++ hide, // passed in automatically in the background ++ }, ++ }); +``` diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx index 97060a164..c2400eb19 100644 --- a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx +++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx @@ -16,6 +16,7 @@ import loadingIndicatorProgress from '../../../assets/img/loading-indicator-icon import loadingIndicatorPercent from '../../../assets/img/loading-indicator-percent.png'; import viewportActionCorners from '../../../assets/img/viewport-action-corners.png'; import contextMenu from '../../../assets/img/context-menu.jpg'; +import viewportDownloadWarning from '../../../assets/img/viewport-download-warning.png'; import segmentationOverlay from '../../../assets/img/segmentation-overlay.png'; import segDisplayEditingTrue from '../../../assets/img/segDisplayEditingTrue.png'; @@ -24,6 +25,8 @@ import thumbnailMenuItemsImage from '../../../assets/img/thumbnailMenuItemsImage 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'; export const viewportOverlayCustomizations = [ { @@ -269,24 +272,6 @@ window.config = { default: 'The CinePlayer component in the UI', configuration: null, }, - { - id: 'cornerstone.windowLevelActionMenu', - description: 'Window level action menu for the cornerstone viewport.', - image: windowLevelActionMenu, - default: null, - configuration: ` - window.config = { - // rest of window config - customizationService: [ - { - 'cornerstone.windowLevelActionMenu': { - $set: CustomizedComponent, - }, - }, - ], - }; - `, - }, { id: 'cornerstone.windowLevelPresets', description: 'Window level presets for the cornerstone viewport.', @@ -785,10 +770,142 @@ window.config = { }; `, }, + { + id: 'viewportDownload.warningMessage', + description: 'Customizes the warning message for the viewport download form.', + image: viewportDownloadWarning, + default: { + enabled: true, + value: 'Not For Diagnostic Use', + }, + configuration: ` + window.config = { + // rest of window config + customizationService: [ + { + 'viewportDownload.warningMessage': { + $set: { + enabled: true, + value: 'Careful! This is not for diagnostic use.', + }, + }, + }, + ], + }; + `, + }, + { + id: 'ohif.captureViewportModal', + description: 'The modal for capturing the viewport image.', + image: captureViewportModal, + default: 'Our own default component', + configuration: ` + window.config = { + // rest of window config + + // You can use the component from ImageModal and FooterAction + // to build your own custom component + customizationService: [ + { + 'ohif.captureViewportModal': { + $set: CustomizedComponent, + }, + }, + ], + }; + `, + }, + { + id: 'ohif.aboutModal', + description: 'The About modal', + image: aboutModal, + default: 'Our own default component', + configuration: ` + window.config = { + // rest of window config + + // You can use the component from AboutModal + // to build your own custom component + customizationService: [ + { + 'ohif.aboutModal': { + $set: CustomizedComponent, + }, + }, + ], + }; + `, + }, + { + id: 'viewportDownload.warningMessage', + description: 'Customizes the warning message for the viewport download form.', + image: viewportDownloadWarning, + default: { + enabled: true, + value: 'Not For Diagnostic Use', + }, + configuration: ` + window.config = { + // rest of window config + customizationService: [ + { + 'viewportDownload.warningMessage': { + $set: { + enabled: true, + value: 'Careful! This is not for diagnostic use.', + }, + }, + }, + ], + }; + `, + }, + { + id: 'ohif.captureViewportModal', + description: 'The modal for capturing the viewport image.', + image: captureViewportModal, + default: 'Our own default component', + configuration: ` + window.config = { + // rest of window config + + // You can use the component from ImageModal and FooterAction + // to build your own custom component + customizationService: [ + { + 'ohif.captureViewportModal': { + $set: CustomizedComponent, + }, + }, + ], + }; + `, + }, + { + id: 'ohif.aboutModal', + description: 'The About modal', + image: aboutModal, + default: 'Our own default component', + configuration: ` + window.config = { + // rest of window config + + // You can use the component from AboutModal + // to build your own custom component + customizationService: [ + { + 'ohif.aboutModal': { + $set: CustomizedComponent, + }, + }, + ], + }; + `, + }, { id: 'viewportActionMenu.windowLevelActionMenu', description: - 'Configures the display and location of the window level action menu in the viewport.', + 'Configures the display and location of the window level action menu in the viewport.', image: windowLevelActionMenu, default: null, configuration: ` @@ -797,9 +914,8 @@ window.config = { customizationService: [ { 'viewportActionMenu.windowLevelActionMenu': { - $set: { - enabled: true, - location: 1, // Set the location of the menu in the viewport. + $merge: { + location: 0, // Set the location of the menu in the viewport. // 0: topLeft // 1: topRight // 2: bottomLeft @@ -822,7 +938,7 @@ window.config = { customizationService: [ { 'viewportActionMenu.segmentationOverlay': { - $set: { + $merge: { enabled: true, location: 1, // Set the location of the overlay in the viewport. // 0: topLeft diff --git a/platform/docs/docusaurus.config.js b/platform/docs/docusaurus.config.js index a10ffa01c..8643de7b8 100644 --- a/platform/docs/docusaurus.config.js +++ b/platform/docs/docusaurus.config.js @@ -136,6 +136,7 @@ module.exports = { prism: { theme: require('prism-react-renderer').themes.github, darkTheme: require('prism-react-renderer').themes.dracula, + additionalLanguages: ['diff'], }, algolia: { appId: 'EFLT6YIHHZ', diff --git a/platform/docs/src/css/custom.css b/platform/docs/src/css/custom.css index 48c072bd7..b1bd9aa2a 100644 --- a/platform/docs/src/css/custom.css +++ b/platform/docs/src/css/custom.css @@ -233,11 +233,6 @@ input[type='number'] { -moz-appearance: textfield; /* For Firefox */ } -.navbar__item { - display: flex; - align-items: center; -} - .navbar__item svg { margin-right: 5px; display: inline-block; @@ -642,6 +637,7 @@ html[data-theme='dark'] .markdown hr { /* Markdown code block styling */ .markdown pre { font-size: 0.9rem; + margin-top: 1em; } .theme-code-block { @@ -725,3 +721,7 @@ a.dropdown__link[href='/3.9/migration-guide/3p8-to-3p9/']::after { font-size: 0.9em; margin-left: 4px; } + +li:first-child { + margin-top: 0.5em; /* Adjust '1em' to your desired spacing */ +} diff --git a/platform/i18n/src/index.js b/platform/i18n/src/index.js index b2ccc6fc3..50c29e6fe 100644 --- a/platform/i18n/src/index.js +++ b/platform/i18n/src/index.js @@ -98,8 +98,7 @@ function initI18n( }, }, react: { - useSuspense: false, // TODO: Was seeing weird errors without this - wait: true, + useSuspense: true, bindI18n: 'languageChanged editorSaved', }, }); @@ -123,7 +122,7 @@ function initI18n( }, detection, react: { - wait: true, + useSuspense: true, }, }); } diff --git a/platform/ui-next/src/components/DataRow/DataRow.tsx b/platform/ui-next/src/components/DataRow/DataRow.tsx index b0c3e9307..b50630e50 100644 --- a/platform/ui-next/src/components/DataRow/DataRow.tsx +++ b/platform/ui-next/src/components/DataRow/DataRow.tsx @@ -190,7 +190,8 @@ const DataRow: React.FC = ({ return (
+ className={`flex flex-col ${isVisible ? '' : 'opacity-60'}`} + >
= ({ - + e.preventDefault()} + > <> handleAction('Rename', e)}> diff --git a/platform/ui-next/src/components/Dialog/Dialog.tsx b/platform/ui-next/src/components/Dialog/Dialog.tsx index 8e69250f6..4bb4cae6e 100644 --- a/platform/ui-next/src/components/Dialog/Dialog.tsx +++ b/platform/ui-next/src/components/Dialog/Dialog.tsx @@ -3,8 +3,41 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'; import { Cross2Icon } from '@radix-ui/react-icons'; import { cn } from '../../lib/utils'; +import { useDraggable } from './useDraggable'; -const Dialog = DialogPrimitive.Root; +interface DialogContextValue { + isDraggable?: boolean; + shouldCloseOnEsc?: boolean; + shouldCloseOnOverlayClick?: boolean; + showOverlay?: boolean; +} + +const DialogContext = React.createContext({ + isDraggable: false, + shouldCloseOnEsc: true, + shouldCloseOnOverlayClick: true, +}); + +interface DialogRootProps extends DialogPrimitive.DialogProps { + isDraggable?: boolean; + shouldCloseOnEsc?: boolean; + shouldCloseOnOverlayClick?: boolean; + showOverlay?: boolean; +} + +const Dialog = ({ + isDraggable, + shouldCloseOnEsc = true, + shouldCloseOnOverlayClick = true, + showOverlay = true, + ...props +}: DialogRootProps) => ( + + + +); const DialogTrigger = DialogPrimitive.Trigger; @@ -14,12 +47,14 @@ const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( - {} + const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - + DialogContentProps & { + children?: React.ReactNode; + } +>(({ className, children, ...props }, ref) => { + const { isDraggable, shouldCloseOnEsc, shouldCloseOnOverlayClick, showOverlay } = + React.useContext(DialogContext); + + const { handlePointerDown, setRefs, initialTransform } = useDraggable( + { + enabled: isDraggable, + }, + ref + ); + + // When not isDraggable, Tailwind centers the dialog. + // When isDraggable, we remove the built‑in centering so our inline transform takes over. + const contentClassName = cn( + 'max-w-md w-full bg-muted data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid gap-4 p-4 shadow-lg duration-200 sm:rounded-lg', + !isDraggable ? 'translate-x-[-50%] translate-y-[-50%]' : '', + className + ); + + const style = isDraggable ? { ...props.style, transform: initialTransform } : props.style; + + const content = ( { + if (!shouldCloseOnEsc) { + event.preventDefault(); + } + }} + onInteractOutside={event => { + if (!shouldCloseOnOverlayClick) { + event.preventDefault(); + } + }} + > + {children} + + + Close + + + ); + + return ( + + {showOverlay && !isDraggable && } + {content} + + ); +}); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => { + return ( +
- {children} - - - Close - - - -)); -DialogContent.displayName = DialogPrimitive.Content.displayName; - -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-); + {props.children} +
+ ); +}; DialogHeader.displayName = 'DialogHeader'; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( @@ -69,11 +151,13 @@ DialogFooter.displayName = 'DialogFooter'; const DialogTitle = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( )); @@ -81,11 +165,13 @@ DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & { + className?: string; + } >(({ className, ...props }, ref) => ( )); diff --git a/platform/ui-next/src/components/Dialog/useDraggable.ts b/platform/ui-next/src/components/Dialog/useDraggable.ts new file mode 100644 index 000000000..158929f82 --- /dev/null +++ b/platform/ui-next/src/components/Dialog/useDraggable.ts @@ -0,0 +1,108 @@ +import * as React from 'react'; + +interface Offset { + x: number; + y: number; +} + +interface DragState { + startX: number; + startY: number; + initialOffset: Offset; +} + +interface UseDraggableProps { + enabled?: boolean; +} + +interface UseDraggableReturn { + offset: React.RefObject; + internalRef: React.RefObject; + handlePointerDown: (e: React.PointerEvent) => void; + setRefs: (node: HTMLDivElement) => void; + initialTransform: string | undefined; +} + +export function useDraggable( + props: UseDraggableProps, + ref?: React.ForwardedRef +): UseDraggableReturn { + const { enabled = false } = props; + const offsetRef = React.useRef({ x: 0, y: 0 }); + const internalRef = React.useRef(null); + + const dragState = React.useRef(null); + + const handlePointerMove = React.useCallback((e: PointerEvent) => { + if (!dragState.current) { + return; + } + + const deltaX = e.clientX - dragState.current.startX; + const deltaY = e.clientY - dragState.current.startY; + const newOffset = { + x: dragState.current.initialOffset.x + deltaX, + y: dragState.current.initialOffset.y + deltaY, + }; + + offsetRef.current = newOffset; + if (internalRef.current) { + internalRef.current.style.transform = `translate(-50%, -50%) translate(${newOffset.x}px, ${newOffset.y}px)`; + } + }, []); + + const handlePointerUp = React.useCallback(() => { + if (internalRef.current) { + internalRef.current.style.transition = ''; + } + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + dragState.current = null; + }, [handlePointerMove]); + + const handlePointerDown = React.useCallback( + (e: React.PointerEvent) => { + const target = e.target as HTMLElement; + if (!target.closest('.drag-handle')) { + return; + } + + if (internalRef.current) { + internalRef.current.style.transition = 'none'; + } + dragState.current = { + startX: e.clientX, + startY: e.clientY, + initialOffset: { ...offsetRef.current }, + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + }, + [handlePointerMove, handlePointerUp] + ); + + const setRefs = React.useCallback( + (node: HTMLDivElement) => { + internalRef.current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + (ref as React.MutableRefObject).current = node; + } + }, + [ref] + ); + + const initialTransform = enabled + ? `translate(-50%, -50%) translate(${offsetRef.current.x}px, ${offsetRef.current.y}px)` + : undefined; + + return { + offset: offsetRef, + internalRef, + handlePointerDown, + setRefs, + initialTransform, + }; +} diff --git a/platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx b/platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx index bbf8343e8..f7ba45360 100644 --- a/platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx +++ b/platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx @@ -62,6 +62,11 @@ const DropdownMenuContent = React.forwardRef< { + // Todo: or maybe we just want to prevent arrows? + e.stopPropagation(); + props.onKeyDown?.(e); + }} className={cn( 'bg-popover text-popover-foreground border-input z-50 min-w-[8rem] overflow-hidden rounded border p-1 shadow-md', 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', diff --git a/platform/ui-next/src/components/FooterAction/FooterAction.tsx b/platform/ui-next/src/components/FooterAction/FooterAction.tsx new file mode 100644 index 000000000..052dc466f --- /dev/null +++ b/platform/ui-next/src/components/FooterAction/FooterAction.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Button } from '../Button/Button'; +import { cn } from '../../lib/utils'; + +interface FooterActionProps { + children: React.ReactNode; + className?: string; +} + +interface ActionProps extends FooterActionProps { + onClick: () => void; + className?: string; +} + +type FooterActionComponent = React.FC & { + Left: React.FC; + Right: React.FC; + Primary: React.FC; + Secondary: React.FC; + Auxiliary: React.FC; +}; + +export const FooterAction: FooterActionComponent = ({ children, className }: FooterActionProps) => { + // Convert children to array for easier inspection + const arrayChildren = React.Children.toArray(children); + + // Check if we have a or among children + const hasLeft = arrayChildren.some( + (child: any) => child.type?.displayName === 'FooterAction.Left' + ); + const hasRight = arrayChildren.some( + (child: any) => child.type?.displayName === 'FooterAction.Right' + ); + + // Decide on the justification class based on presence of Left/Right + let justifyClass = 'justify-between'; // default + if (hasLeft && !hasRight) { + justifyClass = 'justify-start'; + } else if (!hasLeft && hasRight) { + justifyClass = 'justify-end'; + } + // If both or neither are present, keep justify-between (or adjust if you like) + return ( +
+ {children} +
+ ); +}; + +FooterAction.displayName = 'FooterAction'; + +FooterAction.Left = ({ children }: FooterActionProps) => { + return
{children}
; +}; +FooterAction.Left.displayName = 'FooterAction.Left'; + +FooterAction.Right = ({ children }: FooterActionProps) => { + return
{children}
; +}; +FooterAction.Right.displayName = 'FooterAction.Right'; + +// Primary action: Solid button (default) +FooterAction.Primary = ({ children, onClick, className = 'min-w-[80px]' }: ActionProps) => { + return ( + + ); +}; +FooterAction.Primary.displayName = 'FooterAction.Primary'; + +// Secondary action: Ghost button +FooterAction.Secondary = ({ children, onClick, className = 'min-w-[80px]' }: ActionProps) => { + return ( + + ); +}; +FooterAction.Secondary.displayName = 'FooterAction.Secondary'; + +// Tertiary action: Ghost button with different styling +FooterAction.Auxiliary = ({ children, onClick, className }: ActionProps) => { + return ( + + ); +}; +FooterAction.Auxiliary.displayName = 'FooterAction.Auxiliary'; diff --git a/platform/ui-next/src/components/FooterAction/index.tsx b/platform/ui-next/src/components/FooterAction/index.tsx new file mode 100644 index 000000000..2ee8e7e38 --- /dev/null +++ b/platform/ui-next/src/components/FooterAction/index.tsx @@ -0,0 +1,3 @@ +import { FooterAction } from './FooterAction'; + +export { FooterAction }; diff --git a/platform/ui-next/src/components/Icons/Icons.tsx b/platform/ui-next/src/components/Icons/Icons.tsx index bcfdd1411..e311f4779 100644 --- a/platform/ui-next/src/components/Icons/Icons.tsx +++ b/platform/ui-next/src/components/Icons/Icons.tsx @@ -46,6 +46,7 @@ import Show from './Sources/Show'; import SidePanelCloseLeft from './Sources/SidePanelCloseLeft'; import SidePanelCloseRight from './Sources/SidePanelCloseRight'; import SortingAscending from './Sources/SortingAscending'; +import SocialGithub from './Sources/SocialGithub'; import SortingDescending from './Sources/SortingDescending'; import StatusError from './Sources/StatusError'; import StatusSuccess from './Sources/StatusSuccess'; @@ -471,6 +472,7 @@ export const Icons = { Show, SidePanelCloseLeft, SidePanelCloseRight, + SocialGithub, SortingAscending, SortingDescending, Sorting, diff --git a/platform/ui-next/src/components/Icons/Sources/SocialGithub.tsx b/platform/ui-next/src/components/Icons/Sources/SocialGithub.tsx new file mode 100644 index 000000000..58d52e645 --- /dev/null +++ b/platform/ui-next/src/components/Icons/Sources/SocialGithub.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import type { IconProps } from '../types'; + +export const SocialGithub = (props: IconProps) => ( + + + + + + + + +); + +export default SocialGithub; diff --git a/platform/ui-next/src/components/Modal/Modal.tsx b/platform/ui-next/src/components/Modal/Modal.tsx new file mode 100644 index 000000000..bd618f436 --- /dev/null +++ b/platform/ui-next/src/components/Modal/Modal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../Dialog'; +import { cn } from '../../lib/utils'; + +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; + contentClassName?: string; + shouldCloseOnEsc?: boolean; + shouldCloseOnOverlayClick?: boolean; + containerClassName?: string; +} + +const Modal: React.FC = ({ + isOpen, + onClose, + title, + children, + shouldCloseOnEsc = true, + shouldCloseOnOverlayClick = true, + containerClassName, +}) => { + return ( + !open && onClose()} + shouldCloseOnEsc={shouldCloseOnEsc} + shouldCloseOnOverlayClick={shouldCloseOnOverlayClick} + > + + {title && ( + + {title} + + )} +
{children}
+
+
+ ); +}; + +export default Modal; diff --git a/platform/ui-next/src/components/OHIFDialogs/InputDialog.tsx b/platform/ui-next/src/components/OHIFDialogs/InputDialog.tsx new file mode 100644 index 000000000..a62663c58 --- /dev/null +++ b/platform/ui-next/src/components/OHIFDialogs/InputDialog.tsx @@ -0,0 +1,258 @@ +import React, { createContext, useContext, useRef, useEffect } from 'react'; +import { useControllableState } from '@radix-ui/react-use-controllable-state'; +import { Label, Input as InputComponent, FooterAction } from '../../components'; +import { cn } from '../../lib/utils'; + +interface InputDialogContextValue { + value: string; + setValue: (value: string) => void; + submitOnEnter?: boolean; +} + +const InputDialogContext = createContext(null); + +export type InputDialogRootProps = { + /** The controlled value of the input */ + value?: string; + /** The default value for uncontrolled usage */ + defaultValue?: string; + /** Callback when input value changes */ + onChange?: (value: string) => void; + /** Optional className for the root container */ + className?: string; + /** Enable save on Enter key press */ + submitOnEnter?: boolean; + children: React.ReactNode; +}; + +const InputDialogRoot = React.forwardRef( + ({ value, defaultValue = '', onChange, className, submitOnEnter, children }, ref) => { + const [internalValue, setInternalValue] = useControllableState({ + prop: value, + defaultProp: defaultValue, + onChange, + }); + + return ( + +
+ {children} +
+
+ ); + } +); + +InputDialogRoot.displayName = 'InputDialog'; + +export interface InputDialogFieldProps extends React.HTMLAttributes { + /** Optional className for the field container */ + className?: string; + children: React.ReactNode; +} + +const Field = React.forwardRef( + ({ className, children, ...props }, ref) => { + return ( +
+ {children} +
+ ); + } +); + +Field.displayName = 'InputDialog.Field'; + +export interface InputDialogInputProps + extends Omit, 'onChange' | 'value'> { + /** ID for the input field */ + id?: string; + /** Optional className for the input container */ + className?: string; + /** Save handler */ + onSave?: (value: string) => void; + /** Placeholder text for the input field */ + placeholder?: string; +} + +const InputDialogInput = React.forwardRef( + ({ id = 'dialog-input', className, onSave, ...props }, ref) => { + const context = useContext(InputDialogContext); + if (!context) { + throw new Error('InputDialog.Input must be used within an InputDialog'); + } + + const { value, setValue } = context; + const inputRef = useRef(null); + + // Combine the forwarded ref with our local ref + React.useImperativeHandle(ref, () => inputRef.current); + + // Focus the input when it mounts + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (context.submitOnEnter && e.key === 'Enter') { + e.preventDefault(); + const saveButton = document.querySelector( + '[data-cy="input-dialog-save-button"]' + ) as HTMLButtonElement; + if (saveButton) { + saveButton.click(); + } + } + }; + + return ( +
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + {...props} + /> +
+ ); + } +); + +InputDialogInput.displayName = 'InputDialog.Input'; + +export interface InputDialogLabelProps extends React.LabelHTMLAttributes { + /** Optional className for the label */ + className?: string; + /** For attribute to match input ID */ + htmlFor?: string; + children: React.ReactNode; +} + +const InputDialogLabel = React.forwardRef( + ({ className, htmlFor = 'dialog-input', children, ...props }, ref) => { + return ( + + ); + } +); + +InputDialogLabel.displayName = 'InputDialog.Label'; + +export interface InputDialogActionsProps extends React.HTMLAttributes { + /** Optional className for the actions container */ + className?: string; + children: React.ReactNode; +} + +const Actions = React.forwardRef( + ({ className, children, ...props }, ref) => { + return ( +
+ + {children} + +
+ ); + } +); + +Actions.displayName = 'InputDialog.Actions'; + +export interface InputDialogActionButtonProps { + /** Optional className for the button */ + className?: string; + /** Click handler that receives the current input value */ + onClick: (value: string) => void; + children: React.ReactNode; +} + +const ActionsSecondary = React.forwardRef( + ({ className, onClick, children, ...props }, ref) => { + const context = useContext(InputDialogContext); + if (!context) { + throw new Error('InputDialog.ActionsSecondary must be used within an InputDialog'); + } + + const { value } = context; + + return ( +
+ onClick(value)} + className={cn(className)} + > + {children} + +
+ ); + } +); + +ActionsSecondary.displayName = 'InputDialog.ActionsSecondary'; + +const ActionsPrimary = React.forwardRef( + ({ className, onClick, children, ...props }, ref) => { + const context = useContext(InputDialogContext); + if (!context) { + throw new Error('InputDialog.ActionsPrimary must be used within an InputDialog'); + } + + const { value } = context; + return ( +
onClick(value)} + > + onClick(value)} + className={cn(className)} + > + {children} + +
+ ); + } +); + +ActionsPrimary.displayName = 'InputDialog.ActionsPrimary'; + +export const InputDialog = Object.assign(InputDialogRoot, { + Label: InputDialogLabel, + Input: InputDialogInput, + Field, + Actions, + ActionsSecondary, + ActionsPrimary, +}); diff --git a/platform/ui-next/src/components/OHIFDialogs/PresetDialog.tsx b/platform/ui-next/src/components/OHIFDialogs/PresetDialog.tsx new file mode 100644 index 000000000..77b9ca971 --- /dev/null +++ b/platform/ui-next/src/components/OHIFDialogs/PresetDialog.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { Input } from '../Input'; +import { ScrollArea } from '../ScrollArea'; +import { cn } from '../../lib/utils'; + +interface PresetDialogProps { + children: React.ReactNode; + className?: string; +} + +export function PresetDialog({ children, className }: PresetDialogProps) { + return
{children}
; +} + +/** + * Subcomponent: PresetBody + * A dark "box" container that wraps the filter and grid area. + * Adjust bg color, padding, border, etc. to match your design. + */ +interface PresetBodyProps { + children: React.ReactNode; + className?: string; +} +function PresetBody({ children, className }: PresetBodyProps) { + return ( +
+ {children} +
+ ); +} + +/** + * Subcomponent: PresetFilter + */ +interface PresetFilterProps { + children: React.ReactNode; + className?: string; +} +function PresetFilter({ children, className }: PresetFilterProps) { + return ( +
+ {children} +
+ ); +} + +/** + * Subcomponent: PresetSearch + */ +interface PresetSearchProps extends React.InputHTMLAttributes { + className?: string; +} +function PresetSearch({ className, ...props }: PresetSearchProps) { + return ( + + ); +} + +/** + * Subcomponent: PresetGrid + */ +interface PresetGridProps { + children: React.ReactNode; + maxHeight?: string; + className?: string; +} +function PresetGrid({ children, maxHeight = 'flex-1', className }: PresetGridProps) { + return ( + +
{children}
+
+ ); +} + +/** + * Subcomponent: PresetOption + */ +interface PresetOptionProps { + label?: string; + className?: string; +} +function PresetOption({ label = 'Label', className }: PresetOptionProps) { + return ( +
+ {/* Default dark placeholder box (swap in an if you like) */} +
+
{label}
+
+ ); +} + +/* Attach subcomponents to PresetDialog as static properties */ +PresetDialog.PresetBody = PresetBody; +PresetDialog.PresetFilter = PresetFilter; +PresetDialog.PresetSearch = PresetSearch; +PresetDialog.PresetGrid = PresetGrid; +PresetDialog.PresetOption = PresetOption; diff --git a/platform/ui-next/src/components/OHIFDialogs/index.ts b/platform/ui-next/src/components/OHIFDialogs/index.ts new file mode 100644 index 000000000..f0657dc4c --- /dev/null +++ b/platform/ui-next/src/components/OHIFDialogs/index.ts @@ -0,0 +1,2 @@ +export { InputDialog } from './InputDialog'; +export { PresetDialog } from './PresetDialog'; diff --git a/platform/ui-next/src/components/OHIFModals/AboutModal.tsx b/platform/ui-next/src/components/OHIFModals/AboutModal.tsx new file mode 100644 index 000000000..92662b081 --- /dev/null +++ b/platform/ui-next/src/components/OHIFModals/AboutModal.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import { cn } from '../../lib/utils'; +import { Button } from '../Button'; +import { Icons } from '../Icons'; + +interface AboutModalProps { + children: React.ReactNode; + className?: string; +} + +export function AboutModal({ children, className }: AboutModalProps) { + return
{children}
; +} + +/** Sub component: Product Name */ +interface ProductNameProps { + children: React.ReactNode; + className?: string; +} +function ProductName({ children, className }: ProductNameProps) { + return ( +
+ {children} +
+ ); +} + +/** Sub-component: Product Version */ +interface ProductVersionProps { + children: React.ReactNode; + className?: string; +} +function ProductVersion({ children, className }: ProductVersionProps) { + return ( +
+ {children} +
+ ); +} + +/** Sub-component: Product Beta */ +interface ProductBetaProps { + children: React.ReactNode; + className?: string; +} +function ProductBeta({ children, className }: ProductBetaProps) { + return ( +
{children}
+ ); +} + +/** Sub-component: Body (wraps all detail items) */ +interface BodyProps { + children: React.ReactNode; + className?: string; +} +function Body({ children, className }: BodyProps) { + return ( +
{children}
+ ); +} + +/** Sub-component: Detail Item */ +interface DetailItemProps { + label: string; + value: string; + className?: string; +} +function DetailItem({ label, value, className }: DetailItemProps) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +/** Sub-component: Social Item */ +interface SocialItemProps { + icon: string; + url: string; + text: string; + className?: string; +} +function SocialItem({ icon, url, text, className }: SocialItemProps) { + return ( +
+
+ +
+ +
+ ); +} + +/** Attach sub-components to AboutModal as static properties */ +AboutModal.ProductName = ProductName; +AboutModal.ProductVersion = ProductVersion; +AboutModal.ProductBeta = ProductBeta; +AboutModal.Body = Body; +AboutModal.DetailItem = DetailItem; +AboutModal.SocialItem = SocialItem; diff --git a/platform/ui-next/src/components/OHIFModals/ImageModal.tsx b/platform/ui-next/src/components/OHIFModals/ImageModal.tsx new file mode 100644 index 000000000..e248d7a6f --- /dev/null +++ b/platform/ui-next/src/components/OHIFModals/ImageModal.tsx @@ -0,0 +1,231 @@ +import * as React from 'react'; +import { Input } from '../Input/Input'; +import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '../Select/Select'; +import { Switch } from '../Switch/Switch'; +import { cn } from '../../lib/utils'; + +interface ImageModalProps { + children: React.ReactNode; + className?: string; +} + +/** + * Main ImageModal container. By default, we do not force any + * layout here. We'll use a "Body" subcomponent for the main area + * that sets up a flex row with a 70/30 split. + */ +export function ImageModal({ children, className }: ImageModalProps) { + return
{children}
; +} + +/* -------------------------------------------------------------------------- */ +/* Body subcomponent */ + +interface ImageBodyProps { + children: React.ReactNode; + className?: string; +} +function ImageBody({ children, className }: ImageBodyProps) { + return
{children}
; +} + +/* -------------------------------------------------------------------------- */ +/* ImageVisual subcomponent */ + +interface ImageVisualProps { + className?: string; + children: React.ReactNode; +} +function ImageVisual({ children, className }: ImageVisualProps) { + return ( +
+
{children}
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* ImageOptions subcomponent */ + +interface ImageOptionsProps { + children: React.ReactNode; + className?: string; +} +function ImageOptions({ children, className }: ImageOptionsProps) { + return
{children}
; +} + +/* -------------------------------------------------------------------------- */ +/* Filename subcomponent */ + +interface FilenameProps extends Omit, 'children'> { + children: React.ReactNode; + className?: string; + /** Handler is optional. If not provided, default to a no‐op. */ + onChange?: React.ChangeEventHandler; +} + +function Filename({ children, className, value, onChange, ...props }: FilenameProps) { + return ( +
+ + {})} + /> +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Filetype subcomponent */ + +interface FiletypeProps { + selected: string; + /** Handler is optional. If not provided, we do nothing. */ + onSelect?: (val: string) => void; + className?: string; + /** Array of file type options */ + options?: Array<{ value: string; label: string }>; +} + +function Filetype({ selected, onSelect, className, options = [] }: FiletypeProps) { + const defaultOptions = [ + { value: 'jpg', label: 'JPG' }, + { value: 'png', label: 'PNG' }, + ]; + + const fileTypeOptions = options.length ? options : defaultOptions; + + return ( + + ); +} + +/* -------------------------------------------------------------------------- */ +/* ImageSize subcomponent */ + +interface ImageSizeProps { + children: React.ReactNode; + width: string; + height: string; + /** Handlers optional. If not provided, default no‐op. */ + onWidthChange?: React.ChangeEventHandler; + onHeightChange?: React.ChangeEventHandler; + className?: string; + maxWidth?: string; + maxHeight?: string; +} + +function ImageSize({ + children, + width, + height, + onWidthChange, + onHeightChange, + className, + maxWidth, + maxHeight, +}: ImageSizeProps) { + return ( +
+ + + {/* Flex container for width/height inputs */} +
+ {/* Width group */} +
+ W + {})} + placeholder="Width" + className="w-20" + max={maxWidth} + /> +
+ + {/* Height group */} +
+ H + {})} + placeholder="Height" + className="w-20" + max={maxHeight} + /> +
+
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* SwitchOption subcomponent */ + +interface SwitchOptionProps { + children: React.ReactNode; + checked?: boolean; + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean) => void; + className?: string; +} + +function SwitchOption({ + children, + checked, + defaultChecked, + onCheckedChange, + className, +}: SwitchOptionProps) { + return ( +
+ onCheckedChange?.(val)} + /> + {children} +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Attach subcomponents onto the main ImageModal function. */ + +ImageModal.Body = ImageBody; +ImageModal.ImageVisual = ImageVisual; +ImageModal.ImageOptions = ImageOptions; +ImageModal.Filename = Filename; +ImageModal.Filetype = Filetype; +ImageModal.ImageSize = ImageSize; +ImageModal.SwitchOption = SwitchOption; diff --git a/platform/ui-next/src/components/OHIFModals/UserPreferencesModal.tsx b/platform/ui-next/src/components/OHIFModals/UserPreferencesModal.tsx new file mode 100644 index 000000000..eeb95fa7c --- /dev/null +++ b/platform/ui-next/src/components/OHIFModals/UserPreferencesModal.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import { Label } from '../Label'; +import { Input } from '../Input'; +import { cn } from '../../lib/utils'; + +interface UserPreferencesModalProps { + children: React.ReactNode; + className?: string; +} + +export function UserPreferencesModal({ children, className }: UserPreferencesModalProps) { + return ( +
+ {children} +
+ ); +} + +/** Body + * Automatically wraps content in a scrollable area. + */ +interface BodyProps { + children: React.ReactNode; + className?: string; +} +function Body({ children, className }: BodyProps) { + return ( +
+
{children}
+
+ ); +} + +/** Subheading + * Section labels + */ +interface SubHeadingProps { + children: React.ReactNode; + className?: string; +} +function SubHeading({ children, className }: SubHeadingProps) { + return {children}; +} + +/** Responsive 3-column grid for hotkeys, etc. */ +interface HotkeysGridProps { + children: React.ReactNode; + className?: string; +} +function HotkeysGrid({ children, className }: HotkeysGridProps) { + return ( +
+ {children} +
+ ); +} + +/** A single hotkey row: label + input */ +interface HotkeyProps { + label: string; + placeholder?: string; + className?: string; + value?: string; + onChange?: (value: string) => void; + hotkeys?: { + record: (callback: (sequence: string[]) => void) => void; + pause: () => void; + unpause: () => void; + startRecording: () => void; + }; +} + +function Hotkey({ label, placeholder, className, value, onChange, hotkeys }: HotkeyProps) { + const [isRecording, setIsRecording] = React.useState(false); + + const onInputKeyDown = (event: React.KeyboardEvent) => { + event.preventDefault(); + hotkeys?.record((sequence: string[]) => { + const keys = sequence.join('+'); + hotkeys?.unpause(); + setIsRecording(false); + onChange?.(keys); + }); + }; + + const onFocus = () => { + setIsRecording(true); + hotkeys?.pause(); + hotkeys?.startRecording(); + }; + + const onBlur = () => { + setIsRecording(false); + hotkeys?.unpause(); + }; + + return ( +
+ + +
+ ); +} + +/** Attach subcomponents as static properties for a nicer API */ +UserPreferencesModal.Body = Body; +UserPreferencesModal.HotkeysGrid = HotkeysGrid; +UserPreferencesModal.Hotkey = Hotkey; +UserPreferencesModal.SubHeading = SubHeading; diff --git a/platform/ui-next/src/components/OHIFModals/index.ts b/platform/ui-next/src/components/OHIFModals/index.ts new file mode 100644 index 000000000..1a8740622 --- /dev/null +++ b/platform/ui-next/src/components/OHIFModals/index.ts @@ -0,0 +1,3 @@ +export { UserPreferencesModal } from './UserPreferencesModal'; +export { ImageModal } from './ImageModal'; +export { AboutModal } from './AboutModal'; diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 0183ed417..78ef6800d 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -54,6 +54,10 @@ import { DisplaySetMessageListTooltip } from './DisplaySetMessageListTooltip'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from './Tooltip'; import { ToolboxUI, Toolbox } from './OHIFToolbox'; import Numeric from './Numeric'; +import { InputDialog, PresetDialog } from './OHIFDialogs'; +import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals'; +import Modal from './Modal/Modal'; +import { FooterAction } from './FooterAction'; import { DropdownMenu, @@ -225,4 +229,11 @@ export { ToolButtonListDropDown, ToolButtonListItem, ToolButtonListDivider, + InputDialog, + PresetDialog, + Modal, + AboutModal, + ImageModal, + UserPreferencesModal, + FooterAction, }; diff --git a/platform/ui-next/src/contextProviders/DialogProvider.tsx b/platform/ui-next/src/contextProviders/DialogProvider.tsx new file mode 100644 index 000000000..940aed9b5 --- /dev/null +++ b/platform/ui-next/src/contextProviders/DialogProvider.tsx @@ -0,0 +1,89 @@ +import React, { useState, createContext, useContext, useCallback, useEffect, useMemo } from 'react'; +import ManagedDialog, { ManagedDialogProps } from './ManagedDialog'; + +interface DialogContextType { + show: (options: ManagedDialogProps) => string; + hide: (id: string) => void; + hideAll: () => void; + isEmpty: () => boolean; +} + +interface DialogService { + setServiceImplementation: (implementation: DialogContextType) => void; + getCustomComponent?: () => React.ComponentType | null; +} + +interface DialogProviderProps { + children: React.ReactNode; + dialog?: React.ComponentType; + service?: DialogService | null; +} + +const DialogContext = createContext(null); + +export const useDialog = () => { + const context = useContext(DialogContext); + if (!context) { + throw new Error('useDialog must be used within a DialogProvider'); + } + return context; +}; + +const DialogProvider: React.FC = ({ + children, + dialog: DialogComponent = ManagedDialog, + service = null, +}) => { + const [dialogs, setDialogs] = useState<(ManagedDialogProps & { id: string })[]>([]); + + const show = useCallback((options: ManagedDialogProps) => { + const id = options.id; + setDialogs(prev => [...prev, { ...options, id }]); + return id; + }, []); + + const hide = useCallback((id: string) => { + setDialogs(prev => prev.filter(dialog => dialog.id !== id)); + }, []); + + const hideAll = useCallback(() => { + setDialogs([]); + }, []); + + const isEmpty = useCallback(() => dialogs.length === 0, [dialogs]); + + const contextValue = useMemo( + () => ({ + show, + hide, + hideAll, + isEmpty, + }), + [show, hide, hideAll, isEmpty] + ); + + useEffect(() => { + if (service) { + service.setServiceImplementation(contextValue); + } + }, [service, contextValue]); + + const CustomDialog = service?.getCustomComponent?.(); + const RenderedDialog = CustomDialog || DialogComponent; + + return ( + + {dialogs.map(dialog => ( + + ))} + {children} + + ); +}; + +export { DialogProvider }; diff --git a/platform/ui-next/src/contextProviders/ManagedDialog.tsx b/platform/ui-next/src/contextProviders/ManagedDialog.tsx new file mode 100644 index 000000000..02a9c926f --- /dev/null +++ b/platform/ui-next/src/contextProviders/ManagedDialog.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../components/Dialog/Dialog'; +import { cn } from '../lib/utils'; + +export interface ManagedDialogProps { + id: string; + isOpen?: boolean; + title?: string; + description?: string; + content: React.ComponentType<{ hide?: () => void }>; + contentProps?: Record; + isDraggable?: boolean; + shouldCloseOnEsc?: boolean; + shouldCloseOnOverlayClick?: boolean; + defaultPosition?: { x: number; y: number }; + onClose?: (id: string) => void; + unstyled?: boolean; + showOverlay?: boolean; + containerClassName?: string; +} + +const ManagedDialog: React.FC = ({ + id, + isOpen, + title, + content: DialogContentComponent, + contentProps, + isDraggable, + shouldCloseOnEsc = false, + shouldCloseOnOverlayClick = false, + showOverlay = true, + defaultPosition, + onClose, + unstyled, + containerClassName, +}) => { + return ( + { + if (!open) { + onClose(id); + } + }} + isDraggable={isDraggable} + shouldCloseOnEsc={shouldCloseOnEsc} + shouldCloseOnOverlayClick={shouldCloseOnOverlayClick} + showOverlay={showOverlay} + > + + {!unstyled && {title && {title}}} + onClose(id)} + /> + + + ); +}; + +export default ManagedDialog; diff --git a/platform/ui-next/src/contextProviders/ModalProvider.tsx b/platform/ui-next/src/contextProviders/ModalProvider.tsx new file mode 100644 index 000000000..94c31a1b5 --- /dev/null +++ b/platform/ui-next/src/contextProviders/ModalProvider.tsx @@ -0,0 +1,102 @@ +import React, { useState, createContext, useContext, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface ModalOptions { + title?: string; + shouldCloseOnEsc?: boolean; + content?: React.ComponentType; + contentProps?: Record; + containerClassName?: string; +} + +interface ModalContextType { + show: (options: Partial) => void; + hide: () => void; + containerClassName?: string; +} + +interface ModalComponentProps { + isOpen: boolean; + onClose: () => void; + title?: string; + children: React.ReactNode; +} + +const ModalContext = createContext(null); + +export const useModal = () => { + const ctx = useContext(ModalContext); + if (!ctx) { + throw new Error('useModal must be used within a ModalProvider'); + } + return ctx; +}; + +const DEFAULT_OPTIONS: ModalOptions = { + title: '', + shouldCloseOnEsc: true, +}; + +interface ModalService { + setServiceImplementation: (implementation: ModalContextType) => void; + getCustomComponent: () => React.ComponentType | null; +} + +interface ModalProviderProps { + children: React.ReactNode; + modal: React.ComponentType; + service?: ModalService | null; +} + +const ModalProvider: React.FC = ({ + children, + modal: ModalComponent, + service = null, +}) => { + const { t } = useTranslation('Modals'); + const [options, setOptions] = useState(DEFAULT_OPTIONS); + + const ModalContent = options.content; + + const show = useCallback((props: Partial) => { + setOptions(prev => ({ ...prev, ...props })); + }, []); + + const hide = useCallback(() => { + setOptions(DEFAULT_OPTIONS); + }, []); + + useEffect(() => { + if (service) { + service.setServiceImplementation({ show, hide }); + } + }, [hide, service, show]); + + const { title } = options; + + const CustomModal = service?.getCustomComponent(); + const RenderedModal = CustomModal || ModalComponent; + + return ( + + {ModalContent && ( + + + + )} + {children} + + ); +}; + +export { ModalProvider }; +export const ModalConsumer = ModalContext.Consumer; diff --git a/platform/ui-next/src/contextProviders/index.ts b/platform/ui-next/src/contextProviders/index.ts index 088b51419..91181d26b 100644 --- a/platform/ui-next/src/contextProviders/index.ts +++ b/platform/ui-next/src/contextProviders/index.ts @@ -1,7 +1,13 @@ import NotificationProvider, { useNotification } from './NotificationProvider'; import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './ViewportGridProvider'; import { ToolboxProvider, useToolbox } from './ToolboxContext'; +import { ModalProvider, useModal } from './ModalProvider'; +import { DialogProvider, useDialog } from './DialogProvider'; +import ManagedDialog from './ManagedDialog'; export { useNotification, NotificationProvider }; export { ViewportGridContext, ViewportGridProvider, useViewportGrid }; export { ToolboxProvider, useToolbox }; +export { ModalProvider, useModal }; +export { DialogProvider, useDialog }; +export { ManagedDialog }; diff --git a/platform/ui-next/src/index.ts b/platform/ui-next/src/index.ts index a53bde494..e492e5cbd 100644 --- a/platform/ui-next/src/index.ts +++ b/platform/ui-next/src/index.ts @@ -100,6 +100,13 @@ import { ToolButtonListDivider, Toolbox, Numeric, + InputDialog, + PresetDialog, + Modal, + AboutModal, + ImageModal, + UserPreferencesModal, + FooterAction, } from './components'; import { DataRow } from './components/DataRow'; @@ -108,6 +115,11 @@ import { NotificationProvider, useToolbox, ToolboxProvider, + useModal, + ModalProvider, + DialogProvider, + useDialog, + ManagedDialog, } from './contextProviders'; import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './contextProviders'; import * as utils from './utils'; @@ -225,4 +237,16 @@ export { useToolbox, utils, Numeric, + AboutModal, + ImageModal, + UserPreferencesModal, + InputDialog, + PresetDialog, + Modal, + useModal, + ModalProvider, + FooterAction, + DialogProvider, + useDialog, + ManagedDialog, }; diff --git a/platform/ui/src/components/Labelling/LabellingFlow.tsx b/platform/ui/src/components/Labelling/LabellingFlow.tsx index 8d1dfe75f..91b12da7a 100644 --- a/platform/ui/src/components/Labelling/LabellingFlow.tsx +++ b/platform/ui/src/components/Labelling/LabellingFlow.tsx @@ -37,6 +37,7 @@ class LabellingFlow extends Component { this.state = { location, label, + hide: props.hide, componentClassName: className, confirmationState: false, displayComponent: true, @@ -56,33 +57,19 @@ class LabellingFlow extends Component { displayComponent={this.state.displayComponent} onTransitionExit={this.props.labellingDoneCallback} > - <> -
- {this.labellingStateFragment()} -
- +
+ {this.labellingStateFragment()} +
); } - closePopup = () => { - this.setState({ - displayComponent: false, - }); - - setTimeout(() => { - this.setState({ - displayComponent: false, - }); - }, 2000); - }; - selectTreeSelectCalback = (event, itemSelected) => { const label = itemSelected.value; - this.closePopup(); + this.props.hide(); return this.props.labellingDoneCallback(label); }; @@ -92,7 +79,7 @@ class LabellingFlow extends Component { items={this.currentItems} columns={1} onSelected={this.selectTreeSelectCalback} - closePopup={this.closePopup} + closePopup={this.props.hide} selectTreeFirstTitle="Annotation" exclusive={this.props.exclusive} label={this.state.label} diff --git a/platform/ui/src/components/Modal/Modal.tsx b/platform/ui/src/components/Modal/Modal.tsx index 87ef5c272..a342aa1c3 100644 --- a/platform/ui/src/components/Modal/Modal.tsx +++ b/platform/ui/src/components/Modal/Modal.tsx @@ -22,7 +22,7 @@ const Modal = ({ onClose, children, shouldCloseOnOverlayClick = true, - movable = false, + isDraggable = false, containerDimensions = null, contentDimensions = null, }) => { @@ -75,7 +75,7 @@ const Modal = ({ : 'relative max-h-full w-11/12 text-white outline-none lg:w-10/12 xl:w-9/12' } overlayClassName={ - movable + isDraggable ? 'fixed top-0 left-0 right-0 bottom-0 z-50 flex items-center justify-center py-16 pointer-events-none' : 'fixed top-0 left-0 right-0 bottom-0 z-50 bg-overlay flex items-center justify-center py-16' } @@ -85,7 +85,7 @@ const Modal = ({ title={title} shouldCloseOnOverlayClick={shouldCloseOnOverlayClick} > - {movable ? ( + {isDraggable ? ( { headerItem = () => { const inputLeftPadding = this.props.items.length > 0 ? 'pl-8' : 'pl-4'; - const title = this.props.selectTreeFirstTitle; return ( -
-
- {title} -
- this.props.closePopup()} - fill="#a3a3a3" - /> -
-
+
{this.props.searchEnabled && (
{this.props.items.length > 0 && ( diff --git a/platform/ui/src/contextProviders/ModalProvider.tsx b/platform/ui/src/contextProviders/ModalProvider.tsx index fa9b98aee..2bd247339 100644 --- a/platform/ui/src/contextProviders/ModalProvider.tsx +++ b/platform/ui/src/contextProviders/ModalProvider.tsx @@ -31,7 +31,7 @@ const ModalProvider = ({ children, modal: Modal, service = null }) => { closeButton: true, title: null, customClassName: '', - movable: false, + isDraggable: false, containerDimensions: null, contentDimensions: null, }; @@ -78,7 +78,7 @@ const ModalProvider = ({ children, modal: Modal, service = null }) => { shouldCloseOnEsc, closeButton, shouldCloseOnOverlayClick, - movable, + isDraggable, containerDimensions, contentDimensions, } = options; @@ -96,7 +96,7 @@ const ModalProvider = ({ children, modal: Modal, service = null }) => { closeButton={closeButton} onClose={hide} shouldCloseOnOverlayClick={shouldCloseOnOverlayClick} - movable={movable} + isDraggable={isDraggable} containerDimensions={containerDimensions} contentDimensions={contentDimensions} >